From 83edf7d956cae55e7a9d860ed84ce61124d80146 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 15:50:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=EC=8B=9C=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=ED=84=B4=20=EC=97=94=EC=A7=84=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/lobby/index.ts | 3 + app/game-api/src/server.ts | 23 ++++-- app/game-api/src/services/turnEngineStatus.ts | 63 +++++++++++++++ app/game-api/test/lobbyRouter.test.ts | 25 +++++- app/game-api/test/turnEngineStatus.test.ts | 80 +++++++++++++++++++ app/game-frontend/e2e/mainNavigation.spec.ts | 36 +++++---- .../src/components/main/MainFrontStatus.vue | 32 ++++---- app/game-frontend/src/stores/mainDashboard.ts | 36 ++++++++- app/game-frontend/src/views/MainView.vue | 1 + 9 files changed, 260 insertions(+), 39 deletions(-) create mode 100644 app/game-api/src/services/turnEngineStatus.ts create mode 100644 app/game-api/test/turnEngineStatus.test.ts diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index a2fe2dde..93052114 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -5,6 +5,7 @@ import { asNumber, asRecord } from '@sammo-ts/common'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; +import { loadTurnEngineRunning } from '../../services/turnEngineStatus.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ @@ -35,6 +36,7 @@ export const lobbyRouter = router({ .map(([option]) => option) : []; const gameTime = await loadCurrentGameTime(ctx.db); + const turnEngineRunning = await loadTurnEngineRunning(ctx.profileStatusSource, ctx.db, ctx.profile.name); let myGeneral = null; if (ctx.auth?.user.id) { @@ -72,6 +74,7 @@ export const lobbyRouter = router({ clockMode: gameTime.mode ?? 'realtime', clockRunning: gameTime.running, clockStartsAt: gameTime.startsAt?.toISOString() ?? null, + turnEngineRunning, otherTextInfo: worldState.meta.otherTextInfo ?? '', npcMode: worldState.config.npcMode ?? 0, defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165), diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 11c14a1e..5cb33387 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -40,6 +40,7 @@ import { } from './realtime/publicEvent.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; +import { CachedTurnEngineStatus } from './services/turnEngineStatus.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; @@ -115,6 +116,7 @@ export const createGameApiServer = async () => { config.gatewayInternalApiUrl, config.gameTokenSecret ); + const turnEngineStatus = new CachedTurnEngineStatus(profileStatusSource, postgres.prisma, config.profileName); const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const accountIconResetReconciler = new AccountIconResetReconciler( @@ -383,13 +385,24 @@ export const createGameApiServer = async () => { }); }); + let heartbeatPending = false; const heartbeat = setInterval(() => { - sendFrame( - formatSseFrame({ - event: 'ping', - data: '{}', + if (heartbeatPending) return; + heartbeatPending = true; + void turnEngineStatus + .get() + .then((turnEngineRunning) => { + if (closed) return; + sendFrame( + formatSseFrame({ + event: 'ping', + data: JSON.stringify({ turnEngineRunning }), + }) + ); }) - ); + .finally(() => { + heartbeatPending = false; + }); }, 15000); const close = () => { diff --git a/app/game-api/src/services/turnEngineStatus.ts b/app/game-api/src/services/turnEngineStatus.ts new file mode 100644 index 00000000..7b4dfde4 --- /dev/null +++ b/app/game-api/src/services/turnEngineStatus.ts @@ -0,0 +1,63 @@ +import { gatewayProfileCapabilities } from '@sammo-ts/common'; + +import type { ProfileStatusSource } from '../auth/profileStatusSource.js'; + +interface TurnDaemonLeaseSource { + turnDaemonLease: { + findUnique(input: { + where: { profile: string }; + select: { leaseUntil: true }; + }): Promise<{ leaseUntil: Date } | null>; + }; +} + +export const loadTurnEngineRunning = async ( + source: ProfileStatusSource | undefined, + db: TurnDaemonLeaseSource, + profileName: string, + now = new Date() +): Promise => { + if (!source) return null; + try { + const status = await source.get(profileName); + if (status === null) return null; + if (!gatewayProfileCapabilities(status).turnsRunning) return false; + const lease = await db.turnDaemonLease.findUnique({ + where: { profile: profileName }, + select: { leaseUntil: true }, + }); + return lease !== null && lease.leaseUntil.getTime() > now.getTime(); + } catch { + return null; + } +}; + +export class CachedTurnEngineStatus { + private cachedAt = Number.NEGATIVE_INFINITY; + private cachedValue: boolean | null = null; + private pending: Promise | null = null; + + constructor( + private readonly source: ProfileStatusSource, + private readonly db: TurnDaemonLeaseSource, + private readonly profileName: string, + private readonly cacheMs = 2_000, + private readonly now = () => Date.now() + ) {} + + get(): Promise { + if (this.now() - this.cachedAt < this.cacheMs) { + return Promise.resolve(this.cachedValue); + } + if (this.pending) return this.pending; + + this.pending = loadTurnEngineRunning(this.source, this.db, this.profileName).then((value) => { + this.cachedValue = value; + this.cachedAt = this.now(); + return value; + }); + return this.pending.finally(() => { + this.pending = null; + }); + } +} diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index c9c09ec8..f1932c0c 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -39,8 +39,12 @@ const buildContext = ( nation: { count: vi.fn(async () => 0), }, + turnDaemonLease: { + findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })), + }, } as unknown as DatabaseClient, - }) as GameApiContext; + profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) }, + }) as unknown as GameApiContext; describe('lobby season state', () => { it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { @@ -70,9 +74,28 @@ describe('lobby season state', () => { expect(result.clockMode).toBe('manual'); expect(result.clockRunning).toBe(false); expect(result.clockStartsAt).toBeNull(); + expect(result.turnEngineRunning).toBe(true); expect(new Date(result.serverWallTime).getTime()).not.toBeNaN(); }); + it('projects the explicit Gateway turn-running capability independently of the game clock mode', async () => { + const context = buildContext( + {}, + { + baseTime: new Date('2026-08-15T00:00:00.000Z'), + tick: 72_000_000n, + mode: 'realtime', + wallAnchor: new Date('2026-08-15T00:00:00.000Z'), + } + ); + context.profileStatusSource = { get: vi.fn(async () => 'PAUSED' as const) }; + + const result = await appRouter.createCaller(context).lobby.info(); + + expect(result.clockRunning).toBe(true); + expect(result.turnEngineRunning).toBe(false); + }); + it('exposes the future realtime wall anchor without advancing the preopen clock', async () => { const wallAnchor = new Date('2099-08-21T11:00:00.000Z'); const result = await appRouter diff --git a/app/game-api/test/turnEngineStatus.test.ts b/app/game-api/test/turnEngineStatus.test.ts new file mode 100644 index 00000000..8d6e1e26 --- /dev/null +++ b/app/game-api/test/turnEngineStatus.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/turnEngineStatus.js'; + +describe('turn engine status projection', () => { + it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => { + const activeLease = { + turnDaemonLease: { + findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })), + }, + }; + const now = new Date('2026-08-24T00:00:00.000Z'); + await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe( + true + ); + await expect(loadTurnEngineRunning({ get: async () => 'PREOPEN' }, activeLease, 'che:default', now)).resolves.toBe( + false + ); + await expect(loadTurnEngineRunning({ get: async () => 'PAUSED' }, activeLease, 'che:default', now)).resolves.toBe( + false + ); + await expect(loadTurnEngineRunning({ get: async () => null }, activeLease, 'che:default', now)).resolves.toBeNull(); + await expect( + loadTurnEngineRunning( + { get: async () => Promise.reject(new Error('gateway unavailable')) }, + activeLease, + 'che:default', + now + ) + ).resolves.toBeNull(); + }); + + it('marks a RUNNING profile stopped when its daemon lease is missing or expired', async () => { + const source = { get: async () => 'RUNNING' as const }; + const now = new Date('2026-08-24T00:00:00.000Z'); + await expect( + loadTurnEngineRunning( + source, + { turnDaemonLease: { findUnique: async () => null } }, + 'che:default', + now + ) + ).resolves.toBe(false); + await expect( + loadTurnEngineRunning( + source, + { + turnDaemonLease: { + findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }), + }, + }, + 'che:default', + now + ) + ).resolves.toBe(false); + }); + + it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => { + let now = 1_000; + const get = vi.fn(async () => 'RUNNING' as const); + const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })); + const cache = new CachedTurnEngineStatus( + { get }, + { turnDaemonLease: { findUnique } }, + 'che:default', + 2_000, + () => now + ); + + await expect(Promise.all([cache.get(), cache.get()])).resolves.toEqual([true, true]); + expect(get).toHaveBeenCalledTimes(1); + now += 1_999; + await expect(cache.get()).resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(1); + now += 1; + await expect(cache.get()).resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(2); + expect(findUnique).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 772269e1..edbe8e46 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -42,6 +42,7 @@ type NavigationFixture = { clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + turnEngineRunning?: boolean | null; cityDefence?: number; cityState?: number; nationRate?: number; @@ -598,6 +599,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { clockMode: state.clockMode ?? 'realtime', clockRunning: state.clockRunning ?? true, clockStartsAt: state.clockStartsAt ?? null, + turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning, scenarioTitle: state.scenarioTitle ?? '', }); } @@ -2037,7 +2039,7 @@ test('main general card uses local turn time and command clock tracks corrected expect(state.operations).toHaveLength(operationsBeforePreopenBoundary); }); -test('main header clock follows minute boundaries only while game-server contact is recent', async ({ +test('main header clock follows minute boundaries only while the turn engine is running', async ({ page, }, testInfo) => { const state: NavigationFixture = { @@ -2052,6 +2054,7 @@ test('main header clock follows minute boundaries only while game-server contact serverWallTime: '2026-08-13T00:00:00.000Z', clockMode: 'realtime', clockRunning: true, + turnEngineRunning: true, }; await installRealtimeHarness(page); await installFixture(page, state); @@ -2063,37 +2066,42 @@ test('main header clock follows minute boundaries only while game-server contact const clock = page.locator('.execution-status'); const initialRequestCount = state.trpcRequests?.length ?? 0; await expect(clock).toHaveText('현재 시각: 08-13 09:00'); - await expect(clock).not.toHaveClass(/execution-status--stale/u); + await expect(clock).not.toHaveClass(/execution-status--stopped/u); await page.clock.runFor(25_000); await expect(clock).toHaveText('현재 시각: 08-13 09:01'); - await page.clock.runFor(21_000); - await expect(clock).toHaveClass(/execution-status--stale/u); - await expect(clock).toHaveAttribute('title', '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.'); + await page.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( + 'ping', + { turnEngineRunning: false } + ); + }); + await expect(clock).toHaveClass(/execution-status--stopped/u); + await expect(clock).toHaveAttribute('title', '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.'); await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(255, 0, 255)'); - const staleDesktopGeometry = await clock.evaluate((element) => ({ + const stoppedDesktopGeometry = await clock.evaluate((element) => ({ rect: element.getBoundingClientRect().toJSON(), overflow: element.scrollWidth - element.clientWidth, color: getComputedStyle(element).color, fontSize: getComputedStyle(element).fontSize, lineHeight: getComputedStyle(element).lineHeight, })); - expect(staleDesktopGeometry.rect.width).toBeCloseTo(333.33, 0); - expect(staleDesktopGeometry.rect.height).toBeGreaterThanOrEqual(36); - expect(staleDesktopGeometry.overflow).toBeLessThanOrEqual(0); - await clock.screenshot({ path: testInfo.outputPath('main-header-clock-stale-desktop-1200.png') }); - await page.clock.runFor(60_000); + expect(stoppedDesktopGeometry.rect.width).toBeCloseTo(333.33, 0); + expect(stoppedDesktopGeometry.rect.height).toBeGreaterThanOrEqual(36); + expect(stoppedDesktopGeometry.overflow).toBeLessThanOrEqual(0); + await clock.screenshot({ path: testInfo.outputPath('main-header-clock-stopped-desktop-1200.png') }); + await page.clock.runFor(81_000); await expect(clock).toHaveText('현재 시각: 08-13 09:01'); await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( 'ping', - {} + { turnEngineRunning: true } ); }); await expect(clock).toHaveText('현재 시각: 08-13 09:02'); - await expect(clock).not.toHaveClass(/execution-status--stale/u); + await expect(clock).not.toHaveClass(/execution-status--stopped/u); await page.clock.runFor(39_000); await expect(clock).toHaveText('현재 시각: 08-13 09:03'); await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(0, 255, 255)'); @@ -2114,7 +2122,7 @@ test('main header clock follows minute boundaries only while game-server contact clock.screenshot({ path: testInfo.outputPath('main-header-clock-fresh-mobile-500.png') }), writeFile( testInfo.outputPath('main-header-clock-geometry.json'), - `${JSON.stringify({ staleDesktopGeometry, freshMobileGeometry }, null, 2)}\n` + `${JSON.stringify({ stoppedDesktopGeometry, freshMobileGeometry }, null, 2)}\n` ), ]); expect(state.trpcRequests?.length ?? 0).toBe(initialRequestCount); diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index b8e5d1d2..037bbb14 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -2,11 +2,6 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { computed, onUnmounted, ref, watch } from 'vue'; import { resolveTournamentStageName } from '../../utils/tournamentStatus'; -import { - GAME_SERVER_ACTIVITY_FRESHNESS_MS, - gameServerActivity, - isRecentGameServerActivity, -} from '../../utils/gameServerActivity'; import { millisecondsUntilNextMinute, projectServerClock, @@ -21,6 +16,7 @@ const props = defineProps<{ clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + turnEngineRunning?: boolean | null; status: { onlineUserCount: number; onlineNations: string; @@ -38,10 +34,12 @@ const props = defineProps<{ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const currentServerTime = ref('기록 없음'); const hasServerClock = ref(false); -const serverClockFresh = ref(false); +const turnEngineStopped = computed(() => props.turnEngineRunning === false); +const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean'); const serverClockTitle = computed(() => { if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.'; - if (!serverClockFresh.value) return '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.'; + if (turnEngineStopped.value) return '턴 엔진이 정지하여 현재 시각 보정을 멈췄습니다.'; + if (turnEngineStatusUnknown.value) return '턴 엔진 진행 상태를 확인하지 못했습니다.'; return undefined; }); @@ -54,7 +52,6 @@ const updateServerClock = () => { if (serverClockSample === null) { currentServerTime.value = '기록 없음'; hasServerClock.value = false; - serverClockFresh.value = false; return; } @@ -65,16 +62,14 @@ const updateServerClock = () => { fallback: '기록 없음', }); hasServerClock.value = true; + if (props.turnEngineRunning !== true) return; - const lastContactAt = gameServerActivity.lastContactAt.value; - serverClockFresh.value = isRecentGameServerActivity(lastContactAt, now); - if (!serverClockFresh.value || lastContactAt === null) return; - - const nextDelays = [lastContactAt + GAME_SERVER_ACTIVITY_FRESHNESS_MS - now + 1]; + const nextDelays: number[] = []; if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs; nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time)); } + if (nextDelays.length === 0) return; serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays))); }; @@ -86,7 +81,7 @@ watch( }, { immediate: true } ); -watch(() => gameServerActivity.lastContactAt.value, updateServerClock); +watch(() => props.turnEngineRunning, updateServerClock); onUnmounted(() => { if (serverClockTimer !== undefined) clearTimeout(serverClockTimer); @@ -100,7 +95,8 @@ onUnmounted(() => { class="status-row execution-status" :class="{ 'execution-status--empty': !hasServerClock, - 'execution-status--stale': hasServerClock && !serverClockFresh, + 'execution-status--stopped': hasServerClock && turnEngineStopped, + 'execution-status--unknown': hasServerClock && turnEngineStatusUnknown, }" :title="serverClockTitle" > @@ -195,10 +191,14 @@ onUnmounted(() => { color: magenta; } -.execution-status--stale { +.execution-status--stopped { color: magenta; } +.execution-status--unknown { + color: #aaa; +} + .vote-label { color: cyan; } diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index ad44ed3f..ed15d8ca 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -81,7 +81,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { tournamentType?: TournamentType | null; }; type DashboardTabMessage = - { kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' }; + | { kind: 'patch'; patch: DashboardReadModelPatch } + | { + kind: 'status'; + status: 'idle' | 'connected'; + turnEngineRunning?: boolean | null; + }; const loading = ref(false); const refreshing = ref(false); @@ -467,6 +472,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const applyTurnEngineRunning = (turnEngineRunning: boolean | null | undefined) => { + if (turnEngineRunning === undefined || !lobbyInfo.value) return; + lobbyInfo.value = structurallyShare(lobbyInfo.value, { + ...lobbyInfo.value, + turnEngineRunning, + }); + }; + const currentDashboardPatch = (): DashboardReadModelPatch => { const patch: DashboardReadModelPatch = {}; patch.contextSnapshot = toRaw(contextSnapshot); @@ -1115,6 +1128,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return; } realtimeStatus.value = message.status; + applyTurnEngineRunning(message.turnEngineRunning); if (message.status === 'connected') markGameServerContact(); }, }); @@ -1209,11 +1223,27 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { markGameServerContact(); void refreshMessages(); }); - source.addEventListener('ping', () => { + source.addEventListener('ping', (event) => { + let turnEngineRunning: boolean | null | undefined; + if (event instanceof MessageEvent && typeof event.data === 'string') { + try { + const payload = JSON.parse(event.data) as { turnEngineRunning?: unknown }; + if (typeof payload.turnEngineRunning === 'boolean' || payload.turnEngineRunning === null) { + turnEngineRunning = payload.turnEngineRunning; + } + } catch { + // Older APIs send an empty heartbeat. Keep the last explicit engine state. + } + } + applyTurnEngineRunning(turnEngineRunning); markGameServerContact(); if (realtimeEnabled.value) { realtimeStatus.value = 'connected'; - realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); + realtimeCoordinator?.postFromLeader({ + kind: 'status', + status: 'connected', + ...(turnEngineRunning === undefined ? {} : { turnEngineRunning }), + }); } }); }; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d5de7c4b..1a782515 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -253,6 +253,7 @@ watch( :clock-mode="lobbyInfo?.clockMode" :clock-running="lobbyInfo?.clockRunning" :clock-starts-at="lobbyInfo?.clockStartsAt" + :turn-engine-running="lobbyInfo?.turnEngineRunning" />