fix: 재개 시 미처리 턴을 실행하고 장기 지연은 12턴씩 보정
This commit is contained in:
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user