diff --git a/hwe/compare/general_ai_decision.php b/hwe/compare/general_ai_decision.php new file mode 100644 index 00000000..3f1c7ce5 --- /dev/null +++ b/hwe/compare/general_ai_decision.php @@ -0,0 +1,65 @@ +getAll(true); +$result = []; +$includeNation = ($request['includeNation'] ?? false) === true; +foreach ($generalIds as $generalId) { + if (!is_int($generalId) || $generalId < 1) { + throw new \InvalidArgumentException('generalIds must contain positive integers'); + } + $general = General::createObjFromDB($generalId); + if ($general->getNPCType() < 2) { + throw new \InvalidArgumentException("general {$generalId} is not an NPC"); + } + $ai = new GeneralAI($general); + $nationDecision = null; + if ($includeNation && $general->getNationID() !== 0 && $general->getVar('officer_level') >= 5) { + $nationStorage = KVStorage::getStorage($db, $general->getNationID(), 'nation_env'); + $lastNationTurn = LastTurn::fromRaw($nationStorage->getValue("turn_last_{$general->getVar('officer_level')}")); + $reservedNation = buildNationCommandClass(null, $general, $env, $lastNationTurn); + $selectedNation = $ai->chooseNationTurn($reservedNation); + $nationDecision = [ + 'action' => $selectedNation->getRawClassName(), + 'args' => $selectedNation->getArg(), + 'reason' => $selectedNation->reason, + ]; + } + $reserved = $general->getReservedTurn(0, $env); + $selected = $ai->chooseGeneralTurn($reserved); + $result[] = [ + 'generalId' => $generalId, + 'name' => $general->getName(), + 'action' => $selected->getRawClassName(), + 'brief' => $selected->getBrief(), + 'args' => $selected->getArg(), + 'reason' => $selected->reason, + 'nationDecision' => $nationDecision, + ]; +} + +echo json_encode([ + 'year' => (int) $env['year'], + 'month' => (int) $env['month'], + 'decisions' => $result, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); diff --git a/hwe/compare/monthly_state_snapshot.php b/hwe/compare/monthly_state_snapshot.php new file mode 100644 index 00000000..a2d93515 --- /dev/null +++ b/hwe/compare/monthly_state_snapshot.php @@ -0,0 +1,129 @@ + */ +function monthlySnapshotPick(array $row, array $mapping, array $jsonColumns = []): array +{ + $result = []; + foreach ($mapping as $target => $source) { + $value = $row[$source] ?? null; + $result[$target] = in_array($source, $jsonColumns, true) + ? monthlySnapshotJson($value) + : $value; + } + return $result; +} + +$db = DB::db(); +$game = KVStorage::getStorage($db, 'game_env'); +$game->resetCache(); +$world = $game->getValues([ + 'scenario', 'year', 'month', 'turnterm', 'turntime', 'starttime', + 'init_year', 'init_month', 'develcost', 'killturn', 'isunited', +]); + +$generals = []; +foreach ($db->query('SELECT * FROM general ORDER BY no') as $row) { + $general = monthlySnapshotPick($row, [ + 'id' => 'no', 'name' => 'name', 'nationId' => 'nation', 'cityId' => 'city', + 'troopId' => 'troop', 'leadership' => 'leadership', 'strength' => 'strength', + 'intelligence' => 'intel', 'experience' => 'experience', 'dedication' => 'dedication', + 'officerLevel' => 'officer_level', 'injury' => 'injury', 'gold' => 'gold', + 'rice' => 'rice', 'crew' => 'crew', 'crewTypeId' => 'crewtype', 'train' => 'train', + 'atmos' => 'atmos', 'age' => 'age', 'npcState' => 'npc', 'turnTime' => 'turntime', + 'recentWarTime' => 'recent_war', 'lastTurn' => 'last_turn', 'meta' => 'aux', + 'affinity' => 'affinity', 'birthYear' => 'bornyear', 'deathYear' => 'deadyear', + 'personality' => 'personal', 'specialDomestic' => 'special', 'specialWar' => 'special2', + 'specAge' => 'specage', 'specAge2' => 'specage2', + 'dex1' => 'dex1', 'dex2' => 'dex2', 'dex3' => 'dex3', 'dex4' => 'dex4', 'dex5' => 'dex5', + 'killTurn' => 'killturn', + ], ['last_turn', 'aux']); + foreach (['id', 'nationId', 'cityId', 'troopId', 'leadership', 'strength', 'intelligence', + 'experience', 'dedication', 'officerLevel', 'injury', 'gold', 'rice', 'crew', + 'crewTypeId', 'train', 'atmos', 'age', 'npcState', 'dex1', 'dex2', 'dex3', + 'dex4', 'dex5', 'killTurn', 'affinity', 'birthYear', 'deathYear', 'specAge', 'specAge2'] as $key) { + $general[$key] = (int)$general[$key]; + } + $generals[] = $general; +} + +$cities = []; +foreach ($db->query('SELECT * FROM city ORDER BY city') as $row) { + $city = monthlySnapshotPick($row, [ + 'id' => 'city', 'name' => 'name', 'nationId' => 'nation', 'level' => 'level', + 'population' => 'pop', 'populationMax' => 'pop_max', 'agriculture' => 'agri', + 'agricultureMax' => 'agri_max', 'commerce' => 'comm', 'commerceMax' => 'comm_max', + 'security' => 'secu', 'securityMax' => 'secu_max', 'supplyState' => 'supply', + 'frontState' => 'front', 'defence' => 'def', 'defenceMax' => 'def_max', + 'wall' => 'wall', 'wallMax' => 'wall_max', 'state' => 'state', 'term' => 'term', + 'trust' => 'trust', 'trade' => 'trade', 'conflict' => 'conflict', + ], ['conflict']); + foreach (array_keys($city) as $key) { + if ($key !== 'name' && $key !== 'conflict' && $key !== 'trust') { + $city[$key] = (int)$city[$key]; + } + } + $cities[] = $city; +} + +$nations = []; +foreach ($db->query('SELECT * FROM nation ORDER BY nation') as $row) { + $nation = monthlySnapshotPick($row, [ + 'id' => 'nation', 'name' => 'name', 'color' => 'color', 'capitalCityId' => 'capital', + 'gold' => 'gold', 'rice' => 'rice', 'tech' => 'tech', 'level' => 'level', + 'typeCode' => 'type', 'generalCount' => 'gennum', 'power' => 'power', + 'war' => 'war', 'diplomacyLimit' => 'surlimit', 'capitalRevision' => 'capset', + 'strategicCommandLimit' => 'strategic_cmd_limit', 'rate' => 'rate', + 'rateTmp' => 'rate_tmp', 'bill' => 'bill', 'meta' => 'aux', + ], ['aux']); + foreach (['id', 'capitalCityId', 'gold', 'rice', 'level', 'generalCount', + 'power', 'war', 'diplomacyLimit', 'capitalRevision', 'strategicCommandLimit', + 'rate', 'rateTmp', 'bill'] as $key) { + $nation[$key] = (int)$nation[$key]; + } + $nation['tech'] = (float)$nation['tech']; + $nations[] = $nation; +} + +$diplomacy = []; +foreach ($db->query('SELECT me, you, state, term, dead FROM diplomacy ORDER BY me, you') as $row) { + $diplomacy[] = [ + 'fromNationId' => (int)$row['me'], 'toNationId' => (int)$row['you'], + 'state' => (int)$row['state'], 'term' => (int)$row['term'], 'dead' => (int)$row['dead'], + ]; +} + +echo json_encode([ + 'schemaVersion' => 1, + 'engine' => 'ref', + 'world' => $world, + 'generals' => $generals, + 'cities' => $cities, + 'nations' => $nations, + 'diplomacy' => $diplomacy, +], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION), PHP_EOL; diff --git a/hwe/compare/turn_state_snapshot.php b/hwe/compare/turn_state_snapshot.php index f206941b..173a46b9 100644 --- a/hwe/compare/turn_state_snapshot.php +++ b/hwe/compare/turn_state_snapshot.php @@ -181,6 +181,7 @@ function comparisonRowsById(string $table, string $idColumn, array $ids): array function comparisonTurnStateSnapshot(array $request): array { + $db = DB::db(); $observe = $request['observe'] ?? []; if (!is_array($observe)) { throw new \InvalidArgumentException('observe must be an object'); @@ -188,6 +189,15 @@ function comparisonTurnStateSnapshot(array $request): array $generalIds = comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds'); $cityIds = comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds'); $nationIds = comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds'); + if (($observe['allGenerals'] ?? false) === true) { + $generalIds = array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no')); + } + if (($observe['allCities'] ?? false) === true) { + $cityIds = array_map('intval', $db->queryFirstColumn('SELECT city FROM city ORDER BY city')); + } + if (($observe['allNations'] ?? false) === true) { + $nationIds = array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation')); + } $logAfterId = $observe['logAfterId'] ?? 0; $messageAfterId = $observe['messageAfterId'] ?? 0; $includeNationHistoryLogs = $observe['includeNationHistoryLogs'] ?? false; @@ -219,7 +229,6 @@ function comparisonTurnStateSnapshot(array $request): array throw new \InvalidArgumentException('includeGlobalHistoryLogs must be a boolean'); } - $db = DB::db(); $game = KVStorage::getStorage($db, 'game_env'); $game->resetCache(); $worldValues = $game->getValues([ diff --git a/hwe/func.php b/hwe/func.php index 026c4b69..37b5f7f2 100644 --- a/hwe/func.php +++ b/hwe/func.php @@ -1169,7 +1169,9 @@ function checkDelay() $gameStor = KVStorage::getStorage($db, 'game_env'); //서버정보 - $now = new \DateTimeImmutable(); + // ng_compare can inject a deterministic semantic clock. Runtime limits + // still use the wall clock; only delay normalization follows game time. + $now = TimeUtil::nowDateTimeImmutable(); $turntime = new \DateTimeImmutable($gameStor->turntime); $timeMinDiff = intdiv($now->getTimestamp() - $turntime->getTimestamp(), 60); @@ -1293,12 +1295,19 @@ function triggerTournament(RandUtil $rng) return; } - if (!$tnmt_pattern) { - // 0 : 전력전, 1 : 통솔전, 2 : 일기토, 3 : 설전 - //전력전 40%, 통, 일, 설 각 20% - $tnmt_pattern = [0, 0, 1, 2, 3]; - shuffle($tnmt_pattern); - } + if (!$tnmt_pattern) { + // 0 : 전력전, 1 : 통솔전, 2 : 일기토, 3 : 설전 + //전력전 40%, 통, 일, 설 각 20% + $tnmt_pattern = [0, 0, 1, 2, 3]; + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') { + // The legacy global shuffle escapes the configured hidden seed. + // The comparison branch keeps production behaviour untouched and + // consumes the current month's deterministic RNG instead. + $tnmt_pattern = $rng->shuffle($tnmt_pattern); + } else { + shuffle($tnmt_pattern); + } + } $tnmt_type = array_pop($tnmt_pattern); $gameStor->setValue('tnmt_pattern', $tnmt_pattern); diff --git a/hwe/func_gamerule.php b/hwe/func_gamerule.php index 56883c7f..cb057d73 100644 --- a/hwe/func_gamerule.php +++ b/hwe/func_gamerule.php @@ -319,7 +319,38 @@ function postUpdateMonthly(RandUtil $rng) //약간의 랜덤치 부여 (95% ~ 105%) - $nation['power'] = Util::round($nation['power'] * $rng->nextRange(0.95, 1.05)); + $powerMultiplier = $rng->nextRange(0.95, 1.05); + $basePower = $nation['power']; + $nation['power'] = Util::round($basePower * $powerMultiplier); + $traceNationIDs = getenv('REF_TRACE_NATION_POWER_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceNationIDs) + && in_array((string) $nationID, explode(',', $traceNationIDs), true) + ) { + $traceComponents = $db->queryFirstRow('SELECT + ((A.gold + A.rice) + (SELECT SUM(gold + rice) FROM general WHERE nation = A.nation)) / 100 AS resourcePowerRaw, + ROUND(((A.gold + A.rice) + (SELECT SUM(gold + rice) FROM general WHERE nation = A.nation)) / 100) AS resourcePower, + A.tech AS techPower, + (SELECT SUM(pop) * SUM(pop + agri + comm + secu + wall + def) / SUM(pop_max + agri_max + comm_max + secu_max + wall_max + def_max) / 100 FROM city WHERE nation = A.nation AND supply = 1) AS cityPowerRaw, + (SELECT ROUND(SUM(pop) * SUM(pop + agri + comm + secu + wall + def) / SUM(pop_max + agri_max + comm_max + secu_max + wall_max + def_max) / 100) FROM city WHERE nation = A.nation AND supply = 1) AS cityPower, + (SELECT SUM((ra.value + 1000) / (rb.value + 1000) * (CASE WHEN g.npc < 2 THEN 1.2 ELSE 1 END) * (CASE WHEN g.leadership >= 40 THEN g.leadership ELSE 0 END) * 2 + (SQRT(g.intel * g.strength) * 2 + g.leadership / 2) / 2) + FROM general g + LEFT JOIN rank_data ra ON g.no = ra.general_id AND ra.type = \'killcrew_person\' + LEFT JOIN rank_data rb ON g.no = rb.general_id AND rb.type = \'deathcrew_person\' + WHERE g.nation = A.nation) AS generalPower, + (SELECT SUM(experience + dedication) / 100 FROM general WHERE nation = A.nation) AS experiencePowerRaw, + (SELECT ROUND(SUM(experience + dedication) / 100) FROM general WHERE nation = A.nation) AS experiencePower + FROM nation A WHERE A.nation = %i', $nationID) ?? []; + fwrite(STDOUT, 'NATION_POWER_TRACE ' . Json::encode([ + 'nationId' => $nationID, + ...$traceComponents, + 'basePower' => $basePower, + 'multiplier' => $powerMultiplier, + 'power' => $nation['power'], + ]) . PHP_EOL); + } $powerValues['maxPower'] = max($powerValues['maxPower'] ?? 0, $nation['power']); $powerValues['maxCrew'] = max($powerValues['maxCrew'] ?? 0, Util::toInt($nation['totalCrew'])); diff --git a/hwe/func_time_event.php b/hwe/func_time_event.php index bd1086b4..efd3ff60 100644 --- a/hwe/func_time_event.php +++ b/hwe/func_time_event.php @@ -367,7 +367,7 @@ function disaster(RandUtil $rng) { [$queryColumns,,] = General::mergeQueryColumn(); $generalListByCity = Util::arrayGroupBy( $db->query( - 'SELECT %l FROM general WHERE city IN %li', + 'SELECT %l FROM general WHERE city IN %li ORDER BY city, no', Util::formatListOfBackticks($queryColumns), Util::squeezeFromArray($targetCityList, 'city') ), diff --git a/hwe/func_tournament.php b/hwe/func_tournament.php index c8046516..423937d7 100644 --- a/hwe/func_tournament.php +++ b/hwe/func_tournament.php @@ -15,11 +15,24 @@ function calcTournamentTerm(int $turnTerm): int{ function processTournament() { + if ( + getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('REF_DETERMINISTIC_TOURNAMENT_MODE') === 'hold' + ) { + // Legacy tournament matches mix PHP rand(), shuffle(), and MariaDB + // ORDER BY RAND(), so they are outside the hidden/monthly seed stream. + // Hold the real-time subsystem during monthly world-state comparison. + return; + } + $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); $admin = $gameStor->getValues(['tournament', 'phase', 'tnmt_type', 'tnmt_auto', 'tnmt_time', 'turnterm', 'last_tournament_betting_id']); - $now = new \DateTime(); + // The comparison harness supplies a fixed clock through TimeUtil. Keep the + // production clock unchanged while preventing tournament catch-up from + // escaping that deterministic month boundary. + $now = TimeUtil::nowDateTime(); $offset = $now->getTimestamp() - (new \DateTime($admin['tnmt_time']))->getTimestamp(); //수동일땐 무시 @@ -286,7 +299,9 @@ function startTournament($type) $unit = calcTournamentTerm($turnTerm); $gameStor->tnmt_auto = true; - $gameStor->tnmt_time = (new \DateTimeImmutable())->add(new \DateInterval("PT{$unit}M"))->format('Y-m-d H:i:s'); + $gameStor->tnmt_time = TimeUtil::nowDateTimeImmutable() + ->add(new \DateInterval("PT{$unit}M")) + ->format('Y-m-d H:i:s'); $gameStor->tournament = 1; $gameStor->tnmt_type = $type; $gameStor->last_tournament_betting_id = 0; diff --git a/hwe/j_install.php b/hwe/j_install.php index 6aa8f724..32c5a11f 100644 --- a/hwe/j_install.php +++ b/hwe/j_install.php @@ -199,8 +199,16 @@ if($reserve_open){ ]); } -try{ - Json::die(ResetHelper::buildScenario( +try{ + $installTime = TimeUtil::now(); + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') { + $comparisonInstallAt = getenv('REF_INSTALL_AT'); + if (!is_string($comparisonInstallAt) || $comparisonInstallAt === '') { + throw new \RuntimeException('REF_INSTALL_AT is required for deterministic comparison install'); + } + $installTime = (new \DateTimeImmutable($comparisonInstallAt))->format('Y-m-d H:i:s'); + } + Json::die(ResetHelper::buildScenario( $turnterm, $sync, $scenario, @@ -211,7 +219,7 @@ try{ $show_img_level, !!$tournament_trig, $join_mode, - TimeUtil::now(), + $installTime, $autorun_user )); } @@ -220,4 +228,4 @@ catch(\Exception $e){ 'result'=>false, 'reason'=>$e->getMessage() ]); -} \ No newline at end of file +} diff --git a/hwe/process_war.php b/hwe/process_war.php index d1ba138e..502314c4 100644 --- a/hwe/process_war.php +++ b/hwe/process_war.php @@ -42,10 +42,20 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke /** @var WarUnit[] */ $defenderList = []; + $defenderCandidateTrace = []; foreach($defenderCityGeneralList as $defenderGeneral){ $defenderGeneral->setRawCity($rawDefenderCity); $defenderCandidate = new WarUnitGeneral($rng, $defenderGeneral, $rawDefenderNation, false); - if(extractBattleOrder($defenderCandidate, $attacker) <= 0){ + $defenderOrder = extractBattleOrder($defenderCandidate, $attacker); + $defenderCandidateTrace[] = [ + 'id' => $defenderGeneral->getID(), + 'crew' => $defenderGeneral->getVar('crew'), + 'crewtype' => $defenderGeneral->getVar('crewtype'), + 'train' => $defenderGeneral->getVar('train'), + 'atmos' => $defenderGeneral->getVar('atmos'), + 'order' => $defenderOrder, + ]; + if($defenderOrder <= 0){ continue; } @@ -56,6 +66,14 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke $defenderList[] = $city; } + $summarizeDefenderOrder = static function (WarUnit $unit) use ($attacker): array { + return [ + 'id' => $unit instanceof WarUnitGeneral ? $unit->getGeneral()->getID() : 0, + 'order' => extractBattleOrder($unit, $attacker), + ]; + }; + $defenderOrderBeforeSort = array_map($summarizeDefenderOrder, $defenderList); + usort($defenderList, function (WarUnit $lhs, WarUnit $rhs) use ($attacker) { return - (extractBattleOrder($lhs, $attacker) <=> extractBattleOrder($rhs, $attacker)); }); @@ -85,7 +103,39 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke return $nextDefender; }; - $conquerCity = processWar_NG($warSeed, $attacker, $getNextDefender, $city); + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + $traceGeneralIdList = is_string($traceGeneralIds) ? explode(',', $traceGeneralIds) : []; + $traceDefender = false; + foreach ($defenderCityGeneralList as $defenderGeneral) { + if (in_array((string)$defenderGeneral->getID(), $traceGeneralIdList, true)) { + $traceDefender = true; + break; + } + } + $warTrace = + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && (in_array((string)$attackerGeneral->getID(), $traceGeneralIdList, true) || $traceDefender) + ? static function (array $event) use ($attackerGeneral): void { + fwrite(STDOUT, 'AI_WAR_TRACE ' . Json::encode([ + 'generalId' => $attackerGeneral->getID(), + 'event' => $event, + ]) . "\n"); + } + : null; + if ($warTrace !== null) { + fwrite(STDOUT, 'AI_WAR_DEFENDER_CANDIDATE_TRACE ' . Json::encode([ + 'generalId' => $attackerGeneral->getID(), + 'candidates' => $defenderCandidateTrace, + ]) . "\n"); + fwrite(STDOUT, 'AI_WAR_DEFENDER_ORDER_TRACE ' . Json::encode([ + 'generalId' => $attackerGeneral->getID(), + 'before' => $defenderOrderBeforeSort, + 'after' => array_map($summarizeDefenderOrder, $defenderList), + ]) . "\n"); + } + $conquerCity = processWar_NG($warSeed, $attacker, $getNextDefender, $city, $warTrace); $attacker->applyDB($db); @@ -157,6 +207,32 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke $defenderIncTech /= 4; } + $traceNationIds = array_filter(explode(',', (string) getenv('REF_WAR_TECH_TRACE_NATION_IDS')), 'strlen'); + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') { + foreach ([ + ['side' => 'attacker', 'nation' => $rawAttackerNation, 'baseGain' => $attacker->getDead() * 0.012, 'gain' => $attackerIncTech, 'effective' => $attackerGenCnt_eff], + ['side' => 'defender', 'nation' => $rawDefenderNation, 'baseGain' => $attacker->getKilled() * 0.009, 'gain' => $defenderIncTech, 'effective' => $defenderGenCnt_eff], + ] as $traceEntry) { + if (!in_array((string) $traceEntry['nation']['nation'], $traceNationIds, true)) { + continue; + } + $divisor = max(GameConst::$initialNationGenLimit, $traceEntry['nation']['gennum']); + fwrite(STDOUT, 'WAR_TECH_TRACE ' . Json::encode([ + 'engine' => 'ref', + 'nationId' => $traceEntry['nation']['nation'], + 'side' => $traceEntry['side'], + 'currentTech' => $traceEntry['nation']['tech'], + 'baseGain' => $traceEntry['baseGain'], + 'gain' => $traceEntry['gain'], + 'total' => $traceEntry['nation']['gennum'], + 'effective' => $traceEntry['effective'], + 'divisor' => $divisor, + 'delta' => $traceEntry['gain'] / $divisor, + 'attackerGeneralId' => $attacker->getGeneral()->getID(), + ]) . "\n"); + } + } + $updateAttackerNation['tech'] = $db->sqleval( 'tech + %d', $attackerIncTech / max(GameConst::$initialNationGenLimit, $rawAttackerNation['gennum']) diff --git a/hwe/sammo/Command/General/che_견문.php b/hwe/sammo/Command/General/che_견문.php index d7b4f7f4..f3712060 100644 --- a/hwe/sammo/Command/General/che_견문.php +++ b/hwe/sammo/Command/General/che_견문.php @@ -64,7 +64,9 @@ class che_견문 extends Command\GeneralCommand{ $sightseeing = new SightseeingMessage(); - [$type, $text] = $sightseeing->pickAction(); + // The command RNG is derived from the global seed, year/month, + // general id, and action. Avoid PHP's process-global mt_rand here. + [$type, $text] = $sightseeing->pickAction($rng); $exp = 0; @@ -122,4 +124,4 @@ class che_견문 extends Command\GeneralCommand{ } -} \ No newline at end of file +} diff --git a/hwe/sammo/Command/General/che_기술연구.php b/hwe/sammo/Command/General/che_기술연구.php index 5e7f9d92..2f0c926e 100644 --- a/hwe/sammo/Command/General/che_기술연구.php +++ b/hwe/sammo/Command/General/che_기술연구.php @@ -2,7 +2,7 @@ namespace sammo\Command\General; use \sammo\{ - DB, Util, JosaUtil, + DB, Util, JosaUtil, Json, General, ActionLogger, LastTurn, @@ -118,12 +118,35 @@ class che_기술연구 extends che_상업투자{ $score /= 4; } - $genCount = Util::valueFit( - $db->queryFirstField('SELECT gennum FROM nation WHERE nation=%i', $general->getVar('nation')), - GameConst::$initialNationGenLimit - ); - - $nationUpdated = [ + $genCount = Util::valueFit( + $db->queryFirstField('SELECT gennum FROM nation WHERE nation=%i', $general->getVar('nation')), + GameConst::$initialNationGenLimit + ); + + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + $traceNationIds = getenv('REF_AI_TRACE_NATION_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && ( + (is_string($traceGeneralIds) + && in_array((string) $general->getID(), explode(',', $traceGeneralIds), true)) + || (is_string($traceNationIds) + && in_array((string) $general->getVar('nation'), explode(',', $traceNationIds), true)) + ) + ) { + fwrite(STDOUT, 'AI_ACTION_PATCH_TRACE ' . Json::encode([ + 'engine' => 'ref-tech', + 'generalId' => $general->getID(), + 'nationId' => $general->getVar('nation'), + 'currentTech' => $this->nation['tech'], + 'techScore' => $score, + 'generalCount' => $genCount, + 'delta' => $score / $genCount, + ]) . "\n"); + } + + $nationUpdated = [ 'tech' => $this->nation['tech'] + $score/$genCount ]; $db->update('nation', $nationUpdated, 'nation=%i', $general->getVar('nation')); @@ -143,4 +166,4 @@ class che_기술연구 extends che_상업투자{ } -} \ No newline at end of file +} diff --git a/hwe/sammo/Command/General/che_랜덤임관.php b/hwe/sammo/Command/General/che_랜덤임관.php index 715dfa43..9f40539f 100644 --- a/hwe/sammo/Command/General/che_랜덤임관.php +++ b/hwe/sammo/Command/General/che_랜덤임관.php @@ -187,7 +187,8 @@ class che_랜덤임관 extends Command\GeneralCommand LEFT JOIN `rank_data` AS rb ON g.`no` = rb.general_id AND rb.`type` = 'deathcrew_person' LEFT JOIN `nation` AS n ON g.`nation` = n.`nation` WHERE g.`npc` IN (0, 1, 2, 3, 6) AND g.nation != 0 AND n.scout=0 AND n.gennum < %i AND n.nation NOT IN %li - GROUP BY g.`nation`", + GROUP BY g.`nation` + ORDER BY g.`nation`", $genLimit, $notIn ); @@ -199,7 +200,8 @@ class che_랜덤임관 extends Command\GeneralCommand LEFT JOIN `rank_data` AS rb ON g.`no` = rb.general_id AND rb.`type` = 'deathcrew_person' LEFT JOIN `nation` AS n ON g.`nation` = n.`nation` WHERE g.`npc` IN (0, 1, 2, 3, 6) AND g.nation != 0 AND n.scout=0 AND n.gennum < %i - GROUP BY g.`nation`", + GROUP BY g.`nation` + ORDER BY g.`nation`", $genLimit ); } diff --git a/hwe/sammo/Event/Action/ProcessIncome.php b/hwe/sammo/Event/Action/ProcessIncome.php index 599fc9fc..bf4ef0e9 100644 --- a/hwe/sammo/Event/Action/ProcessIncome.php +++ b/hwe/sammo/Event/Action/ProcessIncome.php @@ -94,6 +94,21 @@ class ProcessIncome extends \sammo\Event\Action foreach ($generalRawList as $rawGeneral) { $generalObj = new General($rawGeneral, null, null, null, null, $year, $month, false); $gold = Util::round(getBill($generalObj->getVar('dedication')) * $ratio); + if ( + getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('SEED_PARITY_MONTHLY_RESOURCE_TRACE') === '1' + && in_array((string)$generalObj->getID(), explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), true) + ) { + fwrite(STDOUT, 'MONTHLY_RESOURCE_REF ' . json_encode([ + 'action' => 'ProcessIncome', + 'type' => 'gold', + 'generalId' => $generalObj->getID(), + 'current' => $generalObj->getVar('gold'), + 'pay' => $gold, + 'ratio' => $ratio, + 'originOutcome' => $originoutcome, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + } $generalObj->increaseVar('gold', $gold); $logger = $generalObj->getLogger(); diff --git a/hwe/sammo/Event/Action/ProcessSemiAnnual.php b/hwe/sammo/Event/Action/ProcessSemiAnnual.php index b016a867..285fcc74 100644 --- a/hwe/sammo/Event/Action/ProcessSemiAnnual.php +++ b/hwe/sammo/Event/Action/ProcessSemiAnnual.php @@ -71,6 +71,21 @@ class ProcessSemiAnnual extends \sammo\Event\Action $resource = $this->resource; + $traceGeneralIDs = array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'); + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' && getenv('SEED_PARITY_MONTHLY_RESOURCE_TRACE') === '1') { + foreach ($traceGeneralIDs as $generalID) { + $current = $db->queryFirstField('SELECT %b FROM general WHERE no = %i', $resource, (int)$generalID); + if ($current !== null) { + fwrite(STDOUT, 'MONTHLY_RESOURCE_REF ' . json_encode([ + 'action' => 'ProcessSemiAnnual', + 'resource' => $resource, + 'generalId' => (int)$generalID, + 'current' => (int)$current, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + } + } + } + // 내정 1% 감소 $db->update('city', [ 'dead' => 0, @@ -90,6 +105,20 @@ class ProcessSemiAnnual extends \sammo\Event\Action $resource => $db->sqleval('IF(%b > 10000, %b * 0.97, %b * 0.99)', $resource, $resource, $resource) ], '%b > 1000', $resource); + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' && getenv('SEED_PARITY_MONTHLY_RESOURCE_TRACE') === '1') { + foreach ($traceGeneralIDs as $generalID) { + $next = $db->queryFirstField('SELECT %b FROM general WHERE no = %i', $resource, (int)$generalID); + if ($next !== null) { + fwrite(STDOUT, 'MONTHLY_RESOURCE_REF ' . json_encode([ + 'action' => 'ProcessSemiAnnualAfter', + 'resource' => $resource, + 'generalId' => (int)$generalID, + 'next' => (int)$next, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + } + } + } + // > 100000 유지비 5%, > 100000 유지비 3%, > 1000 유지비 1% $db->update('nation', [ $resource => $db->sqleval('IF(%b > 100000, %b * 0.95, IF(%b > 10000, %b * 0.97, %b * 0.99))', $resource, $resource, $resource, $resource, $resource) diff --git a/hwe/sammo/Event/Action/RaiseDisaster.php b/hwe/sammo/Event/Action/RaiseDisaster.php index 793b279a..e9bd7c93 100644 --- a/hwe/sammo/Event/Action/RaiseDisaster.php +++ b/hwe/sammo/Event/Action/RaiseDisaster.php @@ -114,7 +114,7 @@ class RaiseDisaster extends \sammo\Event\Action if (!$isGood) { //FIXME: factory 형태로 바꿔야함 $generalListByCity = Util::arrayGroupBy($db->query( - 'SELECT %l FROM general WHERE city IN %li', + 'SELECT %l FROM general WHERE city IN %li ORDER BY city, no', Util::formatListOfBackticks(General::mergeQueryColumn()[0]), Util::squeezeFromArray($targetCityList, 'city')), 'city'); @@ -133,6 +133,19 @@ class RaiseDisaster extends \sammo\Event\Action 'def' => $db->sqleval('def * %d', $affectRatio), 'wall' => $db->sqleval('wall * %d', $affectRatio), ], 'city = %i', $city['city']); + if (in_array((string)$city['city'], array_filter(explode(',', (string)getenv('REF_AI_TRACE_CITY_IDS')), 'strlen'), true)) { + $storedTrust = $db->queryFirstField('SELECT trust FROM city WHERE city = %i', $city['city']); + fwrite(STDOUT, 'MONTHLY_FLOAT_TRACE ' . json_encode([ + 'engine' => 'ref', + 'cityId' => $city['city'], + 'year' => $year, + 'month' => $month, + 'isGood' => false, + 'inputTrust' => $city['trust'], + 'affectRatio' => $affectRatio, + 'storedTrust' => $storedTrust, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + } $generalList = array_map( function ($rawGeneral) use ($city, $year, $month) { @@ -158,6 +171,19 @@ class RaiseDisaster extends \sammo\Event\Action 'def' => $db->sqleval('least(def * %d, def_max)', $affectRatio), 'wall' => $db->sqleval('least(wall * %d, wall_max)', $affectRatio), ], 'city = %i', $city['city']); + if (in_array((string)$city['city'], array_filter(explode(',', (string)getenv('REF_AI_TRACE_CITY_IDS')), 'strlen'), true)) { + $storedTrust = $db->queryFirstField('SELECT trust FROM city WHERE city = %i', $city['city']); + fwrite(STDOUT, 'MONTHLY_FLOAT_TRACE ' . json_encode([ + 'engine' => 'ref', + 'cityId' => $city['city'], + 'year' => $year, + 'month' => $month, + 'isGood' => true, + 'inputTrust' => $city['trust'], + 'affectRatio' => $affectRatio, + 'storedTrust' => $storedTrust, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + } } } } diff --git a/hwe/sammo/GeneralAI.php b/hwe/sammo/GeneralAI.php index 5364c7b1..2563245e 100644 --- a/hwe/sammo/GeneralAI.php +++ b/hwe/sammo/GeneralAI.php @@ -9,8 +9,30 @@ use sammo\Enums\PenaltyKey; use sammo\Enums\RankColumn; use sammo\Scenario\NPC; -class GeneralAI -{ +class GeneralAI +{ + private function traceNationRng(string $phase): void + { + $traceNationIds = getenv('REF_AI_TRACE_NATION_IDS'); + if ( + PHP_SAPI !== 'cli' + || getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1' + || !is_string($traceNationIds) + || !in_array((string) $this->general->getID(), explode(',', $traceNationIds), true) + ) { + return; + } + + $rng = $this->rng->rng; + $reflection = new \ReflectionObject($rng); + $bufferIdx = $reflection->getProperty('bufferIdx')->getValue($rng); + fwrite(STDOUT, 'AI_NATION_RNG_TRACE ' . Json::encode([ + 'generalId' => $this->general->getID(), + 'phase' => $phase, + 'bufferIdx' => $bufferIdx, + ]) . "\n"); + } + protected RandUtil $rng; protected array $city; @@ -150,13 +172,25 @@ class GeneralAI $gameStor = KVStorage::getStorage($db, 'game_env'); $this->env = $gameStor->getAll(true); - $this->rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( - UniqueConst::$hiddenSeed, - 'GeneralAI', - $this->env['year'], - $this->env['month'], - $general->getID(), - ))); + $serializedSeed = Util::simpleSerialize( + UniqueConst::$hiddenSeed, + 'GeneralAI', + $this->env['year'], + $this->env['month'], + $general->getID(), + ); + if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_GENERAL_SEED_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'year' => $this->env['year'], + 'month' => $this->env['month'], + 'seedHex' => bin2hex($serializedSeed), + ]) . "\n"); + } + $this->rng = new RandUtil(new LiteHashDRBG($serializedSeed)); + if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + $this->rng->setTraceGeneralId($general->getID()); + } $this->leadership = $general->getLeadership(); $this->strength = $general->getStrength(); @@ -172,17 +206,21 @@ class GeneralAI return $this->general; } - protected function calcGenType(General $general) - { - $leadership = $general->getLeadership(false); - $strength = Util::valueFit($general->getStrength(false), 1); - $intel = Util::valueFit($general->getIntel(false), 1); + protected function calcGenType(General $general) + { + $leadership = $general->getLeadership(false); + $strength = Util::valueFit($general->getStrength(false), 1); + $intel = Util::valueFit($general->getIntel(false), 1); + $mixedDraw = null; + $mixedProbability = null; //무장 if ($strength >= $intel) { $genType = self::t무장; if ($intel >= $strength * 0.8) { //무지장 - if ($this->rng->nextBool($intel / $strength / 2)) { + $mixedProbability = $intel / $strength / 2; + $mixedDraw = $this->rng->nextBool($mixedProbability); + if ($mixedDraw) { $genType |= self::t지장; } } @@ -190,17 +228,32 @@ class GeneralAI } else { $genType = self::t지장; if ($strength >= $intel * 0.8) { //지무장 - if ($this->rng->nextBool($strength / $intel / 2)) { + $mixedProbability = $strength / $intel / 2; + $mixedDraw = $this->rng->nextBool($mixedProbability); + if ($mixedDraw) { $genType |= self::t무장; } } } //통솔 - if ($leadership >= $this->nationPolicy->minNPCWarLeadership) { - $genType |= self::t통솔장; - } - return $genType; + if ($leadership >= $this->nationPolicy->minNPCWarLeadership) { + $genType |= self::t통솔장; + } + if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'stage' => 'calc-gen-type', + 'leadership' => $leadership, + 'strength' => $strength, + 'intel' => $intel, + 'mixedProbability' => $mixedProbability, + 'mixedDraw' => $mixedDraw, + 'minLeadership' => $this->nationPolicy->minNPCWarLeadership, + 'genType' => $genType, + ]) . "\n"); + } + return $genType; } protected function calcDiplomacyState() @@ -857,12 +910,20 @@ class GeneralAI return null; } - $cityCandidates = []; - foreach ($this->frontCities as $frontCity) { - $cityCandidates[$frontCity['city']] = $frontCity['important']; - } - - $cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [ + $cityCandidates = []; + foreach ($this->frontCities as $frontCity) { + $cityCandidates[$frontCity['city']] = $frontCity['important']; + } + + if (in_array((string)$me->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_NPC_FRONT_ASSIGN_TRACE ' . Json::encode([ + 'generalId' => $me->getID(), + 'frontCityIds' => array_keys($cityCandidates), + 'candidateGeneralIds' => array_keys($generalCandidates), + ]) . "\n"); + } + + $cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [ 'destGeneralID' => $this->rng->choice($generalCandidates)->getID(), 'destCityID' => $this->rng->choiceUsingWeight($cityCandidates) ]); @@ -1135,12 +1196,20 @@ class GeneralAI return null; } - $cityCandidates = []; - foreach ($this->frontCities as $frontCity) { - $cityCandidates[$frontCity['city']] = $frontCity['important']; - } - - $cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [ + $cityCandidates = []; + foreach ($this->frontCities as $frontCity) { + $cityCandidates[$frontCity['city']] = $frontCity['important']; + } + + if (in_array((string)$me->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_NPC_FRONT_ASSIGN_TRACE ' . Json::encode([ + 'generalId' => $me->getID(), + 'frontCityIds' => array_keys($cityCandidates), + 'candidateGeneralIds' => array_keys($generalCandidates), + ]) . "\n"); + } + + $cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [ 'destGeneralID' => $this->rng->choice($generalCandidates)->getID(), 'destCityID' => $this->rng->choiceUsingWeight($cityCandidates) ]); @@ -1564,18 +1633,33 @@ class GeneralAI continue; } //국고와 '충분한 금액'의 기하평균 - $payAmount = sqrt(($enoughMoney - $targetNPCGeneral->getVar($resName)) * $resVal); - $payAmount = Util::valueFit($payAmount, $resVal - $reqNationRes, $enoughMoney - $targetNPCGeneral->getVar($resName)); + $payAmount = sqrt(($enoughMoney - $targetNPCGeneral->getVar($resName)) * $resVal); + $payAmount = Util::valueFit($payAmount, $resVal - $reqNationRes, $enoughMoney - $targetNPCGeneral->getVar($resName)); if ($resVal < $payAmount / 2) { continue; } - $candidateArgs[] = [ + $payAmount = Util::valueFit($payAmount, 100, $this->maxResourceActionAmount); + if (in_array((string)$this->general->getID(), explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), true)) { + fwrite(STDOUT, 'AI_REWARD_TRACE ' . Json::encode([ + 'engine' => 'ref', + 'actor' => $this->general->getID(), + 'target' => $targetNPCGeneral->getID(), + 'resource' => $resName, + 'nationResource' => $resVal, + 'targetResource' => $targetNPCGeneral->getVar($resName), + 'required' => $reqMoney, + 'enough' => $enoughMoney, + 'maxResourceActionAmount' => $this->maxResourceActionAmount, + 'amount' => $payAmount, + ]) . "\n"); + } + $candidateArgs[] = [ [ 'destGeneralID' => $targetNPCGeneral->getID(), 'isGold' => $resName == 'gold', - 'amount' => Util::valueFit($payAmount, 100, $this->maxResourceActionAmount) + 'amount' => $payAmount ], max(count($npcWarGenerals), count($npcCivilGenerals)) - $idx ]; @@ -2214,7 +2298,28 @@ class GeneralAI return null; } - return $this->rng->choiceUsingWeightPair($cmdList); + $picked = $this->rng->choiceUsingWeightPair($cmdList); + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && in_array((string) $general->getID(), explode(',', $traceGeneralIds), true) + ) { + fwrite(STDOUT, 'AI_DEVEL_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'year' => $this->env['year'], + 'month' => $this->env['month'], + 'genType' => $genType, + 'city' => $city, + 'candidates' => array_map(static fn (array $entry): array => [ + 'action' => $entry[0]->getRawClassName(), + 'weight' => $entry[1], + ], $cmdList), + 'picked' => $picked->getRawClassName(), + ]) . "\n"); + } + return $picked; } protected function do긴급내정(): ?GeneralCommand @@ -2359,18 +2464,54 @@ class GeneralAI return null; } - $cmd = $this->rng->choiceUsingWeightPair($cmdList); - return $cmd; + $cmd = $this->rng->choiceUsingWeightPair($cmdList); + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && in_array((string) $general->getID(), explode(',', $traceGeneralIds), true) + ) { + fwrite(STDOUT, 'AI_DEVEL_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'year' => $this->env['year'], + 'month' => $this->env['month'], + 'mode' => '전쟁내정', + 'genType' => $genType, + 'dipState' => $this->dipState, + 'city' => $city, + 'nationTech' => $nation['tech'], + 'candidates' => array_map(static fn (array $entry): array => [ + 'action' => $entry[0]->getRawClassName(), + 'weight' => $entry[1], + ], $cmdList), + 'picked' => $cmd->getRawClassName(), + ]) . "\n"); + } + return $cmd; } - protected function do금쌀구매(): ?GeneralCommand - { - $general = $this->general; - - if ($this->city['trade'] === null && !$this->generalPolicy->can상인무시) { - return null; - } + protected function do금쌀구매(): ?GeneralCommand + { + $general = $this->general; + $traceGeneralIDs = array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'); + $traceEnabled = in_array((string)$general->getID(), $traceGeneralIDs, true); + $trace = static function (string $stage, array $values = []) use ($traceEnabled, $general): void { + if (!$traceEnabled) { + return; + } + fwrite(STDOUT, 'AI_ECONOMY_TRACE ' . json_encode([ + 'generalId' => $general->getID(), + 'stage' => $stage, + ...$values, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + }; + + if ($this->city['trade'] === null && !$this->generalPolicy->can상인무시) { + $trace('no-trader'); + return null; + } $kill = $general->getRankVar(RankColumn::killcrew) + 50000; $death = $general->getRankVar(RankColumn::deathcrew) + 50000; @@ -2379,12 +2520,24 @@ class GeneralAI $absGold = $general->getVar('gold'); $absRice = $general->getVar('rice'); - $relGold = $absGold; - $relRice = $absRice * $deathRate; - - if ($absGold + $absRice < $this->baseDevelCost * 2) { - return null; - } + $relGold = $absGold; + $relRice = $absRice * $deathRate; + + $trace('resources', [ + 'absGold' => $absGold, + 'absRice' => $absRice, + 'relGold' => $relGold, + 'relRice' => $relRice, + 'deathRate' => $deathRate, + 'baseDevelCost' => $this->baseDevelCost, + 'canIgnoreTrader' => $this->generalPolicy->can상인무시, + 'trade' => $this->city['trade'], + ]); + + if ($absGold + $absRice < $this->baseDevelCost * 2) { + $trace('insufficient-base-resource'); + return null; + } $crewType = $general->getCrewTypeObj(); if ($this->generalPolicy->can모병) { @@ -2400,10 +2553,19 @@ class GeneralAI } $goldCost = $costCmd->getCost()[0]; - $riceCost = $crewType->riceWithTech( - $this->nation['tech'], - Util::toInt($this->fullLeadership * 100) - ); + $riceCost = $crewType->riceWithTech( + $this->nation['tech'], + Util::toInt($this->fullLeadership * 100) + ); + $trace('recruit-cost', [ + 'crewTypeId' => $crewType->id, + 'crewCost' => $crewType->cost, + 'crewRice' => $crewType->rice, + 'tech' => $this->nation['tech'], + 'crewAmount' => Util::toInt($this->fullLeadership * 100), + 'goldCost' => $goldCost, + 'riceCost' => $riceCost, + ]); if (($relGold + $relRice) * 1.5 <= $goldCost + $riceCost) { return null; @@ -2437,10 +2599,16 @@ class GeneralAI 'buyRice' => true, 'amount' => $amount ] - ); - if ($cmd->hasFullConditionMet()) { - return $cmd; - } + ); + $conditionMet = $cmd->hasFullConditionMet(); + $trace('buy', [ + 'amount' => $amount, + 'minimumResourceActionAmount' => $this->nationPolicy->minimumResourceActionAmount, + 'conditionMet' => $conditionMet, + ]); + if ($conditionMet) { + return $cmd; + } } } @@ -2480,14 +2648,28 @@ class GeneralAI return null; } - protected function do징병(): ?GeneralCommand - { - if (in_array($this->dipState, [self::d평화, self::d선포])) { - return null; - } - - if (!($this->genType & self::t통솔장)) { - return null; + protected function do징병(): ?GeneralCommand + { + $traceGeneralIds = array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'); + $traceEnabled = in_array((string)$this->general->getID(), $traceGeneralIds, true); + $trace = function (string $stage, array $values = []) use ($traceEnabled): void { + if (!$traceEnabled) { + return; + } + fwrite(STDOUT, 'AI_RECRUIT_TRACE ' . Json::encode([ + 'generalId' => $this->general->getID(), + 'stage' => $stage, + ...$values, + ]) . "\n"); + }; + if (in_array($this->dipState, [self::d평화, self::d선포])) { + $trace('diplomacy', ['dipState' => $this->dipState]); + return null; + } + + if (!($this->genType & self::t통솔장)) { + $trace('general-type', ['genType' => $this->genType]); + return null; } @@ -2497,21 +2679,32 @@ class GeneralAI $nation = $this->nation; $env = $this->env; - if ($general->getVar('crew') >= $this->nationPolicy->minWarCrew) { - return null; - } - - if (!$this->generalPolicy->can한계징병) { + if ($general->getVar('crew') >= $this->nationPolicy->minWarCrew) { + $trace('existing-crew', ['crew' => $general->getVar('crew'), 'minWarCrew' => $this->nationPolicy->minWarCrew]); + return null; + } + + $trace('population-policy', [ + 'population' => $city['pop'], + 'populationMax' => $city['pop_max'], + 'safeRatio' => $this->nationPolicy->safeRecruitCityPopulationRatio, + 'minPopulation' => $this->nationPolicy->minNPCRecruitCityPopulation, + 'canLimitRecruit' => $this->generalPolicy->can한계징병, + ]); + + if (!$this->generalPolicy->can한계징병) { $remainPop = $city['pop'] - $this->nationPolicy->minNPCRecruitCityPopulation - $this->fullLeadership * 100; - if ($remainPop <= 0) { - return null; + if ($remainPop <= 0) { + $trace('population-floor', ['remainPop' => $remainPop, 'fullLeadership' => $this->fullLeadership]); + return null; } $maxPop = $city['pop_max'] - $this->nationPolicy->minNPCRecruitCityPopulation; if (($city['pop'] / $city['pop_max'] < $this->nationPolicy->safeRecruitCityPopulationRatio) && - ($this->rng->nextBool($remainPop / $maxPop)) - ) { - return null; + ($this->rng->nextBool($remainPop / $maxPop)) + ) { + $trace('population-random', ['remainPop' => $remainPop, 'maxPop' => $maxPop, 'fullLeadership' => $this->fullLeadership]); + return null; } } @@ -2532,7 +2725,7 @@ class GeneralAI } } - if (!$armType) { + if (!$armType) { $dex = [ GameUnitConst::T_FOOTMAN => sqrt($general->getVar('dex1') + 500), GameUnitConst::T_ARCHER => sqrt($general->getVar('dex2') + 500), @@ -2551,8 +2744,26 @@ class GeneralAI $availableArmType[GameUnitConst::T_WIZARD] = $dex[GameUnitConst::T_WIZARD] * $this->fullIntel * 3; } - $armType = $this->rng->choiceUsingWeight($availableArmType); - } + if ($traceEnabled) { + $armTypeDraw = $this->rng->nextFloat1(); + $cursor = $armTypeDraw * array_sum($availableArmType); + foreach ($availableArmType as $candidateArmType => $weight) { + if ($cursor <= $weight) { + $armType = $candidateArmType; + break; + } + $cursor -= max(0, $weight); + } + } else { + $armType = $this->rng->choiceUsingWeight($availableArmType); + } + } + $trace('arm-type', [ + 'forcedArmType' => $general->getAuxVar('armType'), + 'armType' => $armType, + 'armTypeDraw' => $armTypeDraw ?? null, + 'armTypeWeights' => $availableArmType ?? [], + ]); $cities = []; @@ -2576,9 +2787,10 @@ class GeneralAI } } - if ($types) { - $type = $this->rng->choiceUsingWeight($types); - } else { + if ($types) { + $type = $this->rng->choiceUsingWeight($types); + $trace('crew-type', ['armType' => $armType, 'candidates' => $types, 'picked' => $type]); + } else { throw new MustNotBeReachedException('에러:' . print_r([$general->getName(), $general->getAuxVar('armType'), $armType, $cities, $regions, $relYear, $tech], true)); } @@ -2604,8 +2816,9 @@ class GeneralAI $rice = $general->getVar('rice'); $rice -= $this->fullLeadership * 4; - if ($gold <= 0 || $rice <= 0) { - return null; + if ($gold <= 0 || $rice <= 0) { + $trace('reserve-floor', ['remainingGold' => $gold, 'remainingRice' => $rice, 'fullLeadership' => $this->fullLeadership]); + return null; } $crew = $this->fullLeadership * 100; @@ -2639,15 +2852,18 @@ class GeneralAI ]); } - if (!$this->generalPolicy->can한계징병 && $rice * 1.1 <= $riceCost) { - //이 쌀도 없어? - return null; - } - - if (!$cmd->hasFullConditionMet()) { - return null; - } - return $cmd; + if (!$this->generalPolicy->can한계징병 && $rice * 1.1 <= $riceCost) { + //이 쌀도 없어? + $trace('rice-cost', ['remainingGold' => $gold, 'remainingRice' => $rice, 'goldCost' => $cost, 'riceCost' => $riceCost, 'crewAmount' => $crew, 'crewTypeId' => $type]); + return null; + } + + if (!$cmd->hasFullConditionMet()) { + $trace('constraint', ['remainingGold' => $gold, 'remainingRice' => $rice, 'goldCost' => $cost, 'riceCost' => $riceCost, 'crewAmount' => $crew, 'crewTypeId' => $type]); + return null; + } + $trace('selected', ['remainingGold' => $gold, 'remainingRice' => $rice, 'goldCost' => $cost, 'riceCost' => $riceCost, 'crewAmount' => $crew, 'crewTypeId' => $type]); + return $cmd; } protected function do전투준비(): ?GeneralCommand @@ -2766,10 +2982,18 @@ class GeneralAI throw new \RuntimeException('출병 불가' . $cityID . var_export($attackableNations, true) . var_export($nearCities, true)); } - $cmd = buildGeneralCommandClass('che_출병', $general, $this->env, ['destCityID' => $this->rng->choice($attackableCities)]); - if (!$cmd->hasFullConditionMet()) { - return null; - } + $cmd = buildGeneralCommandClass('che_출병', $general, $this->env, ['destCityID' => $this->rng->choice($attackableCities)]); + if (!$cmd->hasFullConditionMet()) { + if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + error_log('AI_GENERAL_CONSTRAINT_TRACE ' . json_encode([ + 'generalId' => $general->getID(), + 'action' => 'che_출병', + 'args' => $cmd->getArg(), + 'reason' => $cmd->testFullConditionMet(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + return null; + } return $cmd; } @@ -2949,13 +3173,23 @@ class GeneralAI } } - if (!$recruitableCityList) { - LogText("{$this->general->getName()}, {$this->general->getID()} 후방워프 불가: 배후 도시", [count($this->backupCities), count($this->supplyCities)]); - return null; - } - - - $cmd = buildGeneralCommandClass('che_NPC능동', $this->general, $this->env, [ + if (!$recruitableCityList) { + LogText("{$this->general->getName()}, {$this->general->getID()} 후방워프 불가: 배후 도시", [count($this->backupCities), count($this->supplyCities)]); + return null; + } + + if (in_array((string) $this->general->getID(), array_filter(explode(',', (string) getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_WARP_TRACE ' . Json::encode([ + 'generalId' => $this->general->getID(), + 'kind' => 'rear', + 'fullLeadership' => $this->fullLeadership, + 'minRecruitPop' => $minRecruitPop, + 'recruitable' => $recruitableCityList, + ]) . "\n"); + } + + + $cmd = buildGeneralCommandClass('che_NPC능동', $this->general, $this->env, [ 'optionText' => '순간이동', 'destCityID' => $this->rng->choiceUsingWeight($recruitableCityList), ]); @@ -3483,7 +3717,13 @@ class GeneralAI $supplyCities = []; $backupCities = []; - foreach ($db->query('SELECT * FROM city WHERE nation = %i', $nationID) as $nationCity) { + $cityQuery = 'SELECT * FROM city WHERE nation = %i'; + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') { + // ng_compare must not let MariaDB's physical row order redefine + // which candidate a deterministic RNG index points at. + $cityQuery .= ' ORDER BY city'; + } + foreach ($db->query($cityQuery, $nationID) as $nationCity) { $nationCity['generals'] = new \ArrayObject(); $cityID = $nationCity['city']; $dev = @@ -3534,7 +3774,11 @@ class GeneralAI $nationCities = &$this->nationCities; $db = DB::db(); - $generalIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation = %i AND no != %i', $nationID, $this->general->getID()); + $generalQuery = 'SELECT no FROM general WHERE nation = %i AND no != %i'; + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') { + $generalQuery .= ' ORDER BY no'; + } + $generalIDList = $db->queryFirstColumn($generalQuery, $nationID, $this->general->getID()); $nationGenerals = General::createObjListFromDB($generalIDList); @@ -3613,9 +3857,10 @@ class GeneralAI } - public function chooseNationTurn(NationCommand $reservedCommand): NationCommand - { - $this->updateInstance(); + public function chooseNationTurn(NationCommand $reservedCommand): NationCommand + { + $this->updateInstance(); + $this->traceNationRng('after-update'); //TODO: NationTurn과 InstantNationTurn 구분 필요 $lastTurn = $reservedCommand->getLastTurn(); @@ -3658,7 +3903,7 @@ class GeneralAI $general->getLogger()->pushGeneralActionLog($text); } - foreach ($this->nationPolicy->priority as $actionName) { + foreach ($this->nationPolicy->priority as $actionName) { if (!property_exists($this->nationPolicy, 'can' . $actionName)) { trigger_error("can{$actionName}이 없음", E_USER_NOTICE); @@ -3671,7 +3916,9 @@ class GeneralAI continue; } /** @var ?NationCommand */ - $result = $this->{'do' . $actionName}($lastTurn); + $this->traceNationRng("before-{$actionName}"); + $result = $this->{'do' . $actionName}($lastTurn); + $this->traceNationRng("after-{$actionName}"); if ($result !== null) { $result->reason = 'do' . $actionName; return $result; @@ -3826,17 +4073,33 @@ class GeneralAI } } - foreach ($this->generalPolicy->priority as $actionName) { - if (!property_exists($this->generalPolicy, 'can' . $actionName)) { - trigger_error("can{$actionName}이 없음", E_USER_NOTICE); - continue; - } - if (!($this->generalPolicy->{'can' . $actionName})) { - continue; - } - /** @var ?GeneralCommand */ - $result = $this->{'do' . $actionName}(); - if ($result !== null) { + foreach ($this->generalPolicy->priority as $actionName) { + if (!property_exists($this->generalPolicy, 'can' . $actionName)) { + trigger_error("can{$actionName}이 없음", E_USER_NOTICE); + continue; + } + if (!($this->generalPolicy->{'can' . $actionName})) { + if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_GENERAL_PRIORITY_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'actionName' => $actionName, + 'allowed' => false, + 'result' => null, + ]) . "\n"); + } + continue; + } + /** @var ?GeneralCommand */ + $result = $this->{'do' . $actionName}(); + if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) { + fwrite(STDOUT, 'AI_GENERAL_PRIORITY_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'actionName' => $actionName, + 'allowed' => true, + 'result' => $result === null ? null : get_class($result), + ]) . "\n"); + } + if ($result !== null) { $result->reason = 'do' . $actionName; return $result; } @@ -3947,9 +4210,18 @@ class GeneralAI continue; } - $randGeneral->setVar('officer_level', $chiefLevel); - $randGeneral->setVar('officer_city', 0); - $randGeneral->applyDB($db); + $randGeneral->setVar('officer_level', $chiefLevel); + $randGeneral->setVar('officer_city', 0); + if (getenv('REF_AI_TRACE_SEQUENCE') === '1') { + fwrite(STDOUT, 'AI_PROMOTION_TRACE ' . Json::encode([ + 'engine' => 'ref', + 'mode' => 'non-lord', + 'actor' => $this->general->getID(), + 'chiefLevel' => $chiefLevel, + 'picked' => $randGeneral->getID(), + ]) . "\n"); + } + $randGeneral->applyDB($db); $this->nation['chief_set'] |= doOfficerSet(0, $chiefLevel); $setChiefLevel |= doOfficerSet(0, $chiefLevel); $this->chiefGenerals[$chiefLevel] = $randGeneral; @@ -4145,8 +4417,17 @@ class GeneralAI } } - $nextChiefs[$chiefLevel] = $newChief; - $newChief->setVar('officer_level', $chiefLevel); + $nextChiefs[$chiefLevel] = $newChief; + if (getenv('REF_AI_TRACE_SEQUENCE') === '1') { + fwrite(STDOUT, 'AI_PROMOTION_TRACE ' . Json::encode([ + 'engine' => 'ref', + 'mode' => 'lord', + 'actor' => $this->general->getID(), + 'chiefLevel' => $chiefLevel, + 'picked' => $newChief->getID(), + ]) . "\n"); + } + $newChief->setVar('officer_level', $chiefLevel); $newChief->setVar('officer_city', 0); $nation['chief_set'] |= doOfficerSet(0, $chiefLevel); $updatedChiefSet |= doOfficerSet(0, $chiefLevel); diff --git a/hwe/sammo/GeneralTrigger/che_도시치료.php b/hwe/sammo/GeneralTrigger/che_도시치료.php index 54fb371f..089e877c 100644 --- a/hwe/sammo/GeneralTrigger/che_도시치료.php +++ b/hwe/sammo/GeneralTrigger/che_도시치료.php @@ -31,7 +31,7 @@ class che_도시치료 extends BaseGeneralTrigger if ($general->getNationID() == 0) { /** @var array{int,string,string}[] $patients */ $patients = $db->queryAllLists( - 'SELECT no,name,nation FROM general WHERE city=%i AND nation=%i AND injury > 10 AND no != %i', + 'SELECT no,name,nation FROM general WHERE city=%i AND nation=%i AND injury > 10 AND no != %i ORDER BY no', $general->getCityID(), 0, $general->getID() @@ -40,7 +40,7 @@ class che_도시치료 extends BaseGeneralTrigger else { /** @var array{int,string,string}[] $patients */ $patients = $db->queryAllLists( - 'SELECT no,name,nation FROM general WHERE city=%i AND injury > 10 AND no != %i', + 'SELECT no,name,nation FROM general WHERE city=%i AND injury > 10 AND no != %i ORDER BY no', $general->getCityID(), $general->getID() ); diff --git a/hwe/sammo/ResetHelper.php b/hwe/sammo/ResetHelper.php index 3842718f..0ba7a5da 100644 --- a/hwe/sammo/ResetHelper.php +++ b/hwe/sammo/ResetHelper.php @@ -93,7 +93,14 @@ class ResetHelper{ $gameStor->resetCache(); } - $hiddenSeed = bin2hex(random_bytes(16));//32byte, 128bit random seed + $hiddenSeed = bin2hex(random_bytes(16));//32byte, 128bit random seed + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') { + $comparisonSeed = getenv('REF_HIDDEN_SEED'); + if (!is_string($comparisonSeed) || !preg_match('/^[A-Za-z0-9._-]{1,128}$/D', $comparisonSeed)) { + throw new \RuntimeException('REF_HIDDEN_SEED is invalid for deterministic comparison install'); + } + $hiddenSeed = $comparisonSeed; + } $result = Util::generateFileUsingSimpleTemplate( $servRoot.'/d_setting/UniqueConst.orig.php', @@ -302,7 +309,11 @@ class ResetHelper{ 'server_cnt'=>$serverCnt, ]; - foreach(RootDB::db()->query('SELECT `no`, `name`, `picture`, `imgsvr` FROM member WHERE grade >= 6') as $admin){ + $comparisonInstall = getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'; + $adminMembers = $comparisonInstall + ? [] + : RootDB::db()->query('SELECT `no`, `name`, `picture`, `imgsvr` FROM member WHERE grade >= 6'); + foreach($adminMembers as $admin){ $db->insert('general', [ 'owner'=>$admin['no'], 'name'=>$admin['name'], @@ -368,4 +379,4 @@ class ResetHelper{ 'result'=>true ]; } -} \ No newline at end of file +} diff --git a/hwe/sammo/TextDecoration/SightseeingMessage.php b/hwe/sammo/TextDecoration/SightseeingMessage.php index f3bab255..79c596f1 100644 --- a/hwe/sammo/TextDecoration/SightseeingMessage.php +++ b/hwe/sammo/TextDecoration/SightseeingMessage.php @@ -112,10 +112,11 @@ class SightseeingMessage{ } } - public function pickAction():array{ - if (static::$comparisonRng !== null) { - [$type, $texts] = static::$comparisonRng->choiceUsingWeightPair(static::$messages ?? []); - $text = static::$comparisonRng->choice($texts); + public function pickAction(?\sammo\RandUtil $rng = null):array{ + $actionRng = $rng ?? static::$comparisonRng; + if ($actionRng !== null) { + [$type, $texts] = $actionRng->choiceUsingWeightPair(static::$messages ?? []); + $text = $actionRng->choice($texts); } else { [$type, $texts] = Util::choiceRandomUsingWeightPair(static::$messages??[]); $text = Util::choiceRandom($texts); diff --git a/hwe/sammo/TurnExecutionHelper.php b/hwe/sammo/TurnExecutionHelper.php index 5a4a98a8..91d0d002 100644 --- a/hwe/sammo/TurnExecutionHelper.php +++ b/hwe/sammo/TurnExecutionHelper.php @@ -6,8 +6,26 @@ use sammo\Enums\EventTarget; use sammo\Enums\InheritanceKey; use \Symfony\Component\Lock; -class TurnExecutionHelper -{ +class TurnExecutionHelper +{ + /** @var array */ + private static array $comparisonActionCounts = []; + /** @var array> */ + private static array $comparisonActionGeneralIds = []; + + /** @return array */ + public static function getComparisonActionCounts(): array + { + ksort(self::$comparisonActionCounts, SORT_STRING); + return self::$comparisonActionCounts; + } + + /** @return array> */ + public static function getComparisonActionGeneralIds(): array + { + ksort(self::$comparisonActionGeneralIds, SORT_STRING); + return self::$comparisonActionGeneralIds; + } /** @var General*/ protected $generalObj; @@ -241,15 +259,54 @@ class TurnExecutionHelper $currentTurn = null; $gameStor = KVStorage::getStorage($db, 'game_env'); - $autorun_user = $gameStor->autorun_user; + $autorun_user = $gameStor->autorun_user; + $traceCityChanges = PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('REF_AI_TRACE_CITY_CHANGES') === '1'; + $observedCityStates = []; + if ($traceCityChanges) { + foreach ($db->query('SELECT city, nation, front, def, wall, pop, state, term, trust FROM city ORDER BY city') as $row) { + $observedCityStates[(int)$row['city']] = [ + (int)$row['nation'], + (int)$row['front'], + (int)$row['def'], + (int)$row['wall'], + (int)$row['pop'], + (int)$row['state'], + (int)$row['term'], + (float)$row['trust'], + ]; + } + } - foreach ($generalsTodo as $rawGeneral) { - $currActionTime = new \DateTimeImmutable(); - if ($currActionTime > $limitActionTime) { - return [true, $currentTurn]; - } + foreach ($generalsTodo as $rawGeneral) { + // The comparison harness fixes the logical clock. A real wall-clock + // timeout here made the final post-month drain depend on host load, + // so identical seeds processed a variable number of new generals. + // Preserve the production timeout unless deterministic comparison + // mode was explicitly enabled. + if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1') { + $currActionTime = new \DateTimeImmutable(); + if ($currActionTime > $limitActionTime) { + return [true, $currentTurn]; + } + } - $general = General::createObjFromDB($rawGeneral['no']); + $general = General::createObjFromDB($rawGeneral['no']); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('REF_AI_TRACE_SEQUENCE') === '1' + ) { + fwrite(STDOUT, sprintf( + "TURN_START_REF id=%d nation=%d city=%d officer=%d turnTime=%s\n", + $general->getID(), + $general->getNationID(), + $general->getCityID(), + $general->getVar('officer_level'), + $general->getTurnTime() + )); + } $nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env'); $turnObj = new static($general); @@ -302,9 +359,26 @@ class TurnExecutionHelper if (!($nationCommandObj instanceof Command\Nation\휴식)) { $hasReservedTurn = true; } - if ($ai && ($general->getAuxVar('use_auto_nation_turn') ?? 1)) { - $nationCommandObj = $ai->chooseNationTurn($nationCommandObj); - $cityName = CityConst::byID($general->getCityID())->name; + if ($ai && ($general->getAuxVar('use_auto_nation_turn') ?? 1)) { + $nationCommandObj = $ai->chooseNationTurn($nationCommandObj); + $traceNationIds = getenv('REF_AI_TRACE_NATION_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceNationIds) + && in_array((string) $general->getID(), explode(',', $traceNationIds), true) + ) { + fwrite(STDOUT, 'AI_NATION_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'nationId' => $general->getNationID(), + 'year' => $year, + 'month' => $month, + 'action' => $nationCommandObj->getRawClassName(), + 'args' => $nationCommandObj->getArg(), + 'reason' => $nationCommandObj->reason, + ]) . "\n"); + } + $cityName = CityConst::byID($general->getCityID())->name; LogText("NationTurn", "General, {$general->getName()}, {$general->getID()}, {$cityName}, {$general->getStaticNation()['name']}, {$nationCommandObj->getBrief()}, {$nationCommandObj->reason}, "); } $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( @@ -319,8 +393,20 @@ class TurnExecutionHelper $rng, $nationCommandObj ); - $nationStor->setValue($lastNationTurnKey, $resultNationTurn->toRaw()); - $general->setRawCity(null); + $nationStor->setValue($lastNationTurnKey, $resultNationTurn->toRaw()); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('REF_AI_TRACE_SEQUENCE') === '1' + ) { + fwrite(STDOUT, sprintf( + "ACTION_REF kind=nation actor=%d requested=%s resolved=%s\n", + $general->getID(), + $nationCommandObj->getRawClassName(), + $nationCommandObj->getRawClassName() + )); + } + $general->setRawCity(null); } $generalCommandObj = $general->getReservedTurn(0, $env); @@ -328,16 +414,57 @@ class TurnExecutionHelper $hasReservedTurn = true; } - if ($ai) { - $newGeneralCommandObj = $ai->chooseGeneralTurn($generalCommandObj); // npc AI 처리 - if ($generalCommandObj !== $newGeneralCommandObj) { - $autorunMode = true; - $generalCommandObj = $newGeneralCommandObj; - } - $cityName = CityConst::byID($general->getCityID())->name; + if ($ai) { + $newGeneralCommandObj = $ai->chooseGeneralTurn($generalCommandObj); // npc AI 처리 + if ($generalCommandObj !== $newGeneralCommandObj) { + $autorunMode = true; + $generalCommandObj = $newGeneralCommandObj; + } + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && in_array((string) $general->getID(), explode(',', $traceGeneralIds), true) + ) { + fwrite(STDOUT, 'AI_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'year' => $year, + 'month' => $month, + 'npc' => $general->getNPCType(), + 'cityId' => $general->getCityID(), + 'gold' => $general->getVar('gold'), + 'rice' => $general->getVar('rice'), + 'affinity' => $general->getVar('affinity'), + 'stats' => [ + 'leadership' => $general->getVar('leadership'), + 'strength' => $general->getVar('strength'), + 'intel' => $general->getVar('intel'), + ], + 'fullStats' => [ + 'leadership' => $general->getLeadership(false), + 'strength' => $general->getStrength(false), + 'intel' => $general->getIntel(false), + ], + 'action' => $generalCommandObj->getRawClassName(), + 'args' => $generalCommandObj->getArg(), + 'reason' => $generalCommandObj->reason, + ]) . "\n"); + } + $cityName = CityConst::byID($general->getCityID())->name; LogText("turn", "General, {$general->getName()}, {$general->getID()}, {$cityName}, {$general->getStaticNation()['name']}, {$generalCommandObj->getBrief()}, {$generalCommandObj->reason}, "); } - $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('REF_AI_TRACE_SUMMARY') === '1' + ) { + $actionName = $generalCommandObj->getRawClassName(); + self::$comparisonActionCounts[$actionName] = + (self::$comparisonActionCounts[$actionName] ?? 0) + 1; + self::$comparisonActionGeneralIds[$actionName][] = $general->getID(); + } + $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( UniqueConst::$hiddenSeed, 'generalCommand', $year, @@ -345,8 +472,82 @@ class TurnExecutionHelper $general->getID(), $generalCommandObj->getRawClassName() ))); - $turnObj->processCommand($rng, $generalCommandObj, $autorunMode); - } + $turnObj->processCommand($rng, $generalCommandObj, $autorunMode); + if ($traceCityChanges) { + $changes = []; + foreach ($db->query('SELECT city, nation, front, def, wall, pop, state, term, trust FROM city ORDER BY city') as $row) { + $cityId = (int)$row['city']; + $next = [ + (int)$row['nation'], + (int)$row['front'], + (int)$row['def'], + (int)$row['wall'], + (int)$row['pop'], + (int)$row['state'], + (int)$row['term'], + (float)$row['trust'], + ]; + if (($observedCityStates[$cityId] ?? null) !== $next) { + $changes[] = [ + 'id' => $cityId, + 'before' => $observedCityStates[$cityId] ?? null, + 'after' => $next, + ]; + $observedCityStates[$cityId] = $next; + } + } + if ($changes) { + fwrite(STDOUT, 'CITY_CHANGE_REF ' . Json::encode([ + 'actor' => $general->getID(), + 'changes' => $changes, + ]) . "\n"); + } + } + $traceCityIds = array_values(array_filter(array_map( + 'intval', + explode(',', (string)getenv('REF_AI_TRACE_CITY_IDS')) + ))); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && $traceCityIds + && in_array((string)$general->getID(), explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), true) + ) { + fwrite(STDOUT, 'AI_CITY_STATE_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'cities' => $db->query( + 'SELECT city, nation, front, supply, def, wall, state, term FROM city WHERE city IN %li ORDER BY city', + $traceCityIds + ), + ]) . "\n"); + } + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && getenv('REF_AI_TRACE_SEQUENCE') === '1' + ) { + fwrite(STDOUT, sprintf( + "ACTION_REF kind=general actor=%d requested=%s resolved=%s\n", + $general->getID(), + $generalCommandObj->getRawClassName(), + $generalCommandObj->getRawClassName() + )); + } + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && in_array((string) $general->getID(), explode(',', $traceGeneralIds), true) + ) { + fwrite(STDOUT, 'AI_GENERAL_POST_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'action' => $generalCommandObj->getRawClassName(), + 'gold' => $general->getVar('gold'), + 'rice' => $general->getVar('rice'), + ]) . "\n"); + } + } pullNationCommand($general->getVar('nation'), $general->getVar('officer_level')); pullGeneralCommand($general->getID()); @@ -360,8 +561,31 @@ class TurnExecutionHelper $general->setAuxVar('autorun_limit', $autorun_limit); } - $turnObj->updateTurnTime(); - $turnObj->applyDB(); + $turnObj->updateTurnTime(); + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + $traceCurrentGeneral = + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && in_array((string) $general->getID(), explode(',', $traceGeneralIds), true); + if ($traceCurrentGeneral) { + fwrite(STDOUT, 'AI_GENERAL_PRE_APPLY_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'gold' => $general->getVar('gold'), + 'rice' => $general->getVar('rice'), + 'updates' => $general->getUpdatedValues(), + ]) . "\n"); + } + $turnObj->applyDB(); + if ($traceCurrentGeneral) { + fwrite(STDOUT, 'AI_GENERAL_DB_TRACE ' . Json::encode([ + 'generalId' => $general->getID(), + 'stored' => $db->queryFirstRow( + 'SELECT nation,city,gold,rice,crew,crewtype,train,atmos,leadership,strength,intel,leadership_exp,strength_exp,intel_exp,dex1,dex2,dex3,dex4,dex5 FROM general WHERE no=%i', + $general->getID() + ), + ]) . "\n"); + } } return [false, $currentTurn]; @@ -458,27 +682,54 @@ class TurnExecutionHelper return $gameStor->turntime; } - $monthlyRng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( + $monthlyRng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( UniqueConst::$hiddenSeed, 'monthly', $gameStor->year, - $gameStor->month - ))); - - // 1달마다 처리하는 것들, 벌점 감소 및 건국,전턴,합병 -1, 군량 소모 - static::runEventHandler($db, $gameStor, EventTarget::PreMonth); - if (!preUpdateMonthly()) { + $gameStor->month + ))); + + $traceMonthlyGeneral = static function (string $phase) use ($db): void { + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + if ( + PHP_SAPI !== 'cli' + || getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1' + || !is_string($traceGeneralIds) + ) { + return; + } + foreach (array_filter(explode(',', $traceGeneralIds), 'strlen') as $generalID) { + $stored = $db->queryFirstRow('SELECT gold,rice FROM general WHERE no=%i', (int)$generalID); + if ($stored) { + fwrite(STDOUT, 'AI_MONTH_PHASE_TRACE ' . Json::encode([ + 'phase' => $phase, + 'generalId' => (int)$generalID, + 'stored' => $stored, + ]) . "\n"); + } + } + }; + $traceMonthlyGeneral('before-pre-month-event'); + + // 1달마다 처리하는 것들, 벌점 감소 및 건국,전턴,합병 -1, 군량 소모 + static::runEventHandler($db, $gameStor, EventTarget::PreMonth); + $traceMonthlyGeneral('after-pre-month-event'); + if (!preUpdateMonthly()) { unlock(); - throw new \RuntimeException('preUpdateMonthly() 처리 에러'); - } - turnDate($nextTurn); + throw new \RuntimeException('preUpdateMonthly() 처리 에러'); + } + $traceMonthlyGeneral('after-pre-update'); + turnDate($nextTurn); + $traceMonthlyGeneral('after-turn-date'); // 분기계산. 장수들 턴보다 먼저 있다면 먼저처리 if ($gameStor->month == 1) { checkStatistic(); } - static::runEventHandler($db, $gameStor, EventTarget::Month); - postUpdateMonthly($monthlyRng); + static::runEventHandler($db, $gameStor, EventTarget::Month); + $traceMonthlyGeneral('after-month-event'); + postUpdateMonthly($monthlyRng); + $traceMonthlyGeneral('after-post-update'); // 다음달로 넘김 $prevTurn = $nextTurn; diff --git a/hwe/sammo/WarUnitGeneral.php b/hwe/sammo/WarUnitGeneral.php index c9bb5e16..0ab5fc6c 100644 --- a/hwe/sammo/WarUnitGeneral.php +++ b/hwe/sammo/WarUnitGeneral.php @@ -371,6 +371,34 @@ class WarUnitGeneral extends WarUnit function applyDB(\MeekroDB $db): bool { + $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS'); + if ( + PHP_SAPI === 'cli' + && getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' + && is_string($traceGeneralIds) + && in_array((string) $this->getGeneral()->getID(), explode(',', $traceGeneralIds), true) + ) { + $general = $this->getGeneral(); + fwrite(STDOUT, 'AI_GENERAL_PRE_APPLY_TRACE ' . Json::encode([ + 'engine' => 'ref-war', + 'generalId' => $general->getID(), + 'stats' => [ + 'leadership' => $general->getVar('leadership'), + 'strength' => $general->getVar('strength'), + 'intelligence' => $general->getVar('intel'), + ], + 'meta' => [ + 'leadership_exp' => $general->getVar('leadership_exp'), + 'strength_exp' => $general->getVar('strength_exp'), + 'intel_exp' => $general->getVar('intel_exp'), + 'dex1' => $general->getVar('dex1'), + 'dex2' => $general->getVar('dex2'), + 'dex3' => $general->getVar('dex3'), + 'dex4' => $general->getVar('dex4'), + 'dex5' => $general->getVar('dex5'), + ], + ]) . "\n"); + } $affected = $this->getGeneral()->applyDB($db); $this->getLogger()->flush(); return $affected; diff --git a/src/sammo/RandUtil.php b/src/sammo/RandUtil.php index 3bede07a..12fe0c26 100644 --- a/src/sammo/RandUtil.php +++ b/src/sammo/RandUtil.php @@ -4,13 +4,36 @@ namespace sammo; class RandUtil { + private ?int $traceGeneralId = null; + private int $traceSequence = 0; + public function __construct(public readonly RNG $rng) { } + public function setTraceGeneralId(?int $generalId): void + { + $this->traceGeneralId = $generalId; + } + + private function trace(string $method, mixed $result): void + { + if ($this->traceGeneralId === null) { + return; + } + fwrite(STDOUT, 'AI_RNG_TRACE ' . json_encode([ + 'generalId' => $this->traceGeneralId, + 'sequence' => $this->traceSequence++, + 'method' => $method, + 'result' => $result, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + } + public function nextFloat1(): float { - return $this->rng->nextFloat1(); + $result = $this->rng->nextFloat1(); + $this->trace('nextFloat1', $result); + return $result; } public function nextRange(int|float $min, int|float $max): float @@ -25,16 +48,22 @@ class RandUtil if ($range > $this->rng->getMaxInt()) { throw new \InvalidArgumentException("Invalid random int range"); } - return $this->rng->nextInt($range) + $min; + $result = $this->rng->nextInt($range) + $min; + $this->trace('nextRangeInt', $result); + return $result; } public function nextInt(?int $max = null): int{ - return $this->rng->nextInt($max); + $result = $this->rng->nextInt($max); + $this->trace('nextInt', $result); + return $result; } public function nextBit(): bool { - return $this->rng->nextBits(1) !== "\0"; + $result = $this->rng->nextBits(1) !== "\0"; + $this->trace('nextBit', $result); + return $result; } public function nextBool(int|float $prob = 0.5): bool @@ -97,7 +126,7 @@ class RandUtil if(!$keys){ throw new \InvalidArgumentException(); } - $keyIdx = $this->rng->nextInt(count($keys) - 1); + $keyIdx = $this->nextInt(count($keys) - 1); return $items[$keys[$keyIdx]]; } diff --git a/src/sammo/TimeUtil.php b/src/sammo/TimeUtil.php index 29b10b54..7948476a 100644 --- a/src/sammo/TimeUtil.php +++ b/src/sammo/TimeUtil.php @@ -7,6 +7,19 @@ use phpDocumentor\Reflection\Types\Boolean; class TimeUtil { + private static function comparisonNow(): ?\DateTimeImmutable + { + $comparisonNow = getenv('REF_COMPARISON_NOW'); + if ( + getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1' + || !is_string($comparisonNow) + || $comparisonNow === '' + ) { + return null; + } + return new \DateTimeImmutable($comparisonNow); + } + /** @deprecated */ public static function DateToday() { @@ -87,7 +100,7 @@ class TimeUtil public static function now(bool $withFraction = false): string { - $obj = new \DateTime(); + $obj = static::comparisonNow() ?? new \DateTimeImmutable(); return static::format($obj, $withFraction); } @@ -182,11 +195,19 @@ class TimeUtil } public static function nowDateTime(): \DateTime{ + $comparisonNow = static::comparisonNow(); + if ($comparisonNow !== null) { + return \DateTime::createFromImmutable($comparisonNow); + } $now = time(); return static::secondsToDateTime($now, false, true); } public static function nowDateTimeImmutable(): \DateTimeImmutable{ + $comparisonNow = static::comparisonNow(); + if ($comparisonNow !== null) { + return $comparisonNow; + } $now = time(); return static::secondsToDateTime($now, true, true); }