*/ function comparisonIntegerList(mixed $value, string $label): array { if ($value === null) { return []; } if (!is_array($value)) { throw new \InvalidArgumentException("{$label} must be an array"); } $result = []; foreach ($value as $entry) { if (!is_int($entry) || $entry < 0) { throw new \InvalidArgumentException("{$label} entries must be non-negative integers"); } $result[$entry] = $entry; } ksort($result, SORT_NUMERIC); return array_values($result); } function comparisonJsonValue(mixed $value): mixed { if (!is_string($value) || $value === '') { return $value; } try { $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; } } function comparisonOptionalCode(mixed $value): ?string { if (!is_string($value) || $value === '' || $value === 'None') { return null; } 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 { if ($value === null) { return []; } if (!is_array($value)) { throw new \InvalidArgumentException("{$label} must be an array"); } $result = []; foreach ($value as $entry) { if (!is_array($entry)) { throw new \InvalidArgumentException("{$label} entries must be objects"); } $generalId = $entry['generalId'] ?? null; $actionName = $entry['actionName'] ?? null; if (!is_int($generalId) || $generalId < 1 || !is_string($actionName) || $actionName === '') { throw new \InvalidArgumentException( "{$label} entries require a positive generalId and non-empty actionName", ); } $result["{$generalId}:{$actionName}"] = [ 'generalId' => $generalId, 'actionName' => $actionName, ]; } ksort($result, SORT_STRING); return array_values($result); } /** @return list */ function comparisonNationCooldownSelectors(mixed $value, string $label): array { if ($value === null) { return []; } if (!is_array($value)) { throw new \InvalidArgumentException("{$label} must be an array"); } $result = []; foreach ($value as $entry) { if (!is_array($entry)) { throw new \InvalidArgumentException("{$label} entries must be objects"); } $nationId = $entry['nationId'] ?? null; $actionName = $entry['actionName'] ?? null; if (!is_int($nationId) || $nationId < 1 || !is_string($actionName) || $actionName === '') { throw new \InvalidArgumentException( "{$label} entries require a positive nationId and non-empty actionName", ); } $result["{$nationId}:{$actionName}"] = [ 'nationId' => $nationId, 'actionName' => $actionName, ]; } ksort($result, SORT_STRING); return array_values($result); } /** @return list */ function comparisonDiplomacyPairSelectors(mixed $value, string $label): array { if ($value === null) { return []; } if (!is_array($value)) { throw new \InvalidArgumentException("{$label} must be an array"); } $result = []; foreach ($value as $entry) { if (!is_array($entry)) { throw new \InvalidArgumentException("{$label} entries must be objects"); } $fromNationId = $entry['fromNationId'] ?? null; $toNationId = $entry['toNationId'] ?? null; if (!is_int($fromNationId) || $fromNationId < 1 || !is_int($toNationId) || $toNationId < 1) { throw new \InvalidArgumentException( "{$label} entries require positive fromNationId and toNationId", ); } $result["{$fromNationId}:{$toNationId}"] = [ 'fromNationId' => $fromNationId, 'toNationId' => $toNationId, ]; } ksort($result, SORT_STRING); return array_values($result); } /** @param array $row */ function comparisonPickRow(array $row, array $mapping, array $jsonKeys = []): array { $result = []; foreach ($mapping as $canonical => $legacy) { if (!array_key_exists($legacy, $row)) { continue; } $value = $row[$legacy]; if (in_array($legacy, $jsonKeys, true)) { $value = comparisonJsonValue($value); } $result[$canonical] = $value; } return $result; } /** @param list $ids */ function comparisonRowsById(string $table, string $idColumn, array $ids): array { $db = DB::db(); $rows = []; foreach ($ids as $id) { $row = $db->queryFirstRow( "SELECT * FROM `{$table}` WHERE `{$idColumn}` = %i", $id, ); if ($row !== null) { $rows[] = $row; } } return $rows; } function comparisonTurnStateSnapshot(array $request): array { $db = DB::db(); $observe = $request['observe'] ?? []; if (!is_array($observe)) { throw new \InvalidArgumentException('observe must be an object'); } $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')); } 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')); } 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; $includeGlobalHistoryLogs = $observe['includeGlobalHistoryLogs'] ?? false; $generalCooldownSelectors = comparisonGeneralCooldownSelectors( $observe['generalCooldowns'] ?? [], 'generalCooldowns', ); $nationCooldownSelectors = comparisonNationCooldownSelectors( $observe['nationCooldowns'] ?? [], 'nationCooldowns', ); $diplomacyPairSelectors = comparisonDiplomacyPairSelectors( $observe['diplomacyPairs'] ?? [], 'diplomacyPairs', ); if ( !is_int($logAfterId) || $logAfterId < 0 || !is_int($messageAfterId) || $messageAfterId < 0 ) { throw new \InvalidArgumentException('logAfterId and messageAfterId must be non-negative integers'); } if (!is_bool($includeNationHistoryLogs)) { throw new \InvalidArgumentException('includeNationHistoryLogs must be a boolean'); } if (!is_bool($includeGlobalHistoryLogs)) { throw new \InvalidArgumentException('includeGlobalHistoryLogs must be a boolean'); } $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', 'turnterm', 'turntime', 'isunited', 'scenario', 'init_year', 'init_month', 'develcost', 'killturn', ]); $baseTurnTick = Util::toInt($worldValues['turntime']); $ticksPerSecond = $clock->ticksPerSecond(); $nextExecuteStorage = KVStorage::getStorage($db, 'next_execute'); $nextExecuteStorage->resetCache(); $generalCooldowns = array_map( static function (array $selector) use ($nextExecuteStorage): array { $key = "next_execute_{$selector['generalId']}_{$selector['actionName']}"; $value = $nextExecuteStorage->getValue($key); return [ ...$selector, 'nextAvailableTurn' => is_int($value) ? $value : null, ]; }, $generalCooldownSelectors, ); $nationCooldowns = array_map( static function (array $selector) use ($db): array { $nationStorage = KVStorage::getStorage($db, $selector['nationId'], 'nation_env'); $nationStorage->resetCache(); $value = $nationStorage->getValue("next_execute_{$selector['actionName']}"); return [ ...$selector, 'nextAvailableTurn' => is_int($value) ? $value : null, ]; }, $nationCooldownSelectors, ); $generals = array_map( static function (array $row) use ($clock, $currentMessageTick, $baseTurnTick, $ticksPerSecond): array { $result = comparisonPickRow( $row, [ 'id' => 'no', 'name' => 'name', 'nationId' => 'nation', 'cityId' => 'city', 'troopId' => 'troop', 'leadership' => 'leadership', 'strength' => 'strength', 'intelligence' => 'intel', 'experience' => 'experience', 'dedication' => 'dedication', 'expLevel' => 'explevel', 'officerLevel' => 'officer_level', 'officerCityId' => 'officer_city', 'belong' => 'belong', 'permission' => 'permission', 'betray' => 'betray', 'makeLimit' => 'makelimit', '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', 'penalty' => 'penalty', 'leadershipExp' => 'leadership_exp', 'strengthExp' => 'strength_exp', 'intelExp' => 'intel_exp', 'dex1' => 'dex1', 'dex2' => 'dex2', 'dex3' => 'dex3', 'dex4' => 'dex4', 'dex5' => 'dex5', 'specAge' => 'specage', 'specAge2' => 'specage2', 'killTurn' => 'killturn', 'mySet' => 'myset', 'specialDomestic' => 'special', 'specialWar' => 'special2', 'personality' => 'personal', 'itemHorse' => 'horse', 'itemWeapon' => 'weapon', 'itemBook' => 'book', 'itemExtra' => 'item', 'picture' => 'picture', 'imageServer' => 'imgsvr', ], ['last_turn', 'aux', 'penalty'], ); foreach ([ 'specialDomestic', 'specialWar', 'personality', 'itemHorse', 'itemWeapon', 'itemBook', 'itemExtra', ] as $key) { $result[$key] = comparisonOptionalCode($result[$key] ?? null); } $result['turnTime'] = $clock->formatTick(Util::toInt($row['turntime']), true); $result['recentWarTime'] = $row['recent_war'] === null ? null : $clock->formatTick(Util::toInt($row['recent_war']), true); $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) { $inheritanceStorage = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}"); $inheritanceStorage->resetCache(); $stored = $inheritanceStorage->getValue('active_action'); if (is_array($stored) && (is_int($stored[0] ?? null) || is_float($stored[0] ?? null))) { $activeActionPoints = $stored[0]; } } $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; }, comparisonRowsById('general', 'no', $generalIds), ); $rankTypes = array_map( static fn(RankColumn $column): string => $column->value, RankColumn::cases(), ); $rankData = $generalIds === [] ? [] : iterator_to_array($db->query( 'SELECT general_id AS generalId, nation_id AS nationId, `type`, `value`' . ' FROM rank_data WHERE general_id IN %li AND `type` IN %ls' . ' ORDER BY general_id, `type`', $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( $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', 'officerSet' => 'officer_set', ], ['conflict'], ), comparisonRowsById('city', 'city', $cityIds), ); $nations = array_map( static function (array $row) use ($db): array { $projected = comparisonPickRow( $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', 'meta' => 'aux', ], ['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 !== []) { $metaFields['recv_assist'] = $receivedAssist; $projected['meta'] = $metaFields; } $respondedAssist = $nationStor->getValue('resp_assist'); if (is_array($respondedAssist) && $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) { if ($fromNationId === $toNationId) { continue; } $diplomacyPairs["{$fromNationId}:{$toNationId}"] = [ 'fromNationId' => $fromNationId, 'toNationId' => $toNationId, ]; } } foreach ($diplomacyPairSelectors as $pair) { $diplomacyPairs["{$pair['fromNationId']}:{$pair['toNationId']}"] = $pair; } ksort($diplomacyPairs, SORT_STRING); $diplomacy = []; foreach ($diplomacyPairs as $pair) { $row = $db->queryFirstRow( 'SELECT me, you, state, term, dead FROM diplomacy WHERE me = %i AND you = %i', $pair['fromNationId'], $pair['toNationId'], ); if ($row !== null) { $diplomacy[] = comparisonPickRow($row, [ 'fromNationId' => 'me', 'toNationId' => 'you', 'state' => 'state', 'term' => 'term', 'dead' => 'dead', ]); } } $generalTurns = []; foreach ($generalIds as $generalId) { foreach ($db->query( 'SELECT general_id, turn_idx, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx', $generalId, ) as $row) { $generalTurns[] = comparisonPickRow( $row, ['generalId' => 'general_id', 'turnIndex' => 'turn_idx', 'action' => 'action', 'args' => 'arg'], ['arg'], ); } } $nationTurns = []; foreach ($nationIds as $nationId) { foreach ($db->query( 'SELECT nation_id, officer_level, turn_idx, action, arg FROM nation_turn WHERE nation_id = %i ORDER BY officer_level, turn_idx', $nationId, ) as $row) { $nationTurns[] = comparisonPickRow( $row, [ 'nationId' => 'nation_id', 'officerLevel' => 'officer_level', 'turnIndex' => 'turn_idx', 'action' => 'action', 'args' => 'arg', ], ['arg'], ); } } $logs = []; if ($generalIds !== []) { foreach ($db->query( 'SELECT id, general_id, log_type, year, month, text FROM general_record WHERE id > %i ORDER BY id', $logAfterId, ) as $row) { if ((int)$row['general_id'] !== 0 && !in_array((int)$row['general_id'], $generalIds, true)) { continue; } $generalId = (int)$row['general_id']; $category = (string)$row['log_type']; if ($category === 'battle') { $category = 'battle_detail'; } elseif ($generalId === 0) { $category = 'summary'; } $logs[] = comparisonPickRow($row, [ 'id' => 'id', 'generalId' => 'general_id', 'year' => 'year', 'month' => 'month', 'text' => 'text', ]) + [ 'scope' => $generalId === 0 ? 'system' : 'general', 'category' => $category, 'nationId' => null, ]; } } if ($includeNationHistoryLogs && $nationIds !== []) { foreach ($db->query( 'SELECT id, nation_id, year, month, text FROM world_history WHERE nation_id IN %li ORDER BY id', $nationIds, ) as $row) { $logs[] = comparisonPickRow($row, [ 'id' => 'id', 'nationId' => 'nation_id', 'year' => 'year', 'month' => 'month', 'text' => 'text', ]) + [ 'scope' => 'nation', 'category' => 'history', 'generalId' => null, ]; } } if ($includeGlobalHistoryLogs) { foreach ($db->query( 'SELECT id, nation_id, year, month, text FROM world_history WHERE nation_id = 0 ORDER BY id', ) as $row) { $logs[] = comparisonPickRow($row, [ 'id' => 'id', 'nationId' => 'nation_id', 'year' => 'year', 'month' => 'month', 'text' => 'text', ]) + [ 'scope' => 'system', 'category' => 'history', 'generalId' => null, ]; } } $messages = array_map( static function (array $row) use ($clock): array { $result = comparisonPickRow( $row, [ 'id' => 'id', 'mailbox' => 'mailbox', 'type' => 'type', '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, valid_until, message' . ' FROM message WHERE id > %i ORDER BY id', $messageAfterId, ), ); return [ 'schemaVersion' => 1, 'engine' => 'ref', 'world' => [ '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'], 'initMonth' => (int)$worldValues['init_month'], 'develCost' => (int)$worldValues['develcost'], 'killTurn' => (int)$worldValues['killturn'], 'generalCooldowns' => $generalCooldowns, 'nationCooldowns' => $nationCooldowns, ], 'generals' => $generals, 'rankData' => $rankData, 'cities' => $cities, 'nations' => $nations, 'troops' => $troops, 'diplomacy' => $diplomacy, 'generalTurns' => $generalTurns, 'nationTurns' => $nationTurns, 'logs' => $logs, 'messages' => $messages, 'watermarks' => [ 'logId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0), 'historyLogId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM world_history') ?? 0), 'messageId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM message') ?? 0), ], ]; } function comparisonTurnStateSnapshotMain(): void { 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( comparisonTurnStateSnapshot($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); } } if (realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { comparisonTurnStateSnapshotMain(); }