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
@@ -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] } } });