From 311b7e6c39242e743bf31d75603d5dc2244292d4 Mon Sep 17 00:00:00 2001 From: Nodjo Date: Fri, 27 Mar 2026 23:18:17 +0100 Subject: [PATCH 1/8] Add a wrapper for the database to allow injecting an implementation when running tests Enforce that the static Db methods are never called directly but always through the wrapper --- .github/workflows/ci.yml | 7 +- modules/php/Game.php | 251 +++++++++---------- modules/php/PHPStan/EnforceDbWrapperRule.php | 71 ++++++ modules/php/ResourceChoiceHelper.php | 20 +- modules/php/db/Db.php | 30 +++ modules/php/db/TableDb.php | 43 ++++ modules/php/tokens.php | 69 ++--- phpstan-custom-rules.neon | 21 ++ phpstan.neon | 9 +- tests/ResourceChoiceHelperTest.php | 12 +- tests/stubs/PhpstanStubs.php | 81 ++++++ 11 files changed, 428 insertions(+), 186 deletions(-) create mode 100644 modules/php/PHPStan/EnforceDbWrapperRule.php create mode 100644 modules/php/db/Db.php create mode 100644 modules/php/db/TableDb.php create mode 100644 phpstan-custom-rules.neon create mode 100644 tests/stubs/PhpstanStubs.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3c1264..9a00e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - name: Run tests run: ./vendor/bin/phpunit - format: - name: PHP CS Fixer + lint: + name: Lint & Check runs-on: ubuntu-latest steps: @@ -41,3 +41,6 @@ jobs: - name: Check formatting run: ./vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: Run custom PHPStan rules + run: ./vendor/bin/phpstan analyse --configuration=phpstan-custom-rules.neon --no-progress diff --git a/modules/php/Game.php b/modules/php/Game.php index 3afbfa4..6db60ad 100644 --- a/modules/php/Game.php +++ b/modules/php/Game.php @@ -28,12 +28,18 @@ use Bga\GameFramework\UserException; use Bga\GameFramework\Components\Deck; use Bga\GameFramework\StateType; +use Bga\GameFramework\Table; use DiceForge\Resources\ResourceChoice; +use Bga\Games\diceforge\Db\Db; +use Bga\Games\diceforge\Db\TableDb; +use Bga\Games\diceforge\Tokens; require_once __DIR__ . '/resource_choice.php'; require_once __DIR__ . '/ResourceChoiceHelper.php'; +require_once __DIR__ . '/db/Db.php'; +require_once __DIR__ . '/db/TableDb.php'; -class Game extends \Bga\GameFramework\Table implements ResourceChoiceDb +class Game extends Table { public const MAX_GOLD = 12; public const MAX_FIRESHARD = 6; @@ -70,7 +76,7 @@ class Game extends \Bga\GameFramework\Table implements ResourceChoiceDb public array $labyrinth_paths; public array $labyrinth_rewards; - public function __construct() + public function __construct(private readonly Db $db = new TableDb()) { // Your global variables labels: @@ -131,19 +137,8 @@ public function __construct() $this->sides = $this->deckFactory->createDeck('sides'); // Tokens - $this->tokens = new Tokens(); - $this->resourceChoiceHelper = new ResourceChoiceHelper($this); - } - - // ResourceChoiceDb interface implementation - public function executeQuery(string $sql): void - { - self::DbQuery($sql); - } - - public function getUniqueValue(string $sql): mixed - { - return (int) self::getUniqueValueFromDB($sql); + $this->tokens = new Tokens($this->db); + $this->resourceChoiceHelper = new ResourceChoiceHelper($this->db); } /* @@ -350,7 +345,7 @@ protected function setupNewGame($players, $options = []) } $sql .= implode(',', $values); - self::DbQuery($sql); + $this->db->DbQuery($sql); self::reattributeColorsBasedOnPreferences( $players, $gameinfos['player_colors'] @@ -549,14 +544,14 @@ protected function setupNewGame($players, $options = []) // set of correct location arg $sql = "UPDATE sides SET card_location_arg = card_location_arg-1 WHERE card_location LIKE 'dice%'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); // Init gold ressources for players // 3 for 1st, 2 for 2nd, 1 for 3rd $sql = 'UPDATE player SET res_gold=3 where player_id =' . $players_turn['0']; - self::DbQuery($sql); + $this->db->DbQuery($sql); $player_init = $players_turn['0']; for ($i = 1; $i < $nb_players; $i++) { $sql = @@ -564,7 +559,7 @@ protected function setupNewGame($players, $options = []) (3 - $i) . ' where player_id =' . $this->getPlayerAfter($player_init); - self::DbQuery($sql); + $this->db->DbQuery($sql); $player_init = $this->getPlayerAfter($player_init); } @@ -646,7 +641,7 @@ protected function getAllDatas() // Note: you can retrieve some extra field you added for "player" table in "dbmodel.sql" if you need it. $sql = 'SELECT player_id id, player_score score, hammer_position, position, player_color color, player_name name, triton_token triton, cerberus_token cerberus, hammer_auto FROM player '; - $result['players'] = self::getCollectionFromDb($sql); + $result['players'] = $this->db->getCollectionFromDB($sql); $result['counters'] = $this->getPlayersRessources(); $result['secondActionTaken'] = $this->getGameStateValue( 'secondActionTaken' @@ -808,7 +803,7 @@ public function getPlayersRessources($player_id = null) $sql .= ' WHERE player_id = ' . (int) $player_id; } - $query_arr = self::getNonEmptyCollectionFromDB($sql); + $query_arr = $this->db->getNonEmptyCollectionFromDB($sql); foreach ($query_arr as $player_id => $player) { $current_player = $player['id']; @@ -947,7 +942,7 @@ public function getSideIdFromType($side) { $sql = "SELECT card_id FROM sides WHERE card_type = '$side' LIMIT 1"; - return $this->getUniqueValueFromDB($sql); + return $this->db->getUniqueValueFromDB($sql); } public function hasMazeStock($player_id = null) @@ -959,7 +954,7 @@ public function hasMazeStock($player_id = null) $sql .= " AND token_key = 'mazestock_" . $player_id . "'"; } - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($this->getGameStateValue('timeGolem') != 0) { return true; @@ -1180,7 +1175,7 @@ public function resetThrowTokens($player_id = null) )"; } - self::dbQuery($sql); + $this->db->DbQuery($sql); self::notifyAllPlayers('notifThrowToken', '', []); } @@ -1411,7 +1406,7 @@ public function getTridentSides() $sql = 'SELECT card_type, COUNT(*) FROM exploit GROUP BY card_type'; $poolList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; // 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; - $res = self::getCollectionFromDB($sql, true); + $res = $this->db->getCollectionFromDB($sql, true); if (!isset($res['mirror'])) { $poolList[] = 11; @@ -1456,7 +1451,7 @@ public function initTokens() $token_to_init = ['companion', 'scepter']; foreach ($token_to_init as $ind => $value) { $sql = "select concat(card_type, '_', card_id) AS 'key', '1' AS 'nbr', 'deck' AS 'location', '0' AS 'state' FROM exploit WHERE card_type = '$value'"; - $res = self::getObjectListFromDB($sql); + $res = $this->db->getObjectListFromDB($sql); if (count($res) != 0) { $this->tokens->createTokens($res, 'none'); @@ -1472,11 +1467,11 @@ public function calculateTieBreaker() $tiebreak = ['sides', 'exploit', 'resources', '1st']; $sql = 'SELECT player_score FROM player GROUP BY player_score HAVING COUNT(player_score) > 1'; - $scores = self::getObjectListFromDB($sql, true); + $scores = $this->db->getObjectListFromDB($sql, true); foreach ($scores as $score) { $sql = "SELECT player_id FROM player WHERE player_score = $score"; - $tied_players = self::getObjectListFromDB($sql, true); + $tied_players = $this->db->getObjectListFromDB($sql, true); foreach ($tiebreak as $act) { $test = []; @@ -1484,11 +1479,11 @@ public function calculateTieBreaker() $players_info = $this->getPlayersAdditionnalInfo(); $sql = "SELECT player_score_aux FROM player WHERE player_score = $score group by player_score_aux having count(player_score_aux) > 1"; - $aux = self::getObjectListFromDB($sql, true); + $aux = $this->db->getObjectListFromDB($sql, true); foreach ($aux as $score_aux) { $sql = "SELECT player_id FROM player WHERE player_score_aux = $score_aux and player_score = $score"; - $players_being_tied = self::getObjectListFromDB( + $players_being_tied = $this->db->getObjectListFromDB( $sql, true ); @@ -1505,19 +1500,19 @@ public function calculateTieBreaker() ) ) . " WHERE player_id = $player_id"; - self::dbQuery($sql); + $this->db->DbQuery($sql); break; case 'exploit': $sql = "UPDATE player SET player_score_aux = 200 + (SELECT count(1) FROM exploit WHERE card_location like '%-$player_id') WHERE player_id = $player_id"; - self::dbQuery($sql); + $this->db->DbQuery($sql); break; case 'resources': $sql = "UPDATE player SET player_score_aux = 100 + res_fire + res_moon + res_gold WHERE player_id = $player_id"; - self::dbQuery($sql); + $this->db->DbQuery($sql); break; case '1st': $sql = "UPDATE player SET player_score_aux = 100 - player_no WHERE player_id = $player_id"; - self::dbQuery($sql); + $this->db->DbQuery($sql); break; } } @@ -1526,7 +1521,7 @@ public function calculateTieBreaker() } $sql = 'UPDATE player SET player_score_aux = 0 WHERE player_score_aux = 300 OR player_score_aux = 200 OR player_score_aux = 100'; - self::dbQuery($sql); + $this->db->DbQuery($sql); } } @@ -2529,7 +2524,7 @@ public function canForgeSides($player_id, $remainingGold, $alreadyBought) "')"; } - $side_id = self::getUniqueValueFromDB($sql); + $side_id = $this->db->getUniqueValueFromDB($sql); if ($side_id == null) { return false; @@ -2556,7 +2551,7 @@ public function listSidesWithExploits() { $sql = 'SELECT DISTINCT card_type FROM exploit'; $garden = ['shield', 'triple', 'mirror', 'ship', 'boar']; - $exploits = self::getObjectListFromDB($sql, true); + $exploits = $this->db->getObjectListFromDB($sql, true); $info = []; foreach ($garden as $i => $gard) { @@ -2703,10 +2698,10 @@ public function getTitanReward($player_id, $card_type) // get total bought by players $sql = "SELECT count(*) FROM exploit WHERE card_type LIKE '%$card_type%' and card_location NOT LIKE 'M%' AND card_location NOT LIKE 'F%'"; - $totalAlreadyBought = self::getUniqueValueFromDB($sql) - 1; + $totalAlreadyBought = $this->db->getUniqueValueFromDB($sql) - 1; $sql = "SELECT count(*) FROM exploit WHERE card_type LIKE '%$card_type%' and card_location LIKE '%$player_id%'"; - $playerBought = self::getUniqueValueFromDB($sql) - 1; + $playerBought = $this->db->getUniqueValueFromDB($sql) - 1; // if 1st to buy a card max VP if ($totalAlreadyBought == 0) { @@ -4031,7 +4026,7 @@ public function hasUnusedShip($player_id = null) if ($player_id != null) { $sql .= ' AND player_id = ' . $player_id; } - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { if ( @@ -4077,7 +4072,7 @@ public function hasResolutionConflict() { $sql = "SELECT player_id, card_type from sides, player where (card_id = player.throw_1 or card_id = player.throw_2) and (card_type = 'mirror' or card_type = 'ship')"; - $dbres = self::getObjectListFromDB($sql); + $dbres = $this->db->getObjectListFromDB($sql); $ship_owned = []; $mirror_owned = []; @@ -4121,7 +4116,7 @@ public function hasResolutionConflict() "(card_location in (SELECT DISTINCT CONCAT('dice1-p', SUBSTRING(card_location, 7, 99)) FROM exploit WHERE card_type = 'twins' AND card_location like 'pile%') OR "; $sql .= "card_location in (SELECT DISTINCT CONCAT('dice2-p', SUBSTRING(card_location, 7, 99)) FROM exploit WHERE card_type = 'twins' AND card_location like 'pile%')) "; - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres > 0) { return true; } @@ -4129,7 +4124,7 @@ public function hasResolutionConflict() // if we have a mirror & someone else owns a twin $sql = "SELECT DISTINCT SUBSTRING(card_location, 7, 99) FROM exploit WHERE card_type = 'twins' AND card_location LIKE 'pile%'"; - $dbres = self::getObjectListFromDB($sql, true); + $dbres = $this->db->getObjectListFromDB($sql, true); foreach ($dbres as $twin) { // one mirror shown and not the one that have the twin @@ -4150,7 +4145,7 @@ public function hasResolutionConflict() // If we have two owners of misfortune triggered at the same time, mono resolution $sql = "SELECT distinct card_type_arg from sides, player where (card_id = player.throw_1 or card_id = player.throw_2) and (card_type like '%Misfortune')"; - $dbres = self::getObjectListFromDB($sql, true); + $dbres = $this->db->getObjectListFromDB($sql, true); // if we roll a misfortune, mono resolution (test) //if (count($dbres) > 1) if (count($dbres) > 0) { @@ -4178,7 +4173,7 @@ public function isRessourceChoice(?ResourceChoice $action = null, $player_id = n $sql .= " AND player_id = $player_id"; } - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { // no choice @@ -4197,7 +4192,7 @@ public function hasUnresolvedSides($player_id = null) $sql .= ' AND player_id = ' . $player_id; } - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { // no choice return false; @@ -4214,7 +4209,7 @@ public function isMazeChoice($player_id = null) $sql .= "AND player_id = $player_id"; } - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { // no choice @@ -4293,7 +4288,7 @@ public function hasCerberusToken($player_id = null) //$players = $this->getPlayersAdditionnalInfo(); //return $players[ $player_id ]['cerberus_token']; - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { return false; @@ -4309,14 +4304,14 @@ public function getGold($player_id) $sql .= 'union all '; $sql .= "select sum(token_state) gold from token where token_location = $player_id AND token_key like 'scepter%') aa"; - return self::getUniqueValueFromDB($sql); + return $this->db->getUniqueValueFromDB($sql); } public function hasCompanionToken($player_id) { $sql = "SELECT SUM(token_state) FROM token WHERE token_key LIKE 'companion%' AND token_location = '$player_id' AND token_state <= 5"; - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { return false; @@ -4404,14 +4399,14 @@ public function getActiveReinforcements($player_id) $player_id . "'"; - return self::getCollectionFromDb($sql); + return $this->db->getCollectionFromDB($sql); } public function canUseTwins($player_id) { // Scepters & twins cannot be at the same time on play ==> check of only gold $sql = "SELECT count(card_id) FROM exploit, player WHERE card_type = 'twins' AND card_type_arg = 0 AND card_location LIKE '%-$player_id' AND res_gold >= 3 AND player_id = $player_id"; - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { return false; @@ -4423,7 +4418,7 @@ public function canUseTwins($player_id) public function updateAvailableTwin($player_id, $used = true) { $sql = "SELECT card_id FROM exploit WHERE card_type = 'twins' AND card_type_arg = 0 AND card_location LIKE '%-$player_id' LIMIT 1"; - $card_id = self::getUniqueValueFromDB($sql); + $card_id = $this->db->getUniqueValueFromDB($sql); if ($card_id == null) { throw new SystemException('Error in the updateAvailableTwin function'); @@ -4431,7 +4426,7 @@ public function updateAvailableTwin($player_id, $used = true) $sql = "UPDATE exploit SET card_type_arg = $used WHERE card_id = " . $card_id; - self::dbQuery($sql); + $this->db->DbQuery($sql); return $card_id; } } @@ -4450,7 +4445,7 @@ public function resetTwins($player_id = null, $used = false) if ($player_id != null) { $sql .= " AND card_location LIKE '%-$player_id'"; } - return self::dbQuery($sql); + $this->db->DbQuery($sql); } public function checkExploitId($card_id, $card_position) @@ -4460,7 +4455,7 @@ public function checkExploitId($card_id, $card_position) $card_position . "'"; - $id = self::getUniqueValueFromDB($sql); + $id = $this->db->getUniqueValueFromDB($sql); //if ($id != $card_id) // throw new VisibleSystemException ( "You are not buying the first available card"); //else @@ -4475,7 +4470,7 @@ public function getPlayersAdditionnalInfo() if (empty($this->players_info)) { $sql = 'SELECT player_id AS id, player.* FROM player'; - $this->players_info = self::getCollectionFromDb($sql); + $this->players_info = $this->db->getCollectionFromDB($sql); } return $this->players_info; @@ -4498,7 +4493,7 @@ public function dbIncBoar($player_id, $add = true) $this->incStat(1, 'nb_boar', $player_id); } - self::dbQuery($sql); + $this->db->DbQuery($sql); } //function dbIncMisfortune($player_id, $add = true) { @@ -4511,7 +4506,7 @@ public function dbIncBoar($player_id, $add = true) // //if ($add) // // $this->incStat(1, 'nb_boar', $player_id); // - // self::dbQuery($sql); + // $this->db->DbQuery($sql); //} // return player_id owning the card @@ -4542,7 +4537,7 @@ public function dbIncTwins($player_id, $add = true) $this->incStat(1, 'nb_twins', $player_id); } - self::dbQuery($sql); + $this->db->DbQuery($sql); } public function dbUpdateThrow($player_id, $throwNum, $side_id) @@ -4554,7 +4549,7 @@ public function dbUpdateThrow($player_id, $throwNum, $side_id) $side_id . "' WHERE player_id = " . $player_id; - self::dbQuery($sql); + $this->db->DbQuery($sql); } public function dbIncTriton($player_id, $add = true) @@ -4569,7 +4564,7 @@ public function dbIncTriton($player_id, $add = true) $player_id; } - self::dbQuery($sql); + $this->db->DbQuery($sql); } public function dbIncCerberus($player_id, $add = true) @@ -4584,7 +4579,7 @@ public function dbIncCerberus($player_id, $add = true) $player_id; } - self::dbQuery($sql); + $this->db->DbQuery($sql); } public function dbUpdateExploitPlayed($card_id, $played) @@ -4598,7 +4593,7 @@ public function dbUpdateExploitPlayed($card_id, $played) 'UPDATE exploit SET card_type_arg = 0 WHERE card_id = ' . $card_id; } - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbUpdateTokenPlayed($player_id, $token, $played) @@ -4618,7 +4613,7 @@ public function dbUpdateTokenPlayed($player_id, $token, $played) $player_id . "'"; } - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbUpdateRolled($player_id, $rolled) @@ -4630,17 +4625,17 @@ public function dbUpdateRolled($player_id, $rolled) $sql = 'UPDATE player SET rolled = 0 where player_id = ' . $player_id; } - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function updateAllThrows() { $sql = "UPDATE token SET token_state = 0 WHERE token_key like 'throw%'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); $sql = "UPDATE token SET token_state = 0 WHERE token_key like 'mirror%'"; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } /* @@ -4650,13 +4645,13 @@ public function dbUpdateUnrolled() { $sql = "UPDATE player set rolled = 0 where ressource_choice = -1 AND rolled = 1 AND side_choice_1 = '0' AND side_choice_2 = '0'"; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function hasRolled($player_id) { $sql = 'SELECT rolled FROM player WHERE player_id = ' . $player_id; - $dbres = self::getUniqueValueFromDB($sql); + $dbres = $this->db->getUniqueValueFromDB($sql); if ($dbres == 0) { return false; @@ -4668,7 +4663,7 @@ public function hasRolled($player_id) public function debugRessourcesAll() { $sql = 'UPDATE player set res_gold = 12, res_fire=6, res_moon = 6'; - self::DbQuery($sql); + $this->db->DbQuery($sql); self::notifyAllPlayers( 'updateCounters', '', @@ -4963,7 +4958,7 @@ public function dbSetForge($player_id, $value) $value . "' WHERE player_id = " . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } // if $value == -1 : Side to choose @@ -4979,7 +4974,7 @@ public function dbSetSideChoice($player_id, $side_num, $value) $value . "' WHERE player_id = " . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } elseif ($side_num == 99) { //if ($value == "0") // $value = "none"; @@ -4999,7 +4994,7 @@ public function dbSetPosition($player_id, $value) $value . "' WHERE player_id = " . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbSetGold($player_id, $value) @@ -5009,7 +5004,7 @@ public function dbSetGold($player_id, $value) $value . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbSetMoonShard($player_id, $value) @@ -5019,7 +5014,7 @@ public function dbSetMoonShard($player_id, $value) $value . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbSetAncientShard($player_id, $value) @@ -5029,7 +5024,7 @@ public function dbSetAncientShard($player_id, $value) $value . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbSetFireShard($player_id, $value) @@ -5039,7 +5034,7 @@ public function dbSetFireShard($player_id, $value) $value . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbIncreaseVP($player_id, $value) @@ -5049,7 +5044,7 @@ public function dbIncreaseVP($player_id, $value) $value . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbDecreaseVP($player_id, $value) @@ -5063,7 +5058,7 @@ public function dbDecreaseVP($player_id, $value) $new_score . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbIncreaseHammer($player_id, $value) @@ -5073,7 +5068,7 @@ public function dbIncreaseHammer($player_id, $value) $value . ' WHERE player_id = ' . $player_id; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbInitHammer($player_id) @@ -5082,13 +5077,13 @@ public function dbInitHammer($player_id) 'UPDATE player SET hammer_position = 0 WHERE player_id = ' . $player_id . ' and hammer_position is NULL'; - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function hasAutoHammer($player_id) { $sql = 'SELECT hammer_auto FROM player where player_id = ' . $player_id; - return self::getUniqueValueFromDB($sql); + return $this->db->getUniqueValueFromDB($sql); } public function setAutoHammer($player_id, $enable) @@ -5102,7 +5097,7 @@ public function setAutoHammer($player_id, $enable) 'UPDATE player SET hammer_auto = 0 WHERE player_id = ' . $player_id; } - return self::DbQuery($sql); + $this->db->DbQuery($sql); } public function dbGetHammerPosition($player_id) @@ -5111,7 +5106,7 @@ public function dbGetHammerPosition($player_id) 'select hammer_position from player where player_id = ' . $player_id; - return self::getUniqueValueFromDB($sql); + return $this->db->getUniqueValueFromDB($sql); } public function countExploitInLocation($type, $location, $location_arg = null) @@ -5129,7 +5124,7 @@ public function countExploitInLocation($type, $location, $location_arg = null) $sql .= " AND card_location_arg = '" . $location_arg . "'"; } - $dbres = self::DbQuery($sql); + $dbres = $this->db->DbQuery($sql); $res = mysql_fetch_assoc($dbres); return $res['nb']; @@ -5150,7 +5145,7 @@ public function checkValidSide($side, $player_id = null, $exclude_player_id = nu $sql .= ' AND player_id != ' . $exclude_player_id; } - $dbres = self::getCollectionFromDB($sql); + $dbres = $this->db->getCollectionFromDB($sql); if ($dbres == null) { // side not valid @@ -5191,7 +5186,7 @@ public function checkValidVisibleSide( //if ($exclude_player_id != null) // $sql .= ' AND player_id != ' . $exclude_player_id; - $dbres = self::getCollectionFromDB($sql); + $dbres = $this->db->getCollectionFromDB($sql); if ($dbres == null) { // side not valid @@ -6129,14 +6124,14 @@ public function canTakeSecondAction($player_id) "SELECT res_fire from player WHERE player_id = '" . $player_id . "'"; - $res = self::getUniqueValueFromDB($sql); + $res = $this->db->getUniqueValueFromDB($sql); $firePotential = $res; $sql = "SELECT res_ancient from player WHERE player_id = '" . $player_id . "'"; - $res = self::getUniqueValueFromDB($sql); + $res = $this->db->getUniqueValueFromDB($sql); $firePotential += $res; $scepterFire = $this->getGameStateValue('scepterFireshard'); @@ -6166,7 +6161,7 @@ public function canTakeSecondAction($player_id) "SELECT triton_token from player WHERE player_id = '" . $player_id . "'"; - $res = self::getUniqueValueFromDB($sql); + $res = $this->db->getUniqueValueFromDB($sql); if ($res > 0) { $firePotential += 2; @@ -6197,7 +6192,7 @@ public function haveEnoughRessource($player_id, $fireshard, $moonshard) "SELECT res_fire as fire, res_moon as moon, res_ancient as ancient from player WHERE player_id = '" . $player_id . "'"; - $dbres = self::getObjectFromDB($sql); + $dbres = $this->db->getObjectFromDB($sql); $scepterFire = $this->getGameStateValue('scepterFireshard'); $scepterMoon = $this->getGameStateValue('scepterMoonshard'); @@ -6220,7 +6215,7 @@ public function isIslandUsed($player_id, $island) "' and player_id != '" . $player_id . "'"; - $res = self::getUniqueValueFromDB($sql); + $res = $this->db->getUniqueValueFromDB($sql); return $res; } @@ -8653,7 +8648,7 @@ public function actSideChoice($side1, $side2, $side98) $notifPlayerArgs['roll'] = true; $sql = "SELECT DISTINCT card_id, card_location_arg from sides WHERE card_type = '$side1' AND card_location = 'dice1-p$player_id'"; - $roll = self::getCollectionFromDB($sql); + $roll = $this->db->getCollectionFromDB($sql); $roll = reset($roll); $old_side = $this->sides->getCardsInLocation( @@ -8683,7 +8678,7 @@ public function actSideChoice($side1, $side2, $side98) $notifPlayerArgs['dice2'] = $side2; $notifPlayerArgs['roll'] = true; $sql = "SELECT DISTINCT card_id, card_location_arg from sides WHERE card_type = '$side2' AND card_location = 'dice2-p$player_id'"; - $roll = self::getCollectionFromDB($sql); + $roll = $this->db->getCollectionFromDB($sql); $roll = reset($roll); $old_side = $this->sides->getCardsInLocation( @@ -8712,7 +8707,7 @@ public function actSideChoice($side1, $side2, $side98) $notifPlayerArgs['dice' . $celestialDieNum] = $celestialChoice; $notifPlayerArgs['roll'] = true; $sql = "SELECT DISTINCT card_id, card_location_arg from sides WHERE card_type = '$celestialChoice' AND card_location = 'dice$celestialDieNum-p$player_id'"; - $roll = self::getCollectionFromDB($sql); + $roll = $this->db->getCollectionFromDB($sql); $roll = reset($roll); $old_side = $this->sides->getCardsInLocation( @@ -11866,26 +11861,26 @@ public function actBuyForge($toForge, $toReplace, $mode = 'classic') $type_arg . ' WHERE card_id = ' . $toForge; - self::DbQuery($sql); + $this->db->DbQuery($sql); } // cleanup of card_location_arg $sql = 'set @i=0;'; - self::DbQuery($sql); + $this->db->DbQuery($sql); $sql = "set @Count=(SELECT COUNT(*) from sides where card_location = 'dice" . $die_number . '-p' . $player_id . "');"; - self::DbQuery($sql); + $this->db->DbQuery($sql); $sql = "UPDATE sides SET card_location_arg = @Count-(@i:=@i+1) where card_location = 'dice" . $die_number . '-p' . $player_id . "' ORDER BY card_location_arg DESC;"; - self::DbQuery($sql); + $this->db->DbQuery($sql); $desc = clienttranslate( '${player_name} has forged ${side_type} for ${ressources} on dice ${dice_number}, ${old_side_type} is discarded' @@ -12401,11 +12396,11 @@ public function actMazePowerConfirm($willDo) // // // cleanup of card_location_arg // $sql = "set @i=0;"; - // self::DbQuery($sql); + // $this->db->DbQuery($sql); // $sql = "set @Count=(SELECT COUNT(*) from sides where card_location = 'dice" . $new_side['dice_number'] .'-p'. $player_id ."');"; - // self::DbQuery($sql); + // $this->db->DbQuery($sql); // $sql = "UPDATE sides SET card_location_arg = @Count-(@i:=@i+1) where card_location = 'dice" . $new_side['dice_number'] .'-p'. $player_id ."' ORDER BY card_location_arg DESC;"; - // self::DbQuery($sql); + // $this->db->DbQuery($sql); // // // notify the players that the side has been forged // self::notifyAllPlayers("notifSideForged", clienttranslate('${player_name} has forged ${side_type} on their dice ${dice_number}, ${old_side_type} is discarded'), @@ -12571,11 +12566,11 @@ public function actCelestialUpgrade($old_side_id, $new_side_id) // // // cleanup of card_location_arg // $sql = "set @i=0;"; - // self::DbQuery($sql); + // $this->db->DbQuery($sql); // $sql = "set @Count=(SELECT COUNT(*) from sides where card_location = 'dice" . $new_side['dice_number'] .'-p'. $player_id ."');"; - // self::DbQuery($sql); + // $this->db->DbQuery($sql); // $sql = "UPDATE sides SET card_location_arg = @Count-(@i:=@i+1) where card_location = 'dice" . $new_side['dice_number'] .'-p'. $player_id ."' ORDER BY card_location_arg DESC;"; - // self::DbQuery($sql); + // $this->db->DbQuery($sql); // // // notify the players that the side has been forged // self::notifyAllPlayers("notifSideForged", clienttranslate('${player_name} has forged ${side_type} on their dice ${dice_number}, ${old_side_type} is discarded'), @@ -14750,13 +14745,13 @@ public function stBeginTurn() // $table = $this->getNextPlayerTable(); // // $sql = "UPDATE sides set card_location = concat('zz', card_location) where card_location like 'dice%'"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // // foreach($table as $player => $previous_player) { // $sql = "UPDATE sides set card_location = 'dice1-p" . $player . "' WHERE card_location = 'zzdice1-p". $previous_player . "'"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // $sql = "UPDATE sides set card_location = 'dice2-p" . $player . "' WHERE card_location = 'zzdice2-p". $previous_player . "'"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // } // // self::notifyAllPlayers("notifDiceSwitch", "The dice go back to their owners", @@ -14772,13 +14767,13 @@ public function stBeginTurn() // $table = $this->getPrevPlayerTable(); // // $sql = "UPDATE sides set card_location = concat('zz', card_location) where card_location like 'dice%'"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // // foreach($table as $player => $previous_player) { // $sql = "UPDATE sides set card_location = 'dice1-p" . $player . "' WHERE card_location = 'zzdice1-p". $previous_player . "'"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // $sql = "UPDATE sides set card_location = 'dice2-p" . $player . "' WHERE card_location = 'zzdice2-p". $previous_player . "'"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // } // // self::notifyAllPlayers("notifDiceSwitch", "You take the dice of the previous player", @@ -14884,7 +14879,7 @@ public function stBeginPlayerTurn() $sql = "UPDATE sides set card_location = concat('zz', card_location) where card_location like 'dice%'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); foreach ($table as $player => $previous_player) { $sql = @@ -14893,14 +14888,14 @@ public function stBeginPlayerTurn() "' WHERE card_location = 'zzdice1-p" . $previous_player . "'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); $sql = "UPDATE sides set card_location = 'dice2-p" . $player . "' WHERE card_location = 'zzdice2-p" . $previous_player . "'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); } self::notifyAllPlayers( @@ -14918,7 +14913,7 @@ public function stBeginPlayerTurn() $sql = "UPDATE sides set card_location = concat('zz', card_location) where card_location like 'dice%'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); foreach ($table as $player => $previous_player) { $sql = @@ -14927,14 +14922,14 @@ public function stBeginPlayerTurn() "' WHERE card_location = 'zzdice1-p" . $previous_player . "'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); $sql = "UPDATE sides set card_location = 'dice2-p" . $player . "' WHERE card_location = 'zzdice2-p" . $previous_player . "'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); } self::notifyAllPlayers( @@ -14982,7 +14977,7 @@ public function stBlessing() // Ship management, only one at a time // disable all players - self::DbQuery('UPDATE player SET player_is_multiactive = 0'); + $this->db->DbQuery('UPDATE player SET player_is_multiactive = 0'); if ($monoResolution == 0) { // if action ressource to allocate or choice => ressource choice @@ -15318,7 +15313,7 @@ public function stRessourceChoiceAdvanced($activeplayers = null, $continue = fal ($activeplayers == null || count($activeplayers) == 0) ) { // disable all players - self::DbQuery('UPDATE player SET player_is_multiactive = 0'); + $this->db->DbQuery('UPDATE player SET player_is_multiactive = 0'); // if action ressource to allocate or choice => ressource choice if ( $this->isRessourceChoice(ResourceChoice::RC_RESSOURCE) || @@ -16276,7 +16271,7 @@ public function stEffectExploit() $min_gold = -1; $sql = "select player_id, sum(gold) gold from ( select player_id, res_gold gold from player union all select token_location player_id, token_state gold from token where token_key like 'scepter%' and token_location != 'deck') aa group by player_id ORDER BY sum(gold) ASC"; - $players = self::getObjectListFromDB($sql); + $players = $this->db->getObjectListFromDB($sql); foreach ($players as $aff_player_id => $player) { if ($min_gold == -1) { $min_gold = $player['gold']; @@ -16475,7 +16470,7 @@ public function stEffectExploit() case 'countFeats': // goldsmith $sql = "SELECT COUNT(DISTINCT card_type) FROM exploit WHERE card_location LIKE '%-$player_id'"; - $nbFeats = $this->getUniqueValueFromDB($sql); + $nbFeats = $this->db->getUniqueValueFromDB($sql); $this->increaseVP($player_id, $nbFeats * 2); @@ -16913,7 +16908,7 @@ public function stEffectExploit() $this->resetThrowTokens(); // disable all users - self::DbQuery('UPDATE player SET player_is_multiactive = 0'); + $this->db->DbQuery('UPDATE player SET player_is_multiactive = 0'); } // #35073 : add check of misfortune @@ -17194,7 +17189,7 @@ public function zombieTurn($state, $active_player) SET boar = 0 WHERE player_id = $active_player"; - self::DbQuery($sql); + $this->db->DbQuery($sql); // trigger next state //throw new UserException($statename); @@ -17239,7 +17234,7 @@ public function zombieTurn($state, $active_player) SET player_is_multiactive = 0 WHERE player_id = $active_player "; - self::DbQuery($sql); + $this->db->DbQuery($sql); $this->gamestate->setPlayerNonMultiactive( $active_player, @@ -17278,9 +17273,9 @@ public function upgradeTableDb($from_version) // // Please add your future database scheme changes here //if ( $from_version <= 1805251618 ) { // $sql = "ALTER TABLE `sides` CHANGE `card_location` `card_location` VARCHAR(40) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL;"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); // $sql = "ALTER TABLE `exploit` CHANGE `card_location` `card_location` VARCHAR(40) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL;"; - // self::DbQuery( $sql ); + // $this->db->DbQuery( $sql ); //} if ($from_version <= 1806062135) { @@ -17390,7 +17385,7 @@ public function upgradeTableDb($from_version) } if ($from_version <= 2012031120) { - $result = self::getUniqueValueFromDB( + $result = $this->db->getUniqueValueFromDB( "SHOW COLUMNS FROM `player` LIKE 'res_ancient'" ); if (is_null($result)) { diff --git a/modules/php/PHPStan/EnforceDbWrapperRule.php b/modules/php/PHPStan/EnforceDbWrapperRule.php new file mode 100644 index 0000000..4814aa5 --- /dev/null +++ b/modules/php/PHPStan/EnforceDbWrapperRule.php @@ -0,0 +1,71 @@ +db->...() wrappers instead. + * Db implementations are allowed because they forward to Table statics. + * + * @implements Rule + */ +class EnforceDbWrapperRule implements Rule +{ + private const BANNED_METHODS = [ + 'DbQuery', + 'getUniqueValueFromDB', + 'getCollectionFromDB', + 'getObjectListFromDB', + 'getNonEmptyCollectionFromDB', + 'getObjectFromDB', + ]; + + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * @param StaticCall $node + */ + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier || !$node->class instanceof Node\Name) { + return []; + } + + $methodName = $node->name->toString(); + if (!in_array($methodName, self::BANNED_METHODS, true)) { + return []; + } + + $callerName = $node->class->toString(); + if (!in_array($callerName, ['self', 'static', 'Table', 'Bga\GameFramework\Table'], true)) { + return []; + } + + // Allow calls within Db implementations + $classReflection = $scope->getClassReflection(); + if ($classReflection !== null && $classReflection->implementsInterface('Bga\Games\diceforge\Framework\Db\Db')) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf( + 'Do not call %s::%s() directly. Use the $this->db wrapper instead.', + $callerName, + $methodName, + ) + )->identifier('bga.enforceDbWrapper')->build(), + ]; + } +} diff --git a/modules/php/ResourceChoiceHelper.php b/modules/php/ResourceChoiceHelper.php index 01f5cc4..337801d 100644 --- a/modules/php/ResourceChoiceHelper.php +++ b/modules/php/ResourceChoiceHelper.php @@ -3,26 +3,16 @@ namespace Bga\Games\diceforge; use DiceForge\Resources\ResourceChoice; +use Bga\Games\diceforge\Db\Db; -/** - * Abstraction over the two Table DB methods needed by ResourceChoiceHelper. - * - * Why this interface exists: BGA's Table methods (DbQuery, getUniqueValueFromDB) - * are declared `final public static`, so they cannot directly satisfy an interface. - * This interface allows ResourceChoiceHelper to be fully testable via PHPUnit mocks. - */ -interface ResourceChoiceDb -{ - public function executeQuery(string $sql): void; - public function getUniqueValue(string $sql): mixed; -} +require_once __DIR__ . '/db/Db.php'; /** * Handles reading and writing the `ressource_choice` column on the `player` table. */ class ResourceChoiceHelper { - public function __construct(private ResourceChoiceDb $db) + public function __construct(private readonly Db $db) { } @@ -33,7 +23,7 @@ public function dbSetChoice(int|string $player_id, ResourceChoice $value): void $value->value . ' WHERE player_id = ' . $player_id; - $this->db->executeQuery($sql); + $this->db->DbQuery($sql); } public function getRessourceChoice(int|string $player_id): ResourceChoice @@ -44,6 +34,6 @@ public function getRessourceChoice(int|string $player_id): ResourceChoice $sql .= ' WHERE player_id = ' . $player_id; } - return ResourceChoice::from((int) $this->db->getUniqueValue($sql)); + return ResourceChoice::from((int) $this->db->getUniqueValueFromDB($sql)); } } diff --git a/modules/php/db/Db.php b/modules/php/db/Db.php new file mode 100644 index 0000000..eb063a2 --- /dev/null +++ b/modules/php/db/Db.php @@ -0,0 +1,30 @@ +table = 'token'; $this->custom_fields = array(); @@ -109,7 +110,7 @@ public function createTokens($tokens, $location_global, $token_state_global = nu } $sql = "INSERT INTO " . $this->table . " (token_key,token_location,token_state)"; $sql .= " VALUES " . implode(",", $values); - $this->DbQuery($sql); + $this->db->DbQuery($sql); return $keys; } public function createToken($key, $location, $token_state = 0) @@ -121,7 +122,7 @@ public function createToken($key, $location, $token_state = 0) $values [] = "( '$key', '$location', '$token_state' )"; $sql = "INSERT INTO " . $this->table . " (token_key,token_location,token_state)"; $sql .= " VALUES " . implode(",", $values); - $this->DbQuery($sql); + $this->db->DbQuery($sql); } public function createTokensPack($key, $location, $nbr = 1, $nbr_start = 1, $iterArr = null, $token_state = null) { @@ -171,8 +172,8 @@ public function getExtremePosition($getMax, $location, $token_key = null) $sql .= " AND token_key $like '$token_key' "; } - $dbres = self::DbQuery($sql); - $row = mysql_fetch_assoc($dbres); + $dbres = $this->db->DbQuery($sql); + $row = $this->db->mysql_fetch_assoc($dbres); if ($row) { return $row ['res']; } else { @@ -183,11 +184,11 @@ public function getExtremePosition($getMax, $location, $token_key = null) public function shuffle($location) { self::checkLocation($location); - $token_keys = self::getObjectListFromDB("SELECT token_key FROM " . $this->table . " WHERE token_location='$location'", true); + $token_keys = $this->db->getObjectListFromDB("SELECT token_key FROM " . $this->table . " WHERE token_location='$location'", true); shuffle($token_keys); $n = 0; foreach ($token_keys as $token_key) { - self::DbQuery("UPDATE " . $this->table . " SET token_state='$n' WHERE token_key='$token_key'"); + $this->db->DbQuery("UPDATE " . $this->table . " SET token_state='$n' WHERE token_key='$token_key'"); $n++; } } @@ -204,7 +205,7 @@ public function pickTokensForLocation($nbr, $from_location, $to_location, $state } $sql = "UPDATE " . $this->table . " SET token_location='" . addslashes($to_location) . "', token_state='$state' "; $sql .= "WHERE token_key IN ('" . implode("','", $tokens_ids) . "') "; - self::DbQuery($sql); + $this->db->DbQuery($sql); if (isset($this->autoreshuffle_custom [$from_location]) && count($tokens) < $nbr && $this->autoreshuffle && ! $no_deck_reform) { // No more cards in deck & reshuffle is active => form another deck $nbr_token_missing = $nbr - count($tokens); @@ -239,8 +240,8 @@ public function getTokensOnTop($nbr, $location) $sql .= " WHERE token_location='$location'"; $sql .= " ORDER BY token_state DESC"; $sql .= " LIMIT $nbr"; - $dbres = self::DbQuery($sql); - while ($row = mysql_fetch_assoc($dbres)) { + $dbres = $this->db->DbQuery($sql); + while ($row = $this->db->mysql_fetch_assoc($dbres)) { $result [] = $row; } return $result; @@ -270,7 +271,7 @@ public function setTokenState($token_key, $state) $sql = "UPDATE " . $this->table; $sql .= " SET token_state='$state'"; $sql .= " WHERE token_key='$token_key'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); return $state; } // Increment token state @@ -281,7 +282,7 @@ public function incTokenState($token_key, $state) $sql = "UPDATE " . $this->table; $sql .= " SET token_state= token_state + $state"; $sql .= " WHERE token_key='$token_key'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); return $state; } // Move a card to specific location @@ -293,7 +294,7 @@ public function moveToken($token_key, $location, $state = 0) $sql = "UPDATE " . $this->table; $sql .= " SET token_location='$location', token_state='$state'"; $sql .= " WHERE token_key='$token_key'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); } // Move cards to specific location public function moveTokens($tokens, $location, $state = 0) @@ -304,7 +305,7 @@ public function moveTokens($tokens, $location, $state = 0) $sql = "UPDATE " . $this->table; $sql .= " SET token_location='$location', token_state='$state'"; $sql .= " WHERE token_key IN ('" . implode("','", $tokens) . "')"; - self::DbQuery($sql); + $this->db->DbQuery($sql); } // Move a card to a specific location where card are ordered. If location_arg place is already taken, increment // all tokens after location_arg in order to insert new card at this precise location @@ -316,7 +317,7 @@ public function insertToken($token_key, $location, $state = 0) $sql .= " SET token_state=token_state+1"; $sql .= " WHERE token_location='$location' "; $sql .= " AND token_state>=$state"; - self::DbQuery($sql); + $this->db->DbQuery($sql); self::moveToken($token_key, $location, $state); } public function insertTokenOnExtremePosition($token_key, $location, $bOnTop) @@ -345,7 +346,7 @@ public function moveAllTokensInLocation($from_location, $to_location, $from_stat $sql .= "AND token_state='$from_state' "; } } - self::DbQuery($sql); + $this->db->DbQuery($sql); } /** * Move all tokens from a location to another location arg stays with the same value @@ -357,7 +358,7 @@ public function moveAllTokensInLocationKeepOrder($from_location, $to_location) $sql = "UPDATE " . $this->table; $sql .= " SET token_location='$to_location'"; $sql .= " WHERE token_location='$from_location'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); } /** * Return all tokens in specific location @@ -415,10 +416,10 @@ public function getTokensOfTypeInLocation($type, $location = null, $state = null } } - $dbres = self::DbQuery($sql); + $dbres = $this->db->DbQuery($sql); $result = array(); $i = 0; - while ($row = mysql_fetch_assoc($dbres)) { + while ($row = $this->db->mysql_fetch_assoc($dbres)) { if ($order_by !== null) { $result [$i] = $row; } else { @@ -452,8 +453,8 @@ public function getTokenInfo($token_key) self::checkKey($token_key); $sql = $this->getSelectQuery(); $sql .= " WHERE token_key='$token_key' "; - $dbres = self::DbQuery($sql); - return mysql_fetch_assoc($dbres); + $dbres = $this->db->DbQuery($sql); + return $this->db->mysql_fetch_assoc($dbres); } /** * Get specific tokens info @@ -466,9 +467,9 @@ public function getTokensInfo($tokens_array) } $sql = $this->getSelectQuery(); $sql .= " WHERE token_key IN ('" . implode("','", $tokens_array) . "') "; - $dbres = self::DbQuery($sql); + $dbres = $this->db->DbQuery($sql); $result = array(); - while ($row = mysql_fetch_assoc($dbres)) { + while ($row = $this->db->mysql_fetch_assoc($dbres)) { $result [$row ['key']] = $row; } if (count($result) != count($tokens_array)) { @@ -492,8 +493,8 @@ public function countTokensInLocation($location, $state = null) if ($state !== null) { $sql .= "AND token_state='$state' "; } - $dbres = self::DbQuery($sql); - if ($row = mysql_fetch_assoc($dbres)) { + $dbres = $this->db->DbQuery($sql); + if ($row = $this->db->mysql_fetch_assoc($dbres)) { return $row ['cnt']; } else { return 0; @@ -514,8 +515,8 @@ public function countTokensInLocAndKey($key, $location, $state = null) if ($state !== null) { $sql .= "AND token_state='$state' "; } - $dbres = self::DbQuery($sql); - if ($row = mysql_fetch_assoc($dbres)) { + $dbres = $this->db->DbQuery($sql); + if ($row = $this->db->mysql_fetch_assoc($dbres)) { return $row ['cnt']; } else { return 0; @@ -527,8 +528,8 @@ public function countTokensInLocations() { $result = array(); $sql = "SELECT token_location, COUNT( token_key ) cnt FROM " . $this->table . " GROUP BY token_location "; - $dbres = self::DbQuery($sql); - while ($row = mysql_fetch_assoc($dbres)) { + $dbres = $this->db->DbQuery($sql); + while ($row = $this->db->mysql_fetch_assoc($dbres)) { $result [$row ['token_location']] = $row ['cnt']; } return $result; @@ -541,8 +542,8 @@ public function countTokensByState($location) $sql = "SELECT token_state, COUNT( token_key ) cnt FROM " . $this->table . " "; $sql .= "WHERE token_location='$location' "; $sql .= "GROUP BY token_state "; - $dbres = self::DbQuery($sql); - while ($row = mysql_fetch_assoc($dbres)) { + $dbres = $this->db->DbQuery($sql); + while ($row = $this->db->mysql_fetch_assoc($dbres)) { $result [$row ['token_state']] = $row ['cnt']; } return $result; @@ -666,7 +667,7 @@ private function setGlobalIndex($key, $value) $sql = "UPDATE " . $this->table; $sql .= " SET token_state='$value'"; $sql .= " WHERE token_key='$key'"; - self::DbQuery($sql); + $this->db->DbQuery($sql); $this->g_index [$key] = $value; return $value; } @@ -676,8 +677,8 @@ public function syncGlobalIndex($key) $sql = "SELECT token_state"; $sql .= " FROM " . $this->table; $sql .= " WHERE token_key='$key'"; - $dbres = self::DbQuery($sql); - $row = mysql_fetch_assoc($dbres); + $dbres = $this->db->DbQuery($sql); + $row = $this->db->mysql_fetch_assoc($dbres); if ($row) { $value = $row ['token_state']; } else { diff --git a/phpstan-custom-rules.neon b/phpstan-custom-rules.neon new file mode 100644 index 0000000..cf8b892 --- /dev/null +++ b/phpstan-custom-rules.neon @@ -0,0 +1,21 @@ +parameters: + level: 0 + paths: + - modules/php + excludePaths: + - vendor + - modules/php/PHPStan + scanFiles: + - _ide_helper.php + - tests/stubs/BgaFrameworkStubs.php + - modules/php/PHPStan/EnforceDbWrapperRule.php + ignoreErrors: + - identifier: method.notFound + - identifier: parameter.requiredAfterOptional + - identifier: array.duplicateKey + +services: + - + class: Bga\Games\diceforge\PHPStan\EnforceDbWrapperRule + tags: + - phpstan.rules.rule diff --git a/phpstan.neon b/phpstan.neon index 05f037e..66c7563 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,6 +5,7 @@ parameters: excludePaths: - vendor - modules/php/material.inc.php + - modules/php/PHPStan scanFiles: - _ide_helper.php - tests/stubs/BgaFrameworkStubs.php @@ -12,4 +13,10 @@ parameters: - identifier: method.notFound path: modules/php/Game.php - message: '*getStatTypes*' \ No newline at end of file + message: '*getStatTypes*' + +services: + - + class: Bga\Games\diceforge\PHPStan\EnforceDbWrapperRule + tags: + - phpstan.rules.rule \ No newline at end of file diff --git a/tests/ResourceChoiceHelperTest.php b/tests/ResourceChoiceHelperTest.php index ee18a28..aaf0c53 100644 --- a/tests/ResourceChoiceHelperTest.php +++ b/tests/ResourceChoiceHelperTest.php @@ -2,18 +2,18 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; -use Bga\Games\diceforge\ResourceChoiceDb; +use Bga\Games\diceforge\Db\Db; use Bga\Games\diceforge\ResourceChoiceHelper; use DiceForge\Resources\ResourceChoice; class ResourceChoiceHelperTest extends TestCase { - private ResourceChoiceDb&\PHPUnit\Framework\MockObject\MockObject $db; + private Db&\PHPUnit\Framework\MockObject\MockObject $db; private ResourceChoiceHelper $helper; protected function setUp(): void { - $this->db = $this->createMock(ResourceChoiceDb::class); + $this->db = $this->createMock(Db::class); $this->helper = new ResourceChoiceHelper($this->db); } @@ -21,7 +21,7 @@ protected function setUp(): void public function testDbSetChoice(ResourceChoice $choice, int $expectedInt, int|string $playerId): void { $this->db->expects($this->once()) - ->method('executeQuery') + ->method('DBquery') ->with("UPDATE player SET ressource_choice = $expectedInt WHERE player_id = $playerId"); $this->helper->dbSetChoice($playerId, $choice); @@ -40,7 +40,7 @@ public static function dbSetChoiceProvider(): array public function testGetRessourceChoice(int $dbValue, ResourceChoice $expected, int|string $playerId): void { $this->db->expects($this->once()) - ->method('getUniqueValue') + ->method('getUniqueValueFromDB') ->with("SELECT ressource_choice FROM player WHERE player_id = $playerId") ->willReturn($dbValue); @@ -60,7 +60,7 @@ public static function getRessourceChoiceProvider(): array public function testGetRessourceChoiceInvalidIntThrows(): void { - $this->db->method('getUniqueValue')->willReturn(99); + $this->db->method('getUniqueValueFromDB')->willReturn(99); $this->expectException(\ValueError::class); $this->helper->getRessourceChoice(1); diff --git a/tests/stubs/PhpstanStubs.php b/tests/stubs/PhpstanStubs.php new file mode 100644 index 0000000..c796c13 --- /dev/null +++ b/tests/stubs/PhpstanStubs.php @@ -0,0 +1,81 @@ + + */ + public function processNode(\PhpParser\Node $node, \PHPStan\Analyser\Scope $scope): array; + } + + interface RuleError + { + } + + interface IdentifierRuleError extends RuleError + { + } + + class RuleErrorBuilder + { + public static function message(string $message): static + { + throw new \LogicException('stub'); + } + + public function identifier(string $identifier): static + { + throw new \LogicException('stub'); + } + + public function build(): IdentifierRuleError + { + throw new \LogicException('stub'); + } + } +} + +namespace PHPStan\Analyser { + class Scope + { + public function getClassReflection(): ?\PHPStan\Reflection\ClassReflection + { + throw new \LogicException('stub'); + } + } +} + +namespace PHPStan\Reflection { + class ClassReflection + { + public function isSubclassOf(string $className): bool + { + throw new \LogicException('stub'); + } + + public function implementsInterface(string $interfaceName): bool + { + throw new \LogicException('stub'); + } + + public function getDisplayName(): string + { + throw new \LogicException('stub'); + } + } +} From 0010eb971766667e83c06f1d4776bb135cf260c2 Mon Sep 17 00:00:00 2001 From: Nodjo Date: Fri, 27 Mar 2026 23:32:14 +0100 Subject: [PATCH 2/8] Add an example of the sftp.json file that doesn't contain credentials --- .gitignore | 1 + .vscode/sftp.json.example | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .vscode/sftp.json.example diff --git a/.gitignore b/.gitignore index 6b3ee1a..fdcf067 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.vscode/* !/.vscode/settings.json !/.vscode/extensions.json +!/.vscode/sftp.json.example # Composer /vendor/ diff --git a/.vscode/sftp.json.example b/.vscode/sftp.json.example new file mode 100644 index 0000000..24ecdad --- /dev/null +++ b/.vscode/sftp.json.example @@ -0,0 +1,30 @@ +{ + "name": "BGA Studio", + "host": "1.studio.boardgamearena.com", + "protocol": "sftp", + "port": 2022, + "username": "${SFTP_USERNAME}", + "remotePath": "/diceforge", + "password": "${SFTP_PASSWORD}", + "uploadOnSave": true, + "ignore": [ + ".git", + ".prettierignore", + ".prettierrcold", + ".vscode", + ".gitattributes", + ".gitignore", + "docs", + "rebellion.todo", + "tests", + "vendor", + "phpunit.xml.dist", + "phpunit.xml", + "phpunit.result.cache", + "composer.json", + "composer.lock", + "phpunit.xml", + "phpstan.neon", + "phpstan-custom-rules.neon" + ] +} From 25f78940c27f7902c3b3f865b1afe0296a225f47 Mon Sep 17 00:00:00 2001 From: Nodjo Date: Sat, 28 Mar 2026 00:00:51 +0100 Subject: [PATCH 3/8] Optimize CI runs ressource usage Stop enforcing db wrapper usage on CI, it takes quite a bit of time to run on CI. There's not much value to that as it's unlikely to happen, especially as we write tests against a real DB. Additionally, it is currently set up in my git pre-commit hook. So we should be safe. --- .github/workflows/ci.yml | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a00e21..66b6b74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,10 @@ name: CI on: push: pull_request: - + jobs: - phpunit: - name: PHPUnit + ci: + name: CI runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -18,23 +18,12 @@ jobs: with: php-version: '8.3' - - name: Install dependencies - run: composer install --no-progress --prefer-dist - - - name: Run tests - run: ./vendor/bin/phpunit - - lint: - name: Lint & Check - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Cache Composer dependencies + uses: actions/cache@v4 with: - php-version: '8.3' + path: vendor + key: composer-${{ hashFiles('composer.lock') }} + restore-keys: composer- - name: Install dependencies run: composer install --no-progress --prefer-dist @@ -44,3 +33,6 @@ jobs: - name: Run custom PHPStan rules run: ./vendor/bin/phpstan analyse --configuration=phpstan-custom-rules.neon --no-progress + + - name: Run tests + run: ./vendor/bin/phpunit From 1951897e1ca2530bbac6b208c7a0070a941abcf5 Mon Sep 17 00:00:00 2001 From: Nodjo Date: Sat, 28 Mar 2026 19:38:47 +0100 Subject: [PATCH 4/8] Add a Db implementation for mysqli. Also add instructions to install a mysql server locally. The objective is to be able to write non-regression integration tests for the Game class by injecting a Db instance with a test database. --- modules/php/db/MysqliDb.php | 127 ++++++++++++++++++++++++++++++++++++ tests/README.md | 37 +++++++++++ tests/setup-test-db.sh | 75 +++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 modules/php/db/MysqliDb.php create mode 100644 tests/README.md create mode 100644 tests/setup-test-db.sh diff --git a/modules/php/db/MysqliDb.php b/modules/php/db/MysqliDb.php new file mode 100644 index 0000000..9443446 --- /dev/null +++ b/modules/php/db/MysqliDb.php @@ -0,0 +1,127 @@ +connect_error) { + throw new \RuntimeException("MysqliDb: connection failed: " . $mysqli->connect_error); + } + $mysqli->set_charset('utf8'); + return new self($mysqli); + } + + public function getMysqli(): \mysqli + { + return $this->mysqli; + } + + // ------------------------------------------------------------------------- + // Interface implementation + // ------------------------------------------------------------------------- + + public function DbQuery(string $sql): null|\mysqli_result|bool + { + $result = $this->mysqli->query($sql); + if ($result === false) { + throw new \BgaSystemException("DbQuery failed: " . $this->mysqli->error . " | SQL: $sql"); + } + return $result; + } + + public function getUniqueValueFromDB(string $sql): mixed + { + $result = $this->DbQuery($sql); + if (!($result instanceof \mysqli_result)) { + return null; + } + $rows = $result->fetch_all(MYSQLI_NUM); + $result->free(); + if (count($rows) > 1) { + throw new \BgaSystemException("getUniqueValueFromDB: query returned more than 1 row"); + } + return $rows[0][0] ?? null; + } + + public function getCollectionFromDB(string $sql, bool $bSingleValue = false): array + { + $result = $this->DbQuery($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->free(); + $collection = []; + foreach ($rows as $row) { + $values = array_values($row); + $key = $values[0]; + $collection[$key] = $bSingleValue ? ($values[1] ?? null) : $row; + } + return $collection; + } + + public function getObjectListFromDB(string $sql, bool $bUniqueValue = false): array + { + $result = $this->DbQuery($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->free(); + if (!$bUniqueValue) { + return $rows; + } + return array_map(fn (array $row) => reset($row), $rows); + } + + public function getNonEmptyCollectionFromDB(string $sql): array + { + $result = $this->getCollectionFromDB($sql); + if (empty($result)) { + throw new \BgaSystemException("getNonEmptyCollectionFromDB: empty collection"); + } + return $result; + } + + public function getObjectFromDB(string $sql): array + { + $result = $this->DbQuery($sql); + if (!($result instanceof \mysqli_result)) { + return []; + } + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->free(); + if (count($rows) > 1) { + throw new \BgaSystemException("getObjectFromDB: query returned more than 1 row"); + } + return $rows[0] ?? []; + } + + public function mysql_fetch_assoc(\mysqli_result $result): array|false|null + { + return $result->fetch_assoc(); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0fa40f3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,37 @@ +# Running the tests + +## Prerequisites + +Tests require a local MariaDB server and the PHP `mysqli` extension. +Run the setup script **once** on a fresh machine (requires sudo): + +```bash +sudo bash tests/setup-test-db.sh +``` + +This will: +1. Install `mariadb-server` and `php8.3-mysqli` if not already present +2. Start the MariaDB service +3. Create a `bga_test` database and a `bga_test` user (password: `bga_test`) + +The script is idempotent — safe to run again at any time. + +## Running the tests + +```bash +./vendor/bin/phpunit +``` + +## Test database connection + +`MysqliDb::createForTest()` connects with these defaults: + +| Parameter | Value | +|-----------|-------------| +| host | `127.0.0.1` | +| user | `bga_test` | +| password | `bga_test` | +| database | `bga_test` | +| port | `3306` | + +All parameters can be overridden via `createForTest()` arguments if needed. diff --git a/tests/setup-test-db.sh b/tests/setup-test-db.sh new file mode 100644 index 0000000..b6509b2 --- /dev/null +++ b/tests/setup-test-db.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ---------------------------------------------------------------------------- +# setup-test-db.sh +# +# One-time setup for running PHPUnit tests against a local MariaDB/MySQL server. +# Safe to run multiple times (all statements are idempotent). +# +# Requirements: mariadb-server (or mysql-server) + php8.3-mysqli +# +# Usage: +# sudo bash tests/setup-test-db.sh +# +# After running this script, the tests/MysqliDb.php implementation can connect +# with: +# host = 127.0.0.1 +# user = bga_test +# password = bga_test +# database = bga_test +# ---------------------------------------------------------------------------- + +set -euo pipefail + +# ---------- install packages if missing ------------------------------------- +install_if_missing() { + local pkg="$1" + if ! dpkg -s "$pkg" &>/dev/null; then + echo "[setup-test-db] Installing $pkg …" + apt-get install -y "$pkg" + else + echo "[setup-test-db] $pkg already installed" + fi +} + +if [[ $EUID -ne 0 ]]; then + echo "ERROR: Please run this script with sudo." >&2 + exit 1 +fi + +# Update package list before installing +apt-get update + +install_if_missing mariadb-server +install_if_missing php8.3-mysqli + +# ---------- ensure server is running ---------------------------------------- +if ! systemctl is-active --quiet mariadb 2>/dev/null && \ + ! systemctl is-active --quiet mysql 2>/dev/null; then + echo "[setup-test-db] Starting MariaDB/MySQL …" + systemctl start mariadb 2>/dev/null || systemctl start mysql +fi + +# ---------- create database & user ------------------------------------------ +echo "[setup-test-db] Creating database and user …" + +SQL_STATEMENTS=" +CREATE DATABASE IF NOT EXISTS \`bga_test\` + CHARACTER SET utf8 + COLLATE utf8_general_ci; + +CREATE USER IF NOT EXISTS 'bga_test'@'127.0.0.1' IDENTIFIED BY 'bga_test'; +CREATE USER IF NOT EXISTS 'bga_test'@'localhost' IDENTIFIED BY 'bga_test'; + +GRANT ALL PRIVILEGES ON \`bga_test\`.* TO 'bga_test'@'127.0.0.1'; +GRANT ALL PRIVILEGES ON \`bga_test\`.* TO 'bga_test'@'localhost'; + +FLUSH PRIVILEGES; +" + +if command -v mariadb &>/dev/null; then + echo "$SQL_STATEMENTS" | mariadb --batch +else + echo "$SQL_STATEMENTS" | mysql --batch +fi + +echo "[setup-test-db] Done. DB bga_test is ready (user: bga_test / pass: bga_test)." From 57051815d5a1125237c7bc5738a0953369d2cd8b Mon Sep 17 00:00:00 2001 From: Nodjo Date: Thu, 2 Apr 2026 00:23:37 +0200 Subject: [PATCH 5/8] Set up a database for testing on CI --- .github/workflows/ci.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b6b74..555008a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,19 @@ jobs: runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: bga_test + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + ports: + - 3306:3306 steps: - uses: actions/checkout@v4 @@ -31,8 +44,11 @@ jobs: - name: Check formatting run: ./vendor/bin/php-cs-fixer fix --dry-run --diff - - name: Run custom PHPStan rules - run: ./vendor/bin/phpstan analyse --configuration=phpstan-custom-rules.neon --no-progress + - name: Setup test database user + env: + MYSQL_PWD: root + run: | + mysql -h 127.0.0.1 -u root -e "CREATE USER 'bga_test'@'%' IDENTIFIED BY 'bga_test'; GRANT ALL PRIVILEGES ON bga_test.* TO 'bga_test'@'%'; FLUSH PRIVILEGES;" - name: Run tests run: ./vendor/bin/phpunit From f7a04e08c3e85e6ec4d213e87fc25fbe069c0af3 Mon Sep 17 00:00:00 2001 From: Nodjo Date: Sun, 29 Mar 2026 00:06:12 +0100 Subject: [PATCH 6/8] Use the msqli db implementation in tests instead of mocking the db --- tests/DbFixture.php | 173 +++++++++++++++++++++++++++++ tests/ResourceChoiceHelperTest.php | 39 +++++-- tests/bootstrap.php | 15 +++ 3 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 tests/DbFixture.php diff --git a/tests/DbFixture.php b/tests/DbFixture.php new file mode 100644 index 0000000..6d05311 --- /dev/null +++ b/tests/DbFixture.php @@ -0,0 +1,173 @@ +getMysqli(); + + // Discover table names from dbmodel.sql so we can drop them too + $gameTableNames = self::parseTableNames(self::DBMODEL); + + // Drop game tables first (may depend on player), then BGA base tables + foreach ($gameTableNames as $table) { + $mysqli->query("DROP TABLE IF EXISTS `$table`"); + } + foreach (['stats', 'gamelog', 'global', 'player'] as $table) { + $mysqli->query("DROP TABLE IF EXISTS `$table`"); + } + + // ------------------------------------------------------------------ + // BGA standard tables (normally created by the framework, not in repo) + // ------------------------------------------------------------------ + + $mysqli->query(' + CREATE TABLE `player` ( + `player_id` int(10) unsigned NOT NULL, + `player_score` int(10) NOT NULL DEFAULT 0, + `player_score_aux` int(10) NOT NULL DEFAULT 0, + `player_no` int(10) unsigned NOT NULL DEFAULT 0, + `player_name` varchar(32) NOT NULL DEFAULT \'\', + `player_color` varchar(6) NOT NULL DEFAULT \'000000\', + `player_zombie` tinyint(1) NOT NULL DEFAULT 0, + `player_ai` tinyint(1) NOT NULL DEFAULT 0, + `player_eliminated` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`player_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 + '); + + $mysqli->query(' + CREATE TABLE `global` ( + `global_id` int(10) unsigned NOT NULL, + `global_value` int(10) DEFAULT NULL, + PRIMARY KEY (`global_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 + '); + + $mysqli->query(' + CREATE TABLE `stats` ( + `stats_type` varchar(80) NOT NULL, + `stats_player_id` int(10) NOT NULL DEFAULT 0, + `stats_value` varchar(1000) NOT NULL DEFAULT \'0\', + PRIMARY KEY (`stats_type`, `stats_player_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 + '); + + $mysqli->query(' + CREATE TABLE `gamelog` ( + `gamelog_packet_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `gamelog_move_id` int(10) unsigned NOT NULL DEFAULT 0, + `gamelog_player_id` int(10) NOT NULL DEFAULT 0, + `gamelog_type` varchar(32) NOT NULL DEFAULT \'\', + `gamelog_args` text NOT NULL, + PRIMARY KEY (`gamelog_packet_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 + '); + + // ------------------------------------------------------------------ + // Game-specific schema: execute dbmodel.sql directly + // ------------------------------------------------------------------ + foreach (self::parseStatements(self::DBMODEL) as $stmt) { + if ($mysqli->query($stmt) === false) { + throw new \RuntimeException("DbFixture: SQL failed: {$mysqli->error}\n Statement: $stmt"); + } + } + } + + public static function tearDown(MysqliDb $db): void + { + $mysqli = $db->getMysqli(); + $gameTableNames = self::parseTableNames(self::DBMODEL); + foreach ($gameTableNames as $table) { + $mysqli->query("DROP TABLE IF EXISTS `$table`"); + } + foreach (['stats', 'gamelog', 'global', 'player'] as $table) { + $mysqli->query("DROP TABLE IF EXISTS `$table`"); + } + } + + /** + * Insert a minimal player row. Only player_id is required; all game + * columns fall back to their schema defaults. + */ + public static function insertPlayer(MysqliDb $db, int $playerId, array $overrides = []): void + { + $defaults = [ + 'player_score' => 0, + 'player_score_aux' => 0, + 'player_no' => $playerId, + 'player_name' => "Player $playerId", + 'player_color' => '000000', + ]; + $cols = array_merge($defaults, $overrides); + + $setCols = implode(', ', array_map(fn ($k) => "`$k`", array_keys($cols))); + $setVals = implode(', ', array_map(fn ($v) => "'" . $db->getMysqli()->real_escape_string((string) $v) . "'", array_values($cols))); + + $db->getMysqli()->query( + "INSERT INTO `player` (`player_id`, $setCols) VALUES ($playerId, $setVals)" + ); + } + + // ------------------------------------------------------------------------- + // dbmodel.sql parser helpers + // ------------------------------------------------------------------------- + + /** + * Parse dbmodel.sql and return each executable SQL statement (comments and + * blank lines stripped, statements split on semicolons). + * + * @return string[] + */ + private static function parseStatements(string $file): array + { + $raw = file_get_contents($file); + + // Strip single-line comments (-- ...) but preserve the newline + $raw = preg_replace('/--[^\n]*/u', '', $raw); + + $statements = []; + foreach (explode(';', $raw) as $part) { + $stmt = trim($part); + if ($stmt !== '') { + $statements[] = $stmt; + } + } + return $statements; + } + + /** + * Return every table name that appears in a CREATE TABLE statement in the + * given SQL file (used to build the DROP list). + * + * @return string[] + */ + private static function parseTableNames(string $file): array + { + $raw = file_get_contents($file); + preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?/ui', $raw, $matches); + return array_unique($matches[1]); + } +} diff --git a/tests/ResourceChoiceHelperTest.php b/tests/ResourceChoiceHelperTest.php index aaf0c53..290d154 100644 --- a/tests/ResourceChoiceHelperTest.php +++ b/tests/ResourceChoiceHelperTest.php @@ -2,29 +2,43 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; -use Bga\Games\diceforge\Db\Db; +use Bga\Games\diceforge\Db\MysqliDb; +use Bga\Games\diceforge\Tests\DbFixture; use Bga\Games\diceforge\ResourceChoiceHelper; use DiceForge\Resources\ResourceChoice; class ResourceChoiceHelperTest extends TestCase { - private Db&\PHPUnit\Framework\MockObject\MockObject $db; + private MysqliDb $db; private ResourceChoiceHelper $helper; protected function setUp(): void { - $this->db = $this->createMock(Db::class); + $this->db = DbFixture::createDb(); + DbFixture::setUp($this->db); + + foreach ([7, 42, 99, 77, 100, 1] as $id) { + DbFixture::insertPlayer($this->db, $id); + } + $this->helper = new ResourceChoiceHelper($this->db); } + protected function tearDown(): void + { + DbFixture::tearDown($this->db); + } + #[DataProvider('dbSetChoiceProvider')] public function testDbSetChoice(ResourceChoice $choice, int $expectedInt, int|string $playerId): void { - $this->db->expects($this->once()) - ->method('DBquery') - ->with("UPDATE player SET ressource_choice = $expectedInt WHERE player_id = $playerId"); - $this->helper->dbSetChoice($playerId, $choice); + + $result = $this->db->getMysqli()->query( + "SELECT ressource_choice FROM player WHERE player_id = $playerId" + ); + $row = $result->fetch_assoc(); + $this->assertSame($expectedInt, (int) $row['ressource_choice']); } public static function dbSetChoiceProvider(): array @@ -39,10 +53,9 @@ public static function dbSetChoiceProvider(): array #[DataProvider('getRessourceChoiceProvider')] public function testGetRessourceChoice(int $dbValue, ResourceChoice $expected, int|string $playerId): void { - $this->db->expects($this->once()) - ->method('getUniqueValueFromDB') - ->with("SELECT ressource_choice FROM player WHERE player_id = $playerId") - ->willReturn($dbValue); + $this->db->getMysqli()->query( + "UPDATE player SET ressource_choice = $dbValue WHERE player_id = $playerId" + ); $result = $this->helper->getRessourceChoice($playerId); @@ -60,7 +73,9 @@ public static function getRessourceChoiceProvider(): array public function testGetRessourceChoiceInvalidIntThrows(): void { - $this->db->method('getUniqueValueFromDB')->willReturn(99); + $this->db->getMysqli()->query( + "UPDATE player SET ressource_choice = 99 WHERE player_id = 1" + ); $this->expectException(\ValueError::class); $this->helper->getRessourceChoice(1); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e739993..1178353 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -23,3 +23,18 @@ require_once $path; } }); + +spl_autoload_register(static function (string $class): void { + $prefix = 'Bga\\Games\\diceforge\\Tests\\'; + + if (!str_starts_with($class, $prefix)) { + return; + } + + $relativeClass = substr($class, strlen($prefix)); + $path = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php'; + + if (file_exists($path)) { + require_once $path; + } +}); From 08574b8156572b5e11fc2c12262d8ba58d558b1e Mon Sep 17 00:00:00 2001 From: Nodjo Date: Sun, 29 Mar 2026 00:10:59 +0100 Subject: [PATCH 7/8] Inject a RandomProvider into Game This will allow to inject a deterministic provider for intergration test, which will be used to play hardcoded test games with predictable data --- modules/php/Game.php | 20 +++++++++++++------- modules/php/Random/BgaRandomProvider.php | 15 +++++++++++++++ modules/php/Random/RandomProvider.php | 19 +++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 modules/php/Random/BgaRandomProvider.php create mode 100644 modules/php/Random/RandomProvider.php diff --git a/modules/php/Game.php b/modules/php/Game.php index 6db60ad..a0c370a 100644 --- a/modules/php/Game.php +++ b/modules/php/Game.php @@ -33,11 +33,15 @@ use Bga\Games\diceforge\Db\Db; use Bga\Games\diceforge\Db\TableDb; use Bga\Games\diceforge\Tokens; +use Bga\Games\diceforge\Random\RandomProvider; +use Bga\Games\diceforge\Random\BgaRandomProvider; require_once __DIR__ . '/resource_choice.php'; require_once __DIR__ . '/ResourceChoiceHelper.php'; require_once __DIR__ . '/db/Db.php'; require_once __DIR__ . '/db/TableDb.php'; +require_once __DIR__ . '/random/RandomProvider.php'; +require_once __DIR__ . '/random/BgaRandomProvider.php'; class Game extends Table { @@ -76,8 +80,10 @@ class Game extends Table public array $labyrinth_paths; public array $labyrinth_rewards; - public function __construct(private readonly Db $db = new TableDb()) - { + public function __construct( + private readonly Db $db = new TableDb(), + private readonly RandomProvider $randomProvider = new BgaRandomProvider() + ) { // Your global variables labels: // Here, you can assign labels to global variables you are using for this game. @@ -418,7 +424,7 @@ protected function setupNewGame($players, $options = []) } // random if ($deckOption == 2) { - $toPick = bga_rand(0, count($cards) - 1); + $toPick = $this->randomProvider->rand(0, count($cards) - 1); } else { $toPick = 0; } @@ -572,13 +578,13 @@ protected function setupNewGame($players, $options = []) for ($i = 1; $i <= 10; $i++) { $del = $this->sides->getCardsInLocation((string)$i, null, 'card_id'); if (count($del) == 4) { - $throw = bga_rand(0, count($del) - 1); + $throw = $this->randomProvider->rand(0, count($del) - 1); $this->sides->moveCard($del[$throw]['id'], 'discard'); } $del = $this->sides->getCardsInLocation((string)$i, null, 'card_id'); if (count($del) == 3) { - $throw = bga_rand(0, count($del) - 1); + $throw = $this->randomProvider->rand(0, count($del) - 1); $this->sides->moveCard($del[$throw]['id'], 'discard'); } } @@ -1707,7 +1713,7 @@ public function draftSlot() public function rollDice($player_id, $dice_num) { // do not use the shuffle function - $value = bga_rand(0, 5); + $value = $this->randomProvider->rand(0, 5); if ($value != 0) { $old_side = $this->sides->getCardsInLocation( 'dice' . $dice_num . '-p' . $player_id, @@ -1753,7 +1759,7 @@ public function rollCelestial($player_id, $roll = true) { $notifPlayerArgs = $this->initNotif($player_id); if ($roll) { - $value = bga_rand(0, 5); + $value = $this->randomProvider->rand(0, 5); $this->setGameStateValue('celestialDieSide', $value); $this->setGameStateValue('celestialRunning', 1); diff --git a/modules/php/Random/BgaRandomProvider.php b/modules/php/Random/BgaRandomProvider.php new file mode 100644 index 0000000..dd2cef4 --- /dev/null +++ b/modules/php/Random/BgaRandomProvider.php @@ -0,0 +1,15 @@ + Date: Thu, 2 Apr 2026 00:18:14 +0200 Subject: [PATCH 8/8] Write a first test for the game, GameBeginnerTest.php It tests that a game can be created with two players, and that the initial state of the game is correct. For that purpose, the Player entity has been created, backed by a small custom ORM. Reorganize the project structure --- modules/php/Entities/Player.php | 42 ++++ modules/php/{db => Framework/Db}/Db.php | 4 +- modules/php/{db => Framework/Db}/MysqliDb.php | 7 +- modules/php/Framework/Db/Repository.php | 187 ++++++++++++++++++ modules/php/{db => Framework/Db}/TableDb.php | 7 +- modules/php/Framework/Orm/Column.php | 18 ++ modules/php/Framework/Orm/Entity.php | 18 ++ modules/php/Framework/Orm/Id.php | 17 ++ modules/php/Game.php | 8 +- modules/php/ResourceChoiceHelper.php | 4 +- modules/php/tokens.php | 2 +- phpstan-custom-rules.neon | 2 +- phpstan.neon | 2 +- tests/Game/GameBeginnerTest.php | 80 ++++++++ tests/Game/doubles/TestGame.php | 27 +++ tests/Game/fixtures/PlayerProvider.php | 18 ++ tests/ResourceChoiceHelperTest.php | 5 +- tests/{stubs => Stubs}/BgaFrameworkStubs.php | 0 tests/{stubs => Stubs}/PhpstanStubs.php | 0 tests/{ => Support}/DbFixture.php | 24 ++- tests/bootstrap.php | 44 ++++- 21 files changed, 487 insertions(+), 29 deletions(-) create mode 100644 modules/php/Entities/Player.php rename modules/php/{db => Framework/Db}/Db.php (90%) rename modules/php/{db => Framework/Db}/MysqliDb.php (95%) create mode 100644 modules/php/Framework/Db/Repository.php rename modules/php/{db => Framework/Db}/TableDb.php (86%) create mode 100644 modules/php/Framework/Orm/Column.php create mode 100644 modules/php/Framework/Orm/Entity.php create mode 100644 modules/php/Framework/Orm/Id.php create mode 100644 tests/Game/GameBeginnerTest.php create mode 100644 tests/Game/doubles/TestGame.php create mode 100644 tests/Game/fixtures/PlayerProvider.php rename tests/{stubs => Stubs}/BgaFrameworkStubs.php (100%) rename tests/{stubs => Stubs}/PhpstanStubs.php (100%) rename tests/{ => Support}/DbFixture.php (87%) diff --git a/modules/php/Entities/Player.php b/modules/php/Entities/Player.php new file mode 100644 index 0000000..70b22e0 --- /dev/null +++ b/modules/php/Entities/Player.php @@ -0,0 +1,42 @@ +name = $name; + $this->color = $color; + } + + /** + * Returns the player entry as expected by setupNewGame()'s $players array. + */ + public function toSetupArray(): array + { + return [ + 'player_canal' => '', + 'player_name' => $this->name, + 'player_avatar' => '', + ]; + } +} diff --git a/modules/php/db/Db.php b/modules/php/Framework/Db/Db.php similarity index 90% rename from modules/php/db/Db.php rename to modules/php/Framework/Db/Db.php index eb063a2..ad7876f 100644 --- a/modules/php/db/Db.php +++ b/modules/php/Framework/Db/Db.php @@ -1,6 +1,6 @@ fetch_assoc(); } + + public function escape(string $value): string + { + return $this->mysqli->real_escape_string($value); + } } diff --git a/modules/php/Framework/Db/Repository.php b/modules/php/Framework/Db/Repository.php new file mode 100644 index 0000000..ad4d576 --- /dev/null +++ b/modules/php/Framework/Db/Repository.php @@ -0,0 +1,187 @@ +find($id); // T + * $playerRepo->save($player); // void + * $players = $playerRepo->findAll(); // list + * + * For better IDE type hints, optionally create a typed subclass: + * class PlayerRepository extends Repository { + * public function __construct(Db $db) { parent::__construct(Player::class, $db); } + * public function find(int $id): ?Player { return parent::find($id); } + * public function findAll(): array { return parent::findAll(); } // IDE infers Player[] + * } + */ +class Repository +{ + /** + * Cache for entity metadata to avoid redundant reflection. + * @var array}> + */ + private static array $metaCache = []; + + /** + * @param class-string $entityClass The entity class to manage (e.g., Player::class) + */ + public function __construct( + private readonly string $entityClass, + protected readonly Db $db + ) { + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * @return T|null + */ + public function find(int $id): ?object + { + $meta = $this->meta(); + $row = $this->db->getObjectFromDB( + "SELECT * FROM `{$meta['table']}` WHERE `{$meta['idCol']}` = " . (int) $id + ); + return empty($row) ? null : $this->hydrate($row, $meta); + } + + /** + * @return list + */ + public function findAll(): array + { + $meta = $this->meta(); + $rows = $this->db->getObjectListFromDB("SELECT * FROM `{$meta['table']}` ORDER BY `{$meta['idCol']}`"); + return array_map(fn (array $row) => $this->hydrate($row, $meta), $rows); + } + + /** + * INSERT or UPDATE the entity (upsert via ON DUPLICATE KEY UPDATE). + * The entity must have all #[Column] properties set before calling save(). + * + * @param T $entity + */ + public function save(object $entity): void + { + $meta = $this->meta(); + $ref = new \ReflectionClass($this->entityClass); + + /** @var array $allValues colName => value */ + $allValues = []; + foreach ($meta['cols'] as $propName => $colName) { + $prop = $ref->getProperty($propName); + $allValues[$colName] = $prop->getValue($entity); + } + + $escape = fn (mixed $v): string => "'" . $this->db->escape((string) $v) . "'"; + $colList = implode(', ', array_map(fn ($c) => "`$c`", array_keys($allValues))); + $valList = implode(', ', array_map($escape, array_values($allValues))); + + $updateParts = []; + foreach ($allValues as $colName => $value) { + if ($colName === $meta['idCol']) { + continue; + } + $updateParts[] = "`$colName` = " . $escape($value); + } + + $this->db->DbQuery( + "INSERT INTO `{$meta['table']}` ($colList) VALUES ($valList)" + . ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updateParts) + ); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Reads the ORM attributes of the entity class and returns the mapping. + * Results are cached to avoid redundant reflection on repeated calls. + * + * @return array{table: string, idProp: string, idCol: string, cols: array} + */ + private function meta(): array + { + $class = $this->entityClass; + + // Return cached metadata if available + if (isset(self::$metaCache[$class])) { + return self::$metaCache[$class]; + } + + $ref = new \ReflectionClass($class); + + $entityAttrs = $ref->getAttributes(Entity::class); + if (empty($entityAttrs)) { + throw new \LogicException("$class is missing #[Entity] attribute"); + } + $table = $entityAttrs[0]->newInstance()->table; + + $idProp = null; + $idCol = null; + /** @var array $cols propName => colName */ + $cols = []; + + foreach ($ref->getProperties() as $prop) { + $colAttrs = $prop->getAttributes(Column::class); + if (empty($colAttrs)) { + continue; + } + $colName = $colAttrs[0]->newInstance()->name; + $propName = $prop->getName(); + $cols[$propName] = $colName; + + if (!empty($prop->getAttributes(Id::class))) { + $idProp = $propName; + $idCol = $colName; + } + } + + if ($idProp === null) { + throw new \LogicException("$class has no property marked with #[Id]"); + } + + // Cache and return the metadata + return self::$metaCache[$class] = compact('table', 'idProp', 'idCol', 'cols'); + } + + /** + * Builds an entity instance from a raw DB row without calling the constructor, + * so that the ORM works regardless of constructor signature. + */ + private function hydrate(array $row, array $meta): object + { + $class = $this->entityClass; + $ref = new \ReflectionClass($class); + $entity = $ref->newInstanceWithoutConstructor(); + + foreach ($meta['cols'] as $propName => $colName) { + if (!array_key_exists($colName, $row)) { + continue; + } + $prop = $ref->getProperty($propName); + $value = $row[$colName]; + $type = $prop->getType(); + if ($type instanceof \ReflectionNamedType && $type->isBuiltin()) { + settype($value, $type->getName()); + } + $prop->setValue($entity, $value); + } + + return $entity; + } +} diff --git a/modules/php/db/TableDb.php b/modules/php/Framework/Db/TableDb.php similarity index 86% rename from modules/php/db/TableDb.php rename to modules/php/Framework/Db/TableDb.php index af02881..094fcc5 100644 --- a/modules/php/db/TableDb.php +++ b/modules/php/Framework/Db/TableDb.php @@ -1,6 +1,6 @@ db = DbFixture::createDb(); + DbFixture::setUp($this->db); + $this->playerRepository = new Repository(Player::class, $this->db); + } + + protected function tearDown(): void + { + DbFixture::tearDown($this->db); + } + + public static function playerProvider(): array + { + return PlayerProvider::playerSets(); + } + + /** + * @param list $players + */ + #[DataProvider('playerProvider')] + public function testGameSetup(array $players): void + { + $game = new TestGame($this->db); + + // Convert sequential player list to ID-keyed format expected by setupNewGame (starting from ID 1) + $setupPlayers = array_combine( + range(1, count($players)), + array_map(fn (Player $p): array => $p->toSetupArray(), $players) + ); + $game->setupNewGame($setupPlayers); + $this->assertPlayersInserted($players); + } + + /** + * @param list $players + */ + private function assertPlayersInserted(array $players): void + { + $inserted = $this->playerRepository->findAll(); + $this->assertCount(count($players), $inserted, 'Player count mismatch'); + + // Verify player IDs are incremental starting from 1 and names match + for ($id = 1; $id <= count($players); $id++) { + $player = $players[$id - 1]; + $player->id = $id; // Expect ID to be assigned by DB starting from 1 + $found = $inserted[$id - 1]; + $this->assertSamePlayer($found, $player); + } + } + + /** + * Assert that two Player objects have the same properties. + */ + private function assertSamePlayer(Player $actual, Player $expected): void + { + $this->assertSame($actual->id, $expected->id, "Player {$expected->id} ID mismatch"); + $this->assertSame($actual->name, $expected->name, "Player {$expected->id} name mismatch"); + $this->assertSame($actual->score, $expected->score, "Player {$expected->id} score mismatch"); + $this->assertSame($actual->color, $expected->color, "Player {$expected->id} color mismatch"); + } +} diff --git a/tests/Game/doubles/TestGame.php b/tests/Game/doubles/TestGame.php new file mode 100644 index 0000000..bca734d --- /dev/null +++ b/tests/Game/doubles/TestGame.php @@ -0,0 +1,27 @@ + [ + [ + new Player('Alice', 'D56F12'), + new Player('Bob', 'B6B525'), + ], + ], + ]; + } +} diff --git a/tests/ResourceChoiceHelperTest.php b/tests/ResourceChoiceHelperTest.php index 290d154..95468a5 100644 --- a/tests/ResourceChoiceHelperTest.php +++ b/tests/ResourceChoiceHelperTest.php @@ -2,8 +2,9 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; -use Bga\Games\diceforge\Db\MysqliDb; +use Bga\Games\diceforge\Framework\Db\MysqliDb; use Bga\Games\diceforge\Tests\DbFixture; +use Bga\Games\diceforge\Entities\Player; use Bga\Games\diceforge\ResourceChoiceHelper; use DiceForge\Resources\ResourceChoice; @@ -18,7 +19,7 @@ protected function setUp(): void DbFixture::setUp($this->db); foreach ([7, 42, 99, 77, 100, 1] as $id) { - DbFixture::insertPlayer($this->db, $id); + DbFixture::insertPlayer($this->db, $id, new Player("Player $id", "000000")); } $this->helper = new ResourceChoiceHelper($this->db); diff --git a/tests/stubs/BgaFrameworkStubs.php b/tests/Stubs/BgaFrameworkStubs.php similarity index 100% rename from tests/stubs/BgaFrameworkStubs.php rename to tests/Stubs/BgaFrameworkStubs.php diff --git a/tests/stubs/PhpstanStubs.php b/tests/Stubs/PhpstanStubs.php similarity index 100% rename from tests/stubs/PhpstanStubs.php rename to tests/Stubs/PhpstanStubs.php diff --git a/tests/DbFixture.php b/tests/Support/DbFixture.php similarity index 87% rename from tests/DbFixture.php rename to tests/Support/DbFixture.php index 6d05311..6e7ecbc 100644 --- a/tests/DbFixture.php +++ b/tests/Support/DbFixture.php @@ -2,7 +2,8 @@ namespace Bga\Games\diceforge\Tests; -use Bga\Games\diceforge\Db\MysqliDb; +use Bga\Games\diceforge\Framework\Db\MysqliDb; +use Bga\Games\diceforge\Entities\Player; /** * Bootstraps a fresh copy of the full game schema in the bga_test database. @@ -17,7 +18,7 @@ */ class DbFixture { - private const DBMODEL = __DIR__ . '/../dbmodel.sql'; + private const DBMODEL = __DIR__ . '/../../dbmodel.sql'; public static function createDb(): MysqliDb { @@ -50,6 +51,8 @@ public static function setUp(MysqliDb $db): void `player_score_aux` int(10) NOT NULL DEFAULT 0, `player_no` int(10) unsigned NOT NULL DEFAULT 0, `player_name` varchar(32) NOT NULL DEFAULT \'\', + `player_canal` varchar(32) NOT NULL DEFAULT \'\', + `player_avatar` varchar(32) NOT NULL DEFAULT \'\', `player_color` varchar(6) NOT NULL DEFAULT \'000000\', `player_zombie` tinyint(1) NOT NULL DEFAULT 0, `player_ai` tinyint(1) NOT NULL DEFAULT 0, @@ -109,19 +112,18 @@ public static function tearDown(MysqliDb $db): void } /** - * Insert a minimal player row. Only player_id is required; all game - * columns fall back to their schema defaults. + * Insert a minimal player row using a Player object. */ - public static function insertPlayer(MysqliDb $db, int $playerId, array $overrides = []): void + public static function insertPlayer(MysqliDb $db, int $playerId, Player $player): void { $defaults = [ 'player_score' => 0, 'player_score_aux' => 0, 'player_no' => $playerId, - 'player_name' => "Player $playerId", - 'player_color' => '000000', + 'player_name' => $player->name, + 'player_color' => $player->color, ]; - $cols = array_merge($defaults, $overrides); + $cols = $defaults; $setCols = implode(', ', array_map(fn ($k) => "`$k`", array_keys($cols))); $setVals = implode(', ', array_map(fn ($v) => "'" . $db->getMysqli()->real_escape_string((string) $v) . "'", array_values($cols))); @@ -143,6 +145,9 @@ public static function insertPlayer(MysqliDb $db, int $playerId, array $override */ private static function parseStatements(string $file): array { + if (!file_exists($file)) { + throw new \RuntimeException("DbFixture: file not found: $file\nCheck that the path constant is correct after any reorganization."); + } $raw = file_get_contents($file); // Strip single-line comments (-- ...) but preserve the newline @@ -166,6 +171,9 @@ private static function parseStatements(string $file): array */ private static function parseTableNames(string $file): array { + if (!file_exists($file)) { + throw new \RuntimeException("DbFixture: file not found: $file\nCheck that the path constant is correct after any reorganization."); + } $raw = file_get_contents($file); preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?/ui', $raw, $matches); return array_unique($matches[1]); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1178353..9bed663 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,7 +2,10 @@ declare(strict_types=0); -require_once __DIR__ . '/stubs/BgaFrameworkStubs.php'; +require_once __DIR__ . '/Stubs/BgaFrameworkStubs.php'; +require_once __DIR__ . '/../states.inc.php'; +require_once __DIR__ . '/../gameoptions.inc.php'; +require_once __DIR__ . '/../diceforge.action.php'; $autoload = __DIR__ . '/../vendor/autoload.php'; if (file_exists($autoload)) { @@ -24,6 +27,35 @@ } }); +spl_autoload_register(static function (string $class): void { + $prefix = 'Bga\\Games\\diceforge\\'; + $exclude = 'Bga\\Games\\diceforge\\Tests\\'; + + if (!str_starts_with($class, $prefix) || str_starts_with($class, $exclude)) { + return; + } + + $relativeClass = substr($class, strlen($prefix)); + $segments = explode('\\', $relativeClass); + $fileName = array_pop($segments) . '.php'; + $base = __DIR__ . '/../modules/php/'; + + // Try exact-case path first, then lowercase on directory segments + // (namespace uses 'Db' but the folder on disk is 'db'). + $dirCandidates = array_unique([ + implode('/', $segments), + implode('/', array_map('strtolower', $segments)), + ]); + + foreach ($dirCandidates as $dir) { + $path = $base . ($dir !== '' ? $dir . '/' : '') . $fileName; + if (file_exists($path)) { + require_once $path; + return; + } + } +}); + spl_autoload_register(static function (string $class): void { $prefix = 'Bga\\Games\\diceforge\\Tests\\'; @@ -32,9 +64,13 @@ } $relativeClass = substr($class, strlen($prefix)); - $path = __DIR__ . '/' . str_replace('\\', '/', $relativeClass) . '.php'; + $relativePath = str_replace('\\', '/', $relativeClass) . '.php'; - if (file_exists($path)) { - require_once $path; + foreach ([__DIR__, __DIR__ . '/Support', __DIR__ . '/Game'] as $base) { + $path = $base . '/' . $relativePath; + if (file_exists($path)) { + require_once $path; + return; + } } });