fix: 재개 시 미처리 턴을 실행하고 장기 지연은 12턴씩 보정

This commit is contained in:
2026-09-06 08:06:00 +00:00
parent 6dcc914f94
commit 63da0921cb
9 changed files with 116 additions and 74 deletions
+5 -4
View File
@@ -921,13 +921,14 @@ export class InMemoryTurnWorld {
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') { if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
return null; 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 currentTick = clock.nowTick(wallNow);
const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow)); const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow));
const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime); const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime);
const skippedTurns = Math.floor((wallAlignedTick - lastTurnTick) / GAME_TICKS_PER_TURN); // 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
return skippedTurns > threshold ? { clock, wallAlignedTick, lastTurnTick, skippedTurns } : null; // 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
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 { shouldRebaseRealtimeBacklog(wallNow: Date): boolean {
@@ -109,7 +109,7 @@ describeIntegration('durable clock reconciliation', () => {
db, db,
suspensionId: 'maintenance-phase', suspensionId: 'maintenance-phase',
source: 'MAINTENANCE', source: 'MAINTENANCE',
policy: 'LEGACY_COMPLETE_TURNS', policy: 'PRESERVE_SCHEDULE',
authority, authority,
}); });
const plan = await reconcileClockSuspension({ const plan = await reconcileClockSuspension({
@@ -118,9 +118,9 @@ describeIntegration('durable clock reconciliation', () => {
authority, authority,
testResumeWallAt: new Date(suspended.cutWallAt.getTime() + gapMilliseconds), 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.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( expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'phase-test' })).not.toBe(
'IDLE' 'IDLE'
); );
@@ -231,13 +231,13 @@ describe('in-memory scenario general pool availability', () => {
lastTurnTick: 0, lastTurnTick: 0,
}); });
const before = world.captureState(); 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({ expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
skippedTurns: 4, skippedTurns: 12,
shiftedTicks: 4 * GAME_TICKS_PER_TURN, 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); const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
expect(world.captureState().generalPoolEntries).toMatchObject([ expect(world.captureState().generalPoolEntries).toMatchObject([
{ {
+22 -26
View File
@@ -238,12 +238,9 @@ describe('runtime clock shift', () => {
await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/); await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/);
}); });
it.each([ it.each([5, 10, 20, 60])('only skips complete 12-turn blocks for a %i-minute turn', (turnMinutes) => {
[5, 6],
[10, 3],
[20, 1],
])('uses the Ref catch-up threshold for a %i-minute turn', (turnMinutes, threshold) => {
const wallAnchor = new Date('2026-07-30T10:00:00.000Z'); const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
for (const turns of [0, 1, 11, 11.999, 12, 13, 23.999, 24, 25]) {
const world = buildWorld({ const world = buildWorld({
tickSeconds: turnMinutes * 60, tickSeconds: turnMinutes * 60,
clockBaseTime: wallAnchor, clockBaseTime: wallAnchor,
@@ -253,18 +250,17 @@ describe('runtime clock shift', () => {
lastTurnTick: 0, lastTurnTick: 0,
lastTurnTime: wallAnchor, lastTurnTime: wallAnchor,
}); });
const resumedAt = new Date(wallAnchor.getTime() + turns * turnMinutes * 60_000);
expect( expect(world.shouldRebaseRealtimeBacklog(resumedAt)).toBe(turns >= 12);
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + threshold * turnMinutes * 60_000)) const result = world.rebaseRealtimeBacklog(resumedAt);
).toBe(false); if (turns < 12) expect(result).toBeNull();
expect( else expect(result?.skippedTurns).toBe(Math.floor(turns / 12) * 12);
world.shouldRebaseRealtimeBacklog(new Date(wallAnchor.getTime() + (threshold + 1) * turnMinutes * 60_000)) }
).toBe(true);
}); });
it('skips a long realtime backlog while preserving the turn phase and wall-clock display', () => { 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 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({ const world = buildWorld({
tickSeconds: 300, tickSeconds: 300,
clockBaseTime: wallAnchor, clockBaseTime: wallAnchor,
@@ -285,30 +281,30 @@ describe('runtime clock shift', () => {
const result = world.rebaseRealtimeBacklog(resumedAt); const result = world.rebaseRealtimeBacklog(resumedAt);
expect(result).toMatchObject({ expect(result).toMatchObject({
skippedTurns: 7, skippedTurns: 12,
shiftedTicks: 7 * GAME_TICKS_PER_TURN, shiftedTicks: 12 * GAME_TICKS_PER_TURN,
lastTurnTime: resumedAt.toISOString(), lastTurnTime: '2026-07-30T11:00:00.000Z',
}); });
expect(world.getGameNow(resumedAt)).toEqual(resumedAt); expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
expect(world.getState()).toMatchObject({ expect(world.getState()).toMatchObject({
clockTick: 7 * GAME_TICKS_PER_TURN, clockTick: 13 * GAME_TICKS_PER_TURN,
clockWallAnchor: resumedAt, clockWallAnchor: resumedAt,
lastTurnTick: 7 * GAME_TICKS_PER_TURN, lastTurnTick: 12 * GAME_TICKS_PER_TURN,
meta: { meta: {
turntime: '2026-07-30 10:35:00.123456', turntime: '2026-07-30 11:00:00.123456',
starttime: '2026-07-01 00:35:00', starttime: '2026-07-01 01:00:00',
}, },
}); });
expect(world.getGeneralById(1)).toMatchObject({ expect(world.getGeneralById(1)).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN, turnTick: 14 * GAME_TICKS_PER_TURN,
turnTime: new Date('2026-07-30T10:45:00.000Z'), turnTime: new Date('2026-07-30T11:10:00.000Z'),
}); });
expect(world.getCheckpoint()).toMatchObject({ expect(world.getCheckpoint()).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN, turnTick: 14 * GAME_TICKS_PER_TURN,
turnTime: '2026-07-30T10:45:00.000Z', turnTime: '2026-07-30T11:10:00.000Z',
}); });
expect(world.peekDirtyState()).toMatchObject({ expect(world.peekDirtyState()).toMatchObject({
realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN, realtimeBacklogShiftTicks: 12 * GAME_TICKS_PER_TURN,
generals: [], generals: [],
}); });
}); });
@@ -328,7 +324,7 @@ describe('runtime clock shift', () => {
}); });
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z'); 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); expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
}); });
@@ -302,7 +302,7 @@ integration('runtime clock shift persistence', () => {
it('atomically rebases a long realtime backlog and open auction deadlines', async () => { it('atomically rebases a long realtime backlog and open auction deadlines', async () => {
const base = new Date('2099-09-01T00:00:00.000Z'); 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({ const row = await db.worldState.create({
data: { data: {
scenarioCode: 'realtime-backlog-rebase', scenarioCode: 'realtime-backlog-rebase',
@@ -408,7 +408,7 @@ integration('runtime clock shift persistence', () => {
); );
const hooks = await createDatabaseTurnHooks(databaseUrl!, world); const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try { try {
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 }); expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
await hooks.hooks.flushChanges?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: resumedAt.toISOString(), lastTurnTime: resumedAt.toISOString(),
processedGenerals: 0, processedGenerals: 0,
@@ -422,18 +422,18 @@ integration('runtime clock shift persistence', () => {
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld).toMatchObject({ expect(storedWorld).toMatchObject({
clockTick: BigInt(7 * GAME_TICKS_PER_TURN), clockTick: BigInt(12 * GAME_TICKS_PER_TURN),
lastTurnTick: BigInt(7 * GAME_TICKS_PER_TURN), lastTurnTick: BigInt(12 * GAME_TICKS_PER_TURN),
clockWallAnchor: resumedAt, clockWallAnchor: resumedAt,
}); });
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } }); const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: general.id } });
expect(storedGeneral).toMatchObject({ expect(storedGeneral).toMatchObject({
turnTick: BigInt(8 * GAME_TICKS_PER_TURN), turnTick: BigInt(13 * GAME_TICKS_PER_TURN),
turnTime: new Date('2099-09-01T00:40:00.000Z'), turnTime: new Date('2099-09-01T01:05:00.000Z'),
}); });
expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({ expect(await db.auction.findUniqueOrThrow({ where: { id: openAuction.id } })).toMatchObject({
closeTick: BigInt(9 * GAME_TICKS_PER_TURN), closeTick: BigInt(14 * GAME_TICKS_PER_TURN),
closeAt: new Date('2099-09-01T00:45:00.000Z'), closeAt: new Date('2099-09-01T01:10:00.000Z'),
}); });
expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({ expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({
closeTick: BigInt(2 * GAME_TICKS_PER_TURN), 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({ expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({
ownerUserId: 'rebase-pool-user', ownerUserId: 'rebase-pool-user',
generalId: null, generalId: null,
reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN), reservedUntilTick: BigInt(14 * GAME_TICKS_PER_TURN),
reservedUntil: new Date('2099-09-01T00:45:00.000Z'), reservedUntil: new Date('2099-09-01T01:10:00.000Z'),
}); });
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } }); await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
@@ -1281,8 +1281,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
suspensionId: `gateway-maintenance-${suffix}`, suspensionId: `gateway-maintenance-${suffix}`,
source: 'MAINTENANCE', source: 'MAINTENANCE',
// 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다. // 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다.
// 완전한 턴만 건너뛰고 잔여 구간은 저장된 실행 커서부터 이어간다. // 관측 시계만 재개하고 정상 엔진이 미처리 턴을 따라잡게 한다.
policy: 'LEGACY_COMPLETE_TURNS', policy: 'PRESERVE_SCHEDULE',
authority, authority,
}); });
suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({ suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({
+23 -14
View File
@@ -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, resume wall instant without replaying skipped complete turns, monthly events,
RNG, auctions, or tournaments. Every movable future GAME schedule is shifted by RNG, auctions, or tournaments. Every movable future GAME schedule is shifted by
the same tick delta. Exact alignment includes the sub-turn remainder; Gateway 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. deadlines are outside that operation.
The clock state is stored in `world_state`: The clock state is stored in `world_state`:
@@ -42,20 +42,29 @@ alignedTick = cutTick + gapTicks
deadlineAfter = deadlineBefore + shiftTicks deadlineAfter = deadlineBefore + shiftTicks
``` ```
From 2026-09-06, Gateway maintenance suspension explicitly uses From 2026-09-06, Gateway maintenance suspension uses `PRESERVE_SCHEDULE`.
`LEGACY_COMPLETE_TURNS`: it shifts schedules by complete turn intervals and Resume advances only the observed tick/anchor and revision; it does not move
continues the remaining sub-turn interval from the persisted execution cursor. execution cursors, general schedules, auction/message deadlines, or their
This preserves every general's minute/second phase, including the time zone minute/second phases. The ordinary engine then processes overdue generals in
purchased at creation, and keeps general ordering. The remainder is less than time order, bounded by one monthly boundary per pass, followed by that monthly
one turn; completed turns are not recreated. A short interruption can therefore transition. Completed turns are not recreated.
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 The Core product policy for long realtime downtime is now independent of turn
alignment with zero catch-up. Existing suspension ledgers retain their recorded length: fewer than 12 overdue turns are executed normally. At 12 or more turns,
policy when resumed; deployment does not rewrite historical coordinates or only complete blocks of 12 are skipped. For example, a 13-turn backlog shifts
repair previously shifted general times. The maintenance policy is selected by schedules by 12 turns and executes the remaining turn; 23 skips 12 and executes
Gateway, so updating game profile processes alone does not activate it. 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, Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision,
row count, and before/after checksum to `clock_reconciliation_participant`. row count, and before/after checksum to `clock_reconciliation_participant`.
+16 -2
View File
@@ -3,7 +3,7 @@ export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER;
export type GameClockMode = 'realtime' | 'manual'; export type GameClockMode = 'realtime' | 'manual';
export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED'; 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 gameTickBrand: unique symbol;
declare const observedGameInstantBrand: 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}`); 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 => { export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => {
if ((CLOCK_ALIGNMENT_POLICIES as readonly string[]).includes(value)) { if ((CLOCK_ALIGNMENT_POLICIES as readonly string[]).includes(value)) {
@@ -188,6 +193,15 @@ export const buildClockAlignmentPlan = (input: {
ticksPerSecond: number; ticksPerSecond: number;
catchUpTicks?: number; catchUpTicks?: number;
}): ClockAlignmentPlan => { }): 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.policy === 'EXACT') {
if ((input.catchUpTicks ?? 0) !== 0) { if ((input.catchUpTicks ?? 0) !== 0) {
throw new Error('EXACT alignment does not allow catch-up ticks.'); throw new Error('EXACT alignment does not allow catch-up ticks.');
+22
View File
@@ -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', () => { it('preserves schedule ordering, remaining distance, and occurrence ticks across generated exact gaps', () => {
let seed = 0x5eed1234; let seed = 0x5eed1234;
const next = (): number => { const next = (): number => {