From 1721e5f6d416f8c0ce31ccff3152750b8eb47338 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 12 Aug 2026 15:35:52 +0000 Subject: [PATCH] test(compare): reproduce invader game resume --- .../fixtures/unification_invader_resume.json | 85 ++++++++ hwe/compare/monthly_event_trace.php | 206 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 hwe/compare/fixtures/unification_invader_resume.json diff --git a/hwe/compare/fixtures/unification_invader_resume.json b/hwe/compare/fixtures/unification_invader_resume.json new file mode 100644 index 00000000..b2e5b2a2 --- /dev/null +++ b/hwe/compare/fixtures/unification_invader_resume.json @@ -0,0 +1,85 @@ +{ + "action": "UnificationInvaderResume", + "winnerGeneralId": 1, + "setup": { + "resetCities": true, + "setAllCitiesNation": 1, + "resetGenerals": true, + "deleteOtherGenerals": true, + "keepGeneralIds": [1, 2, 3, 4, 5, 6, 7, 8], + "resetNations": true, + "deleteOtherNations": true, + "keepNationIds": [1], + "resetDiplomacy": true, + "resetEvents": true, + "clearInheritanceOwnerIds": [1], + "inheritancePrevious": { + "1": 0 + }, + "nation": [ + { + "id": 1, + "values": { + "name": "재현국", + "color": "#336699", + "capital": 1, + "gennum": 8, + "gold": 100000, + "rice": 100000, + "tech": 1000, + "power": 1000, + "level": 2, + "type": "che_유가", + "aux": {} + } + } + ], + "general": [ + { + "id": 1, + "values": { + "name": "재현군주", + "nation": 1, + "city": 1, + "officer_level": 12, + "owner": 1, + "npc": 0, + "age": 30, + "bornyear": 170, + "deadyear": 260, + "belong": 240, + "aux": {} + } + }, + { "id": 2, "values": { "name": "재현관직11", "nation": 1, "city": 1, "officer_level": 11, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 3, "values": { "name": "재현관직10", "nation": 1, "city": 1, "officer_level": 10, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 4, "values": { "name": "재현관직9", "nation": 1, "city": 1, "officer_level": 9, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 5, "values": { "name": "재현관직8", "nation": 1, "city": 1, "officer_level": 8, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 6, "values": { "name": "재현관직7", "nation": 1, "city": 1, "officer_level": 7, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 7, "values": { "name": "재현관직6", "nation": 1, "city": 1, "officer_level": 6, "owner": 0, "npc": 2, "aux": {} } }, + { "id": 8, "values": { "name": "재현관직5", "nation": 1, "city": 1, "officer_level": 5, "owner": 0, "npc": 2, "aux": {} } } + ], + "nationEnvironment": [ + { "nationId": 1, "key": "nationNotice", "value": { "msg": "재현" } }, + { "nationId": 1, "key": "scout_msg", "value": "재현" }, + { "nationId": 1, "key": "max_power", "value": {} } + ], + "gameEnvironment": { + "isunited": 0, + "refreshLimit": 30, + "block_change_scout": true + }, + "syncEnvironment": true + }, + "environment": { + "year": 200, + "month": 1, + "startyear": 180, + "turnterm": 10, + "turntime": "0200-01-01 00:00:00", + "show_img_level": 3, + "stored_icons": [], + "icon_path": ".", + "fiction": [0] + } +} diff --git a/hwe/compare/monthly_event_trace.php b/hwe/compare/monthly_event_trace.php index f160f5a7..2c100cc3 100644 --- a/hwe/compare/monthly_event_trace.php +++ b/hwe/compare/monthly_event_trace.php @@ -752,6 +752,204 @@ function comparisonNationBettingLifecycleTrace( ]; } +/** + * Reproduce the player-visible handoff from a completed unification to the + * invader event and prove that the real turn engine can cross the next monthly + * boundary afterwards. The surrounding runner executes this only against its + * disposable MariaDB clone. + * + * @param array $request + * @param array $environment + * @return array + */ +function comparisonUnificationInvaderResumeTrace(array $request, array $environment): array +{ + $db = DB::db(); + $gameStorage = KVStorage::getStorage($db, 'game_env'); + $winnerGeneralId = $request['winnerGeneralId'] ?? null; + if (!is_int($winnerGeneralId) || $winnerGeneralId <= 0) { + throw new \InvalidArgumentException('UnificationInvaderResume requires a positive winnerGeneralId'); + } + + $winnerNationId = (int)($db->queryFirstField( + 'SELECT nation FROM general WHERE no = %i', + $winnerGeneralId, + ) ?? 0); + if ($winnerNationId <= 0) { + throw new \RuntimeException('winner general must belong to a nation'); + } + + $allCityCount = count(CityConst::all()); + $winnerCityCount = (int)$db->queryFirstField( + 'SELECT COUNT(*) FROM city WHERE nation = %i', + $winnerNationId, + ); + if ($winnerCityCount !== $allCityCount) { + throw new \RuntimeException( + "winner must occupy every scenario city: winner={$winnerCityCount}, scenario={$allCityCount}", + ); + } + + $year = $environment['year'] ?? null; + $month = $environment['month'] ?? null; + $startYear = $environment['startyear'] ?? null; + if (!is_int($year) || !is_int($month) || !is_int($startYear) || $year - $startYear < 20) { + throw new \InvalidArgumentException('fixture must start at least 20 game years after startyear'); + } + + $unificationTick = Util::toInt($gameStorage->turntime); + $elapsedMonths = ($year - $startYear) * 12 + $month - 1; + $gameStorage->starttime = GameClock::addTicks( + cutTurn($unificationTick, Util::toInt($gameStorage->turnterm)), + -$elapsedMonths * GameClock::TICKS_PER_TURN, + ); + $clock = GameClock::fromStorage($gameStorage); + $clock->persistTick($gameStorage, $unificationTick, GameClock::MODE_MANUAL); + $db->update('general', ['turntime' => $unificationTick], true); + $db->update('plock', ['plock' => 0], true); + $gameStorage->resetCache(); + refreshNationStaticInfo(); + + $before = [ + 'year' => Util::toInt($gameStorage->year), + 'month' => Util::toInt($gameStorage->month), + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'nationCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0'), + 'winnerCityCount' => $winnerCityCount, + ]; + + checkEmperior(); + $gameStorage->resetCache(); + + $invaderMessages = []; + foreach ($db->query( + 'SELECT id, mailbox, message FROM message WHERE mailbox = %i ORDER BY id', + $winnerGeneralId, + ) as $row) { + $message = Json::decode($row['message']); + if (($message['option']['action'] ?? null) !== 'raiseInvader') { + continue; + } + $invaderMessages[] = [ + 'id' => (int)$row['id'], + 'mailbox' => (int)$row['mailbox'], + 'text' => (string)($message['text'] ?? ''), + 'args' => $message['option']['args'] ?? null, + 'used' => (bool)($message['option']['used'] ?? false), + ]; + } + if (count($invaderMessages) !== 3) { + throw new \RuntimeException('unification must create exactly three invader choices for the ruler'); + } + + $buttonSnapshot = [ + 'year' => Util::toInt($gameStorage->year), + 'month' => Util::toInt($gameStorage->month), + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockMode' => GameClock::fromStorage($gameStorage)->getMode(), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"), + 'messages' => $invaderMessages, + ]; + + // Simulate an autoreset poll that recorded the original unification pause. + // A successful event start must replace this stale operational wall anchor. + $pausedAnchor = '2000-01-01 00:00:00.000000'; + $gameStorage->autoreset_united_wall_anchor = $pausedAnchor; + + $selectedMessageId = $invaderMessages[array_key_last($invaderMessages)]['id']; + $message = Message::getMessageByID($selectedMessageId); + if (!$message instanceof RaiseInvaderMessage) { + throw new \RuntimeException('selected invader message did not hydrate as RaiseInvaderMessage'); + } + $reason = ''; + $decisionResult = $message->agreeMessage($winnerGeneralId, $reason); + $gameStorage->resetCache(); + + $afterSummon = [ + 'result' => $decisionResult, + 'reason' => $reason, + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockMode' => GameClock::fromStorage($gameStorage)->getMode(), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'turnterm' => Util::toInt($gameStorage->turnterm), + 'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"), + 'nationCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0'), + 'invaderNationCount' => (int)$db->queryFirstField("SELECT COUNT(*) FROM nation WHERE name LIKE 'ⓞ%족'"), + 'eventCount' => (int)$db->queryFirstField( + "SELECT COUNT(*) FROM event WHERE action LIKE '%Invader%'", + ), + 'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor, + 'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $pausedAnchor, + ]; + + $resumeBoundary = addTurn( + cutTurn(Util::toInt($gameStorage->turntime), Util::toInt($gameStorage->turnterm)), + Util::toInt($gameStorage->turnterm), + ); + GameClock::fromStorage($gameStorage)->persistTick( + $gameStorage, + GameClock::addTicks($resumeBoundary, 1), + GameClock::MODE_MANUAL, + ); + $gameStorage->resetCache(); + + // Likewise, active event progress must move the inactivity anchor. Leaving + // this stale makes j_autoreset close a healthy invader game on the original + // unification timeline. + $stalledAnchor = '2000-01-02 00:00:00.000000'; + $gameStorage->autoreset_united_wall_anchor = $stalledAnchor; + + $engineCalls = 0; + $executedCalls = 0; + $lockedCalls = 0; + while ($engineCalls < 20 && Util::toInt($gameStorage->turntime) < $resumeBoundary) { + $executed = false; + $locked = false; + TurnExecutionHelper::executeAllCommand($executed, $locked); + $engineCalls++; + if ($executed) { + $executedCalls++; + } + if ($locked) { + $lockedCalls++; + } + $gameStorage->resetCache(); + } + + $afterResume = [ + 'year' => Util::toInt($gameStorage->year), + 'month' => Util::toInt($gameStorage->month), + 'isunited' => Util::toInt($gameStorage->isunited), + 'clockMode' => GameClock::fromStorage($gameStorage)->getMode(), + 'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(), + 'turntime' => Util::toInt($gameStorage->turntime), + 'resumeBoundary' => $resumeBoundary, + 'engineCalls' => $engineCalls, + 'executedCalls' => $executedCalls, + 'lockedCalls' => $lockedCalls, + 'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"), + 'worldHistoryCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM world_history'), + 'generalRecordCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM general_record'), + 'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor, + 'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $stalledAnchor, + ]; + + return [ + 'schemaVersion' => 1, + 'engine' => 'ref', + 'action' => 'UnificationInvaderResume', + 'before' => $before, + 'buttonSnapshot' => $buttonSnapshot, + 'afterSummon' => $afterSummon, + 'afterResume' => $afterResume, + ]; +} + function comparisonMonthlyEventTraceMain(): void { try { @@ -775,6 +973,7 @@ function comparisonMonthlyEventTraceMain(): void 'NpcNationLifecycle', 'RaiseInvader', 'InvaderLifecycle', + 'UnificationInvaderResume', 'AutoDeleteInvader', 'InvaderEnding', 'ChangeCity', @@ -1141,6 +1340,13 @@ function comparisonMonthlyEventTraceMain(): void ), PHP_EOL; return; } + if ($actionName === 'UnificationInvaderResume') { + echo json_encode( + comparisonUnificationInvaderResumeTrace($request, $environment), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ); + return; + } if ($actionName === 'NpcNationLifecycle') { foreach (['turnterm', 'turntime'] as $requiredEnvironmentKey) { if (!array_key_exists($requiredEnvironmentKey, $environment)) {