test: 커맨드 차등 스냅샷과 수명주기 계측을 보강

명령 상태의 자료형과 로그, 메시지, 순위 그래프를 보존하고 실제 명령 호출을 관찰하는 비교 전용 trace를 추가합니다.

계측은 CLI 및 명시적 차등 테스트 환경에서만 실행되며 제품 동작은 변경하지 않습니다.
This commit is contained in:
2026-08-23 21:49:10 +00:00
parent e2033637e3
commit a041a8132f
4 changed files with 814 additions and 21 deletions
+300 -6
View File
@@ -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<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
{
@@ -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,