fix: support mixed wall and game clock servers
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, Promise<ServerDetailResponse>> = {};
|
||||
|
||||
@@ -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} <br>~ ${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)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function resolveGatewayOpenState(
|
||||
serverDecision: boolean | undefined,
|
||||
openTime: string,
|
||||
wallNow: string,
|
||||
): boolean {
|
||||
return serverDecision ?? openTime <= wallNow;
|
||||
}
|
||||
+52
-7
@@ -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<string, mixed> */
|
||||
private static function readStorageValues(KVStorage $gameStor): array
|
||||
{
|
||||
return $gameStor->getValues([
|
||||
'clock_base_time',
|
||||
'clock_tick',
|
||||
'clock_mode',
|
||||
'clock_wall_anchor',
|
||||
'turnterm',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $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,
|
||||
|
||||
+27
-11
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user