명령 상태의 자료형과 로그, 메시지, 순위 그래프를 보존하고 실제 명령 호출을 관찰하는 비교 전용 trace를 추가합니다. 계측은 CLI 및 명시적 차등 테스트 환경에서만 실행되며 제품 동작은 변경하지 않습니다.
248 lines
8.8 KiB
PHP
248 lines
8.8 KiB
PHP
<?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);
|
|
}
|