diff --git a/app/game-frontend/e2e/gameDeadlines.spec.ts b/app/game-frontend/e2e/gameDeadlines.spec.ts index 6ea30990..e2e13942 100644 --- a/app/game-frontend/e2e/gameDeadlines.spec.ts +++ b/app/game-frontend/e2e/gameDeadlines.spec.ts @@ -5,7 +5,7 @@ import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const wall = new Date('2026-09-16T12:00:00Z'); const game = new Date('2026-09-16T11:50:00Z'); -type ClockCase = 'normal' | 'recovery' | 'recovery-end' | 'suspended'; +type ClockCase = 'normal' | 'recovery' | 'recovery-end' | 'suspended' | 'preopen'; const install = async (page: Page, routeName: string, clockCase: ClockCase, displayMode: string) => { await page.clock.install({ time: wall }); await page.clock.setFixedTime(wall); @@ -48,7 +48,9 @@ const install = async (page: Page, routeName: string, clockCase: ClockCase, disp serverTime: game.toISOString(), serverWallTime: wall.toISOString(), clockMode: 'realtime', - clockRunning: clockCase !== 'suspended', + clockRunning: clockCase !== 'suspended' && clockCase !== 'preopen', + clockStartsAt: clockCase === 'preopen' ? new Date(wall.getTime() + 600_000).toISOString() : null, + turnEngineRunning: clockCase === 'preopen' ? false : true, clockRecovery: clockCase === 'recovery' || clockCase === 'recovery-end' ? { @@ -170,8 +172,9 @@ const capture = async (page: Page, info: TestInfo, name: string) => { await page.screenshot({ path: info.outputPath(`${name}.png`), fullPage: true }); }; for (const width of [1365, 390]) { - for (const clockCase of ['normal', 'recovery', 'recovery-end', 'suspended'] as const) { + for (const clockCase of ['normal', 'recovery', 'recovery-end', 'suspended', 'preopen'] as const) { for (const routeName of ['survey', 'select-general', 'join']) { + if (clockCase === 'preopen' && routeName !== 'join') continue; test(`${routeName} GAME deadline ${clockCase} ${width}px`, async ({ page }, info) => { await page.setViewportSize({ width, height: 900 }); const calls = await install(page, routeName, clockCase, width === 390 ? 'real' : 'game'); diff --git a/app/game-frontend/src/composables/useClockDisplay.ts b/app/game-frontend/src/composables/useClockDisplay.ts index 985dea42..685da74b 100644 --- a/app/game-frontend/src/composables/useClockDisplay.ts +++ b/app/game-frontend/src/composables/useClockDisplay.ts @@ -18,6 +18,7 @@ const mode = computed({ }, }); const sample = shallowRef>(null); +const preopenLogicalSample = shallowRef>(null); const now = ref(Date.now()); const engineRunning = ref(null); const haltedAt = ref(null); @@ -32,6 +33,9 @@ export const receiveClockSample = ( ): void => { if (!input.serverTime) return; const wallTime = input.serverWallTime ? new Date(input.serverWallTime).getTime() : undefined; + const startsAt = input.clockStartsAt ? new Date(input.clockStartsAt).getTime() : NaN; + const isPreopenSample = + input.clockMode === 'realtime' && wallTime !== undefined && Number.isFinite(startsAt) && wallTime < startsAt; // 메인 화면의 이전 표본을 heartbeat가 재전달해도 더 최신 lobby 표본을 되감지 않는다. if ( wallTime !== undefined && @@ -44,14 +48,20 @@ export const receiveClockSample = ( wallTime !== undefined && wallTime === sample.value.serverWallTimeMs && new Date(input.serverTime).getTime() === sample.value.serverTimeMs && - input.turnEngineRunning === engineRunning.value + input.turnEngineRunning === engineRunning.value && + isPreopenSample === (preopenLogicalSample.value !== null) ) return; + const sampledAt = Date.now(); + // PREOPEN's displayed clock is intentionally frozen. Its negative logical tick + // still advances, so candidate deadlines need the unpaused server sample. + preopenLogicalSample.value = isPreopenSample ? sampleServerClock(input, sampledAt) : null; receiveClockEngineState(input.turnEngineRunning ?? null); sample.value = sampleServerClock( - input.turnEngineRunning === false ? { ...input, clockRunning: false, clockStartsAt: null } : input + input.turnEngineRunning === false ? { ...input, clockRunning: false, clockStartsAt: null } : input, + sampledAt ); - now.value = Date.now(); + now.value = sampledAt; }; export const advanceClockDisplay = (): void => { @@ -64,7 +74,13 @@ const projection = computed(() => sample.value ? projectServerClock(sample.value, haltedAt.value ?? now.value) : null ); // 마감 판정은 표시 모드와 무관하게 서버가 투영한 GAME 시각을 사용한다. -const gameTime = computed(() => projection.value?.time ?? null); +const gameTime = computed(() => { + const preopen = preopenLogicalSample.value; + if (preopen) { + return new Date(preopen.serverTimeMs + Math.max(0, now.value - preopen.sampledClientTimeMs)); + } + return projection.value?.time ?? null; +}); const accelerated = computed(() => projection.value?.rate === 2 && engineRunning.value !== false); const label = computed(() => (mode.value === 'real' ? '실제 시간 기준' : '게임 시간 기준')); const time = computed(() => {