diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index d0614d99..a39893cf 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -70,6 +70,9 @@ type WorldClockFields = | 'clockTick' | 'clockMode' | 'clockWallAnchor' + | 'clockRecoveryStartTick' + | 'clockRecoveryEndTick' + | 'clockRecoveryStartWallAt' | 'lastTurnTick' | 'clockPhase' | 'clockRevision' diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 40723f20..f501b258 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -74,6 +74,7 @@ export const lobbyRouter = router({ clockMode: gameTime.mode ?? 'realtime', clockRunning: gameTime.running, clockStartsAt: gameTime.startsAt?.toISOString() ?? null, + clockRecovery: gameTime.recovery ?? null, turnEngineRunning, otherTextInfo: worldState.meta.otherTextInfo ?? '', npcMode: worldState.config.npcMode ?? 0, diff --git a/app/game-api/src/services/gameClock.ts b/app/game-api/src/services/gameClock.ts index d2c592f7..dd08cfee 100644 --- a/app/game-api/src/services/gameClock.ts +++ b/app/game-api/src/services/gameClock.ts @@ -1,5 +1,6 @@ import { GameClock, + readTurnRecovery, inferClockPhase, parseGameClockPhase, type GameClockMode, @@ -7,6 +8,7 @@ import { } from '@sammo-ts/common'; import type { DatabaseClient } from '../context.js'; +import { readTurnRuntimeReady } from '@sammo-ts/infra'; export interface CurrentGameTime { now: Date; @@ -17,6 +19,8 @@ export interface CurrentGameTime { revision?: number | null; deadlineGeneration?: number | null; running: boolean; + runtimeReady?: boolean; + recovery?: { startsAt: string; endsAt: string } | null; startsAt: Date | null; dateToTick(date: Date): number | null; } @@ -43,6 +47,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date clockTick: true, clockMode: true, clockWallAnchor: true, + clockRecoveryStartTick: true, + clockRecoveryEndTick: true, + clockRecoveryStartWallAt: true, tickSeconds: true, clockPhase: true, clockRevision: true, @@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date tick: storedTick, mode, wallAnchor: state.clockWallAnchor, + recovery: readTurnRecovery(state), turnSeconds: state.tickSeconds, phase, revision, }); - const tick = clock.nowTick(wallNow); - const running = phase === 'RUNNING' && mode === 'realtime'; + const runtimeReady = + phase !== 'RUNNING' || + mode !== 'realtime' || + state.clockRecoveryStartTick === undefined || + (await readTurnRuntimeReady(db, state.clockRevision)); + const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick; + const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor; return { now: clock.tickToDate(tick), wallNow, @@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date revision, deadlineGeneration, running, - startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null, + runtimeReady, + recovery: + clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow + ? { + startsAt: clock.recovery.startWallAt.toISOString(), + endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(), + } + : null, + startsAt: + phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null, dateToTick: (date) => clock.dateToTick(date), }; }; diff --git a/app/game-api/src/services/redisClockFence.ts b/app/game-api/src/services/redisClockFence.ts index 0b192c90..e0067975 100644 --- a/app/game-api/src/services/redisClockFence.ts +++ b/app/game-api/src/services/redisClockFence.ts @@ -39,6 +39,7 @@ const ensureRedisClockFence = async ( allowedPhases: readonly GameClockPhase[] ): Promise => { if ( + gameTime.runtimeReady === false || !gameTime.phase || !allowedPhases.includes(gameTime.phase) || (gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') || @@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async ( profileName: string, gameTime: CurrentGameTime ): Promise => { + if (!gameTime.running) return null; return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']); }; diff --git a/app/game-api/test/gameClock.test.ts b/app/game-api/test/gameClock.test.ts index 8017409e..89a53722 100644 --- a/app/game-api/test/gameClock.test.ts +++ b/app/game-api/test/gameClock.test.ts @@ -23,6 +23,41 @@ const buildDatabase = ( }) as unknown as DatabaseClient; describe('current game time projection', () => { + it('keeps independent workers frozen until this clock revision has a ready daemon', async () => { + const row = { + clockBaseTime: new Date('2026-09-06T00:00:00Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-09-06T00:00:00Z'), + tickSeconds: 3600, + clockPhase: 'RUNNING', + clockRevision: 2n, + deadlineGeneration: 2n, + clockRecoveryStartTick: 0n, + clockRecoveryEndTick: 288_000_000n, + clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'), + }; + let ready = false; + const db = { + worldState: { findFirst: vi.fn(async () => row) }, + $queryRaw: vi.fn(async () => [{ ready }]), + } as unknown as DatabaseClient; + const now = new Date('2026-09-06T05:00:00Z'); + expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false }); + ready = true; + expect(await loadCurrentGameTime(db, now)).toMatchObject({ + tick: 72_000_000, + running: true, + runtimeReady: true, + recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' }, + }); + }); + + it('holds an invader restart until its future turn boundary', async () => { + const db = buildDatabase('realtime', 'RUNNING'); + const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z')); + expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') }); + }); it('projects negative realtime ticks until the future opening anchor', async () => { const db = buildDatabase(); diff --git a/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts b/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts index 3d0778b2..7dac9839 100644 --- a/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts +++ b/app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts @@ -93,6 +93,7 @@ export class DatabaseTurnDaemonLease { ON CONFLICT ("profile") DO UPDATE SET "owner_id" = EXCLUDED."owner_id", + "clock_ready" = FALSE, "lease_until" = EXCLUDED."lease_until", "fencing_epoch" = CASE WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id" @@ -126,6 +127,14 @@ export class DatabaseTurnDaemonLease { return this.token ? { ...this.token } : null; } + async markClockReady(): Promise { + await this.db.$transaction(async (db) => { + await this.assertActive(db); + const token = this.getToken()!; + await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } }); + }); + } + isLost(): boolean { return this.lost; } diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index da8e435a..e2f9a7dd 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -170,6 +170,11 @@ export class TurnDaemonLifecycle { await this.clock.sleepMs(500); continue; } + if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) { + this.status.nextTurnTime = gameClock.startsAt.toISOString(); + await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs)); + continue; + } // 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다. // 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다. if (this.pendingRun) { @@ -219,7 +224,10 @@ export class TurnDaemonLifecycle { continue; } - const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs)); + const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime); + const command = await this.controlQueue.waitFor( + Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs) + ); if (command) { await this.handleCommand(command); } diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index ca27dbb0..7ed0b7f9 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -72,11 +72,13 @@ export interface TurnStateStore { phase?: GameClockPhase; revision?: number; deadlineGeneration?: number; + startsAt?: Date; }>; promotePreopenAtOpening?(wallNow: Date): Promise; shouldRebaseRealtimeBacklog?(wallNow: Date): Promise; rebaseRealtimeBacklog?(wallNow: Date): Promise; advanceGameClockTo?(target: Date, wallNow: Date): Promise; + projectGameDeadline?(gameTime: Date): Promise; } export interface TurnDaemonControlQueue { diff --git a/app/game-engine/src/scenario/gameCancellation.ts b/app/game-engine/src/scenario/gameCancellation.ts index 00ae04fe..7923e159 100644 --- a/app/game-engine/src/scenario/gameCancellation.ts +++ b/app/game-engine/src/scenario/gameCancellation.ts @@ -571,7 +571,10 @@ const cancelGameInTransaction = async ( return { ...resultFromPersisted(created), alreadyApplied: false }; }; -export const cancelGame = async (request: GameCancellationRequest): Promise => { +export const cancelGame = async ( + request: GameCancellationRequest, + connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector +): Promise => { if (!request.reason.trim()) throw new Error('Game cancellation reason is required.'); if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.'); if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.'); @@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise now.getTime() ? install.openAt : now; - const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor); + const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now); + const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow; + const openingFloor = cutTurn(requestedOpening, turnTermMinutes); + const initialClockWallAnchor = + gameClockMode === 'manual' + ? requestedOpening + : new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0)); + const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor); const initialClock = new GameClock({ baseTime: startState.startTime, tick: 0, @@ -317,7 +325,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, starttime: formatDateTime(startState.startTime), turntime: formatDateTime(now), - opentime: formatDateTime(now), + opentime: formatDateTime(initialClockWallAnchor), lastTurnTime: formatDateTime(now), }; @@ -344,7 +352,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom } worldMeta.hiddenSeed = hiddenSeed; - worldMeta.seededAtWall = now.toISOString(); + worldMeta.seededAtWall = wallNow.toISOString(); worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString(); worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString(); worldMeta.calendarStart = startState.startTime.toISOString(); diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index a8691732..6fe4aa67 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -332,7 +332,7 @@ const respondToRaiseInvader = async (options: { reservedTurns, env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()), loadArchivedNationMaxId: options.loadArchivedNationMaxId, - clockWallNow: alignment.resumeWallAt, + clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt, }); const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} }; await handler( diff --git a/app/game-engine/src/turn/clockReconciliation.ts b/app/game-engine/src/turn/clockReconciliation.ts index 2d3248c2..f09eefbb 100644 --- a/app/game-engine/src/turn/clockReconciliation.ts +++ b/app/game-engine/src/turn/clockReconciliation.ts @@ -7,6 +7,10 @@ import { buildClockAlignmentPlan, parseClockAlignmentPolicy, parseGameClockPhase, + readTurnRecovery, + readSerializedTurnRecovery, + serializeTurnRecovery, + type TurnRecoveryWindow, type ClockAlignmentPolicy, } from '@sammo-ts/common'; import { @@ -43,6 +47,8 @@ export interface ClockReconciliationResult { shiftTicks: number; alignedTick: number; resumeWallAt: Date; + recovery?: TurnRecoveryWindow | null; + resumeAnchor?: Date; } interface DbWallRow { @@ -213,6 +219,10 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigi `); }; +// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다. +const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean => + Boolean(db.worldState.fields?.clockRecoveryStartTick); + const readParticipantSnapshots = async ( db: GamePrisma.TransactionClient, worldStateId: number, @@ -224,6 +234,13 @@ const readParticipantSnapshots = async ( where: { id: worldStateId }, select: { clockTick: true, + ...(supportsRecoveryColumns(db) + ? { + clockRecoveryStartTick: true, + clockRecoveryEndTick: true, + clockRecoveryStartWallAt: true, + } + : {}), clockRevision: true, deadlineGeneration: true, lastTurnTick: true, @@ -290,6 +307,7 @@ const readParticipantSnapshots = async ( snapshot('world-clock', 'REBUILD', [ { clockTick: world.clockTick, + ...serializeTurnRecovery(readTurnRecovery(world)), clockRevision: world.clockRevision, deadlineGeneration: world.deadlineGeneration, }, @@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: { cutWallAt: Date; rateTicksPerSecond: number; sourceRevision: number; + normalTickAtCutWall?: number; policy?: ClockAlignmentPolicy; catchUpTicks?: number; }): Promise => { @@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: { rateTicksPerSecond: options.rateTicksPerSecond, catchUpTicks: BigInt(catchUpTicks), participantChecksumBefore: aggregateChecksum(participants), - detail: asJson({ authority: 'DAEMON', profileName: options.profileName }), + detail: asJson({ + authority: 'DAEMON', + profileName: options.profileName, + normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick), + }), }, }); await persistInitialParticipants(options.db, options.suspensionId, participants); @@ -712,6 +735,7 @@ export const startClockSuspension = async (options: { authority: ClockOperationAuthority; policy?: ClockAlignmentPolicy; catchUpTicks?: number; + recoverDurableObservation?: boolean; }): Promise => { if (!options.suspensionId.trim() || options.suspensionId.length > 64) { throw new Error('Clock suspension ID must contain 1-64 characters.'); @@ -761,7 +785,13 @@ export const startClockSuspension = async (options: { if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) { throw new Error('Clock suspension requires a fully initialized logical game clock.'); } - const cutWallAt = await readDbWall(db); + if ( + options.recoverDurableObservation && + (options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS') + ) { + throw new Error('Only daemon outage recovery may use the durable observation.'); + } + const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db); const storedTick = safeNumber(world.clockTick, 'world clock tick'); const sourceRevision = safeNumber(world.clockRevision, 'world clock revision'); const clock = new GameClock({ @@ -769,11 +799,12 @@ export const startClockSuspension = async (options: { tick: storedTick, mode: world.clockMode === 'manual' ? 'manual' : 'realtime', wallAnchor: world.clockWallAnchor, + recovery: readTurnRecovery(world), turnSeconds: world.tickSeconds, phase, revision: sourceRevision, }); - const cutTick = clock.nowTick(cutWallAt); + const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt); await lockParticipants(db, BigInt(cutTick)); await db.worldState.update({ where: { id: worldStateId }, @@ -797,6 +828,7 @@ export const startClockSuspension = async (options: { detail: asJson({ authority: options.authority.kind, profileName: options.authority.profileName, + normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)), }), }, }); @@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: { authority?: ClockOperationAuthority; /** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */ testResumeWallAt?: Date; + upgradeMaintenancePolicy?: boolean; }): Promise => { const db = options.db; const worldStateId = await lockWorld(db); @@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: { shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'), alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'), resumeWallAt: suspension.resumeWallAt, + recovery: readSerializedTurnRecovery(suspension.detail), + resumeAnchor: + suspension.detail && + typeof suspension.detail === 'object' && + !Array.isArray(suspension.detail) && + typeof suspension.detail.resumeAnchor === 'string' + ? new Date(suspension.detail.resumeAnchor) + : (world.clockWallAnchor ?? suspension.resumeWallAt), }; } if (suspension.status !== 'SUSPENDED') { @@ -882,14 +923,41 @@ export const reconcileClockSuspensionInTransaction = async (options: { throw new Error('A clock reconciliation wall override is allowed only in tests.'); } const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db); + const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY'; + const upgradeMaintenance = + options.upgradeMaintenancePolicy === true && + supportsRecoveryColumns(db) && + suspension.source === 'MAINTENANCE' && + suspension.policy !== 'RECOVER_TURNS'; + const effectivePolicy = legacyUnificationWait + ? 'TURN_BOUNDARY' + : upgradeMaintenance + ? 'RECOVER_TURNS' + : parseClockAlignmentPolicy(suspension.policy); + const alignmentCutTick = legacyUnificationWait + ? safeNumber(world.lastTurnTick!, 'unification execution boundary') + : cutTick; const plan = buildClockAlignmentPlan({ - policy: parseClockAlignmentPolicy(suspension.policy), + policy: effectivePolicy, sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'), - cutTick, + cutTick: alignmentCutTick, cutWall: suspension.cutWallAt, resumeWall: resumeWallAt, ticksPerSecond: suspension.rateTicksPerSecond, catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'), + normalTick: (() => { + const detail = + suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail) + ? suspension.detail + : {}; + const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick; + return ( + normalAtCut + + Math.trunc( + ((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000 + ) + ); + })(), }); const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick); assertShiftFits(before, plan.shiftTicks); @@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: { targetGeneration, BigInt(plan.shiftTicks), projectionDeltaMilliseconds, - resumeWallAt + plan.resumeAnchor ?? resumeWallAt ); + if (supportsRecoveryColumns(db)) { + await db.worldState.update({ + where: { id: worldStateId }, + data: { + clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null, + clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null, + clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null, + }, + }); + } else if (plan.recovery) { + throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.'); + } + const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick); const afterByKey = new Map(after.map((participant) => [participant.key, participant])); for (const participant of before) { @@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: { where: { id: suspension.id }, data: { status: 'RECONCILING', + ...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}), resumeWallAt, gapTicks: BigInt(plan.gapTicks), + catchUpTicks: BigInt(plan.catchUpTicks), + detail: asJson({ + ...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail) + ? suspension.detail + : {}), + ...serializeTurnRecovery(plan.recovery ?? null), + resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(), + ...(legacyUnificationWait || upgradeMaintenance + ? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick } + : {}), + }), shiftTicks: BigInt(plan.shiftTicks), alignedTick: BigInt(plan.alignedTick), participantChecksumBefore: aggregateChecksum(before), @@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: { shiftTicks: plan.shiftTicks, alignedTick: plan.alignedTick, resumeWallAt, + recovery: plan.recovery ?? null, + resumeAnchor: plan.resumeAnchor ?? resumeWallAt, }; }; @@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: { authority: ClockOperationAuthority; /** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */ testResumeWallAt?: Date; + upgradeMaintenancePolicy?: boolean; }): Promise => runSerializableClockOperation(() => options.db.$transaction( @@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: { profileName: options.authority.profileName, authority: options.authority, ...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}), + upgradeMaintenancePolicy: options.upgradeMaintenancePolicy, }); }, { isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 } diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index d554fa8d..15eab787 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -65,6 +65,7 @@ import { } from './clockReconciliation.js'; import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js'; import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js'; +import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -73,6 +74,7 @@ export interface DatabaseTurnHooks { close(): Promise; applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise; synchronizeClockAuthority(): Promise; + prepareRealtimeRecovery(options?: { paused?: boolean }): Promise; } export interface CommittedReadModelChangeReceipt { @@ -141,6 +143,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([ 'clockTick', 'clock_tick', 'clockWallAnchor', + 'clockRecoveryStartTick', + 'clockRecoveryEndTick', + 'clockRecoveryStartWallAt', 'clock_wall_anchor', 'heartbeat', 'heartbeatAt', @@ -1139,6 +1144,9 @@ export const createDatabaseTurnHooks = async ( clockTick: BigInt(state.clockTick ?? 0), clockMode: state.clockMode ?? 'manual', clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, + clockRecoveryStartTick: state.clockRecovery ? BigInt(state.clockRecovery.startTick) : null, + clockRecoveryEndTick: state.clockRecovery ? BigInt(state.clockRecovery.endTick) : null, + clockRecoveryStartWallAt: state.clockRecovery?.startWallAt ?? null, lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'), clockRevision: BigInt(state.clockRevision ?? 1), @@ -1260,7 +1268,7 @@ export const createDatabaseTurnHooks = async ( } const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null; const unificationCutTick = unificationCutWallAt - ? world.dateToGameTick(world.getGameNow(unificationCutWallAt)) + ? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)) : null; const suspensionPreparation = unificationCutTick !== null @@ -1909,7 +1917,9 @@ export const createDatabaseTurnHooks = async ( worldStateId: state.id, profileName: options?.profileName ?? 'default', source: 'UNIFICATION_WAIT', + policy: 'TURN_BOUNDARY', cutTick, + normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt), cutWallAt: suspensionPreparation.cutWallAt, rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds, sourceRevision: state.clockRevision ?? 1, @@ -2063,6 +2073,25 @@ export const createDatabaseTurnHooks = async ( }); return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL'; }, + prepareRealtimeRecovery: async (recoveryOptions) => { + const token = options?.turnDaemonLease?.getToken(); + if (!token) return; + await prepareRealtimeRecovery( + prisma, + { + kind: 'DAEMON', + profileName: token.profile, + ownerId: token.ownerId, + fencingEpoch: token.fencingEpoch, + }, + recoveryOptions + ); + await prisma.$transaction(async (transaction) => { + await options?.turnDaemonLease?.assertActive(transaction); + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); + }, transactionOptions); + }, synchronizeClockAuthority: () => prisma.$transaction(async (transaction) => { await options?.turnDaemonLease?.assertActive(transaction); diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index 005493bc..ea4a7a7e 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore { phase: ReturnType['phase']; revision: number; deadlineGeneration: number; + startsAt: Date; }> { const state = this.world.getGameClockState(); return { @@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore { phase: state.phase, revision: state.revision, deadlineGeneration: state.deadlineGeneration, + startsAt: state.wallAnchor, }; } @@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore { async advanceGameClockTo(target: Date, wallNow: Date): Promise { this.world.advanceGameClockTo(target, wallNow); } + + async projectGameDeadline(gameTime: Date): Promise { + return this.world.projectGameDeadline(gameTime); + } } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 9c5614c6..1c32fe24 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -17,6 +17,7 @@ import { inferClockPhase, type GameClockMode, type GameClockPhase, + type TurnRecoveryWindow, } from '@sammo-ts/common'; import type { TurnCheckpoint } from '../lifecycle/types.js'; @@ -129,6 +130,7 @@ export interface InMemoryGameClockState { tick: number; mode: GameClockMode; wallAnchor: Date; + recovery?: TurnRecoveryWindow | null; lastTurnTick: number; phase: GameClockPhase; revision: number; @@ -147,6 +149,8 @@ export interface DurableClockReconciliationAlignment { alignedTick: number; shiftTicks: number; resumeWallAt: Date; + resumeAnchor?: Date; + recovery?: TurnRecoveryWindow | null; } export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle'; @@ -654,6 +658,7 @@ export class InMemoryTurnWorld { tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0, mode: this.state.clockMode ?? 'manual', wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, + recovery: this.state.clockRecovery, turnSeconds: this.state.tickSeconds, phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), revision: this.state.clockRevision ?? 1, @@ -684,6 +689,7 @@ export class InMemoryTurnWorld { tick: this.state.clockTick ?? 0, mode: this.state.clockMode ?? 'manual', wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()), + recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null, lastTurnTick: this.state.lastTurnTick ?? 0, phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), revision: this.state.clockRevision ?? 1, @@ -695,6 +701,15 @@ export class InMemoryTurnWorld { return this.getGameClock().now(wallNow); } + projectGameDeadline(gameTime: Date): Date { + const clock = this.getGameClock(); + return clock.tickToWallDate(clock.dateToTick(gameTime)); + } + + getNormalGameTick(wallNow: Date): number { + return this.getGameClock().normalNowTick(wallNow); + } + promotePreopenAtOpening(wallNow: Date): boolean { const clock = this.getGameClock(); if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) { @@ -759,7 +774,8 @@ export class InMemoryTurnWorld { this.state = { ...this.state, clockTick: input.alignedTick, - clockWallAnchor: new Date(input.resumeWallAt.getTime()), + clockWallAnchor: new Date((input.resumeAnchor ?? input.resumeWallAt).getTime()), + clockRecovery: input.recovery ? structuredClone(input.recovery) : null, lastTurnTick, lastTurnTime, clockPhase: 'RECONCILING', @@ -865,6 +881,7 @@ export class InMemoryTurnWorld { clockTick: input.tick, clockMode: input.mode, clockWallAnchor: new Date(input.wallAnchor.getTime()), + clockRecovery: input.recovery ? structuredClone(input.recovery) : null, clockPhase: input.phase, clockRevision: input.revision, deadlineGeneration: input.deadlineGeneration, @@ -918,11 +935,11 @@ export class InMemoryTurnWorld { skippedTurns: number; } | null { const clock = this.getGameClock(); - if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') { + if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) { return null; } const currentTick = clock.nowTick(wallNow); - const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow)); + const wallAlignedTick = Math.max(currentTick, clock.normalNowTick(wallNow)); const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime); // 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도 // 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다. @@ -1181,6 +1198,9 @@ export class InMemoryTurnWorld { lastTurnTime: this.state.lastTurnTime.toISOString(), }; } + if (previousClock.recovery && wallNow < previousClock.tickToWallDate(previousClock.recovery.endTick)) { + throw new Error('복구가 끝난 뒤 기본 턴 길이를 변경할 수 있습니다.'); + } const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor; const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow; const anchorTick = previousClock.nowTick(anchorWall); @@ -1201,6 +1221,7 @@ export class InMemoryTurnWorld { this.state = { ...this.state, tickSeconds: nextTickSeconds, + clockRecovery: null, clockBaseTime: nextBaseTime, clockTick: anchorTick, clockWallAnchor: new Date(anchorWall.getTime()), @@ -1670,10 +1691,13 @@ export class InMemoryTurnWorld { }; } - shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } { + shiftSchedule(deltaMinutes: number, _wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } { if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) { throw new Error('Schedule shift must be a non-zero integer number of minutes.'); } + if ((deltaMinutes * 60) % this.state.tickSeconds !== 0) { + throw new Error('일정 이동은 현재 턴 길이의 정수 배수여야 합니다.'); + } const deltaMs = deltaMinutes * 60_000; const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs); const previousClock = this.getGameClock(); @@ -1694,6 +1718,7 @@ export class InMemoryTurnWorld { tick: this.state.clockTick ?? 0, mode: this.state.clockMode ?? 'manual', wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, + recovery: this.state.clockRecovery, turnSeconds: this.state.tickSeconds, phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), revision: this.state.clockRevision ?? 1, @@ -1709,14 +1734,14 @@ export class InMemoryTurnWorld { this.state = { ...this.state, clockBaseTime: nextBaseTime, - // Rebasing is also the explicit resume checkpoint. Realtime mode - // must not replay the operational downtime after an administrator - // deliberately delays or accelerates the game schedule. - // 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는 - // 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다. - clockWallAnchor: new Date( - previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime() - ), + // 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다. + clockWallAnchor: shiftDate(previousClock.wallAnchor), + clockRecovery: this.state.clockRecovery + ? { + ...this.state.clockRecovery, + startWallAt: shiftDate(this.state.clockRecovery.startWallAt), + } + : null, lastTurnTime: nextLastTurnTime, meta: nextMeta, }; diff --git a/app/game-engine/src/turn/prepareRealtimeRecovery.ts b/app/game-engine/src/turn/prepareRealtimeRecovery.ts new file mode 100644 index 00000000..b5d4f3d1 --- /dev/null +++ b/app/game-engine/src/turn/prepareRealtimeRecovery.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto'; +import type { GamePrismaClient } from '@sammo-ts/infra'; +import { + readClockDatabaseWall, + reconcileClockSuspension, + startClockSuspension, + type ClockOperationAuthority, +} from './clockReconciliation.js'; + +/** lease를 획득했지만 clock_ready를 공개하기 전, 중단된 복구 또는 새 정전을 처리한다. */ +export const prepareRealtimeRecovery = async ( + db: GamePrismaClient, + authority: Extract, + options: { paused?: boolean } = {} +): Promise => { + const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockMode !== 'realtime') return; + if (world.clockPhase === 'SUSPENDED') { + if (options.paused) return; + const pending = await db.clockSuspension.findFirst({ + where: { worldStateId: world.id, source: 'RECOVERY', policy: 'RECOVER_TURNS', status: 'SUSPENDED' }, + orderBy: { sourceRevision: 'desc' }, + }); + if (pending) await reconcileClockSuspension({ db, suspensionId: pending.id, authority }); + return; + } + if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return; + const now = await readClockDatabaseWall(db); + // 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다. + // 한 턴 미만의 장애는 잔여 구간 실행만 필요하므로 새 좌표 세대를 만들지 않는다. + if (!options.paused && now.getTime() - world.clockWallAnchor.getTime() < world.tickSeconds * 1_000) return; + const suspensionId = `recovery-${randomUUID()}`; + await startClockSuspension({ + db, + suspensionId, + source: 'RECOVERY', + policy: 'RECOVER_TURNS', + authority, + recoverDurableObservation: true, + }); + // 이전 버전의 profile 상태만 PAUSED였던 경우에도 명시적 재개 전에는 실행하지 않는다. + if (!options.paused) await reconcileClockSuspension({ db, suspensionId, authority }); +}; diff --git a/app/game-engine/src/turn/runtimeClockAuthoritySync.ts b/app/game-engine/src/turn/runtimeClockAuthoritySync.ts index d3d96647..6755bc0c 100644 --- a/app/game-engine/src/turn/runtimeClockAuthoritySync.ts +++ b/app/game-engine/src/turn/runtimeClockAuthoritySync.ts @@ -1,4 +1,4 @@ -import { parseGameClockPhase } from '@sammo-ts/common'; +import { parseGameClockPhase, readTurnRecovery, readSerializedTurnRecovery } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async ( clockTick: true, clockMode: true, clockWallAnchor: true, + clockRecoveryStartTick: true, + clockRecoveryEndTick: true, + clockRecoveryStartWallAt: true, lastTurnTick: true, clockPhase: true, clockRevision: true, @@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async ( shiftTicks: true, alignedTick: true, resumeWallAt: true, + detail: true, }, }); let expectedRevision = before.revision; @@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async ( alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`), shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`), resumeWallAt: ledger.resumeWallAt, + recovery: readSerializedTurnRecovery(ledger.detail), }); expectedRevision = targetRevision; } @@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async ( tick: safeNumber(durable.clockTick, 'durable clock tick'), mode: durable.clockMode === 'manual' ? 'manual' : 'realtime', wallAnchor: durable.clockWallAnchor, + recovery: readTurnRecovery(durable), lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'), phase: parseGameClockPhase(durable.clockPhase), revision: durableRevision, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index d4b44cb2..d5131f94 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -900,6 +900,17 @@ const createTurnDaemonRuntimeWithLease = async ( turnDaemonLease: turnDaemonLease ?? undefined, transactionTimeoutMs: options.databaseTransactionTimeoutMs, }); + try { + await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() }); + } catch (error) { + await Promise.allSettled([ + dbHooks.close(), + reservedTurnStoreHandle?.close(), + gatewayGate?.close(), + turnDaemonLease?.close(), + ]); + throw error; + } auctionBidder = await createAuctionBidder({ databaseUrl: options.databaseUrl, world, @@ -983,6 +994,9 @@ const createTurnDaemonRuntimeWithLease = async ( redisConnector = realtimeRuntime.redisConnector; hooks = realtimeRuntime.hooks; stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker; + // 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다. + // RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다. + await turnDaemonLease?.markClockReady(); const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null; const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 001e6275..f7ceecb3 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -11,7 +11,7 @@ import type { WorldSnapshot, GeneralLastTurn, } from '@sammo-ts/logic'; -import type { GameClockMode, GameClockPhase } from '@sammo-ts/common'; +import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common'; export interface TurnWorldState { id: number; @@ -23,6 +23,7 @@ export interface TurnWorldState { clockTick?: number; clockMode?: GameClockMode; clockWallAnchor?: Date; + clockRecovery?: TurnRecoveryWindow | null; lastTurnTick?: number; clockPhase?: GameClockPhase; clockRevision?: number; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 8e0ae79b..1c107783 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite import { z } from 'zod'; import { GameClock, + readTurnRecovery, asRecord, inferClockPhase, isRecord, @@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) worldState.clockWallAnchor !== null && worldState.lastTurnTick !== null; const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual'; - const clockPhase = hasPersistedClock - ? parseGameClockPhase(worldState.clockPhase) - : inferClockPhase(clockMode); + const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode); const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision'); - const deadlineGeneration = toSafeTick( - worldState.deadlineGeneration, - 'world_state.deadline_generation' - ); + const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation'); const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime; const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime; const bootstrapClock = new GameClock({ @@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) tick: 0, mode: clockMode, wallAnchor: clockWallAnchor, + recovery: readTurnRecovery(worldState), turnSeconds: worldState.tickSeconds, phase: clockPhase, revision: clockRevision, @@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) : toSafeTick(worldState.clockTick, 'world_state.clock_tick'), mode: clockMode, wallAnchor: clockWallAnchor, + recovery: readTurnRecovery(worldState), turnSeconds: worldState.tickSeconds, phase: clockPhase, revision: clockRevision, @@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) clockTick: gameClock.tick, clockMode, clockWallAnchor: gameClock.wallAnchor, + clockRecovery: gameClock.recovery, lastTurnTick, clockPhase, clockRevision, diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index fd93411c..6268c92d 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -1,8 +1,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { GameClock } from '@sammo-ts/common'; +import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common'; import { createGamePostgresConnector, + readTurnRuntimeReady, createRedisConnector, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma, @@ -13,6 +14,8 @@ import { import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js'; import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js'; +import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js'; +import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js'; const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL; const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL); @@ -62,6 +65,158 @@ describeIntegration('durable clock reconciliation', () => { await clean(); }); + it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => { + const profile = 'recovery-startup'; + await db.worldState.create({ + data: { + scenarioCode: profile, + currentYear: 199, + currentMonth: 4, + tickSeconds: 3600, + clockBaseTime: new Date('0199-01-01T00:00:00Z'), + clockTick: repeated ? BigInt(4 * T) : 0n, + clockMode: 'realtime', + clockWallAnchor: new Date(Date.now() - 4 * 3_600_000), + lastTurnTick: repeated ? BigInt(4 * T) : 0n, + ...(repeated + ? { + clockRecoveryStartTick: 0n, + clockRecoveryEndTick: BigInt(8 * T), + clockRecoveryStartWallAt: new Date(Date.now() - 6 * 3_600_000), + } + : {}), + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + }, + }); + const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false }); + try { + const token = await lease.acquire(); + expect(token).not.toBeNull(); + expect(await readTurnRuntimeReady(db, 1n)).toBe(false); + const authority = { + kind: 'DAEMON' as const, + profileName: profile, + ownerId: token!.ownerId, + fencingEpoch: token!.fencingEpoch, + }; + if (!repeated) { + await prepareRealtimeRecovery(db, authority, { paused: true }); + const paused = await db.worldState.findFirstOrThrow(); + expect(paused.clockPhase).toBe('SUSPENDED'); + expect(paused.clockTick).toBe(0n); + expect((await db.clockSuspension.findFirstOrThrow()).status).toBe('SUSPENDED'); + await prepareRealtimeRecovery(db, authority, { paused: true }); + expect((await db.worldState.findFirstOrThrow()).clockPhase).toBe('SUSPENDED'); + } + await prepareRealtimeRecovery(db, authority); + const pending = await db.worldState.findFirstOrThrow(); + expect(pending.clockPhase).toBe('RECONCILING'); + const recoveredWindow = readTurnRecovery(pending)!; + expect(recoveredWindow).not.toBeNull(); + expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 12 : 8) * T); + expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false); + await applyNextClockProjection({ db, redis: redis.client, workerId: profile }); + await lease.markClockReady(); + expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(true); + expect(await readTurnRuntimeReady(db, 1n)).toBe(false); + await lease.acquire(); + expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false); + await prepareRealtimeRecovery(db, { + kind: 'DAEMON', + profileName: profile, + ownerId: token!.ownerId, + fencingEpoch: token!.fencingEpoch, + }); + const reloaded = await db.worldState.findFirstOrThrow(); + expect(readTurnRecovery(reloaded)).toEqual(readTurnRecovery(pending)); + expect(reloaded.clockRevision).toBe(pending.clockRevision); + } finally { + await lease.close(); + } + }); + + it.each([4, 12, 13, 23, 24])( + 'persists recovery for %i turns and reloads the same normal boundary', + async (turns) => { + const now = new Date(); + await db.worldState.create({ + data: { + scenarioCode: 'turn-recovery', + currentYear: 199, + currentMonth: 4, + tickSeconds: 3600, + clockBaseTime: new Date('2026-01-01T00:00:00Z'), + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: now, + lastTurnTick: 0n, + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + }, + }); + const generalTick = T + 199_020; + await db.general.create({ + data: { + id: 26, + name: '냥냥', + turnTick: BigInt(generalTick), + turnTime: new Date('2026-01-01T01:00:19.902Z'), + meta: { purchasedPhase: true }, + }, + }); + const authority = { kind: 'OFFLINE' as const, profileName: 'recovery-test', reason: 'fixture' }; + const suspension = await startClockSuspension({ + db, + suspensionId: 'recovery-test', + source: 'MAINTENANCE', + policy: turns === 4 ? 'EXACT' : 'RECOVER_TURNS', + authority, + }); + const resumedAt = new Date(suspension.cutWallAt.getTime() + turns * 3_600_000); + const plan = await reconcileClockSuspension({ + db, + suspensionId: suspension.suspensionId, + authority, + testResumeWallAt: resumedAt, + upgradeMaintenancePolicy: true, + }); + expect(plan.shiftTicks).toBe(Math.floor(turns / 12) * 12 * T); + await applyNextClockProjection({ db, redis: redis.client, workerId: 'recovery-test' }); + const row = await db.worldState.findFirstOrThrow(); + const recovery = readTurnRecovery(row); + const reloaded = new GameClock({ + baseTime: row.clockBaseTime!, + tick: Number(row.clockTick), + wallAnchor: row.clockWallAnchor!, + mode: 'realtime', + turnSeconds: row.tickSeconds, + recovery, + }); + expect(row.currentMonth).toBe(4); + expect(row.lastTurnTick).toBe(BigInt(plan.shiftTicks)); + const general = await db.general.findUniqueOrThrow({ where: { id: 26 } }); + expect(general.turnTick).toBe(BigInt(generalTick + plan.shiftTicks)); + expect(general.turnTime.toISOString().slice(14)).toBe('00:19.902Z'); + expect(general.meta).toEqual({ purchasedPhase: true }); + if (turns % 12 === 0) { + expect(recovery).toBeNull(); + } else { + expect(recovery).not.toBeNull(); + const end = reloaded.tickToWallDate(recovery!.endTick); + expect(reloaded.nowTick(end)).toBe(recovery!.endTick); + expect(reloaded.executionRate(new Date(end.getTime() - 1))).toBe(2); + expect(reloaded.executionRate(end)).toBe(1); + expect(reloaded.tickToWallDate(recovery!.endTick + 199_020).getTime() - end.getTime()).toBe(19_902); + } + const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority }); + expect(retry.recovery).toEqual(plan.recovery); + expect(retry.catchUpTicks).toBe(plan.catchUpTicks); + } + ); + it.each([3_142_625, 6 * 3_600_000 + 3_142_625])( 'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload', async (gapMilliseconds) => { diff --git a/app/game-engine/test/inMemoryGeneralPool.test.ts b/app/game-engine/test/inMemoryGeneralPool.test.ts index d86b02ff..2b45cbcd 100644 --- a/app/game-engine/test/inMemoryGeneralPool.test.ts +++ b/app/game-engine/test/inMemoryGeneralPool.test.ts @@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => { const before = world.captureState(); const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000); - world.shiftSchedule(15, claimedAt); + world.shiftSchedule(20, claimedAt); expect(world.captureState().generalPoolEntries).toMatchObject([ { id: 1, - reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000), + reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000), reservedUntilTick: GAME_TICKS_PER_TURN / 2, }, { id: 2, reservedUntil: null, reservedUntilTick: null }, diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index e4476d88..2e4a2cfd 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { GamePrismaClient } from '@sammo-ts/infra'; -import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, planTurnRecovery } from '@sammo-ts/common'; +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js'; import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js'; @@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial = {}): InMemoryTurnWo }; describe('runtime clock shift', () => { + it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => { + const base = new Date('2026-07-30T10:00:00Z'); + const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000); + const plan = planTurnRecovery({ + observedTick: 0, + normalTick: 4 * GAME_TICKS_PER_TURN, + wallNow: wallAt(40), + turnSeconds: 600, + }); + let world = buildWorld({ + clockBaseTime: base, + clockTick: 0, + clockWallAnchor: wallAt(40), + clockRecovery: plan.recovery, + clockMode: 'realtime', + clockPhase: 'RUNNING', + lastTurnTick: 0, + }); + for (const [id, milliseconds] of [ + [1, 19_902], + [2, 42_001], + ]) { + world.updateGeneral(id!, { + turnTime: new Date(base.getTime() + milliseconds!), + turnTick: milliseconds! * 60, + }); + } + const executions: Array<[number, number, boolean]> = []; + const processorForWorld = () => + new InMemoryTurnProcessor(world, { + afterExecuteGeneral: async (general, result) => { + executions.push([world.getState().currentMonth, general.id, result.ok]); + }, + }); + let processor = processorForWorld(); + for (let minutes = 45; minutes <= 80; minutes += 5) { + const now = wallAt(minutes); + const target = world.getGameNow(now); + world.advanceGameClockTo(target, now); + const result = await processor.run(target, { budgetMs: 10_000, maxGenerals: 10, catchUpCap: 1 }); + expect(result).toMatchObject({ processedGenerals: 2, processedTurns: 1, partial: false }); + if (minutes === 60) { + const snapshot = world.captureState(); + world = buildWorld(); + world.restoreState(snapshot); + processor = processorForWorld(); + } + } + expect(executions).toEqual( + Array.from({ length: 8 }, (_, month) => [ + [month + 1, 1, true], + [month + 1, 2, true], + ]).flat() + ); + expect(world.getState().currentMonth).toBe(9); + expect(world.getGeneralById(1)!.turnTime).toEqual(new Date(wallAt(80).getTime() + 19_902)); + const next = await processor.run(world.getGameNow(wallAt(90)), { + budgetMs: 10_000, + maxGenerals: 10, + catchUpCap: 1, + }); + expect(next).toMatchObject({ processedGenerals: 2, processedTurns: 1 }); + expect(world.getState().currentMonth).toBe(10); + }); it('preserves the scenario config when the raw world config is unavailable', () => { const world = buildWorld(); @@ -99,7 +164,7 @@ describe('runtime clock shift', () => { ['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'], ['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'], ] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => { - const world = buildWorld(); + const world = buildWorld({ tickSeconds: 900 }); world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 }); world.queueNeutralAuction({ registrationKey: 'test', @@ -132,7 +197,7 @@ describe('runtime clock shift', () => { expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]); }); - it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => { + it.each([0, 1.5, 15, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => { const world = buildWorld(); expect(() => world.shiftSchedule(delta)).toThrow(); expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z'); @@ -140,7 +205,7 @@ describe('runtime clock shift', () => { }); it('keeps legacy wall-clock metadata independent from the process timezone', () => { - const world = buildWorld(); + const world = buildWorld({ tickSeconds: 900 }); world.shiftSchedule(-15); @@ -212,7 +277,7 @@ describe('runtime clock shift', () => { expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 }); }); - it('preserves the formal wall opening when PREOPEN game display dates are shifted', () => { + it('moves the formal opening together with an explicit whole-turn schedule shift', () => { const openAt = new Date('2026-09-06T00:00:00.000Z'); const now = new Date('2026-09-05T00:00:00.000Z'); const world = buildWorld({ @@ -223,11 +288,13 @@ describe('runtime clock shift', () => { lastTurnTick: 0, clockPhase: 'PREOPEN', }); - world.shiftSchedule(15, now); - expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt }); + world.shiftSchedule(20, now); + const shiftedOpen = new Date(openAt.getTime() + 20 * 60_000); + expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: shiftedOpen }); expect(world.promotePreopenAtOpening(now)).toBe(false); - expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z')); - expect(world.promotePreopenAtOpening(openAt)).toBe(true); + expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z')); + expect(world.promotePreopenAtOpening(openAt)).toBe(false); + expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true); }); it('rejects gameplay commits while the durable clock is suspended', async () => { @@ -309,7 +376,7 @@ describe('runtime clock shift', () => { }); }); - it('repairs an already accumulated realtime projection lag during a long rebase', () => { + it('preserves the normal game-to-wall mapping during a whole-year 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'); @@ -325,7 +392,7 @@ describe('runtime clock shift', () => { expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z'); expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 }); - expect(world.getGameNow(resumedAt)).toEqual(resumedAt); + expect(world.getGameNow(resumedAt)).toEqual(new Date('2026-07-30T11:15:00.000Z')); }); it('does not lose realtime elapsed time when an overdue target is committed later', () => { diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index ae4069d4..615f649b 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => { type: 'shiftSchedule', requestId, actionId, - deltaMinutes: -15, + deltaMinutes: -20, } as GamePrisma.InputJsonValue, }, }); @@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => { await hooks.close(); } - expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z'); - expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z'); + expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:40:00.000Z'); + expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z'); expect(await stateStore.loadCheckpoint()).toMatchObject({ - turnTime: '2099-07-30T09:45:00.000Z', + turnTime: '2099-07-30T09:40:00.000Z', generalId: 0, }); - expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z'); + // 예정된 재개 경계까지 대기하며, 그 뒤 첫 장수의 시각을 다시 계산한다. + expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:40:00.000Z'); const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); expect(storedWorld.meta).toMatchObject({ - lastTurnTime: '2099-07-30T09:45:00.000Z', - starttime: '2099-06-30 23:45:00', + lastTurnTime: '2099-07-30T09:40:00.000Z', + starttime: '2099-06-30 23:40:00', }); expect(storedWorld.clockTick).toBe(0n); expect(storedWorld.lastTurnTick).toBe(0n); const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } }); - expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z'); + expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:00:00.000Z'); expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN)); const storedAuctions = await db.auction.findMany({ where: { id: { in: auctionRows.map((auction) => auction.id) } }, }); const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()])); expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([ - '2099-07-30T09:45:00.000Z', + '2099-07-30T09:40:00.000Z', '2099-07-30T11:00:00.000Z', '2099-07-30T12:00:00.000Z', '2099-07-30T13:00:00.000Z', @@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => { type: 'shiftSchedule', ok: true, actionId, - deltaMinutes: -15, + deltaMinutes: -20, shiftedGenerals: 2, shiftedAuctions: 1, }, diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index 53879abc..36663bf7 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -611,7 +611,7 @@ integration('unification finalization transaction', () => { const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } }); expect(suspension).toMatchObject({ source: 'UNIFICATION_WAIT', - policy: 'EXACT', + policy: 'TURN_BOUNDARY', status: 'SUSPENDED', sourceRevision: 1n, targetRevision: 2n, diff --git a/app/game-engine/test/unificationInvaderResume.test.ts b/app/game-engine/test/unificationInvaderResume.test.ts index 4059382b..97891275 100644 --- a/app/game-engine/test/unificationInvaderResume.test.ts +++ b/app/game-engine/test/unificationInvaderResume.test.ts @@ -143,7 +143,7 @@ describe('HWE-shaped unification invader resume', () => { tickSeconds: 60, lastTurnTime: liveLastTurnTime, clockBaseTime: new Date('2026-08-19T12:00:00.000Z'), - clockTick: 19_980_000_000, + clockTick: 19_944_000_000, clockMode: 'realtime', clockWallAnchor: liveWallAnchor, lastTurnTick: 19_944_000_000, @@ -202,7 +202,7 @@ describe('HWE-shaped unification invader resume', () => { actorUserId: recipient.userId, target: 'ENGINE', eventType: 'messageRespond', - processingGameTick: 19_980_000_000n, + processingGameTick: 19_944_000_000n, })), }, messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) }, @@ -214,11 +214,11 @@ describe('HWE-shaped unification invader resume', () => { id: 1014, mailbox: recipient.id, type: 'private', - time: world.gameTickToDate(19_980_000_000), + time: world.gameTickToDate(19_944_000_000), validUntil: new Date('9999-12-31T00:00:00.000Z'), actionType: 'raiseInvader', actionStatus: 'PENDING', - createdGameTick: 19_980_000_000n, + createdGameTick: 19_944_000_000n, expiresGameTick: null, message, }, @@ -256,13 +256,22 @@ describe('HWE-shaped unification invader resume', () => { gapTicks: 108_000_000, catchUpTicks: 0, shiftTicks: 108_000_000, - alignedTick: 20_088_000_000, + alignedTick: 20_052_000_000, resumeWallAt: acceptedAt, + resumeAnchor: new Date('2026-08-20T06:25:00Z'), }), }); const processor = new InMemoryTurnProcessor(world); const clock = new ManualClock(acceptedAt.getTime()); + vi.spyOn(queue, 'waitFor').mockImplementation(async (milliseconds) => { + await clock.sleepMs(milliseconds ?? 0); + const pending = await queue.drain(); + for (const command of pending.slice(1)) queue.enqueue(command); + return pending[0] ?? null; + }); + const runWallTimes: Date[] = []; const run = vi.fn(async (...args: Parameters) => { + runWallTimes.push(new Date(clock.nowMs())); const result = await processor.run(...args); if (world.getState().currentMonth === 4) { queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' }); @@ -307,7 +316,7 @@ describe('HWE-shaped unification invader resume', () => { expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2); expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({ where: { messageId: { in: [1014] }, status: 'PENDING' }, - data: { status: 'RESOLVED', resolvedGameTick: 20_088_000_000n }, + data: { status: 'RESOLVED', resolvedGameTick: 20_052_000_000n }, }); expect(world.getState()).toMatchObject({ currentYear: 226, @@ -324,11 +333,11 @@ describe('HWE-shaped unification invader resume', () => { expect(initialInvaderTurnTimes).toHaveLength(10); expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary); expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000); - expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([ - '2026-08-20T06:24:58.611Z', - '2026-08-20T06:25:00.000Z', - ]); - expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z'); + expect(run).toHaveBeenCalled(); + expect(runWallTimes.every((time) => time >= new Date('2026-08-20T06:25:00Z'))).toBe(true); + const nextMonth = addMinutes(liveLastTurnTime, 4); + expect(run.mock.calls.every(([targetTime]) => targetTime <= nextMonth)).toBe(true); + expect(run.mock.calls.at(-1)![0]).toEqual(nextMonth); + expect(lifecycle.getStatus().lastTurnTime).toBe(nextMonth.toISOString()); }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 1046e964..5c17333d 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -46,6 +46,7 @@ type NavigationFixture = { clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + clockRecovery?: { startsAt: string; endsAt: string } | null; turnEngineRunning?: boolean | null; cityDefence?: number; cityState?: number; @@ -636,6 +637,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { clockMode: state.clockMode ?? 'realtime', clockRunning: state.clockRunning ?? true, clockStartsAt: state.clockStartsAt ?? null, + clockRecovery: state.clockRecovery ?? null, turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning, scenarioTitle: state.scenarioTitle ?? '', autorunUser: state.autorunUser ?? null, @@ -6078,3 +6080,63 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn }) .toBe(1); }); + +for (const viewport of [ + { width: 1200, height: 900 }, + { width: 390, height: 844 }, +]) { + test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => { + const start = new Date('2026-09-06T07:59:50Z'); + await page.clock.install({ time: start }); + await page.setViewportSize(viewport); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 1, + npcMode: 1, + generalMeCalls: 0, + operations: [], + serverTime: '2026-09-06T07:59:40Z', + serverWallTime: start.toISOString(), + clockMode: 'realtime', + clockRunning: true, + turnEngineRunning: true, + clockRecovery: { startsAt: '2026-09-06T04:00:00Z', endsAt: '2026-09-06T08:00:00Z' }, + }; + await installFixture(page, state); + await page.goto('./'); + await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 }); + await page.evaluate(() => document.fonts.ready); + const status = page.locator('.execution-status:visible'); + await expect(status).toContainText('복구 2배속'); + const root = process.env.TURN_RECOVERY_ARTIFACT_DIR; + const measure = () => + status.evaluate((element) => ({ + rect: element.getBoundingClientRect().toJSON(), + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + font: getComputedStyle(element).font, + lineHeight: getComputedStyle(element).lineHeight, + text: element.textContent, + })); + const before = await measure(); + expect(before.scrollWidth).toBeLessThanOrEqual(before.clientWidth + 1); + if (root) { + await mkdir(root, { recursive: true }); + await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true }); + } + await page.clock.runFor(20_000); + await expect(status).not.toContainText('복구 2배속'); + await expect(status).toContainText('17:00'); + const after = await measure(); + expect(after.scrollWidth).toBeLessThanOrEqual(after.clientWidth + 1); + if (root) { + await page.screenshot({ path: resolve(root, `normal-${viewport.width}.png`), fullPage: true }); + await writeFile( + resolve(root, `geometry-${viewport.width}.json`), + JSON.stringify({ viewport, before, after }, null, 2) + ); + } + }); +} diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index b6eb1f98..4607a55b 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -29,6 +29,7 @@ const props = defineProps<{ clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + clockRecovery?: { startsAt: string; endsAt: string } | null; autorunLimit?: number | null; storageKey?: string; mapData?: CommandMapData | null; @@ -60,11 +61,7 @@ const labelMap = computed(() => { }); const firstReservedMonth = computed( - () => - (props.currentYear ?? 0) * 12 + - (props.currentMonth ?? 1) - - 1 + - (props.general?.nextTurnMonthOffset ?? 0) + () => (props.currentYear ?? 0) * 12 + (props.currentMonth ?? 1) - 1 + (props.general?.nextTurnMonthOffset ?? 0) ); const rows = computed(() => { @@ -119,7 +116,7 @@ const updateServerClock = () => { currentServerTime.value = '--:--:--'; return; } - const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample); + const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample); currentServerTime.value = formatLocalTimeSeconds(projectedTime); if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs; @@ -127,15 +124,30 @@ const updateServerClock = () => { updateServerClock, untilStartMs > 0 ? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS) - : 1_000 - projectedTime.getMilliseconds() + : (1_000 - projectedTime.getMilliseconds()) / rate ); } }; watch( - () => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const, - ([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => { - serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt }); + () => + [ + props.serverTime, + props.serverWallTime, + props.clockMode, + props.clockRunning, + props.clockStartsAt, + props.clockRecovery, + ] as const, + ([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => { + serverClockSample = sampleServerClock({ + serverTime, + serverWallTime, + clockMode, + clockRunning, + clockStartsAt, + clockRecovery, + }); updateServerClock(); }, { immediate: true } diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index 037bbb14..fe5d6a2a 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -16,6 +16,7 @@ const props = defineProps<{ clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + clockRecovery?: { startsAt: string; endsAt: string } | null; turnEngineRunning?: boolean | null; status: { onlineUserCount: number; @@ -34,6 +35,7 @@ const props = defineProps<{ const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const currentServerTime = ref('기록 없음'); const hasServerClock = ref(false); +const recovering = ref(false); const turnEngineStopped = computed(() => props.turnEngineRunning === false); const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean'); const serverClockTitle = computed(() => { @@ -57,6 +59,7 @@ const updateServerClock = () => { const now = Date.now(); const projection = projectServerClock(serverClockSample, now); + recovering.value = projection.rate === 2; currentServerTime.value = formatServerDateTime(projection.time, { format: 'monthDayTime', fallback: '기록 없음', @@ -67,16 +70,37 @@ const updateServerClock = () => { const nextDelays: number[] = []; if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs; - nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time)); + nextDelays.push( + untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time) / projection.rate + ); + } + for (const boundary of [serverClockSample.recoveryStartDelayMs, serverClockSample.recoveryEndDelayMs]) { + if (boundary !== undefined && boundary > projection.clientElapsedMs) + nextDelays.push(boundary - projection.clientElapsedMs); } if (nextDelays.length === 0) return; serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays))); }; watch( - () => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const, - ([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => { - serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt }); + () => + [ + props.serverTime, + props.serverWallTime, + props.clockMode, + props.clockRunning, + props.clockStartsAt, + props.clockRecovery, + ] as const, + ([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => { + serverClockSample = sampleServerClock({ + serverTime, + serverWallTime, + clockMode, + clockRunning, + clockStartsAt, + clockRecovery, + }); updateServerClock(); }, { immediate: true } @@ -100,7 +124,7 @@ onUnmounted(() => { }" :title="serverClockTitle" > - 현재 시각: {{ currentServerTime }} + 현재 시각: {{ currentServerTime }} · 복구 2배속
diff --git a/app/game-frontend/src/utils/serverClockProjection.ts b/app/game-frontend/src/utils/serverClockProjection.ts index 3376b9cb..02d60551 100644 --- a/app/game-frontend/src/utils/serverClockProjection.ts +++ b/app/game-frontend/src/utils/serverClockProjection.ts @@ -4,6 +4,7 @@ export type ServerClockProjectionInput = { clockMode?: 'realtime' | 'manual'; clockRunning?: boolean; clockStartsAt?: string | null; + clockRecovery?: { startsAt: string; endsAt: string } | null; }; export type SampledServerClock = { @@ -11,6 +12,8 @@ export type SampledServerClock = { sampledClientTimeMs: number; clockMode: 'realtime' | 'manual'; startDelayMs: number | null; + recoveryStartDelayMs?: number; + recoveryEndDelayMs?: number; }; const parseInstant = (value?: string | null): number | null => { @@ -40,11 +43,20 @@ export const sampleServerClock = ( : null; } + const wallSample = parseInstant(input.serverWallTime); + const recoveryStart = parseInstant(input.clockRecovery?.startsAt); + const recoveryEnd = parseInstant(input.clockRecovery?.endsAt); return { serverTimeMs, sampledClientTimeMs, clockMode: input.clockMode ?? 'realtime', startDelayMs, + ...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart + ? { + recoveryStartDelayMs: recoveryStart - wallSample, + recoveryEndDelayMs: recoveryEnd - wallSample, + } + : {}), }; }; @@ -55,9 +67,25 @@ export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Da ? 0 : Math.max(0, clientElapsedMs - sample.startDelayMs); + const accelerationMs = + sample.startDelayMs === null || sample.clockMode === 'manual' + ? 0 + : Math.max( + 0, + Math.min(clientElapsedMs, sample.recoveryEndDelayMs ?? 0) - + Math.max(0, sample.recoveryStartDelayMs ?? 0) + ); + const rate = + sample.startDelayMs !== null && + sample.clockMode !== 'manual' && + clientElapsedMs >= (sample.recoveryStartDelayMs ?? Infinity) && + clientElapsedMs < (sample.recoveryEndDelayMs ?? -Infinity) + ? 2 + : 1; return { clientElapsedMs, - time: new Date(sample.serverTimeMs + elapsedGameMs), + rate, + time: new Date(sample.serverTimeMs + elapsedGameMs + accelerationMs), }; }; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 2bed3f83..bcbfb7e3 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -311,6 +311,7 @@ watch( :clock-mode="lobbyInfo?.clockMode" :clock-running="lobbyInfo?.clockRunning" :clock-starts-at="lobbyInfo?.clockStartsAt" + :clock-recovery="lobbyInfo?.clockRecovery" :turn-engine-running="lobbyInfo?.turnEngineRunning" />
@@ -365,6 +366,7 @@ watch( :clock-mode="lobbyInfo?.clockMode" :clock-running="lobbyInfo?.clockRunning" :clock-starts-at="lobbyInfo?.clockStartsAt" + :clock-recovery="lobbyInfo?.clockRecovery" :autorun-limit="reservedGeneralAutorunLimit" :map-data="worldMap" :map-layout="mapLayout" @@ -547,6 +549,7 @@ watch( :clock-mode="lobbyInfo?.clockMode" :clock-running="lobbyInfo?.clockRunning" :clock-starts-at="lobbyInfo?.clockStartsAt" + :clock-recovery="lobbyInfo?.clockRecovery" :autorun-limit="reservedGeneralAutorunLimit" :map-data="worldMap" :map-layout="mapLayout" diff --git a/app/game-frontend/test/serverClockProjection.test.ts b/app/game-frontend/test/serverClockProjection.test.ts index f638984e..d19b4cdc 100644 --- a/app/game-frontend/test/serverClockProjection.test.ts +++ b/app/game-frontend/test/serverClockProjection.test.ts @@ -49,3 +49,22 @@ void test('holds a preopen clock until its wall-clock start delay passes', () => void test('rejects an invalid server clock sample', () => { assert.equal(sampleServerClock({ serverTime: 'not-a-time' }, 10_000), null); }); + +void test('a single browser sample accelerates only inside the recovery window and rejoins normal time', () => { + const sample = sampleServerClock( + { + serverTime: '2026-09-06T00:20:00Z', + serverWallTime: '2026-09-06T04:20:00Z', + clockRunning: true, + clockRecovery: { startsAt: '2026-09-06T05:00:00Z', endsAt: '2026-09-06T09:00:00Z' }, + }, + 0 + ); + assert.ok(sample); + const minute = 60_000; + assert.equal(projectServerClock(sample, 40 * minute).time.toISOString(), '2026-09-06T01:00:00.000Z'); + assert.equal(projectServerClock(sample, 100 * minute).time.toISOString(), '2026-09-06T03:00:00.000Z'); + assert.equal(projectServerClock(sample, 280 * minute).time.toISOString(), '2026-09-06T09:00:00.000Z'); + assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z'); + assert.equal(projectServerClock(sample, 280 * minute).rate, 1); +}); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 03a04c53..0b3a9980 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -2285,6 +2285,22 @@ export const adminRouter = router({ message: 'preopenAt and openAt are required for RESERVED status.', }); } + const current = await ctx.profiles.getProfile(input.profileName); + if (!current) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + if (current.currentScenario !== null && current.status !== input.status) { + const clockAction = + input.status === 'RUNNING' + ? 'RESUME' + : current.status === 'RUNNING' && (input.status === 'PAUSED' || input.status === 'STOPPED') + ? 'SUSPEND' + : null; + if (clockAction) + await ctx.orchestrator.transitionProfileClock( + input.profileName, + clockAction, + 'operator setStatus' + ); + } const result = await ctx.profiles.updateStatus(input.profileName, input.status, { preopenAt: input.preopenAt, openAt: input.openAt, @@ -2696,6 +2712,18 @@ export const adminRouter = router({ }); } + if (input.action === 'ACCELERATE' || input.action === 'DELAY') { + const [settings] = (await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? []; + if (!settings || input.durationMinutes! % settings.turnTermMinutes !== 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: settings + ? `일정 이동은 현재 턴 길이(${settings.turnTermMinutes}분)의 정수 배로 입력해 주세요.` + : '현재 턴 길이를 확인할 수 없습니다.', + }); + } + } + if ( input.action === 'ACCELERATE' || input.action === 'DELAY' || diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 0b84e27f..0b2abb57 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { stripVTControlCharacters } from 'node:util'; @@ -1237,7 +1238,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (!profile || profile.currentScenario === null) { throw new Error(`Profile clock is unavailable: ${profileName}`); } - const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); + const { connectorFactory, supportsTurnRecovery } = await this.resolveProfileClockAdapter(profile); + const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) }); const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); await postgres.connect(); await redis.connect(); @@ -1265,6 +1267,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { select: { clockPhase: true, clockRevision: true, deadlineGeneration: true }, }); if (!world) throw new Error(`Profile has no world_state: ${profileName}`); + if (['PREOPEN', 'MANUAL', 'COMPLETED'].includes(world.clockPhase)) { + return { phase: world.clockPhase, revision: clockRevisionAsNumber(world.clockRevision) }; + } if (action === 'SUSPEND') { let suspension = await postgres.prisma.clockSuspension.findFirst({ @@ -1281,8 +1286,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { suspensionId: `gateway-maintenance-${suffix}`, source: 'MAINTENANCE', // 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다. - // 관측 시계만 재개하고 정상 엔진이 미처리 턴을 따라잡게 한다. - policy: 'PRESERVE_SCHEDULE', + // 완전한 12턴은 정수 이동하고 잔여 지연은 복구 구간에서 두 배속으로 실행한다. + policy: supportsTurnRecovery ? 'RECOVER_TURNS' : 'PRESERVE_SCHEDULE', authority, }); suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({ @@ -1326,7 +1331,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) }; } if (world.clockPhase === 'SUSPENDED') { - await reconcileClockSuspension({ db: postgres.prisma, suspensionId: suspension.id, authority }); + await reconcileClockSuspension({ + db: postgres.prisma, + suspensionId: suspension.id, + authority, + upgradeMaintenancePolicy: supportsTurnRecovery, + }); } else if (world.clockPhase !== 'RECONCILING') { throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`); } @@ -1353,7 +1363,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { await this.promoteProfileOpeningOverride(profile); return; } - const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); + const { connectorFactory } = await this.resolveProfileClockAdapter(profile); + const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) }); const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); await postgres.connect(); await redis.connect(); @@ -1695,6 +1706,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (!gatewayProfileCapabilities(profile.status).operatorResumable) { throw new Error(`Profile status ${profile.status} cannot be started by an operator.`); } + await this.transitionProfileClock(profile.profileName, 'RESUME', operation.reason ?? 'operator START'); await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.'); const updated = await updateOperationProfile( { @@ -1735,6 +1747,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') { throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`); } + await this.transitionProfileClock(profile.profileName, 'SUSPEND', operation.reason ?? 'operator STOP'); await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.'); await updateOperationProfile({ status: 'STOPPED' }, () => this.repository.updateStatus(profile.profileName, 'STOPPED') @@ -1979,14 +1992,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { 'settlement', '기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.' ); - const result = await this.cancelGame({ - cancellationId: operation.id, - databaseUrl, - cancelledBy: operation.requestedBy, - reason: operation.reason ?? '', - ...options, - cancelledAt: this.now(), - }); + const result = await this.cancelGame( + { + cancellationId: operation.id, + databaseUrl, + cancelledBy: operation.requestedBy, + reason: operation.reason ?? '', + ...options, + cancelledAt: this.now(), + }, + this.cancelGame === defaultCancelGame + ? (await this.resolveProfileClockAdapter(profile)).connectorFactory + : undefined + ); cancellationCommitted = true; await assertLease(); await updateClaimedProfile({ @@ -2493,11 +2511,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`); } await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.'); + // 실제 seed가 정한 경계를 공개 시간표와 scheduler에도 사용한다. + const openingConnector = createGamePostgresConnector({ url: seedInfo.databaseUrl }); + let effectiveOpenAt = openAt; + try { + await openingConnector.connect(); + const clock = await openingConnector.prisma.worldState.findFirstOrThrow({ + select: { clockMode: true, clockWallAnchor: true }, + }); + if (clock.clockMode === 'realtime' && clock.clockWallAnchor) effectiveOpenAt = clock.clockWallAnchor; + } finally { + await openingConnector.disconnect(); + } + await this.clearTournamentRuntimeState(profile.profileName); await assertLease?.(); const completedAt = this.now().toISOString(); const now = this.now(); - const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt); + const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, effectiveOpenAt); const publishedProfile = await updateClaimedProfile( { currentScenario: String(scenarioId), @@ -2508,8 +2539,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { buildLastUsedAt: completedAt, buildCompletedAt: completedAt, buildError: null, - preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, - openAt: openAt ? openAt.toISOString() : null, + preopenAt: preopenAt + ? preopenAt.toISOString() + : effectiveOpenAt + ? effectiveOpenAt.toISOString() + : null, + openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null, scheduledStartAt: action.scheduledAt ?? null, ...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}), }, @@ -2523,8 +2558,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId)); } return this.repository.updateStatus(profile.profileName, desiredStatus, { - preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, - openAt: openAt ? openAt.toISOString() : null, + preopenAt: preopenAt + ? preopenAt.toISOString() + : effectiveOpenAt + ? effectiveOpenAt.toISOString() + : null, + openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null, scheduledStartAt: action.scheduledAt ?? null, }); } @@ -2802,6 +2841,25 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } } + private async resolveProfileClockAdapter(profile: GatewayProfileRecord): Promise<{ + connectorFactory: typeof createGamePostgresConnector; + supportsTurnRecovery: boolean; + }> { + // Gateway와 profile은 독립 배포된다. 이전 profile에는 당시 Prisma 모델과 + // 기존 즉시 따라잡기 정책을 사용하고, 새 profile만 복구 창을 저장한다. + const profileWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot; + const manifest = await readReleaseManifest(profileWorkspace); + const supportsTurnRecovery = manifest.gameSchemaHead >= '20260906090000_add_turn_recovery_window'; + const connectorFactory = supportsTurnRecovery + ? createGamePostgresConnector + : ( + (await import(pathToFileURL(path.join(profileWorkspace, 'packages/infra/dist/index.js')).href)) as { + createGamePostgresConnector: typeof createGamePostgresConnector; + } + ).createGamePostgresConnector; + return { connectorFactory, supportsTurnRecovery }; + } + private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string { return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url; } diff --git a/app/gateway-api/src/orchestrator/profileSeedCli.ts b/app/gateway-api/src/orchestrator/profileSeedCli.ts index 3bafe67d..aeaee086 100644 --- a/app/gateway-api/src/orchestrator/profileSeedCli.ts +++ b/app/gateway-api/src/orchestrator/profileSeedCli.ts @@ -65,6 +65,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P tickSeconds: request.tickSeconds, gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime', now: new Date(request.now), + wallNow: new Date(), installOptions: request.installOptions ? { ...request.installOptions, diff --git a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts index a1e856f2..344a21a6 100644 --- a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -22,6 +22,7 @@ export interface SeedProfileDatabaseOptions { tickSeconds?: number; gameClockMode?: GameClockMode; now?: Date; + wallNow?: Date; installOptions?: ScenarioInstallOptions; scenarioOptions?: Parameters[0]['scenarioOptions']; mapOptions?: Parameters[0]['mapOptions']; @@ -115,9 +116,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null; const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime'; - const phase = worldState.clockPhase - ? parseGameClockPhase(worldState.clockPhase) - : inferClockPhase(mode); + const phase = worldState.clockPhase ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(mode); const gameClock = new GameClock({ baseTime: worldState.clockBaseTime ?? fallbackTurnTime, tick: Number(worldState.clockTick ?? 0n), @@ -164,6 +163,7 @@ export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) = tickSeconds: options.tickSeconds, gameClockMode: options.gameClockMode, now: options.now, + wallNow: options.wallNow, installOptions: options.installOptions, scenarioOptions: options.scenarioOptions, mapOptions: options.mapOptions, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 96bdfb12..5474b90a 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -1566,13 +1566,32 @@ describe('admin runtime clock action API', () => { expect(harness.updatedStatuses).toEqual([]); }); + it('routes direct profile status changes through clock suspension', async () => { + const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' }); + await harness.caller.admin.profiles.setStatus({ profileName: 'che:2', status: 'PAUSED' }); + expect(harness.lifecycle[0]).toBe('clock:SUSPEND'); + expect(harness.updatedStatuses).toEqual(['PAUSED']); + }); + + it('rejects schedule movement that changes within-turn phases', async () => { + const harness = await buildCaller(unusedCreateOperation); + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'ACCELERATE', + durationMinutes: 15, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(harness.createdRuntimeActions).toEqual([]); + }); + it('creates a first-class clock action owned by the authenticated administrator', async () => { const harness = await buildCaller(unusedCreateOperation); const result = await harness.caller.admin.profiles.requestAction({ profileName: 'che:2', action: 'ACCELERATE', - durationMinutes: 15, + durationMinutes: 20, reason: '운영 일정 조정', }); @@ -1580,7 +1599,7 @@ describe('admin runtime clock action API', () => { ok: true, action: { action: 'ACCELERATE', - durationMinutes: 15, + durationMinutes: 20, status: 'REQUESTED', }, }); @@ -1589,7 +1608,7 @@ describe('admin runtime clock action API', () => { profileName: 'che:2', action: 'ACCELERATE', payload: {}, - durationMinutes: 15, + durationMinutes: 20, reason: '운영 일정 조정', requestedBy: harness.admin.id, }, @@ -1605,7 +1624,7 @@ describe('admin runtime clock action API', () => { harness.caller.admin.profiles.requestAction({ profileName: 'che:2', action: 'DELAY', - durationMinutes: 5, + durationMinutes: 20, }) ).rejects.toMatchObject({ code: 'CONFLICT', diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index a3b3b5f9..d7d96228 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -193,6 +193,10 @@ const createHarness = ( scheduleIntervalMs: 60_000, buildIntervalMs: 60_000, adminActionIntervalMs: 60_000, + transitionProfileClock: async (_profileName, action) => { + lifecycle.push(`clock:${action}`); + return { phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', revision: 2 }; + }, now: options.now, cancelGame: options.cancelGame, promoteProfileOpening: options.promoteProfileOpening @@ -218,6 +222,13 @@ const createHarness = ( }; describe('GatewayOrchestrator first-class operations', () => { + it.each(['START', 'STOP'] as const)('routes %s through durable clock transition', async (action) => { + const harness = createHarness(buildOperation(action)); + await harness.orchestrator.runOperationsNow(); + expect(harness.lifecycle[0]).toBe(`clock:${action === 'START' ? 'RESUME' : 'SUSPEND'}`); + expect(harness.completions).toEqual(['SUCCEEDED']); + }); + it('stops runtime, settles once, and seals a cancelled profile', async () => { const operation: GatewayOperationRecord = { id: '88888888-8888-4888-8888-888888888888', diff --git a/app/gateway-api/test/profileSeedCli.integration.test.ts b/app/gateway-api/test/profileSeedCli.integration.test.ts index 7d3220e3..a725eca6 100644 --- a/app/gateway-api/test/profileSeedCli.integration.test.ts +++ b/app/gateway-api/test/profileSeedCli.integration.test.ts @@ -48,14 +48,14 @@ describeDatabase('selected workspace profile seed CLI', () => { JSON.stringify({ scenarioId: 1010, tickSeconds: 60, - now: '2036-03-03T00:00:00.000Z', + now: '2036-03-03T02:10:30.000Z', installOptions: { serverId: 'selected-cli-seed', firstGameIdx: 0, installOperationId: 'selected-cli-operation', installCommitSha: 'selected-cli-commit', preopenAt: '2036-03-03T01:00:00.000Z', - openAt: '2036-03-03T02:00:00.000Z', + openAt: '2036-03-03T02:10:30.000Z', }, adminUser: { id: 'selected-cli-admin', @@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => { const world = await connector.prisma.worldState.findFirstOrThrow(); expect(world).toMatchObject({ scenarioCode: '1010', - clockWallAnchor: new Date('2036-03-03T02:00:00.000Z'), + clockWallAnchor: new Date('2036-03-03T02:11:00.000Z'), + clockPhase: 'PREOPEN', meta: { firstGameIdx: 0, gameIdx: completedGameCount, @@ -83,6 +84,8 @@ describeDatabase('selected workspace profile seed CLI', () => { where: { userId: 'selected-cli-admin' }, }); expect(adminGeneral).toMatchObject({ meta: { createdBy: 'admin-seed' } }); + expect(adminGeneral.turnTick).toBeGreaterThanOrEqual(0n); + expect(adminGeneral.turnTick).toBeLessThan(36_000_000n); const history = await connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: 'selected-cli-seed' }, }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 50a0cb49..85b0ec65 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260825000000_add_bulk_release_batches', - gameSchemaHead: '20260903201500_complete_invader_game_clock', + gameSchemaHead: '20260906090000_add_turn_recovery_window', }); }); diff --git a/docs/architecture/game-clock-participants.json b/docs/architecture/game-clock-participants.json index 3483fabc..06d88987 100644 --- a/docs/architecture/game-clock-participants.json +++ b/docs/architecture/game-clock-participants.json @@ -42,7 +42,9 @@ "clock_suspension.gap_ticks", "clock_suspension.shift_ticks", "clock_suspension.aligned_tick", - "clock_projection_outbox.target_revision" + "clock_projection_outbox.target_revision", + "world_state.clock_recovery_start_tick", + "world_state.clock_recovery_end_tick" ], "wallTimeFields": [ "input_event.created_at", @@ -85,8 +87,17 @@ { "key": "world-clock", "policy": "REBUILD", - "authorityFields": ["world_state.clock_tick", "world_state.clock_revision"], - "projectionFields": ["world_state.clock_base_time", "world_state.clock_wall_anchor"], + "authorityFields": [ + "world_state.clock_tick", + "world_state.clock_revision", + "world_state.clock_recovery_start_tick", + "world_state.clock_recovery_end_tick" + ], + "projectionFields": [ + "world_state.clock_base_time", + "world_state.clock_wall_anchor", + "world_state.clock_recovery_start_wall_at" + ], "owner": "game-engine/clock-operation" }, { diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md index 49dcfc94..8520f18f 100644 --- a/docs/architecture/game-clock-reconciliation.md +++ b/docs/architecture/game-clock-reconciliation.md @@ -5,11 +5,9 @@ Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000` ticks. Wall time is separately authoritative for account, community, audit, lease, retry, notification, and operational rules. It is never projected into a -game deadline. A long suspension advances the observed game coordinate to the -resume wall instant without replaying skipped complete turns, monthly events, -RNG, auctions, or tournaments. Every movable future GAME schedule is shifted by -the same tick delta. Exact alignment includes the sub-turn remainder; Gateway -maintenance preserves schedules and delegates catch-up to the engine as described below. WALL occurrences and +game deadline. A suspension keeps the normal schedule and all within-turn phases. Whole +12-turn blocks of outage are moved without executing gameplay; the remaining +one to eleven turns are executed at twice normal speed. Wall occurrences and deadlines are outside that operation. The clock state is stored in `world_state`: @@ -33,7 +31,7 @@ progression. A suspension begins under the turn-daemon fence and schema-scoped clock lock. It records the cut tick, database wall instant, rate, source revision, and participant checksum in `clock_suspension`. Resume reads the database wall -instant and builds an exact plan: +instant and builds a policy plan. The historical `EXACT` policy uses: ```text gapTicks = max(0, ticksBetween(cutWall, resumeWall, rateAtCut)) @@ -42,29 +40,57 @@ alignedTick = cutTick + gapTicks deadlineAfter = deadlineBefore + shiftTicks ``` -From 2026-09-06, Gateway maintenance suspension uses `PRESERVE_SCHEDULE`. -Resume advances only the observed tick/anchor and revision; it does not move -execution cursors, general schedules, auction/message deadlines, or their -minute/second phases. The ordinary engine then processes overdue generals in -time order, bounded by one monthly boundary per pass, followed by that monthly -transition. Completed turns are not recreated. +From 2026-09-06, maintenance and crash recovery use `RECOVER_TURNS`. +This supersedes the earlier same-day `PRESERVE_SCHEDULE` immediate catch-up +policy. The base turn length does not change. One turn remains 36,000,000 +ticks; a persisted `TurnRecoveryWindow` changes only the wall execution rate. -The Core product policy for long realtime downtime is now independent of turn -length: fewer than 12 overdue turns are executed normally. At 12 or more turns, -only complete blocks of 12 are skipped. For example, a 13-turn backlog shifts -schedules by 12 turns and executes the remaining turn; 23 skips 12 and executes -11; 24 skips 24. Skipping never advances gameplay years, resources, RNG, or -commands. The existing fenced backlog flush shifts the cursor and schedules -together, retaining all sub-turn phases. Explicit operator schedule movement -remains a separate action. +- Count complete overdue turns from the durable observation to the normal + timeline. Skip only `floor(overdueTurns / 12) * 12` turns, moving future + schedules and the execution cursor by the same integer delta. +- Execute the sub-turn remainder immediately through the ordinary engine. + Reach the next normal turn boundary at normal speed, then execute the + remaining one to eleven turns of backlog at 2x speed. +- Join the original schedule at the recorded end boundary and return to 1x. + Four hours of backlog on a 60-minute server needs four hours at 2x; it runs + eight turns in that time. Resources, RNG, commands and monthly handlers run + normally for those turns, in the existing chronological order. +- Purchased within-turn offsets remain logical offsets. Their wall offsets + compress during recovery and return to the original minutes/seconds after + the end boundary. The API and browser expose the recovery interval. -This supersedes the short-lived maintenance `LEGACY_COMPLETE_TURNS` selection, -which skipped every complete suspended turn without the 12-turn policy. Legacy -policy values remain readable for existing ledgers. Unification wait, delayed -opening, and explicit `EXACT` callers keep their distinct exact alignment -contract. Applied historical ledgers are not rewritten by deployment. Both -Gateway (maintenance selection) and game engine (12-turn backlog handling) must -be deployed to activate the new behavior fully. +`clock_recovery_start_tick`, `clock_recovery_end_tick`, and +`clock_recovery_start_wall_at` are an all-or-none durable window. Flush/reload +preserves it; a short restart reuses it. A new long outage replans against the +original normal timeline. Game-date epoch and wall epoch may differ; never +convert the real wall instant using `dateToTick` to compute normal time. + +Before a newly leased daemon permits independent workers to advance time, it +prepares recovery under the clock lock. `turn_daemon_lease.clock_ready` starts +false and becomes true only after durable recovery and projection-worker +setup. A paused profile is durably suspended during upgrade and stays suspended +until explicitly resumed. API/worker clock reads require a live ready lease at the read revision; +RECONCILING remains fenced until the Redis outbox is applied. + +Planned realtime opening rounds upward to a turn boundary. The seed CLI passes +actual wall time separately from the requested game-calendar baseline, and +Gateway publishes the stored opening anchor for both display and scheduling. Unification wait +uses `TURN_BOUNDARY`, cuts at the completed monthly cursor, and resumes at the +next normal boundary without replaying the intentional waiting period. An old +pending unification ledger is upgraded on resume; applied history stays intact. +Explicit operator movement accepts signed whole turns only. A future resume +anchor gates execution until that wall instant. Base-rate changes are rejected +while a recovery window is active. + +Legacy policy values stay readable for historical ledgers. Gateway and game +profiles deploy independently: the Gateway loads the profile workspace's Prisma +connector for profiles without the recovery migration and retains the existing +`PRESERVE_SCHEDULE` maintenance behavior there. It never writes new columns or +an accelerated recovery window through that legacy model. New profiles use the +new connector and `RECOVER_TURNS`, including upgrading a pending old maintenance +ledger on resume. Applied historical operations remain unchanged. DB-preserving +DEPLOY upgrades each profile independently; other profiles need not restart. +The release manifest declares `20260906090000_add_turn_recovery_window`. Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision, row count, and before/after checksum to `clock_reconciliation_participant`. diff --git a/docs/architecture/time-domains.md b/docs/architecture/time-domains.md index 379424ea..ae60cf66 100644 --- a/docs/architecture/time-domains.md +++ b/docs/architecture/time-domains.md @@ -18,6 +18,16 @@ effect; those are two facts, never one fallback clock. `createdAt`/`updatedAt` fields remain wall audit timestamps unless this inventory explicitly calls them game projections. +## Recovery execution rate + +A turn always contains 36,000,000 GAME ticks. The configured normal duration +remains unchanged during outage recovery. A persisted boundary-to-boundary +window projects GAME time at 2x wall speed, then 1x after rejoining the normal +schedule. The window and its wall anchor belong to clock authority; they are +not gameplay deadlines or wall audit occurrences. See +[reconciliation](./game-clock-reconciliation.md) for the 12-turn skip rule, +planned-start boundaries, readiness fencing and deployment compatibility. + ## Game database inventory PREOPEN user commands are executable even though scheduled turns are stopped. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 700899b6..f131188e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,6 +1,7 @@ export * from './rng.js'; export * from './time/Clock.js'; export * from './time/GameClock.js'; +export * from './time/TurnRecovery.js'; export * from './time/ServerDateTime.js'; export * from './util/BytesLike.js'; export * from './util/convertBytesLikeToArrayBuffer.js'; diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts index 8729ec8a..e828fb6c 100644 --- a/packages/common/src/time/GameClock.ts +++ b/packages/common/src/time/GameClock.ts @@ -1,11 +1,21 @@ -export const GAME_TICKS_PER_TURN = 36_000_000; +import { + observeTurnRecovery, + nextTurnBoundary, + planTurnRecovery, + projectRecoveryDeadline, + validateTurnRecovery, + type TurnRecoveryWindow, +} from './TurnRecovery.js'; +import { GAME_TICKS_PER_TURN, asGameTick, type GameTick } from './gameTimeUnits.js'; +export { GAME_TICKS_PER_TURN, asGameTick, type GameTick } from './gameTimeUnits.js'; + export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER; export type GameClockMode = 'realtime' | 'manual'; export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED'; -export type ClockAlignmentPolicy = 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP' | 'PRESERVE_SCHEDULE'; +export type ClockAlignmentPolicy = + 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP' | 'PRESERVE_SCHEDULE' | 'RECOVER_TURNS' | 'TURN_BOUNDARY'; -declare const gameTickBrand: unique symbol; declare const observedGameInstantBrand: unique symbol; declare const scheduleInstantBrand: unique symbol; declare const clockRevisionBrand: unique symbol; @@ -13,7 +23,6 @@ declare const deadlineGenerationBrand: unique symbol; declare const wallInstantBrand: unique symbol; declare const monotonicDurationBrand: unique symbol; -export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' }; export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' }; export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' }; export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' }; @@ -31,6 +40,8 @@ export interface ClockAlignmentPlan { catchUpTicks: GameTick; shiftTicks: GameTick; alignedTick: GameTick; + recovery?: TurnRecoveryWindow | null; + resumeAnchor?: Date; } export interface GameClockState { @@ -41,6 +52,7 @@ export interface GameClockState { turnSeconds: number; phase?: GameClockPhase; revision?: number; + recovery?: TurnRecoveryWindow | null; } const requireSafeTick = (tick: number): number => { @@ -50,8 +62,6 @@ const requireSafeTick = (tick: number): number => { return tick; }; -export const asGameTick = (tick: number): GameTick => requireSafeTick(tick) as GameTick; - export const asObservedGameInstant = (tick: number): ObservedGameInstant => requireSafeTick(tick) as ObservedGameInstant; @@ -108,6 +118,8 @@ const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = [ 'LEGACY_COMPLETE_TURNS', 'CATCH_UP', 'PRESERVE_SCHEDULE', + 'RECOVER_TURNS', + 'TURN_BOUNDARY', ]; export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => { @@ -192,7 +204,56 @@ export const buildClockAlignmentPlan = (input: { resumeWall: Date; ticksPerSecond: number; catchUpTicks?: number; + normalTick?: number; }): ClockAlignmentPlan => { + if (input.policy === 'TURN_BOUNDARY') { + if (input.cutTick % GAME_TICKS_PER_TURN !== 0 || (input.catchUpTicks ?? 0) !== 0) { + throw new Error('Planned resume requires a suspended turn boundary and no catch-up.'); + } + const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 }); + const normalTick = input.normalTick ?? exact.alignedTick; + const alignedTick = nextTurnBoundary(Math.max(input.cutTick, normalTick)); + return { + ...exact, + alignedTick, + shiftTicks: asGameTick(alignedTick - input.cutTick), + catchUpTicks: asGameTick(0), + resumeAnchor: new Date( + input.resumeWall.getTime() + Math.ceil(((alignedTick - normalTick) * 1_000) / input.ticksPerSecond) + ), + recovery: null, + }; + } + if (input.policy === 'RECOVER_TURNS') { + if ((input.catchUpTicks ?? 0) !== 0) + throw new Error('Turn recovery derives its backlog from the saved observation.'); + const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 }); + const recovery = planTurnRecovery({ + observedTick: input.cutTick, + normalTick: input.normalTick ?? exact.alignedTick, + wallNow: input.resumeWall, + turnSeconds: GAME_TICKS_PER_TURN / input.ticksPerSecond, + }); + const shiftTicks = asGameTick(recovery.skippedTurns * GAME_TICKS_PER_TURN); + return { + ...exact, + shiftTicks, + catchUpTicks: asGameTick(Math.max(0, (input.normalTick ?? exact.alignedTick) - input.cutTick - shiftTicks)), + alignedTick: recovery.initialTick, + recovery: recovery.recovery, + ...(recovery.initialTick > (input.normalTick ?? exact.alignedTick) + ? { + resumeAnchor: new Date( + input.resumeWall.getTime() + + Math.ceil( + ((recovery.initialTick - (input.normalTick ?? exact.alignedTick)) * 1_000) / + input.ticksPerSecond + ) + ), + } + : {}), + }; + } if (input.policy === 'PRESERVE_SCHEDULE') { if ((input.catchUpTicks ?? 0) !== 0) { throw new Error('PRESERVE_SCHEDULE derives catch-up from the complete wall gap.'); @@ -239,6 +300,7 @@ export class GameClock { readonly ticksPerSecond: number; readonly phase: GameClockPhase; readonly revision: ClockRevision; + readonly recovery: TurnRecoveryWindow | null; constructor(state: GameClockState) { if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { @@ -261,6 +323,10 @@ export class GameClock { this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds; this.phase = state.phase ?? inferClockPhase(state.mode); this.revision = asClockRevision(state.revision ?? 1); + this.recovery = state.recovery + ? { ...state.recovery, startWallAt: new Date(state.recovery.startWallAt) } + : null; + if (this.recovery) validateTurnRecovery(this.recovery); } static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date { @@ -291,6 +357,9 @@ export class GameClock { ) { return this.tick; } + if (this.recovery && this.phase === 'RUNNING') { + return Math.max(this.tick, observeTurnRecovery(this.recovery, wallNow, this.ticksPerSecond)); + } const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); // A future realtime anchor represents the formal opening at anchor tick. // Before that instant Ref exposes the elapsed offset as a negative tick, @@ -307,6 +376,30 @@ export class GameClock { return this.tickToDate(this.nowTick(wallNow)); } + /** 가속·대기와 별개로 유저가 익숙한 기존 시간표의 현재 좌표를 구한다. */ + normalNowTick(wallNow: Date): number { + if (this.recovery) { + return this.addTicks( + (this.recovery.startTick + this.recovery.endTick) / 2, + this.ticksBetween(this.recovery.startWallAt, wallNow) + ); + } + return this.addTicks(this.tick, this.ticksBetween(this.wallAnchor, wallNow)); + } + + /** tickToDate는 안정된 게임 좌표이며 이 메서드만 실제 실행 예정 시각을 반환한다. */ + tickToWallDate(tick: number): Date { + return this.recovery + ? projectRecoveryDeadline(this.recovery, tick, this.ticksPerSecond) + : new Date(this.wallAnchor.getTime() + tickOffsetMilliseconds(tick - this.tick, this.ticksPerSecond)); + } + + executionRate(wallNow: Date): 1 | 2 { + if (!this.recovery || this.phase !== 'RUNNING' || this.mode !== 'realtime') return 1; + const end = projectRecoveryDeadline(this.recovery, this.recovery.endTick, this.ticksPerSecond); + return wallNow >= this.recovery.startWallAt && wallNow < end ? 2 : 1; + } + dateToTick(date: Date): number { return requireSafeTick(this.ticksBetween(this.baseTime, date)); } diff --git a/packages/common/src/time/TurnRecovery.ts b/packages/common/src/time/TurnRecovery.ts new file mode 100644 index 00000000..05ac99d0 --- /dev/null +++ b/packages/common/src/time/TurnRecovery.ts @@ -0,0 +1,145 @@ +import { asGameTick, GAME_TICKS_PER_TURN, type GameTick } from './gameTimeUnits.js'; + +/** 정상 시간표는 바꾸지 않고, 정수 턴의 지연만 두 배 속도로 소진한다. */ +export interface TurnRecoveryWindow { + startTick: GameTick; + endTick: GameTick; + startWallAt: Date; +} + +export const readTurnRecovery = (row: { + clockRecoveryStartTick?: bigint | number | null; + clockRecoveryEndTick?: bigint | number | null; + clockRecoveryStartWallAt?: Date | null; +}): TurnRecoveryWindow | null => { + const values = [row.clockRecoveryStartTick, row.clockRecoveryEndTick, row.clockRecoveryStartWallAt]; + if (values.every((value) => value == null)) return null; + if (values.some((value) => value == null)) throw new Error('Incomplete durable turn recovery window.'); + const window = { + startTick: asGameTick(Number(row.clockRecoveryStartTick)), + endTick: asGameTick(Number(row.clockRecoveryEndTick)), + startWallAt: new Date(row.clockRecoveryStartWallAt!), + }; + validateTurnRecovery(window); + return window; +}; + +export const serializeTurnRecovery = (window: TurnRecoveryWindow | null) => ({ + clockRecoveryStartTick: window?.startTick ?? null, + clockRecoveryEndTick: window?.endTick ?? null, + clockRecoveryStartWallAt: window?.startWallAt.toISOString() ?? null, +}); + +export const readSerializedTurnRecovery = (value: unknown): TurnRecoveryWindow | null => { + if (value == null) return null; + if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid serialized recovery window.'); + const row = value as Record; + if (row.clockRecoveryStartTick == null && row.clockRecoveryEndTick == null && row.clockRecoveryStartWallAt == null) + return null; + if ( + typeof row.clockRecoveryStartTick !== 'number' || + typeof row.clockRecoveryEndTick !== 'number' || + typeof row.clockRecoveryStartWallAt !== 'string' + ) { + throw new Error('Incomplete serialized recovery window.'); + } + return readTurnRecovery({ + clockRecoveryStartTick: row.clockRecoveryStartTick, + clockRecoveryEndTick: row.clockRecoveryEndTick, + clockRecoveryStartWallAt: new Date(row.clockRecoveryStartWallAt), + }); +}; + +export interface TurnRecoveryPlan { + skippedTurns: number; + recoveryTurns: number; + initialTick: GameTick; + recovery: TurnRecoveryWindow | null; +} + +export const nextTurnBoundary = (tick: number): GameTick => { + asGameTick(tick); + return asGameTick(Math.ceil(tick / GAME_TICKS_PER_TURN) * GAME_TICKS_PER_TURN); +}; + +/** 운영자 이동은 정수 턴으로만 받는다. 과거 실행의 취소를 뜻하지 않는다. */ +export const turnShiftTicks = (turns: number): GameTick => { + if (!Number.isSafeInteger(turns)) throw new Error('Schedule movement requires an integer number of turns.'); + return asGameTick(turns * GAME_TICKS_PER_TURN); +}; + +/** + * observedTick은 중단 전에 저장한 관측 지점, normalTick은 기존 시간표의 현재 지점이다. + * 잔여 한 턴 미만은 정상 실행하고, 다음 경계부터 정수 턴 지연을 두 배속으로 처리한다. + * 반환한 skip은 호출자가 미래 일정과 실행 cursor에 원자적으로 적용해야 한다. + */ +export const planTurnRecovery = (input: { + observedTick: number; + normalTick: number; + wallNow: Date; + turnSeconds: number; +}): TurnRecoveryPlan => { + const { observedTick, normalTick, wallNow, turnSeconds } = input; + asGameTick(observedTick); + asGameTick(normalTick); + if (!Number.isInteger(turnSeconds) || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) { + throw new Error('Recovery requires a representable positive turn length.'); + } + if (!Number.isFinite(wallNow.getTime())) throw new Error('Recovery wall instant is invalid.'); + const overdueTurns = Math.max(0, Math.floor((normalTick - observedTick) / GAME_TICKS_PER_TURN)); + const skippedTurns = Math.floor(overdueTurns / 12) * 12; + const recoveryTurns = overdueTurns % 12; + const initialTick = asGameTick( + Math.max(observedTick + turnShiftTicks(skippedTurns), normalTick - turnShiftTicks(recoveryTurns)) + ); + if (recoveryTurns === 0) return { skippedTurns, recoveryTurns, initialTick, recovery: null }; + const boundary = nextTurnBoundary(normalTick); + const startWallAt = new Date( + wallNow.getTime() + Math.ceil(((boundary - normalTick) * turnSeconds * 1_000) / GAME_TICKS_PER_TURN) + ); + return { + skippedTurns, + recoveryTurns, + initialTick, + recovery: { + startTick: asGameTick(boundary - turnShiftTicks(recoveryTurns)), + endTick: asGameTick(boundary + turnShiftTicks(recoveryTurns)), + startWallAt, + }, + }; +}; + +export const validateTurnRecovery = (window: TurnRecoveryWindow): void => { + asGameTick(window.startTick); + asGameTick(window.endTick); + const span = window.endTick - window.startTick; + if ( + !Number.isFinite(window.startWallAt.getTime()) || + window.startTick % GAME_TICKS_PER_TURN !== 0 || + window.endTick % GAME_TICKS_PER_TURN !== 0 || + span <= 0 || + span % (2 * GAME_TICKS_PER_TURN) !== 0 || + span >= 24 * GAME_TICKS_PER_TURN + ) + throw new Error('Recovery must join turn boundaries after one to eleven turns at double speed.'); +}; + +/** 경계 전에는 정상 속도, 복구 구간은 두 배, 합류 경계 이후는 정상 속도이다. */ +export const observeTurnRecovery = (window: TurnRecoveryWindow, wallNow: Date, ticksPerSecond: number): GameTick => { + validateTurnRecovery(window); + const elapsed = asGameTick( + Math.trunc(((wallNow.getTime() - window.startWallAt.getTime()) * ticksPerSecond) / 1_000) + ); + const halfSpan = (window.endTick - window.startTick) / 2; + return asGameTick(window.startTick + elapsed + Math.max(0, Math.min(elapsed, halfSpan))); +}; + +/** 게임 좌표의 예정 시각을 사용자에게 표시할 실제 실행 시각으로 투영한다. */ +export const projectRecoveryDeadline = (window: TurnRecoveryWindow, tick: number, ticksPerSecond: number): Date => { + validateTurnRecovery(window); + asGameTick(tick); + const offset = tick - window.startTick; + const span = window.endTick - window.startTick; + const elapsed = offset < 0 ? offset : offset <= span ? offset / 2 : offset - span / 2; + return new Date(window.startWallAt.getTime() + Math.ceil((elapsed * 1_000) / ticksPerSecond)); +}; diff --git a/packages/common/src/time/gameTimeUnits.ts b/packages/common/src/time/gameTimeUnits.ts new file mode 100644 index 00000000..09759147 --- /dev/null +++ b/packages/common/src/time/gameTimeUnits.ts @@ -0,0 +1,9 @@ +export const GAME_TICKS_PER_TURN = 36_000_000; + +declare const gameTickBrand: unique symbol; +export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' }; + +export const asGameTick = (tick: number): GameTick => { + if (!Number.isSafeInteger(tick)) throw new Error(`Game tick must be a safe integer: ${tick}`); + return tick as GameTick; +}; diff --git a/packages/common/test/turnRecovery.test.ts b/packages/common/test/turnRecovery.test.ts new file mode 100644 index 00000000..7bd44a4e --- /dev/null +++ b/packages/common/test/turnRecovery.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { GAME_TICKS_PER_TURN as T, GameClock, buildClockAlignmentPlan } from '../src/time/GameClock.js'; +import { + nextTurnBoundary, + observeTurnRecovery, + planTurnRecovery, + projectRecoveryDeadline, + turnShiftTicks, +} from '../src/time/TurnRecovery.js'; + +const base = Date.parse('2026-09-06T00:00:00Z'); +const wall = (hours: number) => new Date(base + hours * 3_600_000); + +describe('turn-aligned double-speed recovery', () => { + it('reloads midway through acceleration without restarting the recovery duration', () => { + const { recovery } = planTurnRecovery({ + observedTick: 0, + normalTick: 4 * T, + wallNow: wall(4), + turnSeconds: 3600, + }); + const reloaded = new GameClock({ + baseTime: wall(0), + tick: 2 * T, + wallAnchor: wall(5), + mode: 'realtime', + turnSeconds: 3600, + recovery, + }); + expect(reloaded.nowTick(wall(6))).toBe(4 * T); + expect(reloaded.nowTick(wall(8))).toBe(8 * T); + expect(reloaded.nowTick(wall(9))).toBe(9 * T); + expect(reloaded.normalNowTick(wall(6))).toBe(6 * T); + expect(reloaded.executionRate(wall(7))).toBe(2); + expect(reloaded.executionRate(wall(8))).toBe(1); + }); + + it('resumes a planned wait at a whole-turn boundary without changing purchased phase', () => { + const plan = buildClockAlignmentPlan({ + policy: 'TURN_BOUNDARY', + sourceRevision: 1, + cutTick: 0, + cutWall: wall(0), + resumeWall: wall(4 + 1 / 3), + ticksPerSecond: 10_000, + }); + expect(plan.shiftTicks).toBe(5 * T); + expect(plan.alignedTick).toBe(5 * T); + expect(plan.resumeAnchor).toEqual(wall(5)); + expect((199_020 + plan.shiftTicks) % T).toBe(199_020); + }); + + it('preserves a normal schedule whose game epoch differs from the real opening date', () => { + const plan = buildClockAlignmentPlan({ + policy: 'RECOVER_TURNS', + sourceRevision: 1, + cutTick: 0, + cutWall: wall(100), + resumeWall: wall(104), + ticksPerSecond: 10_000, + normalTick: 4 * T, + }); + expect(plan.shiftTicks).toBe(0); + const clock = new GameClock({ + baseTime: wall(0), + tick: plan.alignedTick, + wallAnchor: wall(104), + turnSeconds: 3600, + mode: 'realtime', + recovery: plan.recovery, + }); + expect(clock.nowTick(wall(108))).toBe(8 * T); + expect(clock.tickToWallDate(8 * T + 199_020)).toEqual(new Date(wall(108).getTime() + 19_902)); + }); + it.each([0, 1, 4, 11, 12, 13, 23, 24, 28])('preserves twelve-turn blocks for %i overdue turns', (turns) => { + const plan = planTurnRecovery({ + observedTick: 0, + normalTick: turns * T, + wallNow: wall(turns), + turnSeconds: 3600, + }); + expect(plan.skippedTurns).toBe(Math.floor(turns / 12) * 12); + expect(plan.recoveryTurns).toBe(turns % 12); + expect(plan.initialTick).toBe(plan.skippedTurns * T); + if (!plan.recovery) return; + const end = turns + plan.recoveryTurns; + expect(observeTurnRecovery(plan.recovery, wall(end), 10_000)).toBe(end * T); + expect(observeTurnRecovery(plan.recovery, wall(end + 1), 10_000)).toBe((end + 1) * T); + }); + + it('executes four delayed turns over four hours and meets the original eight-hour boundary', () => { + const { recovery } = planTurnRecovery({ + observedTick: 0, + normalTick: 4 * T, + wallNow: wall(4), + turnSeconds: 3600, + }); + expect(recovery).not.toBeNull(); + expect(observeTurnRecovery(recovery!, wall(4), 10_000)).toBe(0); + expect(observeTurnRecovery(recovery!, wall(5), 10_000)).toBe(2 * T); + expect(observeTurnRecovery(recovery!, wall(8), 10_000)).toBe(8 * T); + expect(observeTurnRecovery(recovery!, wall(9), 10_000)).toBe(9 * T); + }); + + it('retains the fractional phase and begins acceleration at the next boundary', () => { + const plan = planTurnRecovery({ + observedTick: 0, + normalTick: 4 * T + T / 3, + wallNow: wall(4 + 1 / 3), + turnSeconds: 3600, + }); + expect(plan.initialTick).toBe(T / 3); + expect(plan.recovery!.startWallAt).toEqual(wall(5)); + expect(observeTurnRecovery(plan.recovery!, wall(4.5), 10_000)).toBe(T / 2); + expect(observeTurnRecovery(plan.recovery!, wall(5), 10_000)).toBe(T); + expect(observeTurnRecovery(plan.recovery!, wall(9), 10_000)).toBe(9 * T); + }); + + it('preserves purchased phase coordinates while projecting compressed wall deadlines', () => { + const { recovery } = planTurnRecovery({ + observedTick: 0, + normalTick: 4 * T, + wallNow: wall(4), + turnSeconds: 3600, + }); + const phase = 199_020; // 00:19.902 at normal speed + expect(projectRecoveryDeadline(recovery!, phase, 10_000).getTime()).toBe(wall(4).getTime() + 9951); + expect(projectRecoveryDeadline(recovery!, 8 * T + phase, 10_000).getTime()).toBe(wall(8).getTime() + 19902); + }); + + it('does not rewind a persisted observation when wall time moves backwards', () => { + const plan = planTurnRecovery({ observedTick: 5 * T, normalTick: 4 * T, wallNow: wall(4), turnSeconds: 3600 }); + expect(plan.initialTick).toBe(5 * T); + expect(plan.recovery).toBeNull(); + }); + + it('accepts signed whole-turn shifts and rejects fractional movement', () => { + expect(turnShiftTicks(-4)).toBe(-4 * T); + expect(turnShiftTicks(12)).toBe(12 * T); + expect(() => turnShiftTicks(0.5)).toThrow(); + expect(nextTurnBoundary(4 * T + 1)).toBe(5 * T); + expect(nextTurnBoundary(4 * T)).toBe(4 * T); + }); +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 748fe4db..c2146311 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -145,6 +145,7 @@ model TurnDaemonLease { ownerId String @map("owner_id") leaseUntil DateTime @map("lease_until") fencingEpoch BigInt @default(1) @map("fencing_epoch") + clockReady Boolean @default(false) @map("clock_ready") heartbeatAt DateTime @default(now()) @map("heartbeat_at") @@map("turn_daemon_lease") @@ -160,6 +161,9 @@ model WorldState { clockTick BigInt? @map("clock_tick") clockMode String @default("realtime") @map("clock_mode") clockWallAnchor DateTime? @map("clock_wall_anchor") + clockRecoveryStartTick BigInt? @map("clock_recovery_start_tick") + clockRecoveryEndTick BigInt? @map("clock_recovery_end_tick") + clockRecoveryStartWallAt DateTime? @map("clock_recovery_start_wall_at") lastTurnTick BigInt? @map("last_turn_tick") clockPhase String @default("RUNNING") @map("clock_phase") clockRevision BigInt @default(1) @map("clock_revision") diff --git a/packages/infra/prisma/migrations/20260906090000_add_turn_recovery_window/migration.sql b/packages/infra/prisma/migrations/20260906090000_add_turn_recovery_window/migration.sql new file mode 100644 index 00000000..3d2ded66 --- /dev/null +++ b/packages/infra/prisma/migrations/20260906090000_add_turn_recovery_window/migration.sql @@ -0,0 +1,18 @@ +ALTER TABLE "turn_daemon_lease" ADD COLUMN "clock_ready" BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE "world_state" + ADD COLUMN "clock_recovery_start_tick" BIGINT, + ADD COLUMN "clock_recovery_end_tick" BIGINT, + ADD COLUMN "clock_recovery_start_wall_at" TIMESTAMP(3), + ADD CONSTRAINT "world_state_turn_recovery_window_check" CHECK ( + ("clock_recovery_start_tick" IS NULL AND "clock_recovery_end_tick" IS NULL AND "clock_recovery_start_wall_at" IS NULL) + OR ( + "clock_recovery_start_tick" IS NOT NULL AND "clock_recovery_end_tick" IS NOT NULL AND "clock_recovery_start_wall_at" IS NOT NULL + AND "clock_recovery_start_tick" BETWEEN -9007199254740991 AND 9007199254740991 + AND "clock_recovery_end_tick" BETWEEN -9007199254740991 AND 9007199254740991 + AND "clock_recovery_start_tick" % 36000000 = 0 + AND "clock_recovery_end_tick" % 36000000 = 0 + AND "clock_recovery_end_tick" - "clock_recovery_start_tick" BETWEEN 72000000 AND 792000000 + AND ("clock_recovery_end_tick" - "clock_recovery_start_tick") % 72000000 = 0 + ) + ); diff --git a/packages/infra/src/inputEventClock.ts b/packages/infra/src/inputEventClock.ts index c4eb121f..c5f21667 100644 --- a/packages/infra/src/inputEventClock.ts +++ b/packages/infra/src/inputEventClock.ts @@ -1,4 +1,4 @@ -import { GameClock, inferClockPhase, parseGameClockPhase } from '@sammo-ts/common'; +import { GameClock, readTurnRecovery, inferClockPhase, parseGameClockPhase } from '@sammo-ts/common'; import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; import { acquireGameSchemaAdvisoryXactLock, CLOCK_OPERATION_PERSISTENCE_LOCK } from './gameSchemaAdvisoryLock.js'; @@ -9,6 +9,21 @@ interface DbWallRow { wallNow: Date; } +/** 새 daemon의 시계 복구가 끝나기 전에는 독립 worker가 시간을 진행하지 않는다. */ +export const readTurnRuntimeReady = async ( + db: Pick, + revision: bigint +): Promise => { + const [row] = await db.$queryRaw>(GamePrisma.sql` + SELECT EXISTS ( + SELECT 1 FROM turn_daemon_lease, world_state + WHERE clock_ready = TRUE AND lease_until > timezone('UTC', clock_timestamp()) + AND clock_revision = ${revision} + ) AS ready + `); + return row?.ready === true; +}; + export interface InputEventClockCoordinate { wallAt: Date; gameAt: Date; @@ -38,6 +53,9 @@ export const readInputEventClockCoordinate = async ( clockTick: true, clockMode: true, clockWallAnchor: true, + clockRecoveryStartTick: true, + clockRecoveryEndTick: true, + clockRecoveryStartWallAt: true, tickSeconds: true, clockPhase: true, clockRevision: true, @@ -60,11 +78,17 @@ export const readInputEventClockCoordinate = async ( tick, mode, wallAnchor: world.clockWallAnchor, + recovery: readTurnRecovery(world), turnSeconds: world.tickSeconds, phase, revision, }); - const observedTick = clock.nowTick(wall.wallNow); + const ready = + phase !== 'RUNNING' || + mode !== 'realtime' || + world.clockRecoveryStartTick === undefined || + (await readTurnRuntimeReady(db, world.clockRevision)); + const observedTick = ready ? clock.nowTick(wall.wallNow) : clock.tick; return { wallAt: wall.wallNow, gameAt: clock.tickToDate(observedTick), diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 44aad37e..51897875 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -15,6 +15,9 @@ export interface TurnEngineWorldStateRow { clockTick: bigint | null; clockMode: string; clockWallAnchor: Date | null; + clockRecoveryStartTick?: bigint | null; + clockRecoveryEndTick?: bigint | null; + clockRecoveryStartWallAt?: Date | null; lastTurnTick: bigint | null; clockPhase: string; clockRevision: bigint; @@ -183,6 +186,9 @@ export interface TurnEngineWorldStateUpdateInput { clockTick: bigint; clockMode: string; clockWallAnchor: Date; + clockRecoveryStartTick?: bigint | null; + clockRecoveryEndTick?: bigint | null; + clockRecoveryStartWallAt?: Date | null; lastTurnTick: bigint; clockPhase: string; clockRevision: bigint; @@ -200,6 +206,9 @@ export interface TurnEngineWorldStateCreateInput { clockTick: bigint; clockMode: string; clockWallAnchor: Date; + clockRecoveryStartTick?: bigint | null; + clockRecoveryEndTick?: bigint | null; + clockRecoveryStartWallAt?: Date | null; lastTurnTick: bigint; clockPhase: string; clockRevision: bigint; diff --git a/release-manifest.json b/release-manifest.json index 9373355e..ebec96a1 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", - "gameSchemaHead": "20260903201500_complete_invader_game_clock", + "gameSchemaHead": "20260906090000_add_turn_recovery_window", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }