From eaf7ef8c2202d7ab3a564e86a2733938945537f2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 23 Aug 2026 02:00:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(engine):=20=EC=84=9C=EB=B2=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B0=9C=20=EC=8B=9C=20=EA=B2=8C=EC=9E=84=20=EC=8B=9C=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20Ref=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 짧은 중단은 순서대로 따라잡고 장기 중단은 턴 간격별 Ref 한도를 넘어선 완전 턴을 일괄 이동한다. 장수와 미완료 경매 시각을 같은 트랜잭션에서 저장하고 표시 시각 지연을 복구한다. --- .../src/lifecycle/turnDaemonLifecycle.ts | 62 ++++++- app/game-engine/src/lifecycle/types.ts | 9 + app/game-engine/src/turn/databaseHooks.ts | 24 ++- .../src/turn/inMemoryStateStore.ts | 8 + app/game-engine/src/turn/inMemoryWorld.ts | 175 ++++++++++++++---- .../test/realtimeReadModelChanges.test.ts | 1 + .../test/runtimeClockShift.test.ts | 114 ++++++++++++ ...eClockShiftPersistence.integration.test.ts | 135 +++++++++++++- .../test/turnDaemonLifecycle.test.ts | 97 ++++++++++ docs/architecture/game-clock.md | 18 +- 10 files changed, 596 insertions(+), 47 deletions(-) diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 08c19558..7ae38a7b 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -171,7 +171,8 @@ export class TurnDaemonLifecycle { } const nowMs = this.clock.nowMs(); - const gameClock = await this.stateStore.loadGameClock?.(new Date(nowMs)); + const wallNow = new Date(nowMs); + const gameClock = await this.stateStore.loadGameClock?.(wallNow); if (gameClock?.mode === 'manual') { // Ref observes all generals due before one monthly boundary in // a single snapshot. Manual mode advances directly to that @@ -186,6 +187,13 @@ export class TurnDaemonLifecycle { await this.runOnce({ reason: 'schedule', targetTime }); continue; } + if ( + gameClock?.mode === 'realtime' && + (await this.stateStore.shouldRebaseRealtimeBacklog?.(wallNow)) && + (await this.rebaseRealtimeBacklog(wallNow)) + ) { + continue; + } const gameNowMs = gameClock?.now.getTime() ?? nowMs; const nextTurnMs = nextRunTime.getTime(); if (gameNowMs >= nextTurnMs) { @@ -419,6 +427,58 @@ export class TurnDaemonLifecycle { } } + private async rebaseRealtimeBacklog(wallNow: Date): Promise { + if (!this.stateStore.rebaseRealtimeBacklog) { + return false; + } + const startMs = this.clock.nowMs(); + let result: TurnRunResult | null; + try { + const rebaseAndFlush = async (): Promise => { + const rebased = await this.stateStore.rebaseRealtimeBacklog!(wallNow); + if (!rebased) { + return null; + } + const nextResult: TurnRunResult = { + lastTurnTime: rebased.lastTurnTime, + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + checkpoint: rebased.checkpoint, + }; + this.status.state = 'flushing'; + await this.hooks?.flushChanges?.(nextResult); + return nextResult; + }; + result = this.stateManager ? await this.stateManager.transaction(rebaseAndFlush) : await rebaseAndFlush(); + } catch (error) { + this.status.running = false; + this.status.state = 'paused'; + this.status.paused = true; + this.errorPaused = true; + this.status.lastError = error instanceof Error ? error.message : 'Unknown realtime backlog rebase error.'; + await this.hooks?.onRunError?.(error); + // The rebase attempt was handled, albeit as a pause. Do not fall + // through and execute overdue turns against a transaction that + // failed to persist its clock/schedule shift. + return true; + } + if (!result) { + return false; + } + + await this.applyRunResult(result, startMs); + this.status.state = 'idle'; + try { + await this.hooks?.publishEvents?.(result); + } catch (error) { + this.status.lastError = + error instanceof Error ? error.message : 'Unknown backlog rebase event publication error.'; + } + return true; + } + private async applyRunResult(result: TurnRunResult, startMs: number): Promise { this.status.lastRunAt = new Date(startMs).toISOString(); this.status.lastDurationMs = Math.max(0, this.clock.nowMs() - startMs); diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index f6efd4da..81ef8936 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -45,6 +45,13 @@ export interface TurnProcessor { run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise; } +export interface RealtimeBacklogRebaseResult { + skippedTurns: number; + shiftedTicks: number; + lastTurnTime: string; + checkpoint?: TurnCheckpoint; +} + export interface TurnStateStore { loadLastTurnTime(): Promise; // 월드에서 관리하는 턴 대기열의 선두(가장 이른 장수 턴 시간)를 조회한다. @@ -54,6 +61,8 @@ export interface TurnStateStore { saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; shouldHaltScheduledRuns?(): Promise; loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>; + shouldRebaseRealtimeBacklog?(wallNow: Date): Promise; + rebaseRealtimeBacklog?(wallNow: Date): Promise; advanceGameClockTo?(target: Date, wallNow: Date): Promise; } diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index c80b3124..bee09456 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -379,7 +379,7 @@ export const summarizeRealtimeReadModelChanges = ( lobbyGeneralIds, reservedGeneralIds, recordGeneralIds, - worldChanged: false, + worldChanged: changes.realtimeBacklogShiftTicks > 0, globalRecordsChanged, worldHistoryChanged, contactsChanged, @@ -1078,6 +1078,7 @@ export const createDatabaseTurnHooks = async ( let persistedVisibleLogs: PersistedVisibleLogRow[] = []; let visibleLogFloor = directLogFloor; const { + realtimeBacklogShiftTicks, accessScoreResetGeneralIds, generals, cities, @@ -1161,6 +1162,27 @@ export const createDatabaseTurnHooks = async ( data: worldStateUpdate, }); + if (realtimeBacklogShiftTicks > 0) { + const deltaTicks = BigInt(realtimeBacklogShiftTicks); + const deltaSeconds = (realtimeBacklogShiftTicks / GAME_TICKS_PER_TURN) * state.tickSeconds; + await prisma.$executeRaw( + GamePrisma.sql` + UPDATE general + SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END, + turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second') + ` + ); + await prisma.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET close_tick = CASE WHEN close_tick IS NULL THEN NULL ELSE close_tick + ${deltaTicks} END, + close_at = close_at + (${deltaSeconds} * INTERVAL '1 second'), + updated_at = NOW() + WHERE status = 'OPEN' + ` + ); + } + if ( state.tickSeconds !== persistedTickSeconds && commandCompletion?.result.type !== 'updateRuntimeSettings' diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index 989b9195..2a0722b9 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -42,6 +42,14 @@ export class InMemoryTurnStateStore implements TurnStateStore { }; } + async rebaseRealtimeBacklog(wallNow: Date) { + return this.world.rebaseRealtimeBacklog(wallNow); + } + + async shouldRebaseRealtimeBacklog(wallNow: Date): Promise { + return this.world.shouldRebaseRealtimeBacklog(wallNow); + } + async advanceGameClockTo(target: Date, wallNow: Date): Promise { this.world.advanceGameClockTo(target, wallNow); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 1fa3aeba..c055bc25 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -9,7 +9,7 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { getNextTurnAt } from '@sammo-ts/logic'; -import { GameClock, type GameClockMode } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common'; import type { TurnCheckpoint } from '../lifecycle/types.js'; import type { @@ -115,6 +115,7 @@ export interface InMemoryGameClockState { } export interface TurnWorldChanges { + realtimeBacklogShiftTicks: number; accessScoreResetGeneralIds: number[]; generals: TurnGeneral[]; cities: City[]; @@ -178,6 +179,7 @@ export interface InMemoryTurnWorldStateSnapshot { pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingYearbookSnapshots: PendingYearbookSnapshot[]; pendingUnificationFinalizations: PendingUnificationFinalization[]; + pendingRealtimeBacklogShiftTicks: number; } export interface InMemoryTurnWorldInspection { @@ -380,6 +382,38 @@ const readMetaNumber = (meta: Record, key: string): number | nu return null; }; +const shiftGameClockMetaDate = (value: unknown, deltaMilliseconds: number): unknown => { + if (typeof value !== 'string' || !value.trim()) { + return value; + } + if (value.includes('T')) { + const shifted = new Date(new Date(value).getTime() + deltaMilliseconds); + return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString(); + } + const match = /^(\d{4})-(\d{2})-(\d{2})[ ](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?$/.exec(value); + if (!match) { + return value; + } + const parts = match.slice(1).map(Number); + const shifted = new Date( + Date.UTC(parts[0]!, parts[1]! - 1, parts[2]!, parts[3]!, parts[4]!, parts[5]!) + deltaMilliseconds + ); + return ( + [ + shifted.getUTCFullYear().toString().padStart(4, '0'), + (shifted.getUTCMonth() + 1).toString().padStart(2, '0'), + shifted.getUTCDate().toString().padStart(2, '0'), + ].join('-') + + ' ' + + [ + shifted.getUTCHours().toString().padStart(2, '0'), + shifted.getUTCMinutes().toString().padStart(2, '0'), + shifted.getUTCSeconds().toString().padStart(2, '0'), + ].join(':') + + (match[7] ?? '') + ); +}; + const resolveWorldKillturn = (meta: Record): number | null => { const killturn = readMetaNumber(meta, 'killturn'); if (killturn !== null) { @@ -448,6 +482,7 @@ export class InMemoryTurnWorld { private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; + private pendingRealtimeBacklogShiftTicks = 0; private readonly scenarioConfig: ScenarioConfig; private readonly worldConfig: Record; private readonly unitSet?: UnitSetDefinition; @@ -592,7 +627,11 @@ export class InMemoryTurnWorld { advanceGameClockTo(target: Date, wallNow: Date): void { const clock = this.getGameClock(); const targetTick = clock.dateToTick(target); - const nextTick = Math.max(clock.tick, targetTick); + // Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거 + // target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린 + // 시간만큼 게임 시계가 매 pass마다 뒤로 누적됩니다. + const observedTick = clock.mode === 'realtime' ? clock.nowTick(wallNow) : clock.tick; + const nextTick = Math.max(observedTick, targetTick); this.state = { ...this.state, clockTick: nextTick, @@ -600,6 +639,93 @@ export class InMemoryTurnWorld { }; } + private resolveRealtimeBacklogRebase(wallNow: Date): { + clock: GameClock; + wallAlignedTick: number; + lastTurnTick: number; + skippedTurns: number; + } | null { + const clock = this.getGameClock(); + if (clock.mode !== 'realtime') { + return null; + } + const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60)); + const threshold = turnMinutes >= 20 ? 1 : turnMinutes >= 10 ? 3 : 6; + const currentTick = clock.nowTick(wallNow); + const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow)); + const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime); + const skippedTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN); + return skippedTurns > threshold ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null; + } + + shouldRebaseRealtimeBacklog(wallNow: Date): boolean { + return this.resolveRealtimeBacklogRebase(wallNow) !== null; + } + + rebaseRealtimeBacklog(wallNow: Date): { + skippedTurns: number; + shiftedTicks: number; + lastTurnTime: string; + checkpoint?: TurnCheckpoint; + } | null { + const plan = this.resolveRealtimeBacklogRebase(wallNow); + if (!plan) { + return null; + } + const { clock, wallAlignedTick, lastTurnTick, skippedTurns } = plan; + // Ref realtime clock always projects the current wall time. Core may + // already have accumulated lag from anchoring an overdue target, so a + // long-backlog rebase also repairs that projection without rewinding. + const shiftedTicks = skippedTurns * GAME_TICKS_PER_TURN; + const shiftedMilliseconds = skippedTurns * this.state.tickSeconds * 1_000; + const nextLastTurnTick = clock.addTicks(lastTurnTick, shiftedTicks); + const nextLastTurnTime = clock.tickToDate(nextLastTurnTick); + + this.state = { + ...this.state, + clockTick: wallAlignedTick, + clockWallAnchor: new Date(wallNow.getTime()), + lastTurnTick: nextLastTurnTick, + lastTurnTime: nextLastTurnTime, + meta: { + ...this.state.meta, + lastTurnTime: nextLastTurnTime.toISOString(), + turntime: shiftGameClockMetaDate(this.state.meta.turntime, shiftedMilliseconds), + starttime: shiftGameClockMetaDate(this.state.meta.starttime, shiftedMilliseconds), + }, + }; + + // Ref checkDelay()는 한 번의 UPDATE로 전 장수의 다음 턴만 옮깁니다. + // recent-war를 비롯한 이미 발생한 gameplay 시각은 그대로 보존합니다. + for (const [generalId, general] of this.generals) { + const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks); + this.generals.set(generalId, { + ...general, + turnTick, + turnTime: clock.tickToDate(turnTick), + }); + } + if (this.checkpoint) { + const checkpointTick = clock.addTicks( + this.checkpoint.turnTick ?? clock.dateToTick(new Date(this.checkpoint.turnTime)), + shiftedTicks + ); + this.checkpoint = { + ...this.checkpoint, + turnTick: checkpointTick, + turnTime: clock.tickToDate(checkpointTick).toISOString(), + }; + } + this.pendingRealtimeBacklogShiftTicks += shiftedTicks; + + return { + skippedTurns, + shiftedTicks, + lastTurnTime: nextLastTurnTime.toISOString(), + checkpoint: this.checkpoint, + }; + } + captureState(): InMemoryTurnWorldStateSnapshot { return structuredClone({ schedule: this.schedule, @@ -637,6 +763,7 @@ export class InMemoryTurnWorld { pendingNationBettingFinishes: this.pendingNationBettingFinishes, pendingYearbookSnapshots: this.pendingYearbookSnapshots, pendingUnificationFinalizations: this.pendingUnificationFinalizations, + pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, } satisfies InMemoryTurnWorldStateSnapshot); } @@ -680,6 +807,7 @@ export class InMemoryTurnWorld { this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes); this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); + this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0; } inspectState(): InMemoryTurnWorldInspection { @@ -1156,38 +1284,6 @@ export class InMemoryTurnWorld { } const deltaMs = deltaMinutes * 60_000; const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs); - const shiftMetaDate = (value: unknown): unknown => { - if (typeof value !== 'string' || !value.trim()) { - return value; - } - if (value.includes('T')) { - const shifted = shiftDate(new Date(value)); - return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString(); - } - const match = /^(\d{4})-(\d{2})-(\d{2})[ ](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?$/.exec(value); - if (!match) { - return value; - } - const parts = match.slice(1).map(Number); - const shifted = new Date( - Date.UTC(parts[0]!, parts[1]! - 1, parts[2]!, parts[3]!, parts[4]!, parts[5]!) + deltaMs - ); - return ( - [ - shifted.getUTCFullYear().toString().padStart(4, '0'), - (shifted.getUTCMonth() + 1).toString().padStart(2, '0'), - shifted.getUTCDate().toString().padStart(2, '0'), - ].join('-') + - ' ' + - [ - shifted.getUTCHours().toString().padStart(2, '0'), - shifted.getUTCMinutes().toString().padStart(2, '0'), - shifted.getUTCSeconds().toString().padStart(2, '0'), - ].join(':') + - (match[7] ?? '') - ); - }; - const previousClock = this.getGameClock(); const generalTicks = new Map( Array.from(this.generals.values(), (general) => [ @@ -1212,9 +1308,9 @@ export class InMemoryTurnWorld { const nextMeta = { ...this.state.meta, lastTurnTime: nextLastTurnTime.toISOString(), - turntime: shiftMetaDate(this.state.meta.turntime), - starttime: shiftMetaDate(this.state.meta.starttime), - tnmt_time: shiftMetaDate(this.state.meta.tnmt_time), + turntime: shiftGameClockMetaDate(this.state.meta.turntime, deltaMs), + starttime: shiftGameClockMetaDate(this.state.meta.starttime, deltaMs), + tnmt_time: shiftGameClockMetaDate(this.state.meta.tnmt_time, deltaMs), }; this.state = { ...this.state, @@ -1615,6 +1711,7 @@ export class InMemoryTurnWorld { ); return { + realtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks, accessScoreResetGeneralIds, generals, cities, @@ -1644,6 +1741,10 @@ export class InMemoryTurnWorld { } acknowledgeDirtyState(changes: TurnWorldChanges): void { + this.pendingRealtimeBacklogShiftTicks = Math.max( + 0, + this.pendingRealtimeBacklogShiftTicks - changes.realtimeBacklogShiftTicks + ); for (const id of changes.accessScoreResetGeneralIds) this.accessScoreResetGeneralIds.delete(id); for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id); for (const city of changes.cities) this.dirtyCityIds.delete(city.id); diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index a140393e..786658ea 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -62,6 +62,7 @@ describe('durable read-model change journal mapping', () => { it('detects troop, leader-turn, and aggregate dashboard dependencies conservatively', () => { const emptyWorldChanges = { + realtimeBacklogShiftTicks: 0, accessScoreResetGeneralIds: [], generals: [], cities: [], diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index 4630e89e..660ade94 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { GamePrismaClient } from '@sammo-ts/infra'; +import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js'; import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js'; @@ -172,6 +173,119 @@ describe('runtime clock shift', () => { expect(world.getGeneralById(1)?.turnTick).toBe(beforeTurnTick); expect(world.getGameClockState().wallAnchor).toEqual(resumedAt); }); + + it.each([ + [5, 6], + [10, 3], + [20, 1], + ])('uses the Ref catch-up threshold for a %i-minute turn', (turnMinutes, threshold) => { + const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); + const world = buildWorld({ + tickSeconds: turnMinutes * 60, + clockBaseTime: wallAnchor, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: wallAnchor, + lastTurnTick: 0, + lastTurnTime: wallAnchor, + }); + + expect( + world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + threshold * turnMinutes * 60_000)) + ).toBe(false); + expect( + world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + (threshold + 1) * turnMinutes * 60_000)) + ).toBe(true); + }); + + it('skips a long realtime backlog while preserving the turn phase and wall-clock display', () => { + const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); + const resumedAt = new Date('2026-07-30T10:35:00.000Z'); + const world = buildWorld({ + tickSeconds: 300, + clockBaseTime: wallAnchor, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: wallAnchor, + lastTurnTick: 0, + lastTurnTime: wallAnchor, + }); + world.setCheckpoint({ + turnTime: '2026-07-30T10:10:00.000Z', + turnTick: 2 * GAME_TICKS_PER_TURN, + generalId: 1, + year: 190, + month: 1, + }); + + const result = world.rebaseRealtimeBacklog(resumedAt); + + expect(result).toMatchObject({ + skippedTurns: 7, + shiftedTicks: 7 * GAME_TICKS_PER_TURN, + lastTurnTime: resumedAt.toISOString(), + }); + expect(world.getGameNow(resumedAt)).toEqual(resumedAt); + expect(world.getState()).toMatchObject({ + clockTick: 7 * GAME_TICKS_PER_TURN, + clockWallAnchor: resumedAt, + lastTurnTick: 7 * GAME_TICKS_PER_TURN, + meta: { + turntime: '2026-07-30 10:35:00.123456', + starttime: '2026-07-01 00:35:00', + }, + }); + expect(world.getGeneralById(1)).toMatchObject({ + turnTick: 9 * GAME_TICKS_PER_TURN, + turnTime: new Date('2026-07-30T10:45:00.000Z'), + }); + expect(world.getCheckpoint()).toMatchObject({ + turnTick: 9 * GAME_TICKS_PER_TURN, + turnTime: '2026-07-30T10:45:00.000Z', + }); + expect(world.peekDirtyState()).toMatchObject({ + realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN, + generals: [], + }); + }); + + it('repairs an already accumulated realtime projection lag during a long rebase', () => { + const base = new Date('2026-07-30T10:00:00.000Z'); + const staleAnchor = new Date('2026-07-30T11:00:00.000Z'); + const resumedAt = new Date('2026-07-30T11:50:00.000Z'); + const world = buildWorld({ + tickSeconds: 300, + clockBaseTime: base, + clockTick: 5 * GAME_TICKS_PER_TURN, + clockMode: 'realtime', + clockWallAnchor: staleAnchor, + lastTurnTick: 0, + lastTurnTime: base, + }); + + expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z'); + expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 22 }); + expect(world.getGameNow(resumedAt)).toEqual(resumedAt); + }); + + it('does not lose realtime elapsed time when an overdue target is committed later', () => { + const base = new Date('2026-07-30T10:00:00.000Z'); + const world = buildWorld({ + tickSeconds: 300, + clockBaseTime: base, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: base, + lastTurnTick: 0, + lastTurnTime: base, + }); + const completedAt = new Date('2026-07-30T10:07:00.000Z'); + + world.advanceGameClockTo(new Date('2026-07-30T10:05:00.000Z'), completedAt); + + expect(world.getGameNow(completedAt)).toEqual(completedAt); + expect(world.getGameClockState().tick).toBe(50_400_000); + }); }); describe('runtime turn term change', () => { diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index 4c51137d..1675a88b 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -19,7 +19,7 @@ const requestId = 'integration:engine:runtime-clock-shift'; const actionId = 'b9f68480-dba9-4e03-a62b-499e6234f18a'; const runtimeSettingsRequestId = 'integration:engine:runtime-game-settings'; const runtimeSettingsActionId = 'c9f68480-dba9-4e03-a62b-499e6234f18a'; -const generalIds = [990_301, 990_302, 990_303] as const; +const generalIds = [990_301, 990_302, 990_303, 990_304] as const; const runtimeSettingsLogText = 'runtime-settings-existing-log'; const buildGeneral = (id: number, turnTime: Date): TurnGeneral => @@ -88,7 +88,9 @@ integration('runtime clock shift persistence', () => { await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); await db.worldState.deleteMany({ - where: { scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings'] } }, + where: { + scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] }, + }, }); }); @@ -100,7 +102,9 @@ integration('runtime clock shift persistence', () => { await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } }); await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); await db.worldState.deleteMany({ - where: { scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings'] } }, + where: { + scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] }, + }, }); await closeDb?.(); }); @@ -299,6 +303,131 @@ integration('runtime clock shift persistence', () => { await db.worldState.delete({ where: { id: row.id } }); }); + it('atomically rebases a long realtime backlog and open auction deadlines', async () => { + const base = new Date('2099-09-01T00:00:00.000Z'); + const resumedAt = new Date('2099-09-01T00:35:00.000Z'); + const row = await db.worldState.create({ + data: { + scenarioCode: 'realtime-backlog-rebase', + currentYear: 192, + currentMonth: 3, + tickSeconds: 300, + clockBaseTime: base, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: base, + lastTurnTick: 0, + config: {}, + meta: { + lastTurnTime: base.toISOString(), + turntime: '2099-09-01 00:00:00.123456', + starttime: '2099-08-01 00:00:00', + }, + }, + }); + const general = buildGeneral(generalIds[3], new Date('2099-09-01T00:05:00.000Z')); + await db.general.create({ + data: { + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + turnTime: general.turnTime, + turnTick: BigInt(GAME_TICKS_PER_TURN), + }, + }); + const [openAuction, finishedAuction] = await Promise.all( + (['OPEN', 'FINISHED'] as const).map((status) => + db.auction.create({ + data: { + type: 'BUY_RICE', + hostGeneralId: general.id, + detail: {}, + status, + closeAt: new Date('2099-09-01T00:10:00.000Z'), + closeTick: BigInt(2 * GAME_TICKS_PER_TURN), + }, + }) + ) + ); + const world = new InMemoryTurnWorld( + { + id: row.id, + currentYear: 192, + currentMonth: 3, + tickSeconds: 300, + lastTurnTime: base, + clockBaseTime: base, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: base, + lastTurnTick: 0, + meta: row.meta as Record, + }, + { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [general], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] } } + ); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 }); + await hooks.hooks.flushChanges?.({ + lastTurnTime: resumedAt.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + } finally { + await hooks.close(); + } + + const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); + expect(storedWorld).toMatchObject({ + clockTick: BigInt(7 * GAME_TICKS_PER_TURN), + lastTurnTick: BigInt(7 * GAME_TICKS_PER_TURN), + clockWallAnchor: resumedAt, + }); + const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } }); + expect(storedGeneral).toMatchObject({ + turnTick: BigInt(8 * GAME_TICKS_PER_TURN), + turnTime: new Date('2099-09-01T00:40:00.000Z'), + }); + expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({ + closeTick: BigInt(9 * GAME_TICKS_PER_TURN), + closeAt: new Date('2099-09-01T00:45:00.000Z'), + }); + expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({ + closeTick: BigInt(2 * GAME_TICKS_PER_TURN), + closeAt: new Date('2099-09-01T00:10:00.000Z'), + }); + + await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } }); + await db.general.delete({ where: { id: general.id } }); + await db.worldState.delete({ where: { id: row.id } }); + }); + it('reprojects tick-owned dates for a live turn-term change without rewriting existing log timestamps', async () => { const base = new Date('2099-08-01T10:00:00.000Z'); const row = await db.worldState.create({ diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 176db4f4..0b0e7e5b 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -14,6 +14,103 @@ import { const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); describe('TurnDaemonLifecycle', () => { + it('durably rebases a long realtime backlog before executing another turn', async () => { + const wallNow = new Date('2026-08-23T01:35:00.000Z'); + const clock = new ManualClock(wallNow.getTime()); + const controlQueue = new InMemoryControlQueue(); + const processor = { run: vi.fn() }; + let needsRebase = true; + const flushChanges = vi.fn(async () => {}); + const publishEvents = vi.fn(async () => { + controlQueue.enqueue({ type: 'shutdown', reason: 'rebase verified' }); + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock, + controlQueue, + getNextTickTime: (value) => addMinutes(value, 5), + stateStore: { + loadLastTurnTime: async () => new Date('2026-08-22T16:35:00.000Z'), + loadNextGeneralTurnTime: async () => new Date('2026-08-22T16:36:00.000Z'), + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + loadGameClock: async () => ({ mode: 'realtime', now: wallNow }), + shouldRebaseRealtimeBacklog: async () => needsRebase, + rebaseRealtimeBacklog: async () => { + needsRebase = false; + return { + skippedTurns: 108, + shiftedTicks: 108 * 36_000_000, + lastTurnTime: '2026-08-23T01:35:00.000Z', + }; + }, + }, + processor, + hooks: { flushChanges, publishEvents }, + }, + { + profile: 'realtime-resume-rebase', + defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 }, + } + ); + + await lifecycle.start(); + + expect(flushChanges).toHaveBeenCalledOnce(); + expect(publishEvents).toHaveBeenCalledOnce(); + expect(processor.run).not.toHaveBeenCalled(); + expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-23T01:35:00.000Z'); + }); + + it('does not execute overdue turns when the realtime backlog rebase fails to flush', async () => { + const wallNow = new Date('2026-08-23T01:35:00.000Z'); + const clock = new ManualClock(wallNow.getTime()); + const controlQueue = new InMemoryControlQueue(); + const processor = { run: vi.fn() }; + const failure = new Error('rebase flush failed'); + const onRunError = vi.fn(async () => { + controlQueue.enqueue({ type: 'shutdown', reason: 'failure verified' }); + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock, + controlQueue, + getNextTickTime: (value) => addMinutes(value, 5), + stateStore: { + loadLastTurnTime: async () => new Date('2026-08-22T16:35:00.000Z'), + loadNextGeneralTurnTime: async () => new Date('2026-08-22T16:36:00.000Z'), + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + loadGameClock: async () => ({ mode: 'realtime', now: wallNow }), + shouldRebaseRealtimeBacklog: async () => true, + rebaseRealtimeBacklog: async () => ({ + skippedTurns: 108, + shiftedTicks: 108 * 36_000_000, + lastTurnTime: wallNow.toISOString(), + }), + }, + processor, + hooks: { + flushChanges: async () => { + throw failure; + }, + onRunError, + }, + }, + { + profile: 'realtime-resume-rebase-flush-failure', + defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 }, + } + ); + + await lifecycle.start(); + + expect(onRunError).toHaveBeenCalledWith(failure); + expect(processor.run).not.toHaveBeenCalled(); + }); + it('does not schedule another processor run after the world reaches a terminal united state', async () => { const clock = new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()); const controlQueue = new InMemoryControlQueue(); diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md index ed7434dc..09d57356 100644 --- a/docs/architecture/game-clock.md +++ b/docs/architecture/game-clock.md @@ -31,11 +31,19 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다. ## 중단 후 재개 -운영 중단 시간을 따라잡지 않으려면 Gateway의 일정 지연/가속 작업을 사용해 -표시 기준시각을 옮깁니다. 이 작업은 `clock_base_time`과 DateTime 투영값을 -같이 이동하고, game tick 및 장수 턴 tick은 바꾸지 않습니다. 동시에 -`clock_wall_anchor`를 작업 실행 시각으로 다시 고정하므로 장기간 중단 뒤에도 -누락된 기간의 턴을 몰아서 실행하지 않습니다. +realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다. +밀린 완전 턴 수가 턴 간격 20분 이상이면 1턴, 10분 이상이면 3턴, 그보다 +짧으면 6턴을 초과할 때 장기 중단으로 봅니다. 한도 이내의 짧은 중단은 턴을 +순서대로 실행해 따라잡고, 한도를 넘으면 밀린 완전 턴만큼 `last_turn_tick`, +전 장수의 `turn_tick`, 미완료 경매의 `close_tick`과 각각의 DateTime 투영값을 +한 transaction에서 옮깁니다. 이 보정은 명령이나 월 이벤트를 실행하지 않으므로 +건너뛴 기간의 RNG를 소비하지 않습니다. 이미 처리 중 anchor 갱신으로 표시 +시각이 늦어진 상태도 현재 wall time에 맞추되 game tick은 되감지 않습니다. + +운영자가 명시적으로 일정을 지연하거나 가속하려면 Gateway 작업을 사용합니다. +이 작업은 `clock_base_time`과 DateTime 투영값을 같이 이동하고, game tick 및 +장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행 +시각으로 다시 고정합니다. DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration 재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은