From 21d10d5dfc37696abe6413802edf8ceae8aec4d7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 12 Sep 2026 02:02:31 +0000 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=A0=95=EC=A7=80=20?= =?UTF-8?q?=EC=A4=91=20=EA=B0=80=EC=9E=85=20=ED=84=B4=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B3=A0=20=ED=99=94=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=8B=A4=ED=8C=A8=20=EB=B3=B5=EA=B5=AC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lifecycle/turnDaemonLifecycle.ts | 3 +- .../src/turn/gatewayProfileGate.ts | 7 + app/game-engine/src/turn/inMemoryWorld.ts | 13 ++ app/game-engine/src/turn/runtimePauseGate.ts | 33 +++++ app/game-engine/src/turn/turnDaemon.ts | 37 +++--- .../src/turn/worldCommandHandler.ts | 8 +- .../clockReconciliation.integration.test.ts | 82 ++++++++++++ .../test/runtimeClockShift.test.ts | 15 +++ app/game-engine/test/runtimePauseGate.test.ts | 64 +++++++++ .../test/turnDaemonLifecycle.test.ts | 5 +- app/game-frontend/e2e/mainNavigation.spec.ts | 121 ++++++++++++++++++ app/game-frontend/src/App.vue | 2 + .../components/ui/GameNavigationNotice.vue | 46 +++++++ app/game-frontend/src/router/index.ts | 3 + .../src/utils/routeNavigation.ts | 35 +++++ 15 files changed, 448 insertions(+), 26 deletions(-) create mode 100644 app/game-engine/src/turn/runtimePauseGate.ts create mode 100644 app/game-engine/test/runtimePauseGate.test.ts create mode 100644 app/game-frontend/src/components/ui/GameNavigationNotice.vue create mode 100644 app/game-frontend/src/utils/routeNavigation.ts diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 9b5e1a20..4c817c54 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -142,11 +142,12 @@ export class TurnDaemonLifecycle { private async runLoop(): Promise { await this.initializeState(); while (!this.stopping) { + // 정지 시계 동기화보다 먼저 claim하면 가입에 벽시계 경과가 섞인다. + const gatePaused = (await this.pauseGate?.()) ?? false; await this.drainCommands(); if (this.stopping) { break; } - const gatePaused = (await this.pauseGate?.()) ?? false; if (this.errorPaused && !gatePaused) { this.errorPaused = false; this.status.lastError = undefined; diff --git a/app/game-engine/src/turn/gatewayProfileGate.ts b/app/game-engine/src/turn/gatewayProfileGate.ts index ee756038..f249b092 100644 --- a/app/game-engine/src/turn/gatewayProfileGate.ts +++ b/app/game-engine/src/turn/gatewayProfileGate.ts @@ -14,6 +14,7 @@ export interface GatewayProfileGateOptions { export interface GatewayProfileGate { shouldPause(): Promise; + isExplicitlyPaused(): boolean; markPaused(error?: unknown): Promise; close(): Promise; } @@ -30,12 +31,14 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption const prisma = connector.prisma; let lastCheckedAt = 0; let cachedPause = false; + let cachedStatus: GatewayProfileStatus | null = null; const loadStatus = async (): Promise => { try { const profile = await prisma.gatewayProfile.findUnique({ where: { profileName: options.profileName }, }); + cachedStatus = (profile?.status as GatewayProfileStatus | undefined) ?? null; if (!profile) { return false; } @@ -46,6 +49,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption }; return { + isExplicitlyPaused: () => cachedStatus === 'PAUSED', // 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다. async shouldPause(): Promise { const now = performance.now(); @@ -57,6 +61,9 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption return cachedPause; }, async markPaused(error?: unknown): Promise { + cachedPause = true; + cachedStatus = 'PAUSED'; + lastCheckedAt = performance.now(); const failure = error ? describeRuntimeError(error) : null; const message = failure?.message ?? null; try { diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index f9313224..6d660f16 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -920,6 +920,19 @@ export class InMemoryTurnWorld { return clock.now(wallNow); } + getInitialGeneralTurnTime(processingGameTick: number): Date { + const clock = this.getGameClock(); + const tick = + clock.phase === 'PREOPEN' + ? Math.max(0, processingGameTick) + : clock.phase === 'SUSPENDED' || clock.phase === 'COMPLETED' + ? clock.tick + : processingGameTick; + // 오류 정지 직전에 접수된 가입도 정지된 시각보다 미래에 배치하지 않는다. + // 접수 tick 자체는 RNG/감사 원장의 좌표로 보존한다. + return clock.tickToDate(tick); + } + dateToGameTick(date: Date): number { return this.getGameClock().dateToTick(date); } diff --git a/app/game-engine/src/turn/runtimePauseGate.ts b/app/game-engine/src/turn/runtimePauseGate.ts new file mode 100644 index 00000000..4c106873 --- /dev/null +++ b/app/game-engine/src/turn/runtimePauseGate.ts @@ -0,0 +1,33 @@ +import type { GameClockPhase } from '@sammo-ts/common'; + +/** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */ +export const createRuntimePauseGate = (options: { + assertLease(): void; + shouldPause(): Promise; + getPhase(): GameClockPhase; + isExplicitlyPaused(): boolean; + prepareRecovery(options: { paused: boolean }): Promise; + synchronize(): Promise; +}): (() => Promise) => { + let lastPaused: boolean | null = null; + return async () => { + options.assertLease(); + const paused = await options.shouldPause(); + const phase = options.getPhase(); + // 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은 + // 흐르는 접수 시각을 쓰고, 재개 시 정수 턴 이동까지 중복 적용받는다. + // PREOPEN은 예정된 대기이므로 오픈 시각을 바꾸지 않는다. + if (paused && options.isExplicitlyPaused() && phase === 'RUNNING') { + await options.prepareRecovery({ paused: true }); + } else if (!paused && phase === 'SUSPENDED') { + // 이 runtime이 만든 RECOVERY 정지는 재기동 없이도 재개한다. + // MAINTENANCE/통일 대기의 재개 권한은 기존 운영 경계에 남는다. + await options.prepareRecovery({ paused: false }); + } + if (lastPaused !== paused || phase === 'SUSPENDED' || phase === 'RECONCILING') { + await options.synchronize(); + } + lastPaused = paused; + return paused; + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 4a1a22f8..b291cff2 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { createRuntimePauseGate } from './runtimePauseGate.js'; import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; import { @@ -716,6 +717,7 @@ const createTurnDaemonRuntimeWithLease = async ( let stopClockProjectionWorker = () => {}; let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined; let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined; + let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const monthlyActionModules = await loadActionModuleBundle( @@ -941,11 +943,16 @@ const createTurnDaemonRuntimeWithLease = async ( onRunError: async (error) => { await dbHooks.hooks.onRunError?.(error); await gatewayGate?.markPaused(error); + if (!turnDaemonLease?.isLost() && world.getGameClockState().phase === 'RUNNING') { + // 같은 command batch의 다음 가입도 정지된 시각을 보게 한다. + await dbHooks.prepareRealtimeRecovery({ paused: true }); + } }, }; takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt; applyClockProjection = dbHooks.applyClockProjection; synchronizeClockAuthority = dbHooks.synchronizeClockAuthority; + prepareClockRecovery = dbHooks.prepareRealtimeRecovery; close = async () => { if (auctionBidder) { await auctionBidder.close(); @@ -1060,7 +1067,6 @@ const createTurnDaemonRuntimeWithLease = async ( maxGenerals: 200, catchUpCap: 1, }; - let lastObservedGatewayPause: boolean | null = null; const lifecycle = new TurnDaemonLifecycle( { @@ -1071,23 +1077,18 @@ const createTurnDaemonRuntimeWithLease = async ( stateStore, processor, hooks, - pauseGate: async () => { - if (turnDaemonLease?.isLost()) { - // 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을 - // 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다. - throw turnDaemonLease.getLossError(); - } - const gatewayPaused = (await pauseGate?.()) ?? false; - const phase = world.getGameClockState().phase; - const phaseNeedsSync = gatewayPaused - ? phase !== 'SUSPENDED' - : phase === 'SUSPENDED' || phase === 'RECONCILING'; - if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) { - await synchronizeClockAuthority(); - } - lastObservedGatewayPause = gatewayPaused; - return gatewayPaused; - }, + pauseGate: createRuntimePauseGate({ + assertLease: () => { + if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError(); + }, + shouldPause: async () => (await pauseGate?.()) ?? false, + getPhase: () => world.getGameClockState().phase, + isExplicitlyPaused: () => gatewayGate?.isExplicitlyPaused() ?? false, + prepareRecovery: async (recoveryOptions) => { + await prepareClockRecovery?.(recoveryOptions); + }, + synchronize: async () => synchronizeClockAuthority?.(), + }), commandHandler, commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined), // The exclusive fixture runner aborts the entire in-memory runtime diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 81014aca..79097e8f 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -354,9 +354,7 @@ async function handleJoinCreateGeneral( throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.'); } const acceptedAt = ctx.world.gameTickToDate(processingGameTick); - const turnScheduleAt = ctx.world.gameTickToDate( - ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick - ); + const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick); try { return { type: 'joinCreateGeneral', @@ -475,9 +473,7 @@ async function handleSelectPoolCreate( } const acceptedAt = ctx.world.gameTickToDate(processingGameTick); // 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다. - const turnScheduleAt = ctx.world.gameTickToDate( - ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick - ); + const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick); try { return { type: 'selectPoolCreate', diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index f691a893..aa524e54 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -4,6 +4,7 @@ import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts import { createGamePostgresConnector, readTurnRuntimeReady, + readInputEventClockCoordinate, createRedisConnector, GENERAL_ACCESS_PERSISTENCE_LOCK, CLOCK_OPERATION_PERSISTENCE_LOCK, @@ -15,6 +16,8 @@ import { import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js'; import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js'; +import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js'; +import { resolveJoinTurnTime } from '../src/turn/joinCreateGeneralService.js'; import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; @@ -263,6 +266,85 @@ describeIntegration('durable clock reconciliation', () => { } }); + it('freezes joins in a live PAUSED gate and applies outage recovery only once', async () => { + const profile = 'live-pause-join'; + const base = new Date('2026-09-11T23:00:00Z'); + await db.worldState.create({ + data: { + scenarioCode: profile, + currentYear: 180, + currentMonth: 1, + tickSeconds: 60, + clockBaseTime: base, + clockTick: 0n, + lastTurnTick: 0n, + clockMode: 'realtime', + clockPhase: 'RUNNING', + clockWallAnchor: new Date(Date.now() - 115 * 60_000), + clockRevision: 1n, + deadlineGeneration: 1n, + }, + }); + const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false }); + try { + const token = (await lease.acquire())!; + await lease.markClockReady(); + const authority = { + kind: 'DAEMON' as const, + profileName: profile, + ownerId: token.ownerId, + fencingEpoch: token.fencingEpoch, + }; + let phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING'; + const gate = createRuntimePauseGate({ + assertLease: () => {}, + shouldPause: async () => true, + isExplicitlyPaused: () => true, + getPhase: () => phase, + prepareRecovery: async (options) => { + await prepareRealtimeRecovery(db, authority, options); + phase = 'SUSPENDED'; + }, + synchronize: async () => {}, + }); + const beforePause = await db.$transaction((tx) => readInputEventClockCoordinate(tx)); + expect(beforePause.gameTick).toBeGreaterThan(BigInt(100 * T)); + await gate(); + const accepted = await db.$transaction((tx) => readInputEventClockCoordinate(tx)); + expect(accepted.gameTick).toBe(0n); + const pausedWorld = await db.worldState.findFirstOrThrow(); + const draws = [26, 753000]; + const turnTime = resolveJoinTurnTime( + { nextRangeInt: () => draws.shift()! }, + pausedWorld, + accepted.gameAt, + base, + undefined + ); + expect(turnTime.toISOString()).toBe('2026-09-11T23:00:26.753Z'); + await db.general.create({ + data: { id: 768, name: 'pause-join', turnTick: BigInt(26_753 * 600), turnTime }, + }); + await gate(); + expect(await db.clockSuspension.count()).toBe(1); + const suspension = await db.clockSuspension.findFirstOrThrow(); + const plan = await reconcileClockSuspension({ + db, + authority, + suspensionId: suspension.id, + testResumeWallAt: new Date(suspension.cutWallAt.getTime() + 115 * 60_000), + }); + expect(plan.shiftTicks).toBe(108 * T); + const joined = await db.general.findUniqueOrThrow({ where: { id: 768 } }); + expect(joined.turnTime.toISOString()).toBe('2026-09-12T00:48:26.753Z'); + expect(joined.turnTick! - BigInt(plan.alignedTick)).toBe(BigInt(26_753 * 600)); + await reconcileClockSuspension({ db, authority, suspensionId: suspension.id }); + expect((await db.general.findUniqueOrThrow({ where: { id: 768 } })).turnTime).toEqual(joined.turnTime); + } finally { + await lease.close(); + } + }); + it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => { const profile = 'recovery-startup'; await db.worldState.create({ diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index 2e4a2cfd..998c2002 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -239,6 +239,20 @@ describe('runtime clock shift', () => { expect(world.getGameClockState().wallAnchor).toEqual(resumedAt); }); + it.each(['SUSPENDED', 'COMPLETED'] as const)('keeps a queued join within the frozen %s clock', (phase) => { + const base = new Date('2026-09-11T23:00:00Z'); + const world = buildWorld({ + clockBaseTime: base, + clockTick: 0, + clockMode: 'realtime', + clockPhase: phase, + clockWallAnchor: base, + lastTurnTick: 0, + }); + expect(world.getInitialGeneralTurnTime(103 * 36_000_000)).toEqual(base); + expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(base); + }); + it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => { const gameBase = new Date('2026-07-30T10:00:00.000Z'); const openAt = new Date('2026-09-02T23:30:00.000Z'); @@ -254,6 +268,7 @@ describe('runtime clock shift', () => { expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime()); expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase); + expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(gameBase); expect(world.getRunnableGameNow(openAt)).toEqual(gameBase); expect(world.promotePreopenAtOpening(openAt)).toBe(true); expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual( diff --git a/app/game-engine/test/runtimePauseGate.test.ts b/app/game-engine/test/runtimePauseGate.test.ts new file mode 100644 index 00000000..364b2dc0 --- /dev/null +++ b/app/game-engine/test/runtimePauseGate.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { GameClockPhase } from '@sammo-ts/common'; +import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js'; + +describe('runtime pause clock boundary', () => { + it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => { + let phase: GameClockPhase = 'RUNNING'; + let paused = true; + const calls: string[] = []; + const gate = createRuntimePauseGate({ + assertLease: () => calls.push('lease'), + shouldPause: async () => paused, + isExplicitlyPaused: () => paused, + getPhase: () => phase, + prepareRecovery: async ({ paused }) => { + calls.push(paused ? 'freeze' : 'resume'); + phase = paused ? 'SUSPENDED' : 'RECONCILING'; + }, + synchronize: async () => calls.push('sync'), + }); + expect(await gate()).toBe(true); + expect(phase).toBe('SUSPENDED'); + expect(calls).toEqual(['lease', 'freeze', 'sync']); + await gate(); + expect(calls.filter((call) => call === 'freeze')).toHaveLength(1); + paused = false; + expect(await gate()).toBe(false); + expect(phase).toBe('RECONCILING'); + expect(calls.slice(-3)).toEqual(['lease', 'resume', 'sync']); + }); + + it.each(['PREOPEN', 'RUNNING', 'COMPLETED'] as const)( + 'preserves the planned opening when the Gateway is PREOPEN and the clock is %s', + async (phase) => { + const prepareRecovery = vi.fn(); + const gate = createRuntimePauseGate({ + assertLease: () => {}, + shouldPause: async () => true, + isExplicitlyPaused: () => false, + getPhase: () => phase, + prepareRecovery, + synchronize: async () => {}, + }); + expect(await gate()).toBe(true); + expect(prepareRecovery).not.toHaveBeenCalled(); + } + ); + + it('does not touch the clock after lease loss', async () => { + const prepareRecovery = vi.fn(); + const gate = createRuntimePauseGate({ + assertLease: () => { + throw new Error('lease lost'); + }, + shouldPause: async () => true, + isExplicitlyPaused: () => true, + getPhase: () => 'RUNNING', + prepareRecovery, + synchronize: async () => {}, + }); + await expect(gate()).rejects.toThrow('lease lost'); + expect(prepareRecovery).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 03d79c1d..43febd38 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -19,13 +19,15 @@ describe('TurnDaemonLifecycle', () => { const now = new Date('2026-09-09T17:30:00Z'); const error = new TurnDaemonLeaseLostError('che:default'); const processor = { run: vi.fn() }; + const queue = new InMemoryControlQueue(); + const drain = vi.spyOn(queue, 'drain'); const onRunError = vi.fn(async () => { if (reportFails) throw new Error('gateway unavailable'); }); const lifecycle = new TurnDaemonLifecycle( { clock: new ManualClock(now.getTime()), - controlQueue: new InMemoryControlQueue(), + controlQueue: queue, processor, getNextTickTime: (value) => addMinutes(value, 5), stateStore: { @@ -45,6 +47,7 @@ describe('TurnDaemonLifecycle', () => { await expect(lifecycle.start()).rejects.toBe(error); expect(onRunError).toHaveBeenCalledExactlyOnceWith(error); expect(processor.run).not.toHaveBeenCalled(); + expect(drain).not.toHaveBeenCalled(); expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 64781bbc..555282ac 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -6639,3 +6639,124 @@ for (const width of [1200, 390]) { expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0); }); } + +for (const viewport of [ + { name: 'desktop', width: 1280, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + for (const target of [ + { id: 'finance', chunk: 'NationStratFinanView', path: '/nation/finance' }, + { id: 'nation-cities', chunk: 'NationCitiesView', path: '/nation/cities' }, + ]) { + test(`recovers a stalled and failed ${target.id} navigation on ${viewport.name}`, async ({ + page, + }, testInfo) => { + test.skip(!productionBundle, 'Tests actual production dynamic import failure.'); + await page.setViewportSize(viewport); + const state: NavigationFixture = { + officerLevel: 12, + permission: 4, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.route(`**${basePath}/api/trpc/**`, async (route) => { + const ops = operationNames(route); + if (!ops.some((op) => ['nation.getStratFinan', 'nation.getCityOverview'].includes(op))) { + await route.fallback(); + return; + } + const result = ops.map((op) => + response( + op === 'nation.getStratFinan' + ? { + editable: true, + nationMsg: '', + scoutMsg: '', + nationId: 1, + officerLevel: 12, + year: 185, + month: 1, + nationsList: [], + gold: 1000, + rice: 1000, + income: { gold: { city: 100, war: 0 }, rice: { city: 100, wall: 0 } }, + outcome: 0, + policy: { rate: 20, bill: 100, secretLimit: 3, blockScout: false, blockWar: false }, + warSettingCnt: { remain: 5, inc: 2, max: 10 }, + } + : { + me: { officerLevel: 12 }, + nation: { name: '검증국', color: '#008000' }, + cities: [], + generals: [], + } + ) + ); + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(result.length === 1 ? result[0] : result), + }); + }); + await page.goto('./'); + const link = page.locator(`a[data-navigation-id="${target.id}"]:visible`).first(); + await expect(link).toBeVisible(); + let release: () => void = () => {}; + const hold = new Promise((resolve) => { + release = resolve; + }); + let blocked = false; + await page.route(`**/${target.chunk}-*.js`, async (route) => { + if (blocked) { + await route.continue(); + return; + } + blocked = true; + await hold; + await route.abort('failed'); + }); + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await link.click(); + const notice = page.getByTestId('game-navigation-notice'); + await expect(notice).toContainText('화면을 여는 중'); + await expect(notice).toContainText('시간이 걸리고', { timeout: 12_000 }); + expect(new URL(page.url()).pathname).toBe(`${basePath}/`); + release(); + await expect(notice).toContainText('화면을 불러오지 못했습니다'); + await expect(notice.locator('a')).toHaveAttribute('href', `${basePath}${target.path}`); + await page.evaluate(() => document.fonts.ready); + const geometry = await notice.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + position: style.position, + color: style.color, + background: style.backgroundColor, + }; + }); + expect(geometry.x).toBeGreaterThanOrEqual(0); + expect(geometry.x + geometry.width).toBeLessThanOrEqual(viewport.width); + await page.screenshot({ path: testInfo.outputPath('navigation-failed.png') }); + await writeFile( + testInfo.outputPath('navigation.json'), + JSON.stringify({ geometry, pageErrors, url: page.url(), viewport }) + ); + await writeFile(testInfo.outputPath('navigation.html'), await page.content()); + await notice.locator('a').click(); + await expect(page).toHaveURL(new RegExp(`${target.path}$`)); + await expect(notice).toBeHidden(); + await expect(page.locator('main')).toContainText(target.id === 'finance' ? '내무부' : '세 력 도 시'); + expect(pageErrors).toEqual([]); + await page.screenshot({ path: testInfo.outputPath('navigation-recovered.png') }); + }); + } +} diff --git a/app/game-frontend/src/App.vue b/app/game-frontend/src/App.vue index 7e2d6e19..711fff15 100644 --- a/app/game-frontend/src/App.vue +++ b/app/game-frontend/src/App.vue @@ -3,6 +3,7 @@ import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh'; import { RouterView } from 'vue-router'; import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue'; import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue'; +import GameNavigationNotice from './components/ui/GameNavigationNotice.vue'; import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice'; useDeploymentVersionNotice(); @@ -13,6 +14,7 @@ useClockDisplayRefresh(); + diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 769bb209..f406cfb1 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { gameFrontendRuntimeConfig } from '../config/runtimeConfig'; import { useSessionStore } from '../stores/session'; import { trpc } from '../utils/trpc'; +import { installRouteNavigation } from '../utils/routeNavigation'; const MainView = () => import('../views/MainView.vue'); const PublicView = () => import('../views/PublicView.vue'); @@ -389,6 +390,8 @@ const router = createRouter({ routes, }); +installRouteNavigation(router); + router.beforeEach(async (to) => { const session = useSessionStore(); diff --git a/app/game-frontend/src/utils/routeNavigation.ts b/app/game-frontend/src/utils/routeNavigation.ts new file mode 100644 index 00000000..ef21d04a --- /dev/null +++ b/app/game-frontend/src/utils/routeNavigation.ts @@ -0,0 +1,35 @@ +import { reactive } from 'vue'; +import type { Router } from 'vue-router'; + +export const routeNavigation = reactive({ href: '', state: 'idle' as 'idle' | 'loading' | 'slow' | 'failed' }); + +export const installRouteNavigation = (router: Router): void => { + let pendingPath = ''; + let visibleTimer: ReturnType | undefined; + let slowTimer: ReturnType | undefined; + const clearTimers = () => { + clearTimeout(visibleTimer); + clearTimeout(slowTimer); + }; + router.beforeEach((to) => { + clearTimers(); + pendingPath = to.fullPath; + routeNavigation.href = router.resolve(to).href; + routeNavigation.state = 'idle'; + visibleTimer = setTimeout(() => (routeNavigation.state = 'loading'), 350); + slowTimer = setTimeout(() => (routeNavigation.state = 'slow'), 10_000); + }); + router.afterEach((to) => { + if (to.fullPath !== pendingPath) return; + clearTimers(); + routeNavigation.state = 'idle'; + pendingPath = ''; + }); + router.onError((_error, to) => { + if (to.fullPath !== pendingPath) return; + clearTimers(); + // 실패한 dynamic import는 같은 탭에서 캐시된다. RouterLink 재클릭 대신 + // 원래 목적지의 문서를 새로 받아 모듈 캐시와 세션 초기화를 다시 시작한다. + routeNavigation.state = 'failed'; + }); +};