> */ public array $calls = []; public function __construct(private readonly RNG $inner) { } public static function getMaxInt(): int { return LiteHashDRBG::getMaxInt(); } public function nextBytes(int $bytes): string { $value = $this->inner->nextBytes($bytes); $this->record('nextBytes', ['bytes' => $bytes], bin2hex($value)); return $value; } public function nextBits(int $bits): string { $value = $this->inner->nextBits($bits); $this->record('nextBits', ['bits' => $bits], bin2hex($value)); return $value; } public function nextInt(?int $max = null): int { $value = $this->inner->nextInt($max); $this->record('nextInt', ['maxInclusive' => $max], $value); return $value; } public function nextFloat1(): float { $value = $this->inner->nextFloat1(); $this->record('nextFloat1', [], $value); return $value; } private function record(string $operation, array $arguments, mixed $result): void { $this->calls[] = [ 'seq' => $this->sequence++, 'operation' => $operation, 'arguments' => $arguments === [] ? (object)[] : $arguments, 'result' => $result, ]; } } /** @return array */ function comparisonMappedPatch(array $row, array $mapping): array { $patch = []; foreach ($mapping as $canonical => $legacy) { if (array_key_exists($canonical, $row)) { $patch[$legacy] = $row[$canonical]; } } return $patch; } function comparisonApplyTurnFixtureSetup(mixed $setup): void { if ($setup === null) { return; } if (!is_array($setup)) { throw new \InvalidArgumentException('setup must be an object'); } $db = DB::db(); $world = $setup['world'] ?? null; if ($world !== null) { if (!is_array($world)) { throw new \InvalidArgumentException('setup.world must be an object'); } $game = KVStorage::getStorage($db, 'game_env'); foreach (['year', 'month', 'startyear', 'init_year', 'init_month'] as $key) { $fixtureKey = match ($key) { 'startyear' => 'startYear', 'init_year' => 'initYear', 'init_month' => 'initMonth', default => $key, }; if (!array_key_exists($fixtureKey, $world)) { continue; } $value = $world[$fixtureKey]; if ( !is_int($value) || $value < 1 || (in_array($key, ['month', 'init_month'], true) && $value > 12) ) { throw new \InvalidArgumentException("setup.world.{$fixtureKey} is invalid"); } $game->setValue($key, $value); } $game->resetCache(); if (array_key_exists('hiddenSeed', $world)) { $hiddenSeed = $world['hiddenSeed']; if (!is_string($hiddenSeed) || $hiddenSeed === '' || strlen($hiddenSeed) > 256) { throw new \InvalidArgumentException('setup.world.hiddenSeed is invalid'); } UniqueConst::$hiddenSeed = $hiddenSeed; } } foreach (['nations', 'cities', 'generals', 'troops', 'diplomacy'] as $collection) { if (isset($setup[$collection]) && !is_array($setup[$collection])) { throw new \InvalidArgumentException("setup.{$collection} must be an array"); } } if (isset($setup['randomFoundingCandidateCityIds'])) { $candidateCityIds = comparisonIntegerList( $setup['randomFoundingCandidateCityIds'], 'randomFoundingCandidateCityIds', ); if ($candidateCityIds === []) { throw new \InvalidArgumentException('randomFoundingCandidateCityIds must not be empty'); } $db->update('city', ['level' => 4], '1 = 1'); $db->update('city', ['level' => 5], 'city IN %li', $candidateCityIds); } if (($setup['isolateWorld'] ?? false) === true) { $generalIds = array_values(array_map( static fn(array $row): int => (int)$row['id'], $setup['generals'] ?? [], )); $nationIds = array_values(array_map( static fn(array $row): int => (int)$row['id'], $setup['nations'] ?? [], )); if ($generalIds === [] || $nationIds === []) { throw new \InvalidArgumentException('isolateWorld requires generals and nations'); } $db->delete('general_turn', 'general_id NOT IN %li', $generalIds); $db->delete('rank_data', 'general_id NOT IN %li', $generalIds); $db->delete('nation_turn', 'nation_id NOT IN %li', $nationIds); $db->delete('troop', '1 = 1'); $db->delete('diplomacy', '1 = 1'); $db->delete('general', 'no NOT IN %li', $generalIds); $db->delete('nation', 'nation NOT IN %li', $nationIds); $db->update('city', [ 'nation' => 0, 'supply' => 0, 'front' => 0, 'state' => 0, 'term' => 0, ], '1 = 1'); } foreach ($setup['nations'] ?? [] as $row) { if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) { throw new \InvalidArgumentException('setup.nations requires positive integer ids'); } $data = comparisonMappedPatch($row, [ 'id' => 'nation', 'name' => 'name', 'color' => 'color', 'capitalCityId' => 'capital', 'gold' => 'gold', 'rice' => 'rice', 'tech' => 'tech', 'level' => 'level', 'typeCode' => 'type', 'war' => 'war', 'diplomacyLimit' => 'surlimit', 'generalCount' => 'gennum', 'power' => 'power', 'capitalRevision' => 'capset', 'strategicCommandLimit' => 'strategic_cmd_limit', ]); if (isset($row['meta'])) { $data['aux'] = Json::encode($row['meta']); } $data += [ 'name' => "fixture-nation-{$row['id']}", 'color' => '#777777', 'capital' => 0, 'gold' => 0, 'rice' => 0, 'level' => 1, 'type' => 'che_중립', 'aux' => '{}', ]; $db->insertUpdate('nation', $data, $data); $nationStor = KVStorage::getStorage($db, $row['id'], 'nation_env'); if (($setup['isolateWorld'] ?? false) === true) { $nationStor->deleteValue('recv_assist'); $nationStor->deleteValue('resp_assist'); } if (array_key_exists('nationEnv', $row)) { if (!is_array($row['nationEnv'])) { throw new \InvalidArgumentException('setup.nations.nationEnv must be an object'); } foreach ($row['nationEnv'] as $key => $value) { if (!is_string($key) || $key === '') { throw new \InvalidArgumentException('setup.nations.nationEnv keys must be non-empty strings'); } $nationStor->setValue($key, $value); } $nationStor->resetCache(); } if (array_key_exists('turnLastByOfficerLevel', $row)) { $turnLastByOfficerLevel = $row['turnLastByOfficerLevel']; if (!is_array($turnLastByOfficerLevel)) { throw new \InvalidArgumentException('setup.nations.turnLastByOfficerLevel must be an object'); } foreach ($turnLastByOfficerLevel as $officerLevel => $lastTurn) { $officerLevel = filter_var($officerLevel, FILTER_VALIDATE_INT); if ( $officerLevel === false || $officerLevel < 5 || $officerLevel > 12 || !is_array($lastTurn) ) { throw new \InvalidArgumentException( 'setup.nations.turnLastByOfficerLevel requires officer levels 5 through 12' ); } $nationStor->setValue("turn_last_{$officerLevel}", $lastTurn); } $nationStor->resetCache(); } } if (($setup['isolateWorld'] ?? false) === true && ($setup['nations'] ?? []) !== []) { $maxNationId = max(array_map( static fn(array $row): int => (int)$row['id'], $setup['nations'], )); $db->query('ALTER TABLE nation AUTO_INCREMENT = %i', $maxNationId + 1); } foreach ($setup['cities'] ?? [] as $row) { if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) { throw new \InvalidArgumentException('setup.cities requires positive integer ids'); } $patch = comparisonMappedPatch($row, [ 'nationId' => 'nation', 'level' => 'level', 'population' => 'pop', 'populationMax' => 'pop_max', 'agriculture' => 'agri', 'agricultureMax' => 'agri_max', 'commerce' => 'comm', 'commerceMax' => 'comm_max', 'security' => 'secu', 'securityMax' => 'secu_max', 'supplyState' => 'supply', 'frontState' => 'front', 'defence' => 'def', 'defenceMax' => 'def_max', 'wall' => 'wall', 'wallMax' => 'wall_max', 'state' => 'state', 'term' => 'term', 'trust' => 'trust', 'trade' => 'trade', ]); if ($patch !== []) { $db->update('city', $patch, 'city = %i', $row['id']); } } foreach ($setup['generals'] ?? [] as $row) { if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) { throw new \InvalidArgumentException('setup.generals requires positive integer ids'); } $patch = comparisonMappedPatch($row, [ 'name' => 'name', 'nationId' => 'nation', 'cityId' => 'city', 'troopId' => 'troop', 'leadership' => 'leadership', 'strength' => 'strength', 'intelligence' => 'intel', 'leadershipExp' => 'leadership_exp', 'strengthExp' => 'strength_exp', 'intelExp' => 'intel_exp', 'experience' => 'experience', 'dedication' => 'dedication', 'expLevel' => 'explevel', 'officerLevel' => 'officer_level', 'officerCityId' => 'officer_city', 'injury' => 'injury', 'age' => 'age', 'gold' => 'gold', 'rice' => 'rice', 'crew' => 'crew', 'crewTypeId' => 'crewtype', 'train' => 'train', 'atmos' => 'atmos', 'dex1' => 'dex1', 'dex2' => 'dex2', 'dex3' => 'dex3', 'dex4' => 'dex4', 'dex5' => 'dex5', 'killTurn' => 'killturn', 'npcState' => 'npc', 'blockState' => 'block', 'specialDomestic' => 'special', 'specialWar' => 'special2', 'personality' => 'personal', 'itemHorse' => 'horse', 'itemWeapon' => 'weapon', 'itemBook' => 'book', 'itemExtra' => 'item', ]); if (isset($row['meta'])) { $patch['aux'] = Json::encode($row['meta']); } if (isset($row['lastTurn']) && is_array($row['lastTurn'])) { $patch['last_turn'] = Json::encode($row['lastTurn']); } if ($patch !== []) { $db->update('general', $patch, 'no = %i', $row['id']); } } if (($setup['isolateWorld'] ?? false) === true && ($setup['generals'] ?? []) !== []) { $maxGeneralId = max(array_map( static fn(array $row): int => (int)$row['id'], $setup['generals'], )); $db->query('ALTER TABLE general AUTO_INCREMENT = %i', $maxGeneralId + 1); } foreach ($setup['troops'] ?? [] as $row) { if ( !is_array($row) || !is_int($row['id'] ?? null) || !is_int($row['nationId'] ?? null) || !is_string($row['name'] ?? null) || $row['id'] < 1 || $row['nationId'] < 1 || $row['name'] === '' ) { throw new \InvalidArgumentException('setup.troops requires positive id/nationId and non-empty name'); } $data = [ 'troop_leader' => $row['id'], 'nation' => $row['nationId'], 'name' => $row['name'], ]; $db->insertUpdate('troop', $data, $data); } foreach ($setup['diplomacy'] ?? [] as $row) { if ( !is_array($row) || !is_int($row['fromNationId'] ?? null) || !is_int($row['toNationId'] ?? null) || $row['fromNationId'] < 1 || $row['toNationId'] < 1 ) { throw new \InvalidArgumentException('setup.diplomacy requires positive integer nation ids'); } $data = comparisonMappedPatch($row, [ 'fromNationId' => 'me', 'toNationId' => 'you', 'state' => 'state', 'term' => 'term', 'dead' => 'dead', ]); $data += ['state' => 3, 'term' => 0, 'dead' => 0]; $db->insertUpdate('diplomacy', $data, $data); } } function comparisonRunTurnCommand(array $request): array { if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') { throw new \RuntimeException('TURN_DIFFERENTIAL_ENABLED=1 is required'); } $kind = $request['kind'] ?? null; if (!in_array($kind, ['general', 'nation', 'instantNation'], true)) { throw new \InvalidArgumentException('kind must be general, nation, or instantNation'); } $actorGeneralId = $request['actorGeneralId'] ?? null; $action = $request['action'] ?? null; $args = $request['args'] ?? null; if (!is_int($actorGeneralId) || $actorGeneralId < 1) { throw new \InvalidArgumentException('actorGeneralId must be a positive integer'); } if (!is_string($action) || $action === '') { throw new \InvalidArgumentException('action must be a non-empty string'); } comparisonApplyTurnFixtureSetup($request['setup'] ?? null); $snapshotRequest = ['observe' => $request['observe'] ?? []]; $before = comparisonTurnStateSnapshot($snapshotRequest); $db = DB::db(); $gameStorage = KVStorage::getStorage($db, 'game_env'); $gameStorage->resetCache(); $environment = $gameStorage->getAll(); $general = General::createObjFromDB($actorGeneralId); if ($kind === 'instantNation') { $command = buildNationCommandClass($action, $general, $environment, new LastTurn(), $args); if (!$command->hasFullConditionMet()) { throw new \RuntimeException($command->getFailString()); } $completed = $command->run(NoRNG::rngInstance()); $command->setNextAvailable(); $general->getLogger()->flush(); $after = comparisonTurnStateSnapshot($snapshotRequest); return [ 'schemaVersion' => 1, 'engine' => 'ref', 'execution' => [ 'kind' => $kind, 'actorGeneralId' => $actorGeneralId, 'action' => $action, 'args' => $args, 'seedDomain' => 'none', 'outcome' => [ 'lastTurn' => (object)[], 'commandName' => $command->getName(), 'completed' => $completed, ], ], 'before' => $before, 'after' => $after, 'rng' => [], ]; } $turn = new TurnExecutionHelper($general); $general->increaseInheritancePoint(Enums\InheritanceKey::lived_month, 1); $preprocessRng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( UniqueConst::$hiddenSeed, 'preprocess', (int)$environment['year'], (int)$environment['month'], $actorGeneralId, ))); $turn->preprocessCommand($preprocessRng, $environment); $seedDomain = $kind === 'general' ? 'generalCommand' : 'nationCommand'; $seed = Util::simpleSerialize( UniqueConst::$hiddenSeed, $seedDomain, (int)$environment['year'], (int)$environment['month'], $actorGeneralId, $action, ); $tracingRng = new TurnComparisonTracingRNG(new LiteHashDRBG($seed)); $rng = new RandUtil($tracingRng); if ($kind === 'general' && $action === 'che_견문') { \sammo\TextDecoration\SightseeingMessage::setComparisonRng($rng); } if ($kind === 'general') { $previousLastTurn = $general->getLastTurn(); $previousLastTurnRaw = $previousLastTurn->toRaw(); $command = buildGeneralCommandClass($action, $general, $environment, $args); $resultTurn = $turn->processCommand($rng, $command, false); } else { $nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env'); $lastNationTurnKey = "turn_last_{$general->getVar('officer_level')}"; $previousLastTurn = LastTurn::fromRaw($nationStor->getValue($lastNationTurnKey)); $previousLastTurnRaw = $previousLastTurn->toRaw(); $command = buildNationCommandClass($action, $general, $environment, $previousLastTurn, $args); $resultTurn = $turn->processNationCommand($rng, $command); $nationStor->setValue($lastNationTurnKey, $resultTurn->toRaw()); } $general->getLogger()->flush(); $turn->applyDB(); unset($turn); $after = comparisonTurnStateSnapshot($snapshotRequest); $resultTurnRaw = $resultTurn->toRaw(); $resultTerm = (int)($resultTurnRaw['term'] ?? 0); $preReqTurn = $command->getPreReqTurn(); $acceptScoutCompleted = ( $action === 'che_등용수락' && is_array($args) && is_int($args['destNationID'] ?? null) && $general->getNationID() === $args['destNationID'] ); $foundNationCompleted = ( in_array($action, ['che_건국', 'cr_건국', 'che_무작위건국'], true) && is_array($args) && is_string($args['nationName'] ?? null) && DB::db()->queryFirstField( 'SELECT name FROM nation WHERE nation = %i', $general->getNationID(), ) === $args['nationName'] ); $completed = ( $action === 'che_접경귀환' && $tracingRng->calls !== [] ) || $acceptScoutCompleted || $foundNationCompleted || (($resultTurnRaw['command'] ?? null) === $command->getName() && ( ($preReqTurn === 0 && $resultTerm === 0) || ( $preReqTurn > 0 && ($previousLastTurnRaw['command'] ?? null) === $command->getName() && (int)($previousLastTurnRaw['term'] ?? 0) === $preReqTurn ) )); return [ 'schemaVersion' => 1, 'engine' => 'ref', 'execution' => [ 'kind' => $kind, 'actorGeneralId' => $actorGeneralId, 'action' => $action, 'args' => $args, 'seedDomain' => $seedDomain, 'outcome' => [ 'lastTurn' => $resultTurnRaw, 'commandName' => $command->getName(), 'completed' => $completed, ], ], 'before' => $before, 'after' => $after, 'rng' => $tracingRng->calls, ]; } 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( comparisonRunTurnCommand($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); }