From 68a82c066b2fa5f8fb13e721c042ecd6c107f1da Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 01:09:18 +0000 Subject: [PATCH] fix: support mixed wall and game clock servers --- hwe/j_server_basic_info.php | 3 +- hwe/test-ts/gatewayOpenState.test.ts | 14 ++++++ hwe/ts/gateway/entrance.ts | 11 +++-- hwe/ts/gateway/resolveGatewayOpenState.ts | 7 +++ src/sammo/GameClock.php | 59 ++++++++++++++++++++--- src/sammo/Session.php | 38 ++++++++++----- tests/GameClockBoundaryTest.php | 19 ++++++-- tests/GameClockTest.php | 27 +++++++++++ 8 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 hwe/test-ts/gatewayOpenState.test.ts create mode 100644 hwe/ts/gateway/resolveGatewayOpenState.ts diff --git a/hwe/j_server_basic_info.php b/hwe/j_server_basic_info.php index 27119356..92e86b96 100644 --- a/hwe/j_server_basic_info.php +++ b/hwe/j_server_basic_info.php @@ -77,7 +77,8 @@ $admin['npcMode'] = $admin['npcmode']; $admin['turnTerm'] = $admin['turnterm']; $admin['isUnited'] = $admin['isunited']; $admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']); -$admin['starttime'] = substr($clock->formatTick(Util::toInt($admin['opentime'])), 5, 11); +$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime'])); +$admin['starttime'] = substr($admin['opentime'], 5, 11); $admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); unset($admin['npcmode']); unset($admin['maxgeneral']); diff --git a/hwe/test-ts/gatewayOpenState.test.ts b/hwe/test-ts/gatewayOpenState.test.ts new file mode 100644 index 00000000..9d4a1222 --- /dev/null +++ b/hwe/test-ts/gatewayOpenState.test.ts @@ -0,0 +1,14 @@ +import assert from 'assert'; +import { resolveGatewayOpenState } from '../ts/gateway/resolveGatewayOpenState'; + +describe('resolveGatewayOpenState', () => { + it('keeps the logical server decision even when projected dates disagree with wall time', () => { + assert.strictEqual(resolveGatewayOpenState(false, '2000-01-01 00:00:00', '2026-08-04 00:00:00'), false); + assert.strictEqual(resolveGatewayOpenState(true, '2042-01-01 00:00:00', '2026-08-04 00:00:00'), true); + }); + + it('falls back to legacy wall time only when isOpen is absent', () => { + assert.strictEqual(resolveGatewayOpenState(undefined, '2026-08-03 00:00:00', '2026-08-04 00:00:00'), true); + assert.strictEqual(resolveGatewayOpenState(undefined, '2026-08-05 00:00:00', '2026-08-04 00:00:00'), false); + }); +}); diff --git a/hwe/ts/gateway/entrance.ts b/hwe/ts/gateway/entrance.ts index aa082eb8..cd2e3383 100644 --- a/hwe/ts/gateway/entrance.ts +++ b/hwe/ts/gateway/entrance.ts @@ -5,8 +5,10 @@ import axios from 'axios'; import { initTooltip } from "@/legacy/initTooltip"; import { TemplateEngine } from '@util/TemplateEngine'; import type { InvalidResponse } from '@/defs'; +import { getDateTimeNow } from '@util/getDateTimeNow'; import { setAxiosXMLHttpRequest } from '@util/setAxiosXMLHttpRequest'; import { loadPlugin as loadAdminPlugin } from '@/gateway/admin_server'; +import { resolveGatewayOpenState } from '@/gateway/resolveGatewayOpenState'; import '@/gateway/common'; declare const isAdmin: boolean; @@ -107,7 +109,7 @@ type ReservedGameInfo = { type GameInfo = { isUnited: number, - isOpen: boolean, + isOpen?: boolean, npcMode: '불가' | '가능' | '선택 생성', year: number, month: number, @@ -175,6 +177,7 @@ async function Entrance_UpdateServer() { async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { const $serverList = $('#server_list'); + const now = getDateTimeNow(); const serverDetailInfoP: Record> = {}; @@ -225,6 +228,8 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { } const game = response.game; + // 구버전 wall-clock profile은 isOpen을 아직 반환하지 않습니다. + const isOpen = resolveGatewayOpenState(game.isOpen, game.opentime, now); //TODO: 서버 폐쇄 방식을 새롭게 변경 $serverHtml.find('.server_down').detach(); @@ -238,7 +243,7 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { } else if (game.isUnited == 2) { $serverHtml.find('.n_country').html('§천하통일§'); $serverHtml.find('.server_date').html(`${game.starttime}
~ ${game.turntime}`); - } else if (game.isOpen) { + } else if (isOpen) { $serverHtml.find('.n_country').html(`<${game.nationCnt}국 경쟁중>`); $serverHtml.find('.server_date').html(`${game.starttime} ~`); } else { @@ -246,7 +251,7 @@ async function Entrance_drawServerList(serverInfos: ServerResponseItem[]) { $serverHtml.find('.server_date').html(`${game.starttime} ~`); } - if (game.isOpen) { + if (isOpen) { $serverHtml.append( TemplateEngine(serverTextInfo, game) ); diff --git a/hwe/ts/gateway/resolveGatewayOpenState.ts b/hwe/ts/gateway/resolveGatewayOpenState.ts new file mode 100644 index 00000000..40eb8dd3 --- /dev/null +++ b/hwe/ts/gateway/resolveGatewayOpenState.ts @@ -0,0 +1,7 @@ +export function resolveGatewayOpenState( + serverDecision: boolean | undefined, + openTime: string, + wallNow: string, +): boolean { + return serverDecision ?? openTime <= wallNow; +} diff --git a/src/sammo/GameClock.php b/src/sammo/GameClock.php index cc874989..a6a96ab7 100644 --- a/src/sammo/GameClock.php +++ b/src/sammo/GameClock.php @@ -43,13 +43,10 @@ final class GameClock public static function fromStorage(KVStorage $gameStor, ?callable $wallNowProvider = null): self { - $values = $gameStor->getValues([ - 'clock_base_time', - 'clock_tick', - 'clock_mode', - 'clock_wall_anchor', - 'turnterm', - ]); + $values = self::readStorageValues($gameStor); + if (!self::areStorageValuesInitialized($values)) { + throw new \RuntimeException('game clock storage가 초기화되지 않았습니다. migration 상태를 확인해 주세요.'); + } $baseTime = new \DateTimeImmutable((string)$values['clock_base_time']); $wallAnchor = new \DateTimeImmutable((string)$values['clock_wall_anchor']); @@ -64,6 +61,54 @@ final class GameClock ); } + /** + * 공용 src가 아직 migration하지 않은 wall-clock profile과 함께 배포될 수 + * 있으므로 호출부가 저장 형식을 먼저 판별할 수 있게 합니다. + */ + public static function isInitialized(KVStorage $gameStor): bool + { + $values = self::readStorageValues($gameStor); + $clockKeys = ['clock_base_time', 'clock_tick', 'clock_mode', 'clock_wall_anchor']; + $presentClockKeys = array_filter( + $clockKeys, + static fn (string $key): bool => array_key_exists($key, $values) + && $values[$key] !== null + && $values[$key] !== '', + ); + if (!$presentClockKeys) { + return false; + } + if (!self::areStorageValuesInitialized($values)) { + throw new \RuntimeException('game clock storage가 부분 초기화 상태입니다. migration 상태를 확인해 주세요.'); + } + return true; + } + + /** @return array */ + private static function readStorageValues(KVStorage $gameStor): array + { + return $gameStor->getValues([ + 'clock_base_time', + 'clock_tick', + 'clock_mode', + 'clock_wall_anchor', + 'turnterm', + ]); + } + + /** @param array $values */ + private static function areStorageValuesInitialized(array $values): bool + { + foreach (['clock_base_time', 'clock_tick', 'clock_mode', 'clock_wall_anchor', 'turnterm'] as $key) { + if (!array_key_exists($key, $values) || $values[$key] === null || $values[$key] === '') { + return false; + } + } + return in_array((string)$values['clock_mode'], [self::MODE_REALTIME, self::MODE_MANUAL], true) + && is_numeric($values['clock_tick']) + && is_numeric($values['turnterm']); + } + public static function initializeStorage( KVStorage $gameStor, \DateTimeInterface $baseTime, diff --git a/src/sammo/Session.php b/src/sammo/Session.php index 0f749173..0c5a7c65 100644 --- a/src/sammo/Session.php +++ b/src/sammo/Session.php @@ -36,6 +36,7 @@ class Session const GAME_KEY_GENERAL_ID = '_g_no'; const GAME_KEY_GENERAL_NAME = '_g_name'; const GAME_KEY_EXPECTED_DEADTIME = '_g_deadtime'; + const GAME_KEY_CLOCK_STORAGE = '_g_clock_storage'; protected $writeClosed = false; @@ -239,16 +240,22 @@ class Session $loginDate = $this->get($serverID.static::GAME_KEY_DATE); $generalID = $this->get($serverID.static::GAME_KEY_GENERAL_ID); $generalName = $this->get($serverID.static::GAME_KEY_GENERAL_NAME); - $deadTick = $this->get($serverID.static::GAME_KEY_EXPECTED_DEADTIME); + $deadAt = $this->get($serverID.static::GAME_KEY_EXPECTED_DEADTIME); + $sessionClockStorage = $this->get($serverID.static::GAME_KEY_CLOCK_STORAGE); $wallNow = time(); $db = DB::db(); $gameStor = KVStorage::getStorage($db, 'game_env'); - $gameNowTick = GameClock::fromStorage($gameStor)->nowTick(); + $usesLogicalClock = GameClock::isInitialized($gameStor); + $clockStorage = $usesLogicalClock ? 'logical' : 'wall'; + $gameNow = $usesLogicalClock + ? GameClock::fromStorage($gameStor)->nowTick() + : $wallNow; if ( $globalLoginDate < $loginDate && - $generalID && $generalName && $loginDate && $deadTick - && $loginDate + 1800 > $wallNow && $deadTick > $gameNowTick + $generalID && $generalName && $loginDate && $deadAt + && $sessionClockStorage === $clockStorage + && $loginDate + 1800 > $wallNow && $deadAt > $gameNow ) { //로그인 정보는 30분간 유지한다. if ($result !== null) { @@ -257,7 +264,7 @@ class Session return $this; } - if ($generalID || $generalName || $loginDate || $deadTick) { + if ($generalID || $generalName || $loginDate || $deadAt) { $this->logoutGame(); } @@ -276,11 +283,18 @@ class Session $generalID = $general['no']; $generalName = $general['name']; - $deadTick = GameClock::addTicks( - Util::toInt($general['turntime']), - Util::toInt($general['killturn']) * GameClock::TICKS_PER_TURN, - ); - if ($deadTick < $gameNowTick && !$isUnited) { + if ($usesLogicalClock) { + $deadAt = GameClock::addTicks( + Util::toInt($general['turntime']), + Util::toInt($general['killturn']) * GameClock::TICKS_PER_TURN, + ); + } else { + // migration 전 profile은 기존 DATETIME과 wall-clock 판정을 그대로 + // 유지합니다. 공용 src 배포가 기존 서버의 DB 형식을 바꾸지 않습니다. + $deadAt = (new \DateTimeImmutable((string)$general['turntime']))->getTimestamp() + + Util::toInt($general['killturn']) * Util::toInt($gameStor->turnterm); + } + if ($deadAt < $gameNow && !$isUnited) { $locked = $db->queryFirstField('SELECT plock FROM plock WHERE `type` = "GAME" LIMIT 1'); if (!$locked) { if ($result !== null) { @@ -293,7 +307,8 @@ class Session $this->set($serverID.static::GAME_KEY_DATE, $wallNow); $this->set($serverID.static::GAME_KEY_GENERAL_ID, $generalID); $this->set($serverID.static::GAME_KEY_GENERAL_NAME, $generalName); - $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, $deadTick); + $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, $deadAt); + $this->set($serverID.static::GAME_KEY_CLOCK_STORAGE, $clockStorage); return $this; } @@ -307,6 +322,7 @@ class Session $this->set($serverID.static::GAME_KEY_GENERAL_ID, null); $this->set($serverID.static::GAME_KEY_GENERAL_NAME, null); $this->set($serverID.static::GAME_KEY_EXPECTED_DEADTIME, null); + $this->set($serverID.static::GAME_KEY_CLOCK_STORAGE, null); return $this; } diff --git a/tests/GameClockBoundaryTest.php b/tests/GameClockBoundaryTest.php index 31b763c0..df8d7fb9 100644 --- a/tests/GameClockBoundaryTest.php +++ b/tests/GameClockBoundaryTest.php @@ -114,7 +114,11 @@ final class GameClockBoundaryTest extends TestCase $source = file_get_contents(__DIR__ . '/../src/sammo/Session.php'); self::assertIsString($source); self::assertMatchesRegularExpression( - '/function loginGame\(.*?GameClock::fromStorage\(\$gameStor\)->nowTick\(\).*?GameClock::TICKS_PER_TURN.*?function logoutGame\(/s', + '/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( @@ -128,7 +132,7 @@ final class GameClockBoundaryTest extends TestCase $expectations = [ 'hwe/ts/PageVote.vue' => ['currentVote.value.isOpen'], 'hwe/ts/components/MessagePlate.vue' => ['msg.clockMode === "manual"'], - 'hwe/ts/gateway/entrance.ts' => ['game.isOpen'], + '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'], ]; @@ -140,7 +144,16 @@ final class GameClockBoundaryTest extends TestCase } } self::assertStringNotContainsString('formatTime(new Date())', file_get_contents(__DIR__ . '/../hwe/ts/PageVote.vue')); - self::assertStringNotContainsString('game.opentime <= now', file_get_contents(__DIR__ . '/../hwe/ts/gateway/entrance.ts')); + } + + 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, + ); } } diff --git a/tests/GameClockTest.php b/tests/GameClockTest.php index d62cfc30..063468a6 100644 --- a/tests/GameClockTest.php +++ b/tests/GameClockTest.php @@ -91,6 +91,33 @@ final class GameClockTest extends TestCase 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));