merge: refresh ng_compare for logical game clock

This commit is contained in:
2026-08-04 12:09:58 +00:00
124 changed files with 2713 additions and 807 deletions
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace sammo;
use PHPUnit\Framework\TestCase;
final class GameClockBoundaryTest extends TestCase
{
/** @dataProvider gameSchedulingFiles */
public function testGameSchedulingCodeDoesNotReadWallOrDatabaseClock(string $relativePath): void
{
$source = file_get_contents(__DIR__ . '/../' . $relativePath);
self::assertIsString($source);
foreach ([
'/TimeUtil::now(?:DateTimeImmutable)?\s*\(/',
'/new\s+\\\\?DateTime(?:Immutable)?\s*\(\s*\)/',
'/\bNOW\s*\(/i',
'/\bCURRENT_TIMESTAMP\b/i',
'/\bCURDATE\s*\(/i',
'/\btime\s*\(/i',
] as $pattern) {
self::assertDoesNotMatchRegularExpression($pattern, $source, $relativePath);
}
}
public static function gameSchedulingFiles(): array
{
$paths = [
'hwe/sammo/TurnExecutionHelper.php',
'hwe/sammo/Auction.php',
'hwe/sammo/AuctionBasicResource.php',
'hwe/sammo/AuctionUniqueItem.php',
'hwe/func_auction.php',
'hwe/func_tournament.php',
'hwe/c_tournament.php',
'hwe/sammo/AbsFromUserPool.php',
'hwe/sammo/GeneralPool/RandomNameGeneral.php',
'hwe/sammo/API/General/DieOnPrestart.php',
'hwe/sammo/Message.php',
'hwe/sammo/DiplomaticMessage.php',
'hwe/sammo/ScoutMessage.php',
'hwe/sammo/RaiseInvaderMessage.php',
'hwe/sammo/GeneralAI.php',
'hwe/sammo/API/Vote/NewVote.php',
'hwe/sammo/API/Vote/Vote.php',
'hwe/sammo/API/Vote/GetVoteList.php',
'hwe/sammo/API/Vote/GetVoteDetail.php',
'hwe/sammo/API/Vote/AddComment.php',
'hwe/sammo/API/Nation/SetNotice.php',
'hwe/j_get_select_npc_token.php',
'hwe/j_get_select_pool.php',
'hwe/j_set_npc_control.php',
'hwe/j_board_article_add.php',
'hwe/j_board_comment_add.php',
'hwe/a_traffic.php',
'hwe/j_server_basic_info.php',
'hwe/j_diplomacy_send_letter.php',
'hwe/j_diplomacy_respond_letter.php',
'hwe/j_diplomacy_destroy_letter.php',
'hwe/j_diplomacy_rollback_letter.php',
];
foreach (['hwe/sammo/Command', 'hwe/sammo/Event'] as $relativeDirectory) {
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
__DIR__ . '/../' . $relativeDirectory,
\FilesystemIterator::SKIP_DOTS,
));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$paths[] = $relativeDirectory . '/' . $iterator->getSubPathName();
}
}
}
$paths = array_values(array_unique($paths));
sort($paths);
return array_map(static fn (string $path): array => [$path], $paths);
}
public function testTickSchemaDoesNotUseDatabaseDefaultsForGameSchedules(): void
{
$schema = file_get_contents(__DIR__ . '/../hwe/sql/schema.sql');
self::assertIsString($schema);
foreach ([
'`turntime` BIGINT',
'`recent_war` BIGINT',
'`last_refresh` BIGINT',
'`time` BIGINT',
'`valid_until` BIGINT',
'`reserved_until` BIGINT',
'`open_tick` BIGINT',
'`close_tick` BIGINT',
] as $expected) {
self::assertStringContainsString($expected, $schema);
}
self::assertDoesNotMatchRegularExpression('/\b(?:CURRENT_TIMESTAMP|NOW\s*\()/i', $schema);
}
public function testMonthlyTrafficTimestampUsesLogicalClock(): void
{
$source = file_get_contents(__DIR__ . '/../hwe/func.php');
self::assertIsString($source);
self::assertMatchesRegularExpression(
'/function updateTraffic\(\).*?GameClock::fromStorage\(\$gameStor\)->formatNow\(\).*?function CheckOverhead\(/s',
$source,
);
self::assertDoesNotMatchRegularExpression(
'/function updateTraffic\(\).*?TimeUtil::now\(.*?function CheckOverhead\(/s',
$source,
);
}
public function testGameLoginDeathCheckUsesTicksWhileSessionTtlRemainsOperational(): void
{
$source = file_get_contents(__DIR__ . '/../src/sammo/Session.php');
self::assertIsString($source);
self::assertMatchesRegularExpression(
'/function loginGame\(.*?GameClock::isInitialized\(\$gameStor\).*?GameClock::fromStorage\(\$gameStor\)->nowTick\(\).*?GameClock::TICKS_PER_TURN.*?function logoutGame\(/s',
$source,
);
self::assertMatchesRegularExpression(
'/function loginGame\(.*?new \\\\DateTimeImmutable.*?getTimestamp\(\).*?function logoutGame\(/s',
$source,
);
self::assertDoesNotMatchRegularExpression(
'/function loginGame\(.*?new\s+\\?DateTime(?:Immutable)?\([^)]*turntime.*?function logoutGame\(/s',
$source,
);
}
public function testBrowserDoesNotCompareProjectedGameDatesToItsWallClock(): void
{
$expectations = [
'hwe/ts/PageVote.vue' => ['currentVote.value.isOpen'],
'hwe/ts/components/MessagePlate.vue' => ['msg.clockMode === "manual"'],
'hwe/ts/gateway/entrance.ts' => ['resolveGatewayOpenState(game.isOpen, game.opentime, now)'],
'hwe/ts/select_npc.ts' => ['logicalClockRunning'],
'hwe/ts/select_general_from_pool.ts' => ['logicalClockRunning'],
];
foreach ($expectations as $path => $needles) {
$source = file_get_contents(__DIR__ . '/../' . $path);
self::assertIsString($source);
foreach ($needles as $needle) {
self::assertStringContainsString($needle, $source, $path);
}
}
self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue'));
}
public function testGatewayFormatsLogicalOpenTimeBeforeReturningIt(): void
{
$source = file_get_contents(__DIR__ . '/../hwe/j_server_basic_info.php');
self::assertIsString($source);
self::assertStringContainsString(
'$admin[\'opentime\'] = $clock->formatTick(Util::toInt($admin[\'opentime\']));',
$source,
);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace sammo;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../src/sammo/GameClock.php';
require_once __DIR__ . '/../hwe/sammo/TurnExecutionHelper.php';
final class GameClockTest extends TestCase
{
/** @dataProvider supportedTurnTerms */
public function testEachSupportedTurnTermHasIntegerTicksPerSecond(int $turnTerm, int $ticksPerSecond): void
{
$base = new \DateTimeImmutable('2026-08-03 00:00:00.000000');
$clock = new GameClock($base, $turnTerm, 0, GameClock::MODE_MANUAL, $base);
self::assertSame($ticksPerSecond, $clock->ticksPerSecond());
self::assertSame(GameClock::TICKS_PER_TURN, $clock->ticksFromMinutes($turnTerm));
}
public static function supportedTurnTerms(): array
{
return [
'1 minute' => [1, 600_000],
'2 minutes' => [2, 300_000],
'5 minutes' => [5, 120_000],
'10 minutes' => [10, 60_000],
'60 minutes' => [60, 10_000],
'120 minutes' => [120, 5_000],
];
}
public function testTickFormulaAndDisplayProjectionRoundTrip(): void
{
$base = new \DateTimeImmutable('2026-08-03 12:34:56.000000');
$clock = new GameClock($base, 60, 0, GameClock::MODE_MANUAL, $base);
$tick = GameClock::TICKS_PER_TURN * 7 + 12_345;
self::assertSame(['turn' => 7, 'subTick' => 12_345], $clock->splitTick($tick));
self::assertSame($tick, $clock->dateTimeToTick($clock->tickToDateTime($tick)));
self::assertSame('2026-08-03 19:34:57.234500', $clock->formatTick($tick, true));
}
public function testManualModeDoesNotReadWallClock(): void
{
$base = new \DateTimeImmutable('2026-08-03 00:00:00.000000');
$wallRead = false;
$clock = new GameClock(
$base,
10,
123_456,
GameClock::MODE_MANUAL,
$base,
function () use (&$wallRead): \DateTimeImmutable {
$wallRead = true;
return new \DateTimeImmutable('2099-01-01 00:00:00.000000');
},
);
self::assertSame(123_456, $clock->nowTick());
self::assertSame($clock->formatTick(123_456), $clock->formatNow());
self::assertFalse($wallRead);
}
public function testNegativeTickProjectionAndBaseRecalculation(): void
{
$projected = new \DateTimeImmutable('2026-08-03 12:00:00.123400');
$tick = -36_001_234;
$base = GameClock::baseTimeForProjection($projected, $tick, 60);
$clock = new GameClock($base, 60, $tick, GameClock::MODE_MANUAL, $projected);
self::assertSame($tick, $clock->dateTimeToTick($clock->tickToDateTime($tick)));
self::assertSame('2026-08-03 12:00:00.123400', $clock->formatTick($tick, true));
self::assertSame(['turn' => -2, 'subTick' => 35_998_766], $clock->splitTick($tick));
}
public function testRealtimeModeAdvancesFromAnchorWithoutDatabaseNow(): void
{
$base = new \DateTimeImmutable('2026-08-03 00:00:00.000000');
$wallAnchor = new \DateTimeImmutable('2026-08-03 10:00:00.000000');
$clock = new GameClock(
$base,
120,
100,
GameClock::MODE_REALTIME,
$wallAnchor,
fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-08-03 10:00:01.500000'),
);
self::assertSame(7_600, $clock->nowTick());
}
public function testStorageInitializationDetectionSupportsMixedLegacyProfiles(): void
{
$logicalStorage = $this->createMock(KVStorage::class);
$logicalStorage->method('getValues')->willReturn([
'clock_base_time' => '2026-08-03 00:00:00.000000',
'clock_tick' => 0,
'clock_mode' => GameClock::MODE_REALTIME,
'clock_wall_anchor' => '2026-08-03 00:00:00.000000',
'turnterm' => 60,
]);
self::assertTrue(GameClock::isInitialized($logicalStorage));
$legacyStorage = $this->createMock(KVStorage::class);
$legacyStorage->method('getValues')->willReturn([
'turnterm' => 60,
]);
self::assertFalse(GameClock::isInitialized($legacyStorage));
$partialStorage = $this->createMock(KVStorage::class);
$partialStorage->method('getValues')->willReturn([
'clock_tick' => 0,
'turnterm' => 60,
]);
$this->expectException(\RuntimeException::class);
GameClock::isInitialized($partialStorage);
}
public function testTickArithmeticRejectsValuesThatJavaScriptCannotRepresentExactly(): void
{
self::assertSame(GameClock::MAX_SAFE_TICK, GameClock::addTicks(GameClock::MAX_SAFE_TICK - 1, 1));
$this->expectException(\OverflowException::class);
GameClock::addTicks(GameClock::MAX_SAFE_TICK, 1);
}
public function testGlobalCompletionTickIsMonotonicAcrossSubTickExecution(): void
{
self::assertSame(36_000_000, TurnExecutionHelper::monotonicCompletionTick(36_000_000, 35_500_000));
self::assertSame(36_500_000, TurnExecutionHelper::monotonicCompletionTick(36_000_000, 36_500_000));
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace sammo;
use PHPUnit\Framework\TestCase;
use sammo\Enums\MessageType;
require_once __DIR__ . '/../hwe/sammo/Target.php';
require_once __DIR__ . '/../hwe/sammo/MessageTarget.php';
require_once __DIR__ . '/../hwe/sammo/Enums/MessageType.php';
require_once __DIR__ . '/../hwe/sammo/Message.php';
final class MessageGameClockTest extends TestCase
{
public function testUnlimitedMessageReusesExactTicksAcrossReceiverAndSenderCopies(): void
{
$base = new \DateTimeImmutable('2026-08-04 00:00:00.000000');
$wallTimes = [
new \DateTimeImmutable('2026-08-04 00:00:00.000000'),
new \DateTimeImmutable('2026-08-04 00:00:00.938140'),
];
$wallReads = 0;
$clock = new GameClock(
$base,
1,
0,
GameClock::MODE_REALTIME,
$base,
static function () use (&$wallTimes, &$wallReads): \DateTimeImmutable {
return $wallTimes[$wallReads++];
},
);
$message = new TestableClockMessage(
MessageType::national,
$this->createMock(MessageTarget::class),
$this->createMock(MessageTarget::class),
'선전포고',
new \DateTime('2026-08-04 00:00:00.000000'),
new \DateTime('9999-12-31'),
[],
);
$receiverTicks = $message->resolveTicksForTest($clock);
self::assertLessThan(9000, Util::toInt($message->validUntil->format('Y')));
$legacySecondClock = new GameClock(
$base,
1,
0,
GameClock::MODE_REALTIME,
$base,
static fn (): \DateTimeImmutable => $wallTimes[1],
);
$legacySecondNowTick = $legacySecondClock->nowTick();
self::assertSame(562_884, $legacySecondNowTick);
$legacySecondExpiry = $legacySecondNowTick + $clock->ticksFromSeconds(
$message->validUntil->getTimestamp() - $message->date->getTimestamp(),
);
self::assertSame(9_007_199_254_762_884, $legacySecondExpiry);
$senderTicks = $message->resolveTicksForTest($clock);
self::assertSame([0, GameClock::MAX_SAFE_TICK], $receiverTicks);
self::assertSame($receiverTicks, $senderTicks);
self::assertSame(1, $wallReads);
}
public function testFiniteMessageCachesOneValidatedExpiryForBothCopies(): void
{
$base = new \DateTimeImmutable('2026-08-04 00:00:00.000000');
$clock = new GameClock($base, 1, 120_000, GameClock::MODE_MANUAL, $base);
$message = new TestableClockMessage(
MessageType::national,
$this->createMock(MessageTarget::class),
$this->createMock(MessageTarget::class),
'유한 메시지',
new \DateTime('2026-08-04 00:00:02.000000'),
new \DateTime('2026-08-04 00:01:02.000000'),
[],
);
$receiverTicks = $message->resolveTicksForTest($clock);
$senderTicks = $message->resolveTicksForTest($clock);
self::assertSame([120_000, 36_120_000], $receiverTicks);
self::assertSame($receiverTicks, $senderTicks);
}
}
final class TestableClockMessage extends Message
{
/** @return array{0:int, 1:int} */
public function resolveTicksForTest(GameClock $clock): array
{
return $this->resolveSendTicks($clock);
}
}
@@ -0,0 +1,80 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\GameConst;
use sammo\LiteHashDRBG;
use sammo\RandUtil;
use sammo\Scenario\GeneralBuilder;
use sammo\Util;
$loader = require __DIR__ . '/../vendor/autoload.php';
$loader->addPsr4('sammo\\', __DIR__ . '/../hwe/sammo', true);
require_once __DIR__ . '/../hwe/func_converter.php';
require_once __DIR__ . '/../hwe/sammo/ActionLogger.php';
require_once __DIR__ . '/../hwe/sammo/GameConstBase.php';
require_once __DIR__ . '/../hwe/d_setting/GameConst.php';
require_once __DIR__ . '/../hwe/sammo/Scenario/GeneralBuilder.php';
final class ScenarioGeneralBuilderSpecialTest extends TestCase
{
public function testAsiaPossessionScenarioWarSpecialsUseWarSlot(): void
{
$scenario = json_decode(
file_get_contents(__DIR__ . '/../hwe/scenario/scenario_2702.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$specials = array_values(array_unique(array_filter(array_column($scenario['general'], 12))));
self::assertNotEmpty($specials);
foreach ($specials as $special) {
$builder = $this->newBuilder()->setSpecialSingle($special);
self::assertSame(
GameConst::$defaultSpecialDomestic,
$this->readProperty($builder, 'specialDomestic'),
"{$special} must not occupy the domestic-special slot"
);
self::assertSame(
"che_{$special}",
$this->readProperty($builder, 'specialWar'),
"{$special} must occupy the war-special slot"
);
}
}
public function testScenarioDomesticSpecialStillUsesDomesticSlot(): void
{
$builder = $this->newBuilder()->setSpecialSingle('경작');
self::assertSame('che_경작', $this->readProperty($builder, 'specialDomestic'));
self::assertSame(GameConst::$defaultSpecialWar, $this->readProperty($builder, 'specialWar'));
}
public function testCentennialEventWarSpecialCanStillBeAssignedToDomesticSlotExplicitly(): void
{
$builder = $this->newBuilder()->setSpecial('che_event_위압', GameConst::$defaultSpecialWar);
self::assertSame('che_event_위압', $this->readProperty($builder, 'specialDomestic'));
self::assertSame(GameConst::$defaultSpecialWar, $this->readProperty($builder, 'specialWar'));
}
private function newBuilder(): GeneralBuilder
{
return new GeneralBuilder(
new RandUtil(new LiteHashDRBG(Util::simpleSerialize(self::class))),
'특기 슬롯 검사',
false,
null,
0
);
}
private function readProperty(GeneralBuilder $builder, string $property): mixed
{
$reflection = new ReflectionProperty($builder, $property);
return $reflection->getValue($builder);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace sammo;
use PHPUnit\Framework\TestCase;
use sammo\DTO\VoteInfo;
require_once __DIR__ . '/../src/sammo/GameClock.php';
require_once __DIR__ . '/../hwe/sammo/DTO/VoteInfo.php';
final class VoteGameClockTest extends TestCase
{
public function testLegacyDatesAreConvertedToStableTicksAndProjectedAgain(): void
{
$base = new \DateTimeImmutable('2035-01-01 00:00:00.000000');
$clock = new GameClock($base, 60, 0, GameClock::MODE_MANUAL, $base);
$raw = [
'id' => 7,
'title' => '논리 시계 투표',
'multipleOptions' => 1,
'opener' => 'SYSTEM',
'startDate' => '2035-01-01 01:00:00',
'endDate' => '2035-01-01 03:00:00',
'options' => ['찬성', '반대'],
];
$stored = VoteInfo::normalizeGameStorage($raw, $clock);
self::assertSame(GameClock::TICKS_PER_TURN, $stored['startTick']);
self::assertSame(GameClock::TICKS_PER_TURN * 3, $stored['endTick']);
self::assertSame('2035-01-01 01:00:00', $stored['startDate']);
self::assertSame('2035-01-01 03:00:00', $stored['endDate']);
}
public function testStoredTicksRemainAuthoritativeWhenProjectionBaseChanges(): void
{
$oldBase = new \DateTimeImmutable('2035-01-01 00:00:00');
$newBase = new \DateTimeImmutable('2040-05-01 12:00:00');
$clock = new GameClock($newBase, 60, 0, GameClock::MODE_MANUAL, $newBase);
$stored = [
'id' => 8,
'title' => 'tick 우선',
'multipleOptions' => 1,
'opener' => null,
'startDate' => $oldBase->format('Y-m-d H:i:s'),
'endDate' => $oldBase->modify('+1 hour')->format('Y-m-d H:i:s'),
'startTick' => GameClock::TICKS_PER_TURN * 2,
'endTick' => GameClock::TICKS_PER_TURN * 4,
'options' => ['A'],
];
$normalized = VoteInfo::normalizeGameStorage($stored, $clock);
self::assertSame('2040-05-01 14:00:00', $normalized['startDate']);
self::assertSame('2040-05-01 16:00:00', $normalized['endDate']);
}
}