명령 상태의 자료형과 로그, 메시지, 순위 그래프를 보존하고 실제 명령 호출을 관찰하는 비교 전용 trace를 추가합니다. 계측은 CLI 및 명시적 차등 테스트 환경에서만 실행되며 제품 동작은 변경하지 않습니다.
956 lines
35 KiB
PHP
956 lines
35 KiB
PHP
<?php
|
|
|
|
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);
|
|
exit;
|
|
}
|
|
|
|
chdir(dirname(__DIR__));
|
|
require_once 'lib.php';
|
|
require_once 'func.php';
|
|
|
|
/** @return list<int> */
|
|
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<array{cityId: int, remainingTurns: int}> */
|
|
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<array{generalId: int, actionName: string}> */
|
|
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<array{nationId: int, actionName: string}> */
|
|
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<array{fromNationId: int, toNationId: int}> */
|
|
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<string, mixed> $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<int> $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();
|
|
}
|