중단 후 대기와 2배속 복구 경계 및 전체 공지 적용

This commit is contained in:
2026-09-07 14:37:55 +00:00
parent d57fd3e553
commit c39527bdd7
12 changed files with 541 additions and 69 deletions
+41
View File
@@ -53,6 +53,47 @@ describe('current game time projection', () => {
}); });
}); });
it('exposes waiting, 2x and normal speed boundaries from a reloaded recovery', async () => {
const db = {
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('2026-09-07T00:00:00Z'),
clockTick: 6000000n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-07T00:35:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 6000000n,
clockRecoveryEndTick: 36000000n,
clockRecoveryStartWallAt: new Date('2026-09-07T00:35:00Z'),
})),
},
$queryRaw: vi.fn(async () => [{ ready: true }]),
} as unknown as DatabaseClient;
for (const now of ['00:24:00', '00:34:59.999']) {
expect(await loadCurrentGameTime(db, new Date(`2026-09-07T${now}Z`))).toMatchObject({
tick: 6000000,
running: false,
startsAt: new Date('2026-09-07T00:35:00Z'),
recovery: { startsAt: '2026-09-07T00:35:00.000Z', endsAt: '2026-09-07T01:00:00.000Z' },
});
}
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:35:00.001Z'))).toMatchObject({
tick: 6000020,
running: true,
startsAt: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:59:59.999Z'))).toMatchObject({ tick: 35999980 });
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00Z'))).toMatchObject({
tick: 36000000,
running: true,
recovery: null,
});
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00.001Z'))).toMatchObject({ tick: 36000010 });
});
it('holds an invader restart until its future turn boundary', async () => { it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING'); const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z')); const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
@@ -1,9 +1,18 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { GameClock, parseGameClockPhase } from '@sammo-ts/common'; import {
ChangeJournal,
GameClock,
formatServerDateTime,
parseGameClockPhase,
readTurnRecovery,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_PUBLIC, resolveMessageTargetIcon, sendMessage } from '@sammo-ts/logic';
import { import {
CLOCK_OPERATION_PERSISTENCE_LOCK, CLOCK_OPERATION_PERSISTENCE_LOCK,
GamePrisma, GamePrisma,
persistMessageEnvelope,
writeReadModelChangeJournal,
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
type GamePrismaClient, type GamePrismaClient,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
@@ -302,6 +311,47 @@ export const applyNextClockProjection = async (options: {
throw new Error('Clock projection final RUNNING transition fence failed.'); throw new Error('Clock projection final RUNNING transition fence failed.');
} }
const appliedAt = await readDbWall(transaction); const appliedAt = await readDbWall(transaction);
// RUNNING 전이와 같은 transaction에 남겨 재시도 시 전체 공지를 중복 발송하지 않는다.
const recovery = readTurnRecovery(world);
const journal = new ChangeJournal();
journal.mark('world.content').mark('map.world');
if (recovery) {
const recoveredClock = new GameClock({
baseTime: world.clockBaseTime!,
tick: Number(world.clockTick),
wallAnchor: world.clockWallAnchor!,
mode: 'realtime',
turnSeconds: world.tickSeconds,
recovery,
});
const endsAt = recoveredClock.tickToWallDate(recovery.endTick);
const system = {
generalId: 0,
generalName: '시스템',
nationId: 0,
nationName: '',
color: '#000000',
icon: resolveMessageTargetIcon(),
};
await sendMessage(
{ insertMessage: (draft) => persistMessageEnvelope(transaction, draft) },
{
msgType: 'public',
src: system,
dest: system,
text: `서버 재개에 따른 2배속 복구 시간: ${formatServerDateTime(recovery.startWallAt)} ~ ${formatServerDateTime(endsAt)} (한국 시각). 시작 전까지 대기하며, 종료 시 정상 속도로 진행합니다.`,
time: appliedAt,
validUntil: new Date('9999-12-31T00:00:00Z'),
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: endsAt.toISOString(),
},
}
);
journal.mark('messages.mailbox', MESSAGE_MAILBOX_PUBLIC);
}
await writeReadModelChangeJournal(transaction, journal.snapshot());
await transaction.clockProjectionOutbox.update({ await transaction.clockProjectionOutbox.update({
where: { id: outbox.id }, where: { id: outbox.id },
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null }, data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { immediateRecoveryLimitSeconds } from '@sammo-ts/common';
import type { GamePrismaClient } from '@sammo-ts/infra'; import type { GamePrismaClient } from '@sammo-ts/infra';
import { import {
readClockDatabaseWall, readClockDatabaseWall,
@@ -27,8 +28,12 @@ export const prepareRealtimeRecovery = async (
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return; if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
const now = await readClockDatabaseWall(db); const now = await readClockDatabaseWall(db);
// 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다. // 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다.
// 한 턴 미만의 장애는 잔여 구간 실행만 필요하므로 새 좌표 세대를 만들지 않는다. // 짧은 중단만 즉시 처리한다. 기준값과 같으면 대기 후 복구한다.
if (!options.paused && now.getTime() - world.clockWallAnchor.getTime() < world.tickSeconds * 1_000) return; if (
!options.paused &&
now.getTime() - world.clockWallAnchor.getTime() < immediateRecoveryLimitSeconds(world.tickSeconds) * 1_000
)
return;
const suspensionId = `recovery-${randomUUID()}`; const suspensionId = `recovery-${randomUUID()}`;
await startClockSuspension({ await startClockSuspension({
db, db,
@@ -29,6 +29,8 @@ describeIntegration('durable clock reconciliation', () => {
const clean = async (): Promise<void> => { const clean = async (): Promise<void> => {
await redis.client.flushDb(); await redis.client.flushDb();
await db.$transaction([ await db.$transaction([
db.readModelOutbox.deleteMany(),
db.readModelRevision.deleteMany(),
db.clockProjectionOutbox.deleteMany(), db.clockProjectionOutbox.deleteMany(),
db.clockReconciliationParticipant.deleteMany(), db.clockReconciliationParticipant.deleteMany(),
db.clockSuspension.deleteMany(), db.clockSuspension.deleteMany(),
@@ -65,6 +67,66 @@ describeIntegration('durable clock reconciliation', () => {
await clean(); await clean();
}); });
it('accepts partial starts while rejecting incomplete and off-boundary DB windows', async () => {
const row = await db.worldState.create({
data: {
scenarioCode: 'constraint',
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockRecoveryStartTick: 1n,
clockRecoveryEndTick: BigInt(T),
clockRecoveryStartWallAt: new Date(),
},
});
for (const data of [
{ clockRecoveryStartWallAt: null },
{ clockRecoveryEndTick: BigInt(T + 1) },
{ clockRecoveryStartTick: BigInt(T) },
{ clockRecoveryStartTick: 0n, clockRecoveryEndTick: BigInt(25 * T) },
]) {
await expect(db.worldState.update({ where: { id: row.id }, data })).rejects.toThrow(
'world_state_turn_recovery_window_check'
);
}
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).clockRecoveryStartTick).toBe(1n);
});
it.each([300, 420])('applies startup recovery after %i seconds on a 60-minute server', async (delay) => {
const profile = 'short-startup';
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: BigInt(T / 6),
clockWallAnchor: new Date(Date.now() - delay * 1000),
clockMode: 'realtime',
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
lastTurnTick: 0n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = (await lease.acquire())!;
await prepareRealtimeRecovery(db, {
kind: 'DAEMON',
profileName: profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
});
const world = await db.worldState.findFirstOrThrow();
expect(world.clockPhase).toBe(delay < 360 ? 'RUNNING' : 'RECONCILING');
expect(readTurnRecovery(world) === null).toBe(delay < 360);
} finally {
await lease.close();
}
});
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => { it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
const profile = 'recovery-startup'; const profile = 'recovery-startup';
await db.worldState.create({ await db.worldState.create({
@@ -115,7 +177,7 @@ describeIntegration('durable clock reconciliation', () => {
expect(pending.clockPhase).toBe('RECONCILING'); expect(pending.clockPhase).toBe('RECONCILING');
const recoveredWindow = readTurnRecovery(pending)!; const recoveredWindow = readTurnRecovery(pending)!;
expect(recoveredWindow).not.toBeNull(); expect(recoveredWindow).not.toBeNull();
expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 12 : 8) * T); expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 13 : 9) * T);
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false); expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
await applyNextClockProjection({ db, redis: redis.client, workerId: profile }); await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
await lease.markClockReady(); await lease.markClockReady();
@@ -140,7 +202,7 @@ describeIntegration('durable clock reconciliation', () => {
it.each([4, 12, 13, 23, 24])( it.each([4, 12, 13, 23, 24])(
'persists recovery for %i turns and reloads the same normal boundary', 'persists recovery for %i turns and reloads the same normal boundary',
async (turns) => { async (turns) => {
const now = new Date(); const now = new Date(Date.now() + 3_600_000);
await db.worldState.create({ await db.worldState.create({
data: { data: {
scenarioCode: 'turn-recovery', scenarioCode: 'turn-recovery',
@@ -214,6 +276,102 @@ describeIntegration('durable clock reconciliation', () => {
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority }); const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
expect(retry.recovery).toEqual(plan.recovery); expect(retry.recovery).toEqual(plan.recovery);
expect(retry.catchUpTicks).toBe(plan.catchUpTicks); expect(retry.catchUpTicks).toBe(plan.catchUpTicks);
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'retry' })).toBe('IDLE');
const announcements = await db.message.findMany({ where: { mailbox: 9999 } });
expect(announcements).toHaveLength(recovery ? 1 : 0);
if (recovery) {
expect(announcements[0]!.message).toMatchObject({
src: { generalName: '시스템' },
option: {
recoveryStartsAt: recovery.startWallAt.toISOString(),
recoveryEndsAt: reloaded.tickToWallDate(recovery.endTick).toISOString(),
},
});
expect(await db.messageAction.count()).toBe(0);
expect(
await db.readModelRevision.findFirst({ where: { domain: 'messages.mailbox', entityId: 9999 } })
).toMatchObject({ revision: 1n });
}
}
);
it.each([359999, 360000, 360001, 840000, 12 * 3600000 + 1000])(
'persists strict recovery boundaries for %i ms',
async (gap) => {
const observed = T / 6;
const future = new Date(Date.now() + 3600000);
await db.worldState.create({
data: {
scenarioCode: 'wait-boundary',
currentYear: 199,
currentMonth: 1,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: BigInt(observed),
clockMode: 'realtime',
clockWallAnchor: future,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const authority = { kind: 'OFFLINE' as const, profileName: 'wait-boundary', reason: 'fixture' };
const suspension = await startClockSuspension({
db,
suspensionId: 'wait-boundary',
source: 'MAINTENANCE',
policy: 'RECOVER_TURNS',
authority,
});
const now = new Date(suspension.cutWallAt.getTime() + gap);
const plan = await reconcileClockSuspension({
db,
suspensionId: suspension.suspensionId,
authority,
testResumeWallAt: now,
});
expect(plan.recovery === null).toBe(gap < 360000);
expect(await db.message.count()).toBe(0);
// Redis 장애 후에도 알림은 DB의 RUNNING 전이와 함께 한 번만 저장한다.
await expect(
applyNextClockProjection({
db,
workerId: 'failure',
redis: {
get: (key) => redis.client.get(key),
eval: async (script, options) => {
await redis.client.eval(script, options);
throw new Error('fixture Redis outage');
},
},
})
).rejects.toThrow('fixture Redis outage');
expect(await db.message.count()).toBe(0);
await db.clockProjectionOutbox.updateMany({ data: { availableAt: new Date(0) } });
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'retry' })).toBe('RECOVERED');
const row = await db.worldState.findFirstOrThrow();
const recovery = readTurnRecovery(row);
expect(await db.message.count()).toBe(recovery ? 1 : 0);
const clock = new GameClock({
baseTime: row.clockBaseTime!,
tick: Number(row.clockTick),
wallAnchor: row.clockWallAnchor!,
turnSeconds: row.tickSeconds,
mode: 'realtime',
recovery,
});
if (recovery) {
expect(row.clockWallAnchor).toEqual(recovery.startWallAt);
expect(clock.nowTick(now)).toBe(observed + plan.shiftTicks);
expect(clock.nowTick(new Date(recovery.startWallAt.getTime() - 1))).toBe(observed + plan.shiftTicks);
const end = clock.tickToWallDate(recovery.endTick);
expect(clock.nowTick(end)).toBe(clock.normalNowTick(end));
} else {
expect(clock.nowTick(now)).toBe(observed + gap * 10);
}
expect(await applyNextClockProjection({ db, redis: redis.client, workerId: 'done' })).toBe('IDLE');
expect(await db.message.count()).toBe(recovery ? 1 : 0);
} }
); );
@@ -49,6 +49,36 @@ describe('TurnDaemonLifecycle', () => {
} }
); );
it('holds even an explicit run during the recovery wait', async () => {
const now = new Date('2026-09-07T00:24:00Z');
const startsAt = new Date('2026-09-07T00:35:00Z');
const controlQueue = new InMemoryControlQueue();
controlQueue.enqueue({ type: 'run', reason: 'manual' });
const processor = { run: vi.fn() };
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue,
processor,
getNextTickTime: (value) => addMinutes(value, 60),
stateStore: {
loadLastTurnTime: async () => now,
loadNextGeneralTurnTime: async () => now,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => {
controlQueue.enqueue({ type: 'shutdown' });
return { mode: 'realtime', phase: 'RUNNING', now, startsAt };
},
},
},
{ profile: 'recovery-wait-gate', defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 } }
);
await lifecycle.start();
expect(processor.run).not.toHaveBeenCalled();
});
it('durably rebases a long realtime backlog before executing another turn', async () => { it('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z'); const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime()); const clock = new ManualClock(wallNow.getTime());
+8 -5
View File
@@ -6247,8 +6247,8 @@ for (const viewport of [
{ width: 1200, height: 900 }, { width: 1200, height: 900 },
{ width: 390, height: 844 }, { width: 390, height: 844 },
]) { ]) {
test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => { test(`turn recovery waits then accelerates and returns to normal speed (${viewport.width}px)`, async ({ page }) => {
const start = new Date('2026-09-06T07:59:50Z'); const start = new Date('2026-09-06T07:59:45Z');
await page.clock.install({ time: start }); await page.clock.install({ time: start });
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
const state: NavigationFixture = { const state: NavigationFixture = {
@@ -6262,15 +6262,18 @@ for (const viewport of [
serverTime: '2026-09-06T07:59:40Z', serverTime: '2026-09-06T07:59:40Z',
serverWallTime: start.toISOString(), serverWallTime: start.toISOString(),
clockMode: 'realtime', clockMode: 'realtime',
clockRunning: true, clockRunning: false,
clockStartsAt: '2026-09-06T07:59:50Z',
turnEngineRunning: true, turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-06T04:00:00Z', endsAt: '2026-09-06T08:00:00Z' }, clockRecovery: { startsAt: '2026-09-06T07:59:50Z', endsAt: '2026-09-06T08:00:00Z' },
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('./'); await page.goto('./');
await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 });
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
const status = page.locator('.execution-status:visible'); const status = page.locator('.execution-status:visible');
await expect(status).not.toContainText('복구 2배속');
await page.clock.runFor(5_000);
await expect(status).toContainText('복구 2배속'); await expect(status).toContainText('복구 2배속');
const root = process.env.TURN_RECOVERY_ARTIFACT_DIR; const root = process.env.TURN_RECOVERY_ARTIFACT_DIR;
const measure = () => const measure = () =>
@@ -6288,7 +6291,7 @@ for (const viewport of [
await mkdir(root, { recursive: true }); await mkdir(root, { recursive: true });
await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true }); await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true });
} }
await page.clock.runFor(20_000); await page.clock.runFor(10_000);
await expect(status).not.toContainText('복구 2배속'); await expect(status).not.toContainText('복구 2배속');
await expect(status).toContainText('17:00'); await expect(status).toContainText('17:00');
const after = await measure(); const after = await measure();
@@ -68,3 +68,27 @@ void test('a single browser sample accelerates only inside the recovery window a
assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z'); assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).rate, 1); assert.equal(projectServerClock(sample, 280 * minute).rate, 1);
}); });
void test('waits then accelerates from a partial month and returns to normal without resampling', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-07T00:10:00Z',
serverWallTime: '2026-09-07T00:24:00Z',
clockMode: 'realtime',
clockRunning: false,
clockStartsAt: '2026-09-07T00:35:00Z',
clockRecovery: { startsAt: '2026-09-07T00:35:00Z', endsAt: '2026-09-07T01:00:00Z' },
},
0
);
assert.ok(sample);
for (const elapsed of [0, 660000 - 1, 660000]) {
assert.equal(projectServerClock(sample, elapsed).time.toISOString(), '2026-09-07T00:10:00.000Z');
}
assert.equal(projectServerClock(sample, 660001).time.toISOString(), '2026-09-07T00:10:00.002Z');
assert.equal(projectServerClock(sample, 2160000 - 1).time.toISOString(), '2026-09-07T00:59:59.998Z');
assert.equal(projectServerClock(sample, 2160000 - 1).rate, 2);
assert.equal(projectServerClock(sample, 2160000).time.toISOString(), '2026-09-07T01:00:00.000Z');
assert.equal(projectServerClock(sample, 2160000).rate, 1);
assert.equal(projectServerClock(sample, 2160001).time.toISOString(), '2026-09-07T01:00:00.001Z');
});
+44 -21
View File
@@ -40,30 +40,53 @@ alignedTick = cutTick + gapTicks
deadlineAfter = deadlineBefore + shiftTicks deadlineAfter = deadlineBefore + shiftTicks
``` ```
From 2026-09-06, maintenance and crash recovery use `RECOVER_TURNS`. From 2026-09-07, maintenance and crash recovery use the following
This supersedes the earlier same-day `PRESERVE_SCHEDULE` immediate catch-up `RECOVER_TURNS` policy. The base turn length does not change. One turn remains
policy. The base turn length does not change. One turn remains 36,000,000 36,000,000 ticks; a persisted `TurnRecoveryWindow` changes only wall execution.
ticks; a persisted `TurnRecoveryWindow` changes only the wall execution rate.
- Count complete overdue turns from the durable observation to the normal - An entire delay strictly below `min(600 seconds, turnSeconds / 10)` catches
timeline. Skip only `floor(overdueTurns / 12) * 12` turns, moving future up immediately through the ordinary engine. Equality uses recovery. The
schedules and the execution cursor by the same integer delta. limit is 30 seconds on a 5-minute server and 6 minutes on a 60-minute server.
- Execute the sub-turn remainder immediately through the ordinary engine. - For longer delays, skip only complete 12-turn blocks, moving future
Reach the next normal turn boundary at normal speed, then execute the schedules and the execution cursor by the same integer delta. Never apply
remaining one to eleven turns of backlog at 2x speed. the short-delay exception again to the remainder. An exact multiple of
- Join the original schedule at the recorded end boundary and return to 1x. 12 turns needs no acceleration window.
Four hours of backlog on a 60-minute server needs four hours at 2x; it runs - Preserve the remaining observation, including its partial-month position.
eight turns in that time. Resources, RNG, commands and monthly handlers run Wait without advancing, then execute at 2x until joining the original
normally for those turns, in the existing chronological order. schedule at a logical month boundary. There is no initial 1x segment or
- Purchased within-turn offsets remain logical offsets. Their wall offsets fractional immediate burst in a newly planned window.
compress during recovery and return to the original minutes/seconds after - Let `S` be the observation after skips, `N` the normal tick at resume,
the end boundary. The API and browser expose the recovery interval. `T` one turn, and `r` ticks per second. Choose
`E = ceil((2*N-S)/T)*T`. The end is `resume + (E-N)/r` and the wait is
`(E+S-2*N)/(2*r)`. End wall time and 2x duration round up to milliseconds;
derive the start by subtraction. This preserves the end boundary with at
most one millisecond of wall resolution error.
- On a 60-minute server stopped at game 00:10 and resumed at wall 00:24,
wait until 00:35, then run 2x until game/wall 01:00. A 240-minute outage
from the same stop waits from 04:10 to 04:35 and rejoins at 09:00.
- Resources, RNG, commands and monthly handlers execute in the existing
chronological order. Purchased within-turn offsets remain logical offsets;
their wall offsets compress during recovery and return to normal afterward.
`clock_recovery_start_tick`, `clock_recovery_end_tick`, and `clock_recovery_start_tick`, `clock_recovery_end_tick`, and
`clock_recovery_start_wall_at` are an all-or-none durable window. Flush/reload `clock_recovery_start_wall_at` are an all-or-none durable window. The migration
preserves it; a short restart reuses it. A new long outage replans against the `20260907150000_wait_then_turn_recovery` permits partial-month starts and keeps
original normal timeline. Game-date epoch and wall epoch may differ; never end-boundary and safe-integer constraints. Existing windows remain valid and
convert the real wall instant using `dateToTick` to compute normal time. retain their old pre-start 1x behavior; new windows use the stored tick as the
frozen lower bound and `clockWallAnchor` as the future start gate. Do not roll
back to code requiring whole-turn starts while a new window is stored.
Flush/reload preserves the window; short restarts reuse it. A new long outage
replans against the original normal timeline. Game-date epoch and wall epoch
may differ; never use `dateToTick(realWallInstant)` to compute normal time.
The API and browser expose waiting, start and end boundaries without needing
a fresh response at the speed transitions.
Projection completion stores one public system message with both recovery
wall dates (Korean time) and ISO interval metadata. Message creation, mailbox
read-model invalidation, and the `RUNNING`/outbox `APPLIED` transition share a
PostgreSQL transaction. Retries after Redis application cannot duplicate the
message. Existing read-model outbox delivery refreshes connected clients.
Before a newly leased daemon permits independent workers to advance time, it Before a newly leased daemon permits independent workers to advance time, it
prepares recovery under the clock lock. `turn_daemon_lease.clock_ready` starts prepares recovery under the clock lock. `turn_daemon_lease.clock_ready` starts
+3 -2
View File
@@ -241,6 +241,7 @@ export const buildClockAlignmentPlan = (input: {
catchUpTicks: asGameTick(Math.max(0, (input.normalTick ?? exact.alignedTick) - input.cutTick - shiftTicks)), catchUpTicks: asGameTick(Math.max(0, (input.normalTick ?? exact.alignedTick) - input.cutTick - shiftTicks)),
alignedTick: recovery.initialTick, alignedTick: recovery.initialTick,
recovery: recovery.recovery, recovery: recovery.recovery,
...(recovery.recovery ? { resumeAnchor: recovery.recovery.startWallAt } : {}),
...(recovery.initialTick > (input.normalTick ?? exact.alignedTick) ...(recovery.initialTick > (input.normalTick ?? exact.alignedTick)
? { ? {
resumeAnchor: new Date( resumeAnchor: new Date(
@@ -380,8 +381,8 @@ export class GameClock {
normalNowTick(wallNow: Date): number { normalNowTick(wallNow: Date): number {
if (this.recovery) { if (this.recovery) {
return this.addTicks( return this.addTicks(
(this.recovery.startTick + this.recovery.endTick) / 2, this.recovery.endTick,
this.ticksBetween(this.recovery.startWallAt, wallNow) this.ticksBetween(this.tickToWallDate(this.recovery.endTick), wallNow)
); );
} }
return this.addTicks(this.tick, this.ticksBetween(this.wallAnchor, wallNow)); return this.addTicks(this.tick, this.ticksBetween(this.wallAnchor, wallNow));
+49 -27
View File
@@ -1,6 +1,6 @@
import { asGameTick, GAME_TICKS_PER_TURN, type GameTick } from './gameTimeUnits.js'; import { asGameTick, GAME_TICKS_PER_TURN, type GameTick } from './gameTimeUnits.js';
/** 정상 시간표는 바꾸지 않고, 정수 턴의 지연만 두 배 속도로 소진한다. */ /** 정상 시간표를 유지하며 대기 후 두 배 속도로 월 경계에 합류한다. */
export interface TurnRecoveryWindow { export interface TurnRecoveryWindow {
startTick: GameTick; startTick: GameTick;
endTick: GameTick; endTick: GameTick;
@@ -68,9 +68,12 @@ export const turnShiftTicks = (turns: number): GameTick => {
return asGameTick(turns * GAME_TICKS_PER_TURN); return asGameTick(turns * GAME_TICKS_PER_TURN);
}; };
/** 즉시 처리 여부는 12턴 묶음 생략 전의 전체 지연으로 판정한다. */
export const immediateRecoveryLimitSeconds = (turnSeconds: number): number => Math.min(600, turnSeconds / 10);
/** /**
* observedTick은 중단 전에 저장한 관측 지점, normalTick은 기존 시간표의 현재 지점이다. * observedTick은 중단 전에 저장한 관측 지점, normalTick은 기존 시간표의 현재 지점이다.
* 잔여 한 턴 미만은 정상 실행하고, 다음 경계부터 정수 턴 지연을 두 배속으로 처리한다. * 짧은 전체 지연만 즉시 처리한다. 그 외에는 나머지도 생략하지 않고 대기 후 두 배속으로 처리한다.
* 반환한 skip은 호출자가 미래 일정과 실행 cursor에 원자적으로 적용해야 한다. * 반환한 skip은 호출자가 미래 일정과 실행 cursor에 원자적으로 적용해야 한다.
*/ */
export const planTurnRecovery = (input: { export const planTurnRecovery = (input: {
@@ -86,26 +89,32 @@ export const planTurnRecovery = (input: {
throw new Error('Recovery requires a representable positive turn length.'); throw new Error('Recovery requires a representable positive turn length.');
} }
if (!Number.isFinite(wallNow.getTime())) throw new Error('Recovery wall instant is invalid.'); if (!Number.isFinite(wallNow.getTime())) throw new Error('Recovery wall instant is invalid.');
const overdueTurns = Math.max(0, Math.floor((normalTick - observedTick) / GAME_TICKS_PER_TURN)); const gap = Math.max(0, normalTick - observedTick);
const skippedTurns = Math.floor(overdueTurns / 12) * 12; const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds;
const recoveryTurns = overdueTurns % 12; const immediateLimit = immediateRecoveryLimitSeconds(turnSeconds) * ticksPerSecond;
const initialTick = asGameTick( if (gap < immediateLimit) {
Math.max(observedTick + turnShiftTicks(skippedTurns), normalTick - turnShiftTicks(recoveryTurns)) return {
); skippedTurns: 0,
if (recoveryTurns === 0) return { skippedTurns, recoveryTurns, initialTick, recovery: null }; recoveryTurns: 0,
const boundary = nextTurnBoundary(normalTick); initialTick: asGameTick(Math.max(observedTick, normalTick)),
const startWallAt = new Date( recovery: null,
wallNow.getTime() + Math.ceil(((boundary - normalTick) * turnSeconds * 1_000) / GAME_TICKS_PER_TURN) };
); }
const skippedTurns = Math.floor(gap / (12 * GAME_TICKS_PER_TURN)) * 12;
const initialTick = asGameTick(observedTick + turnShiftTicks(skippedTurns));
const remaining = normalTick - initialTick;
if (remaining === 0) return { skippedTurns, recoveryTurns: 0, initialTick, recovery: null };
// 즉시 2배속으로 따라잡을 수 있는 가장 이른 지점 이후의 월 경계를 고른다.
// 대기도 추가 지연이므로 경계까지 여유의 절반만 기다린다. 밀리초 반올림은 종료 시각을 보존한다.
const endTick = nextTurnBoundary(normalTick + remaining);
const endWallMs = wallNow.getTime() + Math.ceil(((endTick - normalTick) * 1_000) / ticksPerSecond);
const durationMs = Math.ceil(((endTick - initialTick) * 1_000) / (2 * ticksPerSecond));
return { return {
skippedTurns, skippedTurns,
recoveryTurns, recoveryTurns: remaining / GAME_TICKS_PER_TURN,
initialTick, initialTick,
recovery: { recovery: { startTick: initialTick, endTick, startWallAt: new Date(endWallMs - durationMs) },
startTick: asGameTick(boundary - turnShiftTicks(recoveryTurns)),
endTick: asGameTick(boundary + turnShiftTicks(recoveryTurns)),
startWallAt,
},
}; };
}; };
@@ -115,23 +124,32 @@ export const validateTurnRecovery = (window: TurnRecoveryWindow): void => {
const span = window.endTick - window.startTick; const span = window.endTick - window.startTick;
if ( if (
!Number.isFinite(window.startWallAt.getTime()) || !Number.isFinite(window.startWallAt.getTime()) ||
window.startTick % GAME_TICKS_PER_TURN !== 0 ||
window.endTick % GAME_TICKS_PER_TURN !== 0 || window.endTick % GAME_TICKS_PER_TURN !== 0 ||
span <= 0 || span <= 0 ||
span % (2 * GAME_TICKS_PER_TURN) !== 0 || span >= 25 * GAME_TICKS_PER_TURN
span >= 24 * GAME_TICKS_PER_TURN
) )
throw new Error('Recovery must join turn boundaries after one to eleven turns at double speed.'); throw new Error('Recovery must end at a turn boundary with a positive span below twenty-five turns.');
}; };
/** 경계 전에는 정상 속도, 복구 구간은 두 배, 합류 경계 이후는 정상 속도이다. */ /** 경계 전에는 정상 속도, 복구 구간은 두 배, 합류 경계 이후는 정상 속도이다. */
export const observeTurnRecovery = (window: TurnRecoveryWindow, wallNow: Date, ticksPerSecond: number): GameTick => { export const observeTurnRecovery = (window: TurnRecoveryWindow, wallNow: Date, ticksPerSecond: number): GameTick => {
validateTurnRecovery(window); validateTurnRecovery(window);
const elapsed = asGameTick( const elapsed = asGameTick(
Math.trunc(((wallNow.getTime() - window.startWallAt.getTime()) * ticksPerSecond) / 1_000) Math.trunc(
((wallNow.getTime() - window.startWallAt.getTime()) *
ticksPerSecond *
(wallNow < window.startWallAt ? 1 : 2)) /
1_000
)
); );
const halfSpan = (window.endTick - window.startTick) / 2; const endWallAt = projectRecoveryDeadline(window, window.endTick, ticksPerSecond);
return asGameTick(window.startTick + elapsed + Math.max(0, Math.min(elapsed, halfSpan))); if (wallNow >= endWallAt) {
return asGameTick(
window.endTick + Math.trunc(((wallNow.getTime() - endWallAt.getTime()) * ticksPerSecond) / 1_000)
);
}
// 이전 복구 창은 시작 전 1배속이었다. 새 창은 GameClock의 저장 tick 하한으로 대기한다.
return asGameTick(Math.min(window.endTick, window.startTick + elapsed));
}; };
/** 게임 좌표의 예정 시각을 사용자에게 표시할 실제 실행 시각으로 투영한다. */ /** 게임 좌표의 예정 시각을 사용자에게 표시할 실제 실행 시각으로 투영한다. */
@@ -140,6 +158,10 @@ export const projectRecoveryDeadline = (window: TurnRecoveryWindow, tick: number
asGameTick(tick); asGameTick(tick);
const offset = tick - window.startTick; const offset = tick - window.startTick;
const span = window.endTick - window.startTick; const span = window.endTick - window.startTick;
const elapsed = offset < 0 ? offset : offset <= span ? offset / 2 : offset - span / 2; if (offset > span) {
const endWallMs = window.startWallAt.getTime() + Math.ceil((span * 1_000) / (2 * ticksPerSecond));
return new Date(endWallMs + Math.ceil(((offset - span) * 1_000) / ticksPerSecond));
}
const elapsed = offset < 0 ? offset : offset / 2;
return new Date(window.startWallAt.getTime() + Math.ceil((elapsed * 1_000) / ticksPerSecond)); return new Date(window.startWallAt.getTime() + Math.ceil((elapsed * 1_000) / ticksPerSecond));
}; };
+110 -9
View File
@@ -35,6 +35,21 @@ describe('turn-aligned double-speed recovery', () => {
expect(reloaded.executionRate(wall(8))).toBe(1); expect(reloaded.executionRate(wall(8))).toBe(1);
}); });
it('preserves pre-start normal speed for an existing serialized window', () => {
const old = new GameClock({
baseTime: wall(0),
tick: T / 3,
wallAnchor: wall(4 + 1 / 3),
mode: 'realtime',
turnSeconds: 3600,
recovery: { startTick: nextTurnBoundary(T), endTick: nextTurnBoundary(9 * T), startWallAt: wall(5) },
});
expect(old.nowTick(wall(4.5))).toBe(T / 2);
expect(old.nowTick(wall(5))).toBe(T);
expect(old.nowTick(wall(9))).toBe(9 * T);
expect(old.normalNowTick(wall(4.5))).toBe(4.5 * T);
});
it('resumes a planned wait at a whole-turn boundary without changing purchased phase', () => { it('resumes a planned wait at a whole-turn boundary without changing purchased phase', () => {
const plan = buildClockAlignmentPlan({ const plan = buildClockAlignmentPlan({
policy: 'TURN_BOUNDARY', policy: 'TURN_BOUNDARY',
@@ -102,18 +117,104 @@ describe('turn-aligned double-speed recovery', () => {
expect(observeTurnRecovery(recovery!, wall(9), 10_000)).toBe(9 * T); expect(observeTurnRecovery(recovery!, wall(9), 10_000)).toBe(9 * T);
}); });
it('retains the fractional phase and begins acceleration at the next boundary', () => { it.each([
[14, 11, 60],
[49, 6, 120],
[59, 26, 180],
[100, 15, 240],
[240, 25, 540],
[400, 15, 840],
[700, 15, 1440],
])('waits then runs 2x after stopping at 00:10 for %i minutes', (delay, wait, endMinute) => {
const observed = T / 6;
const resumed = wall((10 + delay) / 60);
const plan = planTurnRecovery({ const plan = planTurnRecovery({
observedTick: 0, observedTick: observed,
normalTick: 4 * T + T / 3, normalTick: ((10 + delay) * T) / 60,
wallNow: wall(4 + 1 / 3), wallNow: resumed,
turnSeconds: 3600, turnSeconds: 3600,
}); });
expect(plan.initialTick).toBe(T / 3); const recovery = plan.recovery!;
expect(plan.recovery!.startWallAt).toEqual(wall(5)); const start = recovery.startWallAt;
expect(observeTurnRecovery(plan.recovery!, wall(4.5), 10_000)).toBe(T / 2); const end = wall(endMinute / 60);
expect(observeTurnRecovery(plan.recovery!, wall(5), 10_000)).toBe(T); const clock = new GameClock({
expect(observeTurnRecovery(plan.recovery!, wall(9), 10_000)).toBe(9 * T); baseTime: wall(0),
tick: plan.initialTick,
wallAnchor: start,
mode: 'realtime',
turnSeconds: 3600,
recovery,
});
expect(plan.initialTick).toBe(observed);
expect(start.getTime() - resumed.getTime()).toBeCloseTo(wait * 60000, 0);
expect(clock.nowTick(resumed)).toBe(observed);
expect(clock.nowTick(new Date(start.getTime() - 1))).toBe(observed);
expect(clock.nowTick(start)).toBe(observed);
expect(clock.nowTick(new Date(start.getTime() + 1))).toBe(observed + 20);
expect(clock.tickToWallDate(recovery.endTick)).toEqual(end);
expect(recovery.endTick % T).toBe(0);
expect(clock.nowTick(new Date(end.getTime() - 1))).toBe(recovery.endTick - 20);
expect(clock.nowTick(end)).toBe(clock.normalNowTick(end));
expect(clock.nowTick(new Date(end.getTime() + 1))).toBe(recovery.endTick + 10);
expect(clock.executionRate(new Date(end.getTime() - 1))).toBe(2);
expect(clock.executionRate(end)).toBe(1);
});
it.each([300, 3600, 6000, 7200])('uses the strict whole-delay threshold for a %i second turn', (turnSeconds) => {
const rate = T / turnSeconds;
const limitMs = Math.min(600, turnSeconds / 10) * 1000;
for (const delta of [-1, 0, 1]) {
const delayMs = limitMs + delta;
const normal = Math.trunc((delayMs * rate) / 1000);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: normal,
wallNow: new Date(base + delayMs),
turnSeconds,
});
expect(plan.recovery === null).toBe(delta < 0);
expect(plan.initialTick).toBe(delta < 0 ? normal : 0);
}
// 장시간 중단의 작은 나머지에는 즉시 처리 예외를 다시 적용하지 않는다.
const long = planTurnRecovery({ observedTick: 0, normalTick: 12 * T + rate, wallNow: wall(20), turnSeconds });
expect(long.skippedTurns).toBe(12);
expect(long.initialTick).toBe(12 * T);
expect(long.recovery).not.toBeNull();
});
it('keeps integer ticks and deadline ordering for sub-millisecond phases', () => {
for (const turnSeconds of [300, 3600, 7200, 36000]) {
const rate = T / turnSeconds;
for (const observed of [1, T / 6 + 1, T - 1]) {
const now = wall(4);
const normal = observed + 4 * T + 1;
const plan = planTurnRecovery({
observedTick: observed,
normalTick: normal,
wallNow: now,
turnSeconds,
});
const recovery = plan.recovery!;
const clock = new GameClock({
baseTime: wall(0),
tick: observed,
wallAnchor: recovery.startWallAt,
mode: 'realtime',
turnSeconds,
recovery,
});
expect(recovery.startWallAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
const end = clock.tickToWallDate(recovery.endTick);
expect(clock.nowTick(end)).toBe(recovery.endTick);
expect(clock.nowTick(new Date(end.getTime() - 1))).toBeLessThan(recovery.endTick);
expect(Math.abs(clock.normalNowTick(now) - normal)).toBeLessThanOrEqual(Math.ceil(rate / 1000));
for (const tick of [observed + 1, observed + T, recovery.endTick - 1, recovery.endTick + 1]) {
const deadline = clock.tickToWallDate(tick);
expect(clock.nowTick(deadline)).toBeGreaterThanOrEqual(tick);
expect(clock.nowTick(new Date(deadline.getTime() - 1))).toBeLessThan(tick);
}
}
}
}); });
it('preserves purchased phase coordinates while projecting compressed wall deadlines', () => { it('preserves purchased phase coordinates while projecting compressed wall deadlines', () => {
@@ -0,0 +1,14 @@
-- 복구 시작은 중단 당시 월 내부 좌표를 보존하고, 종료만 월 경계에 맞춘다.
-- 기존 월 경계 시작 창도 그대로 유효하며 저장 좌표를 변경하지 않는다.
ALTER TABLE "world_state"
DROP CONSTRAINT "world_state_turn_recovery_window_check",
ADD CONSTRAINT "world_state_turn_recovery_window_check" CHECK (
("clock_recovery_start_tick" IS NULL AND "clock_recovery_end_tick" IS NULL AND "clock_recovery_start_wall_at" IS NULL)
OR (
"clock_recovery_start_tick" IS NOT NULL AND "clock_recovery_end_tick" IS NOT NULL AND "clock_recovery_start_wall_at" IS NOT NULL
AND "clock_recovery_start_tick" BETWEEN -9007199254740991 AND 9007199254740991
AND "clock_recovery_end_tick" BETWEEN -9007199254740991 AND 9007199254740991
AND "clock_recovery_end_tick" % 36000000 = 0
AND "clock_recovery_end_tick" - "clock_recovery_start_tick" BETWEEN 1 AND 899999999
)
);