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') {
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 {
@@ -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'
);
@@ -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([
{
+31 -35
View File
@@ -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);
});
@@ -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] } } });
@@ -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({
+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,
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`.
+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 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.');
+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', () => {
let seed = 0x5eed1234;
const next = (): number => {