test: 커맨드 차등 스냅샷과 수명주기 계측을 보강
명령 상태의 자료형과 로그, 메시지, 순위 그래프를 보존하고 실제 명령 호출을 관찰하는 비교 전용 trace를 추가합니다. 계측은 CLI 및 명시적 차등 테스트 환경에서만 실행되며 제품 동작은 변경하지 않습니다.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -99,6 +99,10 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void
|
||||
if (!is_array($world)) {
|
||||
throw new \InvalidArgumentException('setup.world must be an object');
|
||||
}
|
||||
$freezeClock = $world['freezeClock'] ?? false;
|
||||
if (!is_bool($freezeClock)) {
|
||||
throw new \InvalidArgumentException('setup.world.freezeClock must be a boolean');
|
||||
}
|
||||
$game = KVStorage::getStorage($db, 'game_env');
|
||||
foreach (['year', 'month', 'startyear', 'init_year', 'init_month'] as $key) {
|
||||
$fixtureKey = match ($key) {
|
||||
@@ -160,6 +164,12 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void
|
||||
}
|
||||
GameConst::$staticEventHandlers = $handlersByEvent;
|
||||
}
|
||||
if ($freezeClock) {
|
||||
$clock = GameClock::fromStorage($game);
|
||||
$frozenTick = $clock->nowTick();
|
||||
$clock->persistTick($game, $frozenTick, GameClock::MODE_MANUAL);
|
||||
$game->resetCache();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['nations', 'cities', 'generals', 'troops', 'diplomacy', 'generalTurns', 'nationTurns'] as $collection) {
|
||||
@@ -440,6 +450,8 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void
|
||||
'killTurn' => 'killturn',
|
||||
'npcState' => 'npc',
|
||||
'blockState' => 'block',
|
||||
'picture' => 'picture',
|
||||
'imageServer' => 'imgsvr',
|
||||
'specialDomestic' => 'special',
|
||||
'specialWar' => 'special2',
|
||||
'personality' => 'personal',
|
||||
@@ -592,6 +604,43 @@ function comparisonApplyTurnFixtureSetup(mixed $setup): void
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{generalIds: list<int>, cityIds: list<int>, nationIds: list<int>, troopIds: list<int>} */
|
||||
function comparisonTurnCommandEntityIds(): array
|
||||
{
|
||||
$db = DB::db();
|
||||
return [
|
||||
'generalIds' => array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no')),
|
||||
'cityIds' => array_map('intval', $db->queryFirstColumn('SELECT city FROM city ORDER BY city')),
|
||||
'nationIds' => array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation')),
|
||||
'troopIds' => array_map('intval', $db->queryFirstColumn('SELECT troop_leader FROM troop ORDER BY troop_leader')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend an explicit selector with entities created by the command. Without
|
||||
* this closure, a successful founding/recruitment can remain absent from both
|
||||
* snapshots and therefore produce a false green differential.
|
||||
*
|
||||
* @param array{observe?: mixed} $snapshotRequest
|
||||
* @param array{generalIds: list<int>, cityIds: list<int>, nationIds: list<int>, troopIds: list<int>} $before
|
||||
* @return array{observe: array<string, mixed>}
|
||||
*/
|
||||
function comparisonCloseSnapshotRequestOverCreatedEntities(array $snapshotRequest, array $before): array
|
||||
{
|
||||
$observe = $snapshotRequest['observe'] ?? [];
|
||||
if (!is_array($observe)) {
|
||||
throw new \InvalidArgumentException('observe must be an object');
|
||||
}
|
||||
$after = comparisonTurnCommandEntityIds();
|
||||
foreach (['generalIds', 'cityIds', 'nationIds', 'troopIds'] as $selector) {
|
||||
$selected = comparisonIntegerList($observe[$selector] ?? [], $selector);
|
||||
$created = array_values(array_diff($after[$selector], $before[$selector]));
|
||||
$observe[$selector] = array_values(array_unique([...$selected, ...$created]));
|
||||
sort($observe[$selector], SORT_NUMERIC);
|
||||
}
|
||||
return ['observe' => $observe];
|
||||
}
|
||||
|
||||
function comparisonRunTurnCommand(array $request): array
|
||||
{
|
||||
if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
|
||||
@@ -662,6 +711,7 @@ function comparisonRunTurnCommand(array $request): array
|
||||
}
|
||||
}
|
||||
$snapshotRequest = ['observe' => $request['observe'] ?? []];
|
||||
$beforeEntityIds = comparisonTurnCommandEntityIds();
|
||||
$before = comparisonTurnStateSnapshot($snapshotRequest);
|
||||
$db = DB::db();
|
||||
$gameStorage = KVStorage::getStorage($db, 'game_env');
|
||||
@@ -705,7 +755,9 @@ function comparisonRunTurnCommand(array $request): array
|
||||
$general->setVar('troop', $troopId);
|
||||
StaticEventHandler::handleEvent($general, null, \sammo\API\Troop\JoinTroop::class, [], $args);
|
||||
$general->applyDB($db);
|
||||
$after = comparisonTurnStateSnapshot($snapshotRequest);
|
||||
$after = comparisonTurnStateSnapshot(
|
||||
comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds),
|
||||
);
|
||||
|
||||
return [
|
||||
'schemaVersion' => 1,
|
||||
@@ -732,7 +784,9 @@ function comparisonRunTurnCommand(array $request): array
|
||||
$completed = $command->run(NoRNG::rngInstance());
|
||||
$command->setNextAvailable();
|
||||
$general->getLogger()->flush();
|
||||
$after = comparisonTurnStateSnapshot($snapshotRequest);
|
||||
$after = comparisonTurnStateSnapshot(
|
||||
comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds),
|
||||
);
|
||||
|
||||
return [
|
||||
'schemaVersion' => 1,
|
||||
@@ -816,7 +870,9 @@ function comparisonRunTurnCommand(array $request): array
|
||||
$general->getLogger()->flush();
|
||||
$turn->applyDB();
|
||||
unset($turn);
|
||||
$after = comparisonTurnStateSnapshot($snapshotRequest);
|
||||
$after = comparisonTurnStateSnapshot(
|
||||
comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds),
|
||||
);
|
||||
$resultTurnRaw = $resultTurn->toRaw();
|
||||
$resultTerm = (int)($resultTurnRaw['term'] ?? 0);
|
||||
$preReqTurn = $command->getPreReqTurn();
|
||||
@@ -853,6 +909,9 @@ function comparisonRunTurnCommand(array $request): array
|
||||
return [
|
||||
'schemaVersion' => 1,
|
||||
'engine' => 'ref',
|
||||
'harness' => [
|
||||
'messageSharedIconBaseUrl' => ServConfig::getSharedIconPath(),
|
||||
],
|
||||
'execution' => [
|
||||
'kind' => $kind,
|
||||
'actorGeneralId' => $actorGeneralId,
|
||||
@@ -871,7 +930,8 @@ function comparisonRunTurnCommand(array $request): array
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
if (!defined('SAMMO_TURN_COMPARISON_LIBRARY_ONLY')) {
|
||||
try {
|
||||
$input = stream_get_contents(STDIN);
|
||||
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
|
||||
if (!is_array($request)) {
|
||||
@@ -881,7 +941,10 @@ try {
|
||||
comparisonRunTurnCommand($request),
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
|
||||
), PHP_EOL;
|
||||
} catch (\Throwable $throwable) {
|
||||
} catch (\Throwable $throwable) {
|
||||
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
} elseif (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
|
||||
throw new \RuntimeException('TURN_DIFFERENTIAL_ENABLED=1 is required for comparison library mode');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace sammo;
|
||||
|
||||
use sammo\Enums\RankColumn;
|
||||
use sammo\Enums\GeneralStorKey;
|
||||
use sammo\Enums\MessageType;
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
@@ -41,7 +43,15 @@ function comparisonJsonValue(mixed $value): mixed
|
||||
return $value;
|
||||
}
|
||||
try {
|
||||
return json_decode($value, true, flags: JSON_THROW_ON_ERROR);
|
||||
$decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
|
||||
// json_decode(..., true) collapses an empty JSON object into the same
|
||||
// PHP array as `[]`, and json_encode would then report both as an
|
||||
// array. Preserve the database container type for fail-closed graph
|
||||
// comparison.
|
||||
if ($decoded === [] && str_starts_with(ltrim($value), '{')) {
|
||||
return (object)[];
|
||||
}
|
||||
return $decoded;
|
||||
} catch (\JsonException) {
|
||||
return $value;
|
||||
}
|
||||
@@ -55,6 +65,91 @@ function comparisonOptionalCode(mixed $value): ?string
|
||||
return $value;
|
||||
}
|
||||
|
||||
function comparisonCommandInteger(mixed $value, string $label, ?int $fallback): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return $fallback;
|
||||
}
|
||||
if (!is_int($value)) {
|
||||
throw new \InvalidArgumentException("{$label} must be an integer");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
function comparisonCommandBoolean(mixed $value, string $label): bool
|
||||
{
|
||||
if ($value === null || $value === false || $value === 0) {
|
||||
return false;
|
||||
}
|
||||
if ($value === true || $value === 1) {
|
||||
return true;
|
||||
}
|
||||
throw new \InvalidArgumentException("{$label} must be a boolean flag");
|
||||
}
|
||||
|
||||
function comparisonCommandOptionalString(mixed $value, string $label): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
throw new \InvalidArgumentException("{$label} must be a string");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @return array{turnSecond: int, turnFraction: int} */
|
||||
function comparisonCommandTurnOffset(int $turnTick, int $baseTurnTick, int $ticksPerSecond): array
|
||||
{
|
||||
if ($ticksPerSecond <= 0) {
|
||||
throw new \InvalidArgumentException('ticksPerSecond must be positive');
|
||||
}
|
||||
$offsetTicks = $turnTick - $baseTurnTick;
|
||||
$turnSecond = intdiv($offsetTicks, $ticksPerSecond);
|
||||
$remainingTicks = $offsetTicks % $ticksPerSecond;
|
||||
if ($remainingTicks < 0) {
|
||||
$turnSecond--;
|
||||
$remainingTicks += $ticksPerSecond;
|
||||
}
|
||||
return [
|
||||
'turnSecond' => $turnSecond,
|
||||
'turnFraction' => intdiv($remainingTicks * 1_000_000, $ticksPerSecond),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array{cityId: int, remainingTurns: int}> */
|
||||
function comparisonCommandSpyState(mixed $value): array
|
||||
{
|
||||
$decoded = comparisonJsonValue($value);
|
||||
if ($decoded instanceof \stdClass) {
|
||||
$decoded = (array)$decoded;
|
||||
}
|
||||
if ($decoded === null) {
|
||||
return [];
|
||||
}
|
||||
if (!is_array($decoded)) {
|
||||
throw new \InvalidArgumentException('nation.commandState.spy must be an object');
|
||||
}
|
||||
$result = [];
|
||||
foreach ($decoded as $cityId => $remainingTurns) {
|
||||
if ((!is_int($cityId) && (!is_string($cityId) || !ctype_digit($cityId))) || (int)$cityId < 1) {
|
||||
throw new \InvalidArgumentException(
|
||||
'nation.commandState.spy has an invalid city id: ' . (string)$cityId,
|
||||
);
|
||||
}
|
||||
$result[] = [
|
||||
'cityId' => (int)$cityId,
|
||||
'remainingTurns' => comparisonCommandInteger(
|
||||
$remainingTurns,
|
||||
"nation.commandState.spy[{$cityId}]",
|
||||
null,
|
||||
),
|
||||
];
|
||||
}
|
||||
usort($result, static fn(array $left, array $right): int => $left['cityId'] <=> $right['cityId']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<array{generalId: int, actionName: string}> */
|
||||
function comparisonGeneralCooldownSelectors(mixed $value, string $label): array
|
||||
{
|
||||
@@ -189,6 +284,12 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
$generalIds = comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds');
|
||||
$cityIds = comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds');
|
||||
$nationIds = comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds');
|
||||
$troopIds = comparisonIntegerList($observe['troopIds'] ?? [], 'troopIds');
|
||||
foreach (['allGenerals', 'allCities', 'allNations', 'allTroops', 'includeRankMirrors'] as $allSelector) {
|
||||
if (array_key_exists($allSelector, $observe) && !is_bool($observe[$allSelector])) {
|
||||
throw new \InvalidArgumentException("{$allSelector} must be a boolean");
|
||||
}
|
||||
}
|
||||
if (($observe['allGenerals'] ?? false) === true) {
|
||||
$generalIds = array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no'));
|
||||
}
|
||||
@@ -198,6 +299,19 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
if (($observe['allNations'] ?? false) === true) {
|
||||
$nationIds = array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation'));
|
||||
}
|
||||
if (($observe['allTroops'] ?? false) === true) {
|
||||
$troopIds = array_map('intval', $db->queryFirstColumn('SELECT troop_leader FROM troop ORDER BY troop_leader'));
|
||||
} elseif ($generalIds !== []) {
|
||||
$linkedTroopIds = array_map(
|
||||
'intval',
|
||||
$db->queryFirstColumn(
|
||||
'SELECT DISTINCT troop FROM general WHERE no IN %li AND troop > 0 ORDER BY troop',
|
||||
$generalIds,
|
||||
),
|
||||
);
|
||||
$troopIds = array_values(array_unique([...$troopIds, ...$generalIds, ...$linkedTroopIds]));
|
||||
sort($troopIds, SORT_NUMERIC);
|
||||
}
|
||||
$logAfterId = $observe['logAfterId'] ?? 0;
|
||||
$messageAfterId = $observe['messageAfterId'] ?? 0;
|
||||
$includeNationHistoryLogs = $observe['includeNationHistoryLogs'] ?? false;
|
||||
@@ -232,6 +346,10 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
$game = KVStorage::getStorage($db, 'game_env');
|
||||
$game->resetCache();
|
||||
$clock = GameClock::fromStorage($game);
|
||||
// Use one logical instant for every general in this snapshot. Calling
|
||||
// nowTick() inside the row loop could cross a realtime tick boundary and
|
||||
// make unread state depend on query order.
|
||||
$currentMessageTick = $clock->nowTick();
|
||||
$worldValues = $game->getValues([
|
||||
'year',
|
||||
'month',
|
||||
@@ -244,6 +362,8 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
'develcost',
|
||||
'killturn',
|
||||
]);
|
||||
$baseTurnTick = Util::toInt($worldValues['turntime']);
|
||||
$ticksPerSecond = $clock->ticksPerSecond();
|
||||
$nextExecuteStorage = KVStorage::getStorage($db, 'next_execute');
|
||||
$nextExecuteStorage->resetCache();
|
||||
$generalCooldowns = array_map(
|
||||
@@ -271,7 +391,7 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
);
|
||||
|
||||
$generals = array_map(
|
||||
static function (array $row) use ($clock): array {
|
||||
static function (array $row) use ($clock, $currentMessageTick, $baseTurnTick, $ticksPerSecond): array {
|
||||
$result = comparisonPickRow(
|
||||
$row,
|
||||
[
|
||||
@@ -325,6 +445,8 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
'itemWeapon' => 'weapon',
|
||||
'itemBook' => 'book',
|
||||
'itemExtra' => 'item',
|
||||
'picture' => 'picture',
|
||||
'imageServer' => 'imgsvr',
|
||||
],
|
||||
['last_turn', 'aux', 'penalty'],
|
||||
);
|
||||
@@ -343,7 +465,55 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
$result['recentWarTime'] = $row['recent_war'] === null
|
||||
? null
|
||||
: $clock->formatTick(Util::toInt($row['recent_war']), true);
|
||||
$result['maxBelong'] = (int)($result['meta']['max_belong'] ?? 0);
|
||||
$meta = $result['meta'] ?? null;
|
||||
$metaFields = $meta instanceof \stdClass ? get_object_vars($meta) : $meta;
|
||||
if ($metaFields === null) {
|
||||
$metaFields = [];
|
||||
}
|
||||
if (!is_array($metaFields)) {
|
||||
throw new \InvalidArgumentException('general.meta must be an object');
|
||||
}
|
||||
$result['maxBelong'] = (int)($metaFields['max_belong'] ?? 0);
|
||||
$result['dedLevel'] = comparisonCommandInteger(
|
||||
$row['dedlevel'] ?? null,
|
||||
'general.dedLevel',
|
||||
0,
|
||||
);
|
||||
$result['affinity'] = comparisonCommandInteger(
|
||||
$row['affinity'] ?? null,
|
||||
'general.affinity',
|
||||
null,
|
||||
);
|
||||
$result['bornYear'] = comparisonCommandInteger(
|
||||
$row['bornyear'] ?? null,
|
||||
'general.bornYear',
|
||||
null,
|
||||
);
|
||||
$result['deadYear'] = comparisonCommandInteger(
|
||||
$row['deadyear'] ?? null,
|
||||
'general.deadYear',
|
||||
null,
|
||||
);
|
||||
$result['npcMessage'] = comparisonCommandOptionalString(
|
||||
$row['npcmsg'] ?? null,
|
||||
'general.npcMessage',
|
||||
);
|
||||
$result['npcOriginalState'] = comparisonCommandInteger(
|
||||
$row['npc_org'] ?? null,
|
||||
'general.npcOriginalState',
|
||||
0,
|
||||
);
|
||||
$result['turnTick'] = Util::toInt($row['turntime']);
|
||||
$turnOffset = comparisonCommandTurnOffset($result['turnTick'], $baseTurnTick, $ticksPerSecond);
|
||||
$result['turnSecond'] = $turnOffset['turnSecond'];
|
||||
$result['turnFraction'] = $turnOffset['turnFraction'];
|
||||
$result['commandState'] = [
|
||||
'recruitmentArmType' => comparisonCommandInteger(
|
||||
$metaFields['armType'] ?? null,
|
||||
'general.commandState.recruitmentArmType',
|
||||
null,
|
||||
),
|
||||
];
|
||||
$ownerId = (int)($row['owner'] ?? 0);
|
||||
$activeActionPoints = 0;
|
||||
if ($ownerId > 0) {
|
||||
@@ -355,6 +525,41 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
}
|
||||
}
|
||||
$result['hasOwner'] = $ownerId > 0;
|
||||
$result['ownerIdentity'] = $ownerId > 0 ? (string)$ownerId : null;
|
||||
$generalId = (int)$row['no'];
|
||||
$nationId = (int)$row['nation'];
|
||||
$generalStorage = KVStorage::getStorage(DB::db(), "general_{$generalId}");
|
||||
$generalStorage->resetCache();
|
||||
[$latestReadDiplomacyMessageId, $latestReadPrivateMessageId] = $generalStorage->getValuesAsArray([
|
||||
GeneralStorKey::latestReadDiplomacyMsg,
|
||||
GeneralStorKey::latestReadPrivateMsg,
|
||||
]);
|
||||
$latestReadPrivateMessageId = (int)($latestReadPrivateMessageId ?? 0);
|
||||
$latestReadDiplomacyMessageId = (int)($latestReadDiplomacyMessageId ?? 0);
|
||||
$unreadPrivateCount = (int)(DB::db()->queryFirstField(
|
||||
'SELECT COUNT(*) FROM message'
|
||||
. ' WHERE mailbox = %i AND type = %s AND src <> %i AND id > %i AND valid_until > %i',
|
||||
$generalId,
|
||||
MessageType::private->value,
|
||||
$generalId,
|
||||
$latestReadPrivateMessageId,
|
||||
$currentMessageTick,
|
||||
) ?? 0);
|
||||
$diplomacyMailbox = Message::MAILBOX_NATIONAL + $nationId;
|
||||
$unreadDiplomacyCount = (int)(DB::db()->queryFirstField(
|
||||
'SELECT COUNT(*) FROM message'
|
||||
. ' WHERE mailbox = %i AND type = %s AND src <> %i AND id > %i AND valid_until > %i',
|
||||
$diplomacyMailbox,
|
||||
MessageType::diplomacy->value,
|
||||
$diplomacyMailbox,
|
||||
$latestReadDiplomacyMessageId,
|
||||
$currentMessageTick,
|
||||
) ?? 0);
|
||||
$result['messageReadState'] = [
|
||||
'unreadPrivateCount' => $unreadPrivateCount,
|
||||
'unreadDiplomacyCount' => $unreadDiplomacyCount,
|
||||
'hasUnreadMessage' => $unreadPrivateCount + $unreadDiplomacyCount > 0,
|
||||
];
|
||||
$result['inheritActiveActionPoints'] = $activeActionPoints;
|
||||
return $result;
|
||||
},
|
||||
@@ -373,6 +578,32 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
$generalIds,
|
||||
$rankTypes,
|
||||
));
|
||||
if (($observe['includeRankMirrors'] ?? false) === true) {
|
||||
$rankMirrorFields = [
|
||||
'experience' => 'experience',
|
||||
'dedication' => 'dedication',
|
||||
'dex1' => 'dex1',
|
||||
'dex2' => 'dex2',
|
||||
'dex3' => 'dex3',
|
||||
'dex4' => 'dex4',
|
||||
'dex5' => 'dex5',
|
||||
];
|
||||
foreach ($generals as $general) {
|
||||
foreach ($rankMirrorFields as $type => $field) {
|
||||
$rankData[] = [
|
||||
'generalId' => (int)$general['id'],
|
||||
'nationId' => (int)$general['nationId'],
|
||||
'type' => $type,
|
||||
'value' => (int)$general[$field],
|
||||
];
|
||||
}
|
||||
}
|
||||
usort(
|
||||
$rankData,
|
||||
static fn(array $left, array $right): int =>
|
||||
[$left['generalId'], $left['type']] <=> [$right['generalId'], $right['type']],
|
||||
);
|
||||
}
|
||||
|
||||
$cities = array_map(
|
||||
static fn(array $row): array => comparisonPickRow(
|
||||
@@ -432,20 +663,74 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
],
|
||||
['aux'],
|
||||
);
|
||||
$meta = $projected['meta'] ?? null;
|
||||
$metaFields = $meta instanceof \stdClass ? get_object_vars($meta) : $meta;
|
||||
if ($metaFields === null) {
|
||||
$metaFields = [];
|
||||
}
|
||||
if (!is_array($metaFields)) {
|
||||
throw new \InvalidArgumentException('nation.meta must be an object');
|
||||
}
|
||||
$nationStor = KVStorage::getStorage($db, (int)$row['nation'], 'nation_env');
|
||||
$receivedAssist = $nationStor->getValue('recv_assist');
|
||||
if (is_array($receivedAssist) && $receivedAssist !== []) {
|
||||
$projected['meta']['recv_assist'] = $receivedAssist;
|
||||
$metaFields['recv_assist'] = $receivedAssist;
|
||||
$projected['meta'] = $metaFields;
|
||||
}
|
||||
$respondedAssist = $nationStor->getValue('resp_assist');
|
||||
if (is_array($respondedAssist) && $respondedAssist !== []) {
|
||||
$projected['meta']['resp_assist'] = $respondedAssist;
|
||||
$metaFields['resp_assist'] = $respondedAssist;
|
||||
$projected['meta'] = $metaFields;
|
||||
}
|
||||
$projected['commandState'] = [
|
||||
'flagChangesRemaining' => comparisonCommandInteger(
|
||||
$metaFields['can_국기변경'] ?? null,
|
||||
'nation.commandState.flagChangesRemaining',
|
||||
0,
|
||||
),
|
||||
'randomCapitalMovesRemaining' => comparisonCommandInteger(
|
||||
$metaFields['can_무작위수도이전'] ?? null,
|
||||
'nation.commandState.randomCapitalMovesRemaining',
|
||||
0,
|
||||
),
|
||||
'spy' => comparisonCommandSpyState($row['spy'] ?? null),
|
||||
'collapsed' => comparisonCommandBoolean(
|
||||
$metaFields['collapsed'] ?? null,
|
||||
'nation.commandState.collapsed',
|
||||
),
|
||||
'rate' => comparisonCommandInteger(
|
||||
$row['rate'] ?? null,
|
||||
'nation.commandState.rate',
|
||||
0,
|
||||
),
|
||||
'bill' => comparisonCommandInteger(
|
||||
$row['bill'] ?? null,
|
||||
'nation.commandState.bill',
|
||||
0,
|
||||
),
|
||||
'secretLimit' => comparisonCommandInteger(
|
||||
$row['secretlimit'] ?? null,
|
||||
'nation.commandState.secretLimit',
|
||||
3,
|
||||
),
|
||||
];
|
||||
return $projected;
|
||||
},
|
||||
comparisonRowsById('nation', 'nation', $nationIds),
|
||||
);
|
||||
|
||||
$troops = array_map(
|
||||
static fn(array $row): array => comparisonPickRow(
|
||||
$row,
|
||||
[
|
||||
'id' => 'troop_leader',
|
||||
'nationId' => 'nation',
|
||||
'name' => 'name',
|
||||
],
|
||||
),
|
||||
comparisonRowsById('troop', 'troop_leader', $troopIds),
|
||||
);
|
||||
|
||||
$diplomacyPairs = [];
|
||||
foreach ($nationIds as $fromNationId) {
|
||||
foreach ($nationIds as $toNationId) {
|
||||
@@ -591,15 +876,21 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
'sourceId' => 'src',
|
||||
'destinationId' => 'dest',
|
||||
'createdAt' => 'time',
|
||||
'validUntil' => 'valid_until',
|
||||
'payload' => 'message',
|
||||
],
|
||||
['message'],
|
||||
);
|
||||
$result['createdAt'] = $clock->formatTick(Util::toInt($row['time']), true);
|
||||
$validUntilTick = Util::toInt($row['valid_until']);
|
||||
$result['validUntil'] = $validUntilTick === GameClock::MAX_SAFE_TICK
|
||||
? 'infinite'
|
||||
: $clock->formatTick($validUntilTick, true);
|
||||
return $result;
|
||||
},
|
||||
$db->query(
|
||||
'SELECT id, mailbox, type, src, dest, time, message FROM message WHERE id > %i ORDER BY id',
|
||||
'SELECT id, mailbox, type, src, dest, time, valid_until, message'
|
||||
. ' FROM message WHERE id > %i ORDER BY id',
|
||||
$messageAfterId,
|
||||
),
|
||||
);
|
||||
@@ -611,7 +902,9 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
'year' => (int)$worldValues['year'],
|
||||
'month' => (int)$worldValues['month'],
|
||||
'tickMinutes' => (int)$worldValues['turnterm'],
|
||||
'lastTurnTick' => $baseTurnTick,
|
||||
'turnTime' => $clock->formatTick(Util::toInt($worldValues['turntime']), true),
|
||||
'gameNow' => $clock->formatTick($currentMessageTick, true),
|
||||
'isUnited' => (int)$worldValues['isunited'],
|
||||
'scenarioId' => (int)$worldValues['scenario'],
|
||||
'initYear' => (int)$worldValues['init_year'],
|
||||
@@ -625,6 +918,7 @@ function comparisonTurnStateSnapshot(array $request): array
|
||||
'rankData' => $rankData,
|
||||
'cities' => $cities,
|
||||
'nations' => $nations,
|
||||
'troops' => $troops,
|
||||
'diplomacy' => $diplomacy,
|
||||
'generalTurns' => $generalTurns,
|
||||
'nationTurns' => $nationTurns,
|
||||
|
||||
Reference in New Issue
Block a user