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
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\API\Message\DecideMessageResponse;
use sammo\Enums\MessageType;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
fwrite(STDERR, "TURN_DIFFERENTIAL_ENABLED=1 is required\n");
exit(1);
}
define('SAMMO_TURN_COMPARISON_LIBRARY_ONLY', true);
require_once __DIR__ . '/turn_command_trace.php';
/** @return array<string, mixed> */
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<string, mixed> */
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);
}