From a041a8132f60fc2b8eb9292bc9cc02151219306b Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 23 Aug 2026 21:49:10 +0000 Subject: [PATCH] =?UTF-8?q?test:=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=B0=A8?= =?UTF-8?q?=EB=93=B1=20=EC=8A=A4=EB=83=85=EC=83=B7=EA=B3=BC=20=EC=88=98?= =?UTF-8?q?=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EA=B3=84=EC=B8=A1=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명령 상태의 자료형과 로그, 메시지, 순위 그래프를 보존하고 실제 명령 호출을 관찰하는 비교 전용 trace를 추가합니다. 계측은 CLI 및 명시적 차등 테스트 환경에서만 실행되며 제품 동작은 변경하지 않습니다. --- .../instant_diplomacy_response_trace.php | 189 +++++++++++ hwe/compare/turn_command_trace.php | 93 +++++- hwe/compare/turn_full_lifecycle_trace.php | 247 ++++++++++++++ hwe/compare/turn_state_snapshot.php | 306 +++++++++++++++++- 4 files changed, 814 insertions(+), 21 deletions(-) create mode 100644 hwe/compare/instant_diplomacy_response_trace.php create mode 100644 hwe/compare/turn_full_lifecycle_trace.php diff --git a/hwe/compare/instant_diplomacy_response_trace.php b/hwe/compare/instant_diplomacy_response_trace.php new file mode 100644 index 00000000..5e36dd15 --- /dev/null +++ b/hwe/compare/instant_diplomacy_response_trace.php @@ -0,0 +1,189 @@ + */ +function comparisonInstantDiplomacyMessageRow(int $messageId): array +{ + $row = DB::db()->queryFirstRow( + 'SELECT id, mailbox, type, src, dest, time, valid_until, message FROM message WHERE id = %i', + $messageId, + ); + if ($row === null) { + throw new \RuntimeException("message {$messageId} was not persisted"); + } + return [ + 'id' => (int)$row['id'], + 'mailbox' => (int)$row['mailbox'], + 'type' => (string)$row['type'], + 'sourceId' => (int)$row['src'], + 'destinationId' => (int)$row['dest'], + 'timeTick' => (int)$row['time'], + 'validUntilTick' => (int)$row['valid_until'], + 'payload' => comparisonJsonValue($row['message']), + ]; +} + +/** @return array */ +function comparisonRunInstantDiplomacyResponse(array $request): array +{ + $action = $request['action'] ?? null; + if (!in_array($action, [ + DiplomaticMessage::TYPE_NO_AGGRESSION, + DiplomaticMessage::TYPE_CANCEL_NA, + DiplomaticMessage::TYPE_STOP_WAR, + ], true)) { + throw new \InvalidArgumentException('action must be noAggression, cancelNA, or stopWar'); + } + $actorGeneralId = $request['actorGeneralId'] ?? null; + $proposerGeneralId = $request['proposerGeneralId'] ?? null; + if (!is_int($actorGeneralId) || $actorGeneralId < 1) { + throw new \InvalidArgumentException('actorGeneralId must be a positive integer'); + } + if (!is_int($proposerGeneralId) || $proposerGeneralId < 1) { + throw new \InvalidArgumentException('proposerGeneralId must be a positive integer'); + } + if (($request['response'] ?? true) !== true) { + throw new \InvalidArgumentException('this comparison runner currently covers accepted responses only'); + } + + comparisonApplyTurnFixtureSetup($request['setup'] ?? null); + $db = DB::db(); + $actor = $db->queryFirstRow( + 'SELECT no, name, nation FROM general WHERE no = %i', + $actorGeneralId, + ); + $proposer = $db->queryFirstRow( + 'SELECT no, name, nation FROM general WHERE no = %i', + $proposerGeneralId, + ); + if ($actor === null || $proposer === null) { + throw new \RuntimeException('fixture generals are missing'); + } + $actorNation = $db->queryFirstRow( + 'SELECT nation, name, color FROM nation WHERE nation = %i', + (int)$actor['nation'], + ); + $proposerNation = $db->queryFirstRow( + 'SELECT nation, name, color FROM nation WHERE nation = %i', + (int)$proposer['nation'], + ); + if ($actorNation === null || $proposerNation === null) { + throw new \RuntimeException('fixture nations are missing'); + } + + $messageBaseline = (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM message') ?? 0); + $logBaseline = (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0); + $src = new MessageTarget( + $proposerGeneralId, + (string)$proposer['name'], + (int)$proposerNation['nation'], + (string)$proposerNation['name'], + (string)$proposerNation['color'], + ); + $dest = new MessageTarget( + $actorGeneralId, + (string)$actor['name'], + (int)$actorNation['nation'], + (string)$actorNation['name'], + (string)$actorNation['color'], + ); + $option = ['action' => $action]; + if ($action === DiplomaticMessage::TYPE_NO_AGGRESSION) { + $year = $request['year'] ?? null; + $month = $request['month'] ?? null; + if (!is_int($year) || !is_int($month)) { + throw new \InvalidArgumentException('noAggression requires integer year and month'); + } + $option['year'] = $year; + $option['month'] = $month; + } + $proposal = new DiplomaticMessage( + MessageType::diplomacy, + $src, + $dest, + '외교 제안', + Message::gameNow(), + new \DateTime('9999-12-31'), + $option, + ); + $proposalMessageId = $proposal->send(); + $proposalBefore = comparisonInstantDiplomacyMessageRow($proposalMessageId); + + $observe = $request['observe'] ?? []; + if (!is_array($observe)) { + throw new \InvalidArgumentException('observe must be an object'); + } + $observe['logAfterId'] = $logBaseline; + $observe['messageAfterId'] = $messageBaseline; + $snapshotRequest = ['observe' => $observe]; + $before = comparisonTurnStateSnapshot($snapshotRequest); + + $session = Session::getInstance(); + $_SESSION[UniqueConst::$serverID . Session::GAME_KEY_GENERAL_ID] = $actorGeneralId; + $_SESSION[UniqueConst::$serverID . Session::GAME_KEY_GENERAL_NAME] = (string)$actor['name']; + $api = new DecideMessageResponse(dirname(__DIR__), [ + 'msgID' => $proposalMessageId, + 'response' => true, + ]); + $validationError = $api->validateArgs(); + if ($validationError !== null) { + throw new \RuntimeException("DecideMessageResponse validation failed: {$validationError}"); + } + $apiResult = $api->launch($session, null, null); + if (!is_array($apiResult)) { + throw new \RuntimeException('DecideMessageResponse did not return an array result'); + } + + return [ + 'schemaVersion' => 1, + 'engine' => 'ref', + 'execution' => [ + 'kind' => 'instantDiplomacyMessageResponse', + 'entryPoint' => DecideMessageResponse::class, + 'actorGeneralId' => $actorGeneralId, + 'proposerGeneralId' => $proposerGeneralId, + 'action' => $action, + 'response' => true, + 'outcome' => $apiResult, + 'proposalMessageId' => $proposalMessageId, + 'proposalBefore' => $proposalBefore, + 'proposalAfter' => comparisonInstantDiplomacyMessageRow($proposalMessageId), + ], + 'before' => $before, + 'after' => comparisonTurnStateSnapshot($snapshotRequest), + 'rng' => [], + ]; +} + +try { + $input = stream_get_contents(STDIN); + $request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($request)) { + throw new \InvalidArgumentException('request must be an object'); + } + echo json_encode( + comparisonRunInstantDiplomacyResponse($request), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ), PHP_EOL; +} catch (\Throwable $throwable) { + fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL); + exit(1); +} diff --git a/hwe/compare/turn_command_trace.php b/hwe/compare/turn_command_trace.php index d7197fd0..908439bc 100644 --- a/hwe/compare/turn_command_trace.php +++ b/hwe/compare/turn_command_trace.php @@ -99,6 +99,10 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void if (!is_array($world)) { throw new \InvalidArgumentException('setup.world must be an object'); } + $freezeClock = $world['freezeClock'] ?? false; + if (!is_bool($freezeClock)) { + throw new \InvalidArgumentException('setup.world.freezeClock must be a boolean'); + } $game = KVStorage::getStorage($db, 'game_env'); foreach (['year', 'month', 'startyear', 'init_year', 'init_month'] as $key) { $fixtureKey = match ($key) { @@ -160,6 +164,12 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void } GameConst::$staticEventHandlers = $handlersByEvent; } + if ($freezeClock) { + $clock = GameClock::fromStorage($game); + $frozenTick = $clock->nowTick(); + $clock->persistTick($game, $frozenTick, GameClock::MODE_MANUAL); + $game->resetCache(); + } } foreach (['nations', 'cities', 'generals', 'troops', 'diplomacy', 'generalTurns', 'nationTurns'] as $collection) { @@ -440,6 +450,8 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void 'killTurn' => 'killturn', 'npcState' => 'npc', 'blockState' => 'block', + 'picture' => 'picture', + 'imageServer' => 'imgsvr', 'specialDomestic' => 'special', 'specialWar' => 'special2', 'personality' => 'personal', @@ -592,6 +604,43 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void } } +/** @return array{generalIds: list, cityIds: list, nationIds: list, troopIds: list} */ +function comparisonTurnCommandEntityIds(): array +{ + $db = DB::db(); + return [ + 'generalIds' => array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no')), + 'cityIds' => array_map('intval', $db->queryFirstColumn('SELECT city FROM city ORDER BY city')), + 'nationIds' => array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation')), + 'troopIds' => array_map('intval', $db->queryFirstColumn('SELECT troop_leader FROM troop ORDER BY troop_leader')), + ]; +} + +/** + * Extend an explicit selector with entities created by the command. Without + * this closure, a successful founding/recruitment can remain absent from both + * snapshots and therefore produce a false green differential. + * + * @param array{observe?: mixed} $snapshotRequest + * @param array{generalIds: list, cityIds: list, nationIds: list, troopIds: list} $before + * @return array{observe: array} + */ +function comparisonCloseSnapshotRequestOverCreatedEntities(array $snapshotRequest, array $before): array +{ + $observe = $snapshotRequest['observe'] ?? []; + if (!is_array($observe)) { + throw new \InvalidArgumentException('observe must be an object'); + } + $after = comparisonTurnCommandEntityIds(); + foreach (['generalIds', 'cityIds', 'nationIds', 'troopIds'] as $selector) { + $selected = comparisonIntegerList($observe[$selector] ?? [], $selector); + $created = array_values(array_diff($after[$selector], $before[$selector])); + $observe[$selector] = array_values(array_unique([...$selected, ...$created])); + sort($observe[$selector], SORT_NUMERIC); + } + return ['observe' => $observe]; +} + function comparisonRunTurnCommand(array $request): array { if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') { @@ -662,6 +711,7 @@ function comparisonRunTurnCommand(array $request): array } } $snapshotRequest = ['observe' => $request['observe'] ?? []]; + $beforeEntityIds = comparisonTurnCommandEntityIds(); $before = comparisonTurnStateSnapshot($snapshotRequest); $db = DB::db(); $gameStorage = KVStorage::getStorage($db, 'game_env'); @@ -705,7 +755,9 @@ function comparisonRunTurnCommand(array $request): array $general->setVar('troop', $troopId); StaticEventHandler::handleEvent($general, null, \sammo\API\Troop\JoinTroop::class, [], $args); $general->applyDB($db); - $after = comparisonTurnStateSnapshot($snapshotRequest); + $after = comparisonTurnStateSnapshot( + comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds), + ); return [ 'schemaVersion' => 1, @@ -732,7 +784,9 @@ function comparisonRunTurnCommand(array $request): array $completed = $command->run(NoRNG::rngInstance()); $command->setNextAvailable(); $general->getLogger()->flush(); - $after = comparisonTurnStateSnapshot($snapshotRequest); + $after = comparisonTurnStateSnapshot( + comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds), + ); return [ 'schemaVersion' => 1, @@ -816,7 +870,9 @@ function comparisonRunTurnCommand(array $request): array $general->getLogger()->flush(); $turn->applyDB(); unset($turn); - $after = comparisonTurnStateSnapshot($snapshotRequest); + $after = comparisonTurnStateSnapshot( + comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds), + ); $resultTurnRaw = $resultTurn->toRaw(); $resultTerm = (int)($resultTurnRaw['term'] ?? 0); $preReqTurn = $command->getPreReqTurn(); @@ -853,6 +909,9 @@ function comparisonRunTurnCommand(array $request): array return [ 'schemaVersion' => 1, 'engine' => 'ref', + 'harness' => [ + 'messageSharedIconBaseUrl' => ServConfig::getSharedIconPath(), + ], 'execution' => [ 'kind' => $kind, 'actorGeneralId' => $actorGeneralId, @@ -871,17 +930,21 @@ function comparisonRunTurnCommand(array $request): array ]; } -try { - $input = stream_get_contents(STDIN); - $request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR); - if (!is_array($request)) { - throw new \InvalidArgumentException('request must be an object'); +if (!defined('SAMMO_TURN_COMPARISON_LIBRARY_ONLY')) { + try { + $input = stream_get_contents(STDIN); + $request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($request)) { + throw new \InvalidArgumentException('request must be an object'); + } + echo json_encode( + comparisonRunTurnCommand($request), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ), PHP_EOL; + } catch (\Throwable $throwable) { + fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL); + exit(1); } - echo json_encode( - comparisonRunTurnCommand($request), - JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, - ), PHP_EOL; -} catch (\Throwable $throwable) { - fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL); - exit(1); +} elseif (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') { + throw new \RuntimeException('TURN_DIFFERENTIAL_ENABLED=1 is required for comparison library mode'); } diff --git a/hwe/compare/turn_full_lifecycle_trace.php b/hwe/compare/turn_full_lifecycle_trace.php new file mode 100644 index 00000000..4fb7c548 --- /dev/null +++ b/hwe/compare/turn_full_lifecycle_trace.php @@ -0,0 +1,247 @@ +> */ + private static array $phases = []; + + private bool $recordedPersistence = false; + + /** @return list> */ + public static function phases(): array + { + return self::$phases; + } + + private function record(string $phase, array $detail = []): void + { + self::$phases[] = [ + 'sequence' => count(self::$phases), + 'phase' => $phase, + 'generalId' => $this->getGeneral()->getID(), + ...$detail, + ]; + } + + public function preprocessCommand(RandUtil $rng, array $env) + { + $this->record('preprocess'); + return parent::preprocessCommand($rng, $env); + } + + public function processBlocked(): bool + { + $blocked = parent::processBlocked(); + $this->record('block', ['blocked' => $blocked]); + return $blocked; + } + + public function processNationCommand(RandUtil $rng, Command\NationCommand $commandObj): LastTurn + { + $this->record('nation_command', ['action' => $commandObj->getRawClassName()]); + $result = parent::processNationCommand($rng, $commandObj); + $this->record('nation_command_resolved', ['lastTurn' => $result->toRaw()]); + return $result; + } + + public function processCommand(RandUtil $rng, Command\GeneralCommand $commandObj, bool $autorunMode) + { + $this->record('general_command', ['action' => $commandObj->getRawClassName()]); + $result = parent::processCommand($rng, $commandObj, $autorunMode); + $this->record('general_command_resolved', ['lastTurn' => $result->toRaw()]); + return $result; + } + + public function updateTurnTime() + { + $db = DB::db(); + $general = $this->getGeneral(); + $nationId = $general->getNationID(); + $officerLevel = (int)$general->getVar('officer_level'); + $this->record('queues_shifted', [ + 'generalAction' => $db->queryFirstField( + 'SELECT action FROM general_turn WHERE general_id = %i AND turn_idx = 0', + $general->getID(), + ), + 'nationAction' => $db->queryFirstField( + 'SELECT action FROM nation_turn WHERE nation_id = %i AND officer_level = %i AND turn_idx = 0', + $nationId, + $officerLevel, + ), + ]); + parent::updateTurnTime(); + $this->record('turn_state_advanced', [ + 'turnTick' => $general->getTurnTick(), + 'killTurn' => (int)$general->getVar('killturn'), + 'mySet' => (int)$general->getVar('myset'), + ]); + } + + public function applyDB() + { + parent::applyDB(); + if ($this->recordedPersistence) { + return; + } + $this->recordedPersistence = true; + $row = DB::db()->queryFirstRow( + 'SELECT turntime, killturn, myset, last_turn FROM general WHERE no = %i', + $this->getGeneral()->getID(), + ); + $this->record('persisted', [ + 'turnTick' => isset($row['turntime']) ? (int)$row['turntime'] : null, + 'killTurn' => isset($row['killturn']) ? (int)$row['killturn'] : null, + 'mySet' => isset($row['myset']) ? (int)$row['myset'] : null, + 'lastTurn' => isset($row['last_turn']) ? comparisonJsonValue($row['last_turn']) : null, + ]); + } +} + +/** @return array */ +function comparisonRunFullTurnLifecycle(array $request): array +{ + $actorGeneralId = $request['actorGeneralId'] ?? null; + $generalAction = $request['generalAction'] ?? null; + $nationAction = $request['nationAction'] ?? null; + $generalArgs = $request['generalArgs'] ?? []; + $nationArgs = $request['nationArgs'] ?? []; + if (!is_int($actorGeneralId) || $actorGeneralId < 1) { + throw new \InvalidArgumentException('actorGeneralId must be a positive integer'); + } + if (!is_string($generalAction) || $generalAction === '') { + throw new \InvalidArgumentException('generalAction must be a non-empty string'); + } + if (!is_string($nationAction) || $nationAction === '') { + throw new \InvalidArgumentException('nationAction must be a non-empty string'); + } + if (!is_array($generalArgs) || !is_array($nationArgs)) { + throw new \InvalidArgumentException('generalArgs and nationArgs must be objects'); + } + + comparisonApplyTurnFixtureSetup($request['setup'] ?? null); + $db = DB::db(); + $actor = $db->queryFirstRow( + 'SELECT no, nation, officer_level, turntime FROM general WHERE no = %i', + $actorGeneralId, + ); + if ($actor === null) { + throw new \RuntimeException('fixture actor is missing'); + } + $nationId = (int)$actor['nation']; + $officerLevel = (int)$actor['officer_level']; + if ($nationId < 1 || $officerLevel < 5) { + throw new \InvalidArgumentException('full lifecycle fixture requires a nation officer actor'); + } + + $generalTurn = [ + 'general_id' => $actorGeneralId, + 'turn_idx' => 0, + 'action' => $generalAction, + 'arg' => Json::encode($generalArgs), + 'brief' => $generalAction, + ]; + $db->insertUpdate('general_turn', $generalTurn, $generalTurn); + $nationTurn = [ + 'nation_id' => $nationId, + 'officer_level' => $officerLevel, + 'turn_idx' => 0, + 'action' => $nationAction, + 'arg' => Json::encode($nationArgs), + 'brief' => $nationAction, + ]; + $db->insertUpdate('nation_turn', $nationTurn, $nationTurn); + + $observe = $request['observe'] ?? []; + if (!is_array($observe)) { + throw new \InvalidArgumentException('observe must be an object'); + } + $snapshotRequest = ['observe' => $observe]; + $before = comparisonTurnStateSnapshot($snapshotRequest); + + $actorTurnTick = (int)$actor['turntime']; + $dueIds = array_map( + static fn(array $row): int => (int)$row['no'], + $db->query( + 'SELECT no FROM general WHERE turntime < %i ORDER BY turntime ASC, no ASC', + $actorTurnTick + 1, + ), + ); + if ($dueIds !== [$actorGeneralId]) { + throw new \RuntimeException( + 'full lifecycle fixture must isolate exactly the actor as due: ' . Json::encode($dueIds), + ); + } + + $game = KVStorage::getStorage($db, 'game_env'); + [$year, $month] = $game->getValuesAsArray(['year', 'month']); + [$executionOver, $currentTurn] = TurnFullLifecycleComparisonHelper::executeGeneralCommandUntil( + $actorTurnTick + 1, + new \DateTimeImmutable('+1 hour'), + (int)$year, + (int)$month, + ); + if ($executionOver || $currentTurn !== $actorTurnTick) { + throw new \RuntimeException('product lifecycle did not complete the isolated actor turn'); + } + + return [ + 'schemaVersion' => 1, + 'engine' => 'ref', + 'execution' => [ + 'kind' => 'general', + 'actorGeneralId' => $actorGeneralId, + 'action' => $generalAction, + 'args' => $generalArgs, + 'seedDomain' => 'generalCommand', + 'outcome' => [ + 'entryPoint' => TurnExecutionHelper::class . '::executeGeneralCommandUntil', + 'generalAction' => $generalAction, + 'nationAction' => $nationAction, + 'phases' => TurnFullLifecycleComparisonHelper::phases(), + ], + ], + 'before' => $before, + 'after' => comparisonTurnStateSnapshot($snapshotRequest), + // This runner proves lifecycle topology. Command RNG remains covered by + // the command-boundary matrices, which expose operation-level traces. + 'rng' => [], + ]; +} + +try { + $input = stream_get_contents(STDIN); + $request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($request)) { + throw new \InvalidArgumentException('request must be an object'); + } + echo json_encode( + comparisonRunFullTurnLifecycle($request), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, + ), PHP_EOL; +} catch (\Throwable $throwable) { + fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL); + exit(1); +} diff --git a/hwe/compare/turn_state_snapshot.php b/hwe/compare/turn_state_snapshot.php index f966baad..5ec4a4a0 100644 --- a/hwe/compare/turn_state_snapshot.php +++ b/hwe/compare/turn_state_snapshot.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace sammo; use sammo\Enums\RankColumn; +use sammo\Enums\GeneralStorKey; +use sammo\Enums\MessageType; if (PHP_SAPI !== 'cli') { http_response_code(404); @@ -41,7 +43,15 @@ function comparisonJsonValue(mixed $value): mixed return $value; } try { - return json_decode($value, true, flags: JSON_THROW_ON_ERROR); + $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + // json_decode(..., true) collapses an empty JSON object into the same + // PHP array as `[]`, and json_encode would then report both as an + // array. Preserve the database container type for fail-closed graph + // comparison. + if ($decoded === [] && str_starts_with(ltrim($value), '{')) { + return (object)[]; + } + return $decoded; } catch (\JsonException) { return $value; } @@ -55,6 +65,91 @@ function comparisonOptionalCode(mixed $value): ?string return $value; } +function comparisonCommandInteger(mixed $value, string $label, ?int $fallback): ?int +{ + if ($value === null) { + return $fallback; + } + if (!is_int($value)) { + throw new \InvalidArgumentException("{$label} must be an integer"); + } + return $value; +} + +function comparisonCommandBoolean(mixed $value, string $label): bool +{ + if ($value === null || $value === false || $value === 0) { + return false; + } + if ($value === true || $value === 1) { + return true; + } + throw new \InvalidArgumentException("{$label} must be a boolean flag"); +} + +function comparisonCommandOptionalString(mixed $value, string $label): ?string +{ + if ($value === null || $value === '') { + return null; + } + if (!is_string($value)) { + throw new \InvalidArgumentException("{$label} must be a string"); + } + return $value; +} + +/** @return array{turnSecond: int, turnFraction: int} */ +function comparisonCommandTurnOffset(int $turnTick, int $baseTurnTick, int $ticksPerSecond): array +{ + if ($ticksPerSecond <= 0) { + throw new \InvalidArgumentException('ticksPerSecond must be positive'); + } + $offsetTicks = $turnTick - $baseTurnTick; + $turnSecond = intdiv($offsetTicks, $ticksPerSecond); + $remainingTicks = $offsetTicks % $ticksPerSecond; + if ($remainingTicks < 0) { + $turnSecond--; + $remainingTicks += $ticksPerSecond; + } + return [ + 'turnSecond' => $turnSecond, + 'turnFraction' => intdiv($remainingTicks * 1_000_000, $ticksPerSecond), + ]; +} + +/** @return list */ +function comparisonCommandSpyState(mixed $value): array +{ + $decoded = comparisonJsonValue($value); + if ($decoded instanceof \stdClass) { + $decoded = (array)$decoded; + } + if ($decoded === null) { + return []; + } + if (!is_array($decoded)) { + throw new \InvalidArgumentException('nation.commandState.spy must be an object'); + } + $result = []; + foreach ($decoded as $cityId => $remainingTurns) { + if ((!is_int($cityId) && (!is_string($cityId) || !ctype_digit($cityId))) || (int)$cityId < 1) { + throw new \InvalidArgumentException( + 'nation.commandState.spy has an invalid city id: ' . (string)$cityId, + ); + } + $result[] = [ + 'cityId' => (int)$cityId, + 'remainingTurns' => comparisonCommandInteger( + $remainingTurns, + "nation.commandState.spy[{$cityId}]", + null, + ), + ]; + } + usort($result, static fn(array $left, array $right): int => $left['cityId'] <=> $right['cityId']); + return $result; +} + /** @return list */ function comparisonGeneralCooldownSelectors(mixed $value, string $label): array { @@ -189,6 +284,12 @@ function comparisonTurnStateSnapshot(array $request): array $generalIds = comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds'); $cityIds = comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds'); $nationIds = comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds'); + $troopIds = comparisonIntegerList($observe['troopIds'] ?? [], 'troopIds'); + foreach (['allGenerals', 'allCities', 'allNations', 'allTroops', 'includeRankMirrors'] as $allSelector) { + if (array_key_exists($allSelector, $observe) && !is_bool($observe[$allSelector])) { + throw new \InvalidArgumentException("{$allSelector} must be a boolean"); + } + } if (($observe['allGenerals'] ?? false) === true) { $generalIds = array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no')); } @@ -198,6 +299,19 @@ function comparisonTurnStateSnapshot(array $request): array if (($observe['allNations'] ?? false) === true) { $nationIds = array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation')); } + if (($observe['allTroops'] ?? false) === true) { + $troopIds = array_map('intval', $db->queryFirstColumn('SELECT troop_leader FROM troop ORDER BY troop_leader')); + } elseif ($generalIds !== []) { + $linkedTroopIds = array_map( + 'intval', + $db->queryFirstColumn( + 'SELECT DISTINCT troop FROM general WHERE no IN %li AND troop > 0 ORDER BY troop', + $generalIds, + ), + ); + $troopIds = array_values(array_unique([...$troopIds, ...$generalIds, ...$linkedTroopIds])); + sort($troopIds, SORT_NUMERIC); + } $logAfterId = $observe['logAfterId'] ?? 0; $messageAfterId = $observe['messageAfterId'] ?? 0; $includeNationHistoryLogs = $observe['includeNationHistoryLogs'] ?? false; @@ -232,6 +346,10 @@ function comparisonTurnStateSnapshot(array $request): array $game = KVStorage::getStorage($db, 'game_env'); $game->resetCache(); $clock = GameClock::fromStorage($game); + // Use one logical instant for every general in this snapshot. Calling + // nowTick() inside the row loop could cross a realtime tick boundary and + // make unread state depend on query order. + $currentMessageTick = $clock->nowTick(); $worldValues = $game->getValues([ 'year', 'month', @@ -244,6 +362,8 @@ function comparisonTurnStateSnapshot(array $request): array 'develcost', 'killturn', ]); + $baseTurnTick = Util::toInt($worldValues['turntime']); + $ticksPerSecond = $clock->ticksPerSecond(); $nextExecuteStorage = KVStorage::getStorage($db, 'next_execute'); $nextExecuteStorage->resetCache(); $generalCooldowns = array_map( @@ -271,7 +391,7 @@ function comparisonTurnStateSnapshot(array $request): array ); $generals = array_map( - static function (array $row) use ($clock): array { + static function (array $row) use ($clock, $currentMessageTick, $baseTurnTick, $ticksPerSecond): array { $result = comparisonPickRow( $row, [ @@ -325,6 +445,8 @@ function comparisonTurnStateSnapshot(array $request): array 'itemWeapon' => 'weapon', 'itemBook' => 'book', 'itemExtra' => 'item', + 'picture' => 'picture', + 'imageServer' => 'imgsvr', ], ['last_turn', 'aux', 'penalty'], ); @@ -343,7 +465,55 @@ function comparisonTurnStateSnapshot(array $request): array $result['recentWarTime'] = $row['recent_war'] === null ? null : $clock->formatTick(Util::toInt($row['recent_war']), true); - $result['maxBelong'] = (int)($result['meta']['max_belong'] ?? 0); + $meta = $result['meta'] ?? null; + $metaFields = $meta instanceof \stdClass ? get_object_vars($meta) : $meta; + if ($metaFields === null) { + $metaFields = []; + } + if (!is_array($metaFields)) { + throw new \InvalidArgumentException('general.meta must be an object'); + } + $result['maxBelong'] = (int)($metaFields['max_belong'] ?? 0); + $result['dedLevel'] = comparisonCommandInteger( + $row['dedlevel'] ?? null, + 'general.dedLevel', + 0, + ); + $result['affinity'] = comparisonCommandInteger( + $row['affinity'] ?? null, + 'general.affinity', + null, + ); + $result['bornYear'] = comparisonCommandInteger( + $row['bornyear'] ?? null, + 'general.bornYear', + null, + ); + $result['deadYear'] = comparisonCommandInteger( + $row['deadyear'] ?? null, + 'general.deadYear', + null, + ); + $result['npcMessage'] = comparisonCommandOptionalString( + $row['npcmsg'] ?? null, + 'general.npcMessage', + ); + $result['npcOriginalState'] = comparisonCommandInteger( + $row['npc_org'] ?? null, + 'general.npcOriginalState', + 0, + ); + $result['turnTick'] = Util::toInt($row['turntime']); + $turnOffset = comparisonCommandTurnOffset($result['turnTick'], $baseTurnTick, $ticksPerSecond); + $result['turnSecond'] = $turnOffset['turnSecond']; + $result['turnFraction'] = $turnOffset['turnFraction']; + $result['commandState'] = [ + 'recruitmentArmType' => comparisonCommandInteger( + $metaFields['armType'] ?? null, + 'general.commandState.recruitmentArmType', + null, + ), + ]; $ownerId = (int)($row['owner'] ?? 0); $activeActionPoints = 0; if ($ownerId > 0) { @@ -355,6 +525,41 @@ function comparisonTurnStateSnapshot(array $request): array } } $result['hasOwner'] = $ownerId > 0; + $result['ownerIdentity'] = $ownerId > 0 ? (string)$ownerId : null; + $generalId = (int)$row['no']; + $nationId = (int)$row['nation']; + $generalStorage = KVStorage::getStorage(DB::db(), "general_{$generalId}"); + $generalStorage->resetCache(); + [$latestReadDiplomacyMessageId, $latestReadPrivateMessageId] = $generalStorage->getValuesAsArray([ + GeneralStorKey::latestReadDiplomacyMsg, + GeneralStorKey::latestReadPrivateMsg, + ]); + $latestReadPrivateMessageId = (int)($latestReadPrivateMessageId ?? 0); + $latestReadDiplomacyMessageId = (int)($latestReadDiplomacyMessageId ?? 0); + $unreadPrivateCount = (int)(DB::db()->queryFirstField( + 'SELECT COUNT(*) FROM message' + . ' WHERE mailbox = %i AND type = %s AND src <> %i AND id > %i AND valid_until > %i', + $generalId, + MessageType::private->value, + $generalId, + $latestReadPrivateMessageId, + $currentMessageTick, + ) ?? 0); + $diplomacyMailbox = Message::MAILBOX_NATIONAL + $nationId; + $unreadDiplomacyCount = (int)(DB::db()->queryFirstField( + 'SELECT COUNT(*) FROM message' + . ' WHERE mailbox = %i AND type = %s AND src <> %i AND id > %i AND valid_until > %i', + $diplomacyMailbox, + MessageType::diplomacy->value, + $diplomacyMailbox, + $latestReadDiplomacyMessageId, + $currentMessageTick, + ) ?? 0); + $result['messageReadState'] = [ + 'unreadPrivateCount' => $unreadPrivateCount, + 'unreadDiplomacyCount' => $unreadDiplomacyCount, + 'hasUnreadMessage' => $unreadPrivateCount + $unreadDiplomacyCount > 0, + ]; $result['inheritActiveActionPoints'] = $activeActionPoints; return $result; }, @@ -373,6 +578,32 @@ function comparisonTurnStateSnapshot(array $request): array $generalIds, $rankTypes, )); + if (($observe['includeRankMirrors'] ?? false) === true) { + $rankMirrorFields = [ + 'experience' => 'experience', + 'dedication' => 'dedication', + 'dex1' => 'dex1', + 'dex2' => 'dex2', + 'dex3' => 'dex3', + 'dex4' => 'dex4', + 'dex5' => 'dex5', + ]; + foreach ($generals as $general) { + foreach ($rankMirrorFields as $type => $field) { + $rankData[] = [ + 'generalId' => (int)$general['id'], + 'nationId' => (int)$general['nationId'], + 'type' => $type, + 'value' => (int)$general[$field], + ]; + } + } + usort( + $rankData, + static fn(array $left, array $right): int => + [$left['generalId'], $left['type']] <=> [$right['generalId'], $right['type']], + ); + } $cities = array_map( static fn(array $row): array => comparisonPickRow( @@ -432,20 +663,74 @@ function comparisonTurnStateSnapshot(array $request): array ], ['aux'], ); + $meta = $projected['meta'] ?? null; + $metaFields = $meta instanceof \stdClass ? get_object_vars($meta) : $meta; + if ($metaFields === null) { + $metaFields = []; + } + if (!is_array($metaFields)) { + throw new \InvalidArgumentException('nation.meta must be an object'); + } $nationStor = KVStorage::getStorage($db, (int)$row['nation'], 'nation_env'); $receivedAssist = $nationStor->getValue('recv_assist'); if (is_array($receivedAssist) && $receivedAssist !== []) { - $projected['meta']['recv_assist'] = $receivedAssist; + $metaFields['recv_assist'] = $receivedAssist; + $projected['meta'] = $metaFields; } $respondedAssist = $nationStor->getValue('resp_assist'); if (is_array($respondedAssist) && $respondedAssist !== []) { - $projected['meta']['resp_assist'] = $respondedAssist; + $metaFields['resp_assist'] = $respondedAssist; + $projected['meta'] = $metaFields; } + $projected['commandState'] = [ + 'flagChangesRemaining' => comparisonCommandInteger( + $metaFields['can_국기변경'] ?? null, + 'nation.commandState.flagChangesRemaining', + 0, + ), + 'randomCapitalMovesRemaining' => comparisonCommandInteger( + $metaFields['can_무작위수도이전'] ?? null, + 'nation.commandState.randomCapitalMovesRemaining', + 0, + ), + 'spy' => comparisonCommandSpyState($row['spy'] ?? null), + 'collapsed' => comparisonCommandBoolean( + $metaFields['collapsed'] ?? null, + 'nation.commandState.collapsed', + ), + 'rate' => comparisonCommandInteger( + $row['rate'] ?? null, + 'nation.commandState.rate', + 0, + ), + 'bill' => comparisonCommandInteger( + $row['bill'] ?? null, + 'nation.commandState.bill', + 0, + ), + 'secretLimit' => comparisonCommandInteger( + $row['secretlimit'] ?? null, + 'nation.commandState.secretLimit', + 3, + ), + ]; return $projected; }, comparisonRowsById('nation', 'nation', $nationIds), ); + $troops = array_map( + static fn(array $row): array => comparisonPickRow( + $row, + [ + 'id' => 'troop_leader', + 'nationId' => 'nation', + 'name' => 'name', + ], + ), + comparisonRowsById('troop', 'troop_leader', $troopIds), + ); + $diplomacyPairs = []; foreach ($nationIds as $fromNationId) { foreach ($nationIds as $toNationId) { @@ -591,15 +876,21 @@ function comparisonTurnStateSnapshot(array $request): array 'sourceId' => 'src', 'destinationId' => 'dest', 'createdAt' => 'time', + 'validUntil' => 'valid_until', 'payload' => 'message', ], ['message'], ); $result['createdAt'] = $clock->formatTick(Util::toInt($row['time']), true); + $validUntilTick = Util::toInt($row['valid_until']); + $result['validUntil'] = $validUntilTick === GameClock::MAX_SAFE_TICK + ? 'infinite' + : $clock->formatTick($validUntilTick, true); return $result; }, $db->query( - 'SELECT id, mailbox, type, src, dest, time, message FROM message WHERE id > %i ORDER BY id', + 'SELECT id, mailbox, type, src, dest, time, valid_until, message' + . ' FROM message WHERE id > %i ORDER BY id', $messageAfterId, ), ); @@ -611,7 +902,9 @@ function comparisonTurnStateSnapshot(array $request): array 'year' => (int)$worldValues['year'], 'month' => (int)$worldValues['month'], 'tickMinutes' => (int)$worldValues['turnterm'], + 'lastTurnTick' => $baseTurnTick, 'turnTime' => $clock->formatTick(Util::toInt($worldValues['turntime']), true), + 'gameNow' => $clock->formatTick($currentMessageTick, true), 'isUnited' => (int)$worldValues['isunited'], 'scenarioId' => (int)$worldValues['scenario'], 'initYear' => (int)$worldValues['init_year'], @@ -625,6 +918,7 @@ function comparisonTurnStateSnapshot(array $request): array 'rankData' => $rankData, 'cities' => $cities, 'nations' => $nations, + 'troops' => $troops, 'diplomacy' => $diplomacy, 'generalTurns' => $generalTurns, 'nationTurns' => $nationTurns,