> */ private static array $phases = []; private bool $recordedPersistence = false; /** @return list> */ 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 */ 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); }