diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 37e8edad..9c5614c6 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -921,13 +921,14 @@ export class InMemoryTurnWorld { if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') { return null; } - const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60)); - const threshold = turnMinutes >= 20 ? 1 : turnMinutes >= 10 ? 3 : 6; const currentTick = clock.nowTick(wallNow); const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow)); const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime); - const skippedTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN); - return skippedTurns > threshold ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null; + // 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도 + // 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다. + const overdueTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN); + const skippedTurns = Math.floor(overdueTurns / 12) * 12; + return skippedTurns > 0 ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null; } shouldRebaseRealtimeBacklog(wallNow: Date): boolean { diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index d1b34c89..fd93411c 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -109,7 +109,7 @@ describeIntegration('durable clock reconciliation', () => { db, suspensionId: 'maintenance-phase', source: 'MAINTENANCE', - policy: 'LEGACY_COMPLETE_TURNS', + policy: 'PRESERVE_SCHEDULE', authority, }); const plan = await reconcileClockSuspension({ @@ -118,9 +118,9 @@ describeIntegration('durable clock reconciliation', () => { authority, testResumeWallAt: new Date(suspended.cutWallAt.getTime() + gapMilliseconds), }); - const expectedShift = Math.floor(gapMilliseconds / 3_600_000) * 36_000_000; + const expectedShift = 0; expect(plan.shiftTicks).toBe(expectedShift); - expect(plan.catchUpTicks).toBe(31_426_250); + expect(plan.catchUpTicks).toBe(gapMilliseconds * 10); expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'phase-test' })).not.toBe( 'IDLE' ); diff --git a/app/game-engine/test/inMemoryGeneralPool.test.ts b/app/game-engine/test/inMemoryGeneralPool.test.ts index e5d302ae..d86b02ff 100644 --- a/app/game-engine/test/inMemoryGeneralPool.test.ts +++ b/app/game-engine/test/inMemoryGeneralPool.test.ts @@ -231,13 +231,13 @@ describe('in-memory scenario general pool availability', () => { lastTurnTick: 0, }); const before = world.captureState(); - const resumedAt = new Date(claimedAt.getTime() + 40 * 60_000); + const resumedAt = new Date(claimedAt.getTime() + 120 * 60_000); expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ - skippedTurns: 4, - shiftedTicks: 4 * GAME_TICKS_PER_TURN, + skippedTurns: 12, + shiftedTicks: 12 * GAME_TICKS_PER_TURN, }); - const rebasedReservedUntilTick = 6 * GAME_TICKS_PER_TURN; + const rebasedReservedUntilTick = 14 * GAME_TICKS_PER_TURN; const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick); expect(world.captureState().generalPoolEntries).toMatchObject([ { diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index 886fe6e3..e4476d88 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -238,33 +238,29 @@ describe('runtime clock shift', () => { await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/); }); - it.each([ - [5, 6], - [10, 3], - [20, 1], - ])('uses the Ref catch-up threshold for a %i-minute turn', (turnMinutes, threshold) => { + it.each([5, 10, 20, 60])('only skips complete 12-turn blocks for a %i-minute turn', (turnMinutes) => { const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); - const world = buildWorld({ - tickSeconds: turnMinutes * 60, - clockBaseTime: wallAnchor, - clockTick: 0, - clockMode: 'realtime', - clockWallAnchor: wallAnchor, - lastTurnTick: 0, - lastTurnTime: wallAnchor, - }); - - expect( - world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + threshold * turnMinutes * 60_000)) - ).toBe(false); - expect( - world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + (threshold + 1) * turnMinutes * 60_000)) - ).toBe(true); + for (const turns of [0, 1, 11, 11.999, 12, 13, 23.999, 24, 25]) { + const world = buildWorld({ + tickSeconds: turnMinutes * 60, + clockBaseTime: wallAnchor, + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: wallAnchor, + lastTurnTick: 0, + lastTurnTime: wallAnchor, + }); + const resumedAt = new Date(wallAnchor.getTime() + turns * turnMinutes * 60_000); + expect(world.shouldRebaseRealtimeBacklog(resumedAt)).toBe(turns >= 12); + const result = world.rebaseRealtimeBacklog(resumedAt); + if (turns < 12) expect(result).toBeNull(); + else expect(result?.skippedTurns).toBe(Math.floor(turns / 12) * 12); + } }); it('skips a long realtime backlog while preserving the turn phase and wall-clock display', () => { const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); - const resumedAt = new Date('2026-07-30T10:35:00.000Z'); + const resumedAt = new Date('2026-07-30T11:05:00.000Z'); const world = buildWorld({ tickSeconds: 300, clockBaseTime: wallAnchor, @@ -285,30 +281,30 @@ describe('runtime clock shift', () => { const result = world.rebaseRealtimeBacklog(resumedAt); expect(result).toMatchObject({ - skippedTurns: 7, - shiftedTicks: 7 * GAME_TICKS_PER_TURN, - lastTurnTime: resumedAt.toISOString(), + skippedTurns: 12, + shiftedTicks: 12 * GAME_TICKS_PER_TURN, + lastTurnTime: '2026-07-30T11:00:00.000Z', }); expect(world.getGameNow(resumedAt)).toEqual(resumedAt); expect(world.getState()).toMatchObject({ - clockTick: 7 * GAME_TICKS_PER_TURN, + clockTick: 13 * GAME_TICKS_PER_TURN, clockWallAnchor: resumedAt, - lastTurnTick: 7 * GAME_TICKS_PER_TURN, + lastTurnTick: 12 * GAME_TICKS_PER_TURN, meta: { - turntime: '2026-07-30 10:35:00.123456', - starttime: '2026-07-01 00:35:00', + turntime: '2026-07-30 11:00:00.123456', + starttime: '2026-07-01 01:00:00', }, }); expect(world.getGeneralById(1)).toMatchObject({ - turnTick: 9 * GAME_TICKS_PER_TURN, - turnTime: new Date('2026-07-30T10:45:00.000Z'), + turnTick: 14 * GAME_TICKS_PER_TURN, + turnTime: new Date('2026-07-30T11:10:00.000Z'), }); expect(world.getCheckpoint()).toMatchObject({ - turnTick: 9 * GAME_TICKS_PER_TURN, - turnTime: '2026-07-30T10:45:00.000Z', + turnTick: 14 * GAME_TICKS_PER_TURN, + turnTime: '2026-07-30T11:10:00.000Z', }); expect(world.peekDirtyState()).toMatchObject({ - realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN, + realtimeBacklogShiftTicks: 12 * GAME_TICKS_PER_TURN, generals: [], }); }); @@ -328,7 +324,7 @@ describe('runtime clock shift', () => { }); expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z'); - expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 22 }); + expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 }); expect(world.getGameNow(resumedAt)).toEqual(resumedAt); }); diff --git a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts index 6bdd5f90..ae4069d4 100644 --- a/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts +++ b/app/game-engine/test/runtimeClockShiftPersistence.integration.test.ts @@ -302,7 +302,7 @@ integration('runtime clock shift persistence', () => { it('atomically rebases a long realtime backlog and open auction deadlines', async () => { const base = new Date('2099-09-01T00:00:00.000Z'); - const resumedAt = new Date('2099-09-01T00:35:00.000Z'); + const resumedAt = new Date('2099-09-01T01:00:00.000Z'); const row = await db.worldState.create({ data: { scenarioCode: 'realtime-backlog-rebase', @@ -408,7 +408,7 @@ integration('runtime clock shift persistence', () => { ); const hooks = await createDatabaseTurnHooks(databaseUrl!, world); try { - expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 }); + expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 }); await hooks.hooks.flushChanges?.({ lastTurnTime: resumedAt.toISOString(), processedGenerals: 0, @@ -422,18 +422,18 @@ integration('runtime clock shift persistence', () => { const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); expect(storedWorld).toMatchObject({ - clockTick: BigInt(7 * GAME_TICKS_PER_TURN), - lastTurnTick: BigInt(7 * GAME_TICKS_PER_TURN), + clockTick: BigInt(12 * GAME_TICKS_PER_TURN), + lastTurnTick: BigInt(12 * GAME_TICKS_PER_TURN), clockWallAnchor: resumedAt, }); const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } }); expect(storedGeneral).toMatchObject({ - turnTick: BigInt(8 * GAME_TICKS_PER_TURN), - turnTime: new Date('2099-09-01T00:40:00.000Z'), + turnTick: BigInt(13 * GAME_TICKS_PER_TURN), + turnTime: new Date('2099-09-01T01:05:00.000Z'), }); expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({ - closeTick: BigInt(9 * GAME_TICKS_PER_TURN), - closeAt: new Date('2099-09-01T00:45:00.000Z'), + closeTick: BigInt(14 * GAME_TICKS_PER_TURN), + closeAt: new Date('2099-09-01T01:10:00.000Z'), }); expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({ closeTick: BigInt(2 * GAME_TICKS_PER_TURN), @@ -442,8 +442,8 @@ integration('runtime clock shift persistence', () => { expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({ ownerUserId: 'rebase-pool-user', generalId: null, - reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN), - reservedUntil: new Date('2099-09-01T00:45:00.000Z'), + reservedUntilTick: BigInt(14 * GAME_TICKS_PER_TURN), + reservedUntil: new Date('2099-09-01T01:10:00.000Z'), }); await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } }); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 5208dd0f..0b84e27f 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1281,8 +1281,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { suspensionId: `gateway-maintenance-${suffix}`, source: 'MAINTENANCE', // 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다. - // 완전한 턴만 건너뛰고 잔여 구간은 저장된 실행 커서부터 이어간다. - policy: 'LEGACY_COMPLETE_TURNS', + // 관측 시계만 재개하고 정상 엔진이 미처리 턴을 따라잡게 한다. + policy: 'PRESERVE_SCHEDULE', authority, }); suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({ diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md index 20d9fb60..49dcfc94 100644 --- a/docs/architecture/game-clock-reconciliation.md +++ b/docs/architecture/game-clock-reconciliation.md @@ -9,7 +9,7 @@ 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 the turn phase as described below. WALL occurrences and +maintenance preserves schedules and delegates catch-up to the engine as described below. WALL occurrences and deadlines are outside that operation. The clock state is stored in `world_state`: @@ -42,20 +42,29 @@ alignedTick = cutTick + gapTicks deadlineAfter = deadlineBefore + shiftTicks ``` -From 2026-09-06, Gateway maintenance suspension explicitly uses -`LEGACY_COMPLETE_TURNS`: it shifts schedules by complete turn intervals and -continues the remaining sub-turn interval from the persisted execution cursor. -This preserves every general's minute/second phase, including the time zone -purchased at creation, and keeps general ordering. The remainder is less than -one turn; completed turns are not recreated. A short interruption can therefore -leave an unprocessed turn immediately due at resume. This is the same whole-turn -alignment used by realtime backlog recovery. +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. -Delayed opening, unification wait, and explicit `EXACT` callers retain exact -alignment with zero catch-up. Existing suspension ledgers retain their recorded -policy when resumed; deployment does not rewrite historical coordinates or -repair previously shifted general times. The maintenance policy is selected by -Gateway, so updating game profile processes alone does not activate it. +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. + +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. Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision, row count, and before/after checksum to `clock_reconciliation_participant`. diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts index fa068be2..8729ec8a 100644 --- a/packages/common/src/time/GameClock.ts +++ b/packages/common/src/time/GameClock.ts @@ -3,7 +3,7 @@ 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'; +export type ClockAlignmentPolicy = 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP' | 'PRESERVE_SCHEDULE'; declare const gameTickBrand: unique symbol; declare const observedGameInstantBrand: unique symbol; @@ -103,7 +103,12 @@ export const parseGameClockPhase = (value: string): GameClockPhase => { throw new Error(`Unknown game clock phase: ${value}`); }; -const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = ['EXACT', 'LEGACY_COMPLETE_TURNS', 'CATCH_UP']; +const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = [ + 'EXACT', + 'LEGACY_COMPLETE_TURNS', + 'CATCH_UP', + 'PRESERVE_SCHEDULE', +]; export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => { if ((CLOCK_ALIGNMENT_POLICIES as readonly string[]).includes(value)) { @@ -188,6 +193,15 @@ export const buildClockAlignmentPlan = (input: { ticksPerSecond: number; catchUpTicks?: number; }): ClockAlignmentPlan => { + if (input.policy === 'PRESERVE_SCHEDULE') { + if ((input.catchUpTicks ?? 0) !== 0) { + throw new Error('PRESERVE_SCHEDULE derives catch-up from the complete wall gap.'); + } + const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 }); + // 운영 재개는 관측 시계만 현재로 돌린다. 예약/실행 커서는 보존하고 + // 정상 엔진이 따라잡으며, 12턴 묶음 생략은 backlog 처리 한 곳에서 결정한다. + return { ...exact, shiftTicks: asGameTick(0), catchUpTicks: exact.gapTicks }; + } if (input.policy === 'EXACT') { if ((input.catchUpTicks ?? 0) !== 0) { throw new Error('EXACT alignment does not allow catch-up ticks.'); diff --git a/packages/common/test/gameClock.test.ts b/packages/common/test/gameClock.test.ts index cd6ae15a..99a050e7 100644 --- a/packages/common/test/gameClock.test.ts +++ b/packages/common/test/gameClock.test.ts @@ -160,6 +160,28 @@ describe('GameClock', () => { ); }); + it.each([0, 3_142_625, 6 * 3_600_000 + 3_142_625, 13 * 3_600_000])( + 'resumes observation after %i ms without moving any schedule', + (gapMs) => { + const cutWall = new Date('2026-09-06T05:48:07.986Z'); + const plan = buildClockAlignmentPlan({ + policy: 'PRESERVE_SCHEDULE', + sourceRevision: 2, + cutTick: 123, + cutWall, + resumeWall: new Date(cutWall.getTime() + gapMs), + ticksPerSecond: 10_000, + }); + expect(plan).toMatchObject({ + shiftTicks: 0, + catchUpTicks: gapMs * 10, + alignedTick: 123 + gapMs * 10, + sourceRevision: 2, + targetRevision: 3, + }); + } + ); + it('preserves schedule ordering, remaining distance, and occurrence ticks across generated exact gaps', () => { let seed = 0x5eed1234; const next = (): number => {