diff --git a/app/game-engine/test/clockReconciliation.integration.test.ts b/app/game-engine/test/clockReconciliation.integration.test.ts index 54125f60..d1b34c89 100644 --- a/app/game-engine/test/clockReconciliation.integration.test.ts +++ b/app/game-engine/test/clockReconciliation.integration.test.ts @@ -62,6 +62,95 @@ describeIntegration('durable clock reconciliation', () => { await clean(); }); + 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) => { + const baseTime = new Date('2026-09-06T00:00:00.000Z'); + const initialTick = 5 * 36_000_000 + 28_879_860; + const clock = new GameClock({ + baseTime, + tick: initialTick, + mode: 'realtime', + wallAnchor: new Date(Date.now() + 3_600_000), + turnSeconds: 3_600, + phase: 'RUNNING', + }); + const lastTurnTick = 5 * 36_000_000; + // 냥냥과 같은 00:19.902 구매 시각. 5시 턴은 이미 처리되어 다음은 6시다. + const nextTicks = [6 * 36_000_000 + 199_020, 6 * 36_000_000 + 420_010]; + await db.worldState.create({ + data: { + scenarioCode: 'maintenance-phase', + currentYear: 199, + currentMonth: 2, + tickSeconds: 3_600, + clockBaseTime: baseTime, + clockTick: BigInt(initialTick), + clockMode: 'realtime', + clockWallAnchor: clock.wallAnchor, + lastTurnTick: BigInt(lastTurnTick), + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, + }, + }); + await db.general.createMany({ + data: nextTicks.map((tick, index) => ({ + id: index + 1, + name: `purchased-${index}`, + turnTick: BigInt(tick), + turnTime: clock.tickToDate(tick), + lastTurn: { command: '휴식' }, + meta: { killturn: 24 }, + })), + }); + const authority = { kind: 'OFFLINE' as const, profileName: 'maintenance-phase', reason: 'fixture' }; + const suspended = await startClockSuspension({ + db, + suspensionId: 'maintenance-phase', + source: 'MAINTENANCE', + policy: 'LEGACY_COMPLETE_TURNS', + authority, + }); + const plan = await reconcileClockSuspension({ + db, + suspensionId: suspended.suspensionId, + authority, + testResumeWallAt: new Date(suspended.cutWallAt.getTime() + gapMilliseconds), + }); + const expectedShift = Math.floor(gapMilliseconds / 3_600_000) * 36_000_000; + expect(plan.shiftTicks).toBe(expectedShift); + expect(plan.catchUpTicks).toBe(31_426_250); + expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'phase-test' })).not.toBe( + 'IDLE' + ); + const reload = createGamePostgresConnector({ url: databaseUrl! }); + try { + const world = await reload.prisma.worldState.findFirstOrThrow(); + const generals = await reload.prisma.general.findMany({ orderBy: { id: 'asc' } }); + expect(world).toMatchObject({ + clockPhase: 'RUNNING', + currentYear: 199, + currentMonth: 2, + lastTurnTick: BigInt(lastTurnTick + expectedShift), + }); + expect(generals.map((general) => Number(general.turnTick))).toEqual( + nextTicks.map((tick) => tick + expectedShift) + ); + expect(generals.map((general) => general.turnTime.toISOString().slice(14))).toEqual([ + '00:19.902Z', + '00:42.001Z', + ]); + expect(generals.map((general) => general.meta)).toEqual([{ killturn: 24 }, { killturn: 24 }]); + // 미처리된 다음 턴만 남고, 처리한 5시 턴을 다시 만들지 않는다. + expect(Number(generals[0]!.turnTick)).toBeGreaterThan(Number(world.lastTurnTick)); + expect((await reload.prisma.clockSuspension.findFirstOrThrow()).status).toBe('APPLIED'); + } finally { + await reload.disconnect(); + } + } + ); + it('preserves every remaining deadline and occurrence across a 65m17.250s exact gap', async () => { const baseTime = new Date('2026-01-01T00:00:00.000Z'); const futureAnchor = new Date(Date.now() + 3_600_000); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 262a8de6..5208dd0f 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1280,6 +1280,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { db: postgres.prisma, suspensionId: `gateway-maintenance-${suffix}`, source: 'MAINTENANCE', + // 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다. + // 완전한 턴만 건너뛰고 잔여 구간은 저장된 실행 커서부터 이어간다. + policy: 'LEGACY_COMPLETE_TURNS', authority, }); suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({ diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md index e841da76..20d9fb60 100644 --- a/docs/architecture/game-clock-reconciliation.md +++ b/docs/architecture/game-clock-reconciliation.md @@ -6,9 +6,10 @@ 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 turns, monthly events, RNG, -auctions, or tournaments. Every movable future GAME schedule is shifted by the -same exact tick delta, including the sub-turn remainder. WALL occurrences and +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 deadlines are outside that operation. The clock state is stored in `world_state`: @@ -41,9 +42,20 @@ alignedTick = cutTick + gapTicks deadlineAfter = deadlineBefore + shiftTicks ``` -Planned maintenance, delayed opening, and unification wait use zero catch-up. -The compatibility-only complete-turn behavior is named -`LEGACY_COMPLETE_TURNS`; it is not the exact policy. +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. + +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. Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision, row count, and before/after checksum to `clock_reconciliation_participant`.