diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index d8f0b4b3..f9313224 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -870,8 +870,22 @@ export class InMemoryTurnWorld { `database ${input.baseTime.toISOString()}.` ); } + // Gateway가 같은 clock lock 아래 DB의 오픈 phase를 먼저 전환할 수 있다. + // 메모리의 가오픈을 따라잡되, 턴 처리나 일정 변경을 오픈으로 오인하지 않는다. + const openingTransition = + current.phase === 'PREOPEN' && + input.phase === 'RUNNING' && + current.mode === 'realtime' && + input.mode === 'realtime' && + current.tick === 0 && + input.tick === 0 && + current.lastTurnTick === 0 && + current.wallAnchor.getTime() === input.wallAnchor.getTime() && + current.recovery === null && + input.recovery === null; const validTransition = current.phase === input.phase || + openingTransition || (current.phase === 'RUNNING' && input.phase === 'SUSPENDED') || (current.phase === 'RECONCILING' && input.phase === 'RUNNING'); if (!validTransition) { diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index a6e45267..f691a893 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -6,6 +6,7 @@ import { readTurnRuntimeReady, createRedisConnector, GENERAL_ACCESS_PERSISTENCE_LOCK, + CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock, type GamePrismaClient, @@ -15,6 +16,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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js'; const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL; @@ -67,6 +70,139 @@ describeIntegration('durable clock reconciliation', () => { await clean(); }); + it('serializes Gateway opening with daemon sync and keeps the opening cursor intact', async () => { + const baseTime = new Date('2026-09-12T00:00:00Z'); + const row = await db.worldState.create({ + data: { + scenarioCode: 'opening-sync', + currentYear: 180, + currentMonth: 1, + tickSeconds: 60, + clockBaseTime: baseTime, + clockWallAnchor: baseTime, + clockTick: 0n, + lastTurnTick: 0n, + clockMode: 'realtime', + clockPhase: 'PREOPEN', + clockRevision: 3n, + deadlineGeneration: 5n, + }, + }); + const world = new InMemoryTurnWorld( + { + id: row.id, + currentYear: 180, + currentMonth: 1, + tickSeconds: 60, + lastTurnTime: baseTime, + clockBaseTime: baseTime, + clockWallAnchor: baseTime, + clockTick: 0, + lastTurnTick: 0, + clockMode: 'realtime', + clockPhase: 'PREOPEN', + clockRevision: 3, + deadlineGeneration: 5, + meta: {}, + }, + { + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'opening', + name: 'opening', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] } } + ); + const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { + profile: 'opening-sync', + heartbeat: false, + }); + expect(await lease.acquire()).not.toBeNull(); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { turnDaemonLease: lease }); + let releaseOpening!: () => void; + let openingLocked!: () => void; + const release = new Promise((resolve) => { + releaseOpening = resolve; + }); + const locked = new Promise((resolve) => { + openingLocked = resolve; + }); + const opening = db.$transaction( + async (tx) => { + await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK); + await tx.worldState.update({ where: { id: row.id }, data: { clockPhase: 'RUNNING' } }); + openingLocked(); + await release; + }, + { timeout: 10_000 } + ); + let sync: Promise | undefined; + try { + await locked; + sync = hooks.synchronizeClockAuthority(); + // 실제 별도 connection의 advisory lock 대기를 관찰한 뒤에만 DB 오픈을 commit한다. + const deadline = Date.now() + 5_000; + let waiting = false; + while (!waiting && Date.now() < deadline) { + const rows = await db.$queryRaw>(GamePrisma.sql` + SELECT EXISTS ( + SELECT 1 FROM pg_locks held JOIN pg_locks pending + ON held.locktype = pending.locktype AND held.database = pending.database + AND held.classid = pending.classid AND held.objid = pending.objid + AND held.objsubid = pending.objsubid + JOIN pg_stat_activity activity ON activity.pid = held.pid + WHERE held.locktype = 'advisory' AND held.granted AND NOT pending.granted + AND activity.datname = current_database() + ) AS waiting + `); + waiting = rows[0]?.waiting ?? false; + if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(waiting).toBe(true); + expect(world.getGameClockState().phase).toBe('PREOPEN'); + releaseOpening(); + await opening; + await expect(sync).resolves.toBe(true); + await expect(hooks.synchronizeClockAuthority()).resolves.toBe(false); + expect(world.getGameClockState()).toMatchObject({ + phase: 'RUNNING', + tick: 0, + lastTurnTick: 0, + revision: 3, + deadlineGeneration: 5, + baseTime, + wallAnchor: baseTime, + }); + expect(await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).toMatchObject({ + clockPhase: 'RUNNING', + clockTick: 0n, + lastTurnTick: 0n, + clockRevision: 3n, + }); + } finally { + releaseOpening(); + await Promise.allSettled([opening, ...(sync ? [sync] : [])]); + await hooks.close(); + await lease.close(); + } + }); + it('accepts partial starts while rejecting incomplete and off-boundary DB windows', async () => { const row = await db.worldState.create({ data: { diff --git a/app/game-engine/test/runtimeClockAuthoritySync.test.ts b/app/game-engine/test/runtimeClockAuthoritySync.test.ts index b7eb9122..2fa77974 100644 --- a/app/game-engine/test/runtimeClockAuthoritySync.test.ts +++ b/app/game-engine/test/runtimeClockAuthoritySync.test.ts @@ -7,7 +7,7 @@ import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn const baseTime = new Date('2026-09-03T10:00:00.000Z'); -const buildWorld = (phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING'): InMemoryTurnWorld => { +const buildWorld = (phase: 'PREOPEN' | 'RUNNING' | 'SUSPENDED' = 'RUNNING'): InMemoryTurnWorld => { const general = { id: 1, name: 'clock-sync-general', @@ -83,6 +83,59 @@ const buildDb = (worldState: Record, ledgers: unknown[] = []): }) as unknown as GamePrisma.TransactionClient; describe('runtime clock authority synchronization', () => { + const openingSnapshot = () => ({ + id: 1, + clockBaseTime: baseTime, + clockTick: 0n, + clockMode: 'realtime', + clockWallAnchor: baseTime, + lastTurnTick: 0n, + clockPhase: 'RUNNING', + clockRevision: 3n, + deadlineGeneration: 5n, + }); + + it('adopts the Gateway opening once without changing schedules or consuming ticks', async () => { + const world = buildWorld('PREOPEN'); + const general = structuredClone(world.getGeneralById(1)); + const db = buildDb(openingSnapshot()); + + await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).resolves.toBe(true); + expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0, revision: 3 }); + expect(world.getGeneralById(1)).toEqual(general); + expect(db.clockSuspension.findMany).not.toHaveBeenCalled(); + await expect(synchronizeRuntimeClockAuthorityUnderHeldLock(db, world)).resolves.toBe(false); + }); + + it.each([ + { clockTick: 1n }, + { clockMode: 'manual' }, + { clockWallAnchor: new Date(baseTime.getTime() + 1) }, + { clockRecoveryStartTick: 0n, clockRecoveryEndTick: 36_000_000n, clockRecoveryStartWallAt: baseTime }, + { lastTurnTick: 1n }, + { clockBaseTime: new Date(baseTime.getTime() + 1) }, + { deadlineGeneration: 6n }, + { clockRevision: 4n }, + { clockPhase: 'SUSPENDED' }, + { clockPhase: 'COMPLETED' }, + ])('rejects an opening with incompatible durable coordinates: %#', async (patch) => { + const world = buildWorld('PREOPEN'); + const before = world.getGameClockState(); + await expect( + synchronizeRuntimeClockAuthorityUnderHeldLock(buildDb({ ...openingSnapshot(), ...patch }), world) + ).rejects.toThrow(); + expect(world.getGameClockState()).toEqual(before); + }); + + it('rejects reversing a completed opening back to PREOPEN', async () => { + await expect( + synchronizeRuntimeClockAuthorityUnderHeldLock( + buildDb({ ...openingSnapshot(), clockPhase: 'PREOPEN' }), + buildWorld('RUNNING') + ) + ).rejects.toThrow(/phase mismatch/); + }); + it('adopts a maintenance suspension cut without advancing the game schedule', async () => { const world = buildWorld('RUNNING'); const cutWallAt = new Date('2026-09-03T10:02:00.000Z');