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
+247
View File
@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace sammo;
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';
/**
* Comparison-only subclass around the real product lifecycle entry point.
*
* executeGeneralCommandUntil() constructs `new static(...)`, so invoking the
* inherited method through this class lets the harness observe phase order
* without copying or changing the legacy lifecycle implementation.
*/
final class TurnFullLifecycleComparisonHelper extends TurnExecutionHelper
{
/** @var list<array<string, mixed>> */
private static array $phases = [];
private bool $recordedPersistence = false;
/** @return list<array<string, mixed>> */
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<string, mixed> */
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);
}