fix: 운영 중단 복구 시 장수의 지정 턴 시각을 보존

This commit is contained in:
2026-09-06 07:37:51 +00:00
parent d3b2bcd155
commit 6dcc914f94
3 changed files with 110 additions and 6 deletions
@@ -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);
@@ -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({
+18 -6
View File
@@ -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`.