merge: 서버 재개 게임 시계 보정을 main에 반영한다

This commit is contained in:
2026-08-23 02:01:22 +00:00
10 changed files with 596 additions and 47 deletions
@@ -171,7 +171,8 @@ export class TurnDaemonLifecycle {
}
const nowMs = this.clock.nowMs();
const gameClock = await this.stateStore.loadGameClock?.(new Date(nowMs));
const wallNow = new Date(nowMs);
const gameClock = await this.stateStore.loadGameClock?.(wallNow);
if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that
@@ -186,6 +187,13 @@ export class TurnDaemonLifecycle {
await this.runOnce({ reason: 'schedule', targetTime });
continue;
}
if (
gameClock?.mode === 'realtime' &&
(await this.stateStore.shouldRebaseRealtimeBacklog?.(wallNow)) &&
(await this.rebaseRealtimeBacklog(wallNow))
) {
continue;
}
const gameNowMs = gameClock?.now.getTime() ?? nowMs;
const nextTurnMs = nextRunTime.getTime();
if (gameNowMs >= nextTurnMs) {
@@ -419,6 +427,58 @@ export class TurnDaemonLifecycle {
}
}
private async rebaseRealtimeBacklog(wallNow: Date): Promise<boolean> {
if (!this.stateStore.rebaseRealtimeBacklog) {
return false;
}
const startMs = this.clock.nowMs();
let result: TurnRunResult | null;
try {
const rebaseAndFlush = async (): Promise<TurnRunResult | null> => {
const rebased = await this.stateStore.rebaseRealtimeBacklog!(wallNow);
if (!rebased) {
return null;
}
const nextResult: TurnRunResult = {
lastTurnTime: rebased.lastTurnTime,
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
checkpoint: rebased.checkpoint,
};
this.status.state = 'flushing';
await this.hooks?.flushChanges?.(nextResult);
return nextResult;
};
result = this.stateManager ? await this.stateManager.transaction(rebaseAndFlush) : await rebaseAndFlush();
} catch (error) {
this.status.running = false;
this.status.state = 'paused';
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown realtime backlog rebase error.';
await this.hooks?.onRunError?.(error);
// The rebase attempt was handled, albeit as a pause. Do not fall
// through and execute overdue turns against a transaction that
// failed to persist its clock/schedule shift.
return true;
}
if (!result) {
return false;
}
await this.applyRunResult(result, startMs);
this.status.state = 'idle';
try {
await this.hooks?.publishEvents?.(result);
} catch (error) {
this.status.lastError =
error instanceof Error ? error.message : 'Unknown backlog rebase event publication error.';
}
return true;
}
private async applyRunResult(result: TurnRunResult, startMs: number): Promise<void> {
this.status.lastRunAt = new Date(startMs).toISOString();
this.status.lastDurationMs = Math.max(0, this.clock.nowMs() - startMs);
+9
View File
@@ -45,6 +45,13 @@ export interface TurnProcessor {
run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise<TurnRunResult>;
}
export interface RealtimeBacklogRebaseResult {
skippedTurns: number;
shiftedTicks: number;
lastTurnTime: string;
checkpoint?: TurnCheckpoint;
}
export interface TurnStateStore {
loadLastTurnTime(): Promise<Date>;
// 월드에서 관리하는 턴 대기열의 선두(가장 이른 장수 턴 시간)를 조회한다.
@@ -54,6 +61,8 @@ export interface TurnStateStore {
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
shouldHaltScheduledRuns?(): Promise<boolean>;
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
}
+23 -1
View File
@@ -379,7 +379,7 @@ export const summarizeRealtimeReadModelChanges = (
lobbyGeneralIds,
reservedGeneralIds,
recordGeneralIds,
worldChanged: false,
worldChanged: changes.realtimeBacklogShiftTicks > 0,
globalRecordsChanged,
worldHistoryChanged,
contactsChanged,
@@ -1078,6 +1078,7 @@ export const createDatabaseTurnHooks = async (
let persistedVisibleLogs: PersistedVisibleLogRow[] = [];
let visibleLogFloor = directLogFloor;
const {
realtimeBacklogShiftTicks,
accessScoreResetGeneralIds,
generals,
cities,
@@ -1161,6 +1162,27 @@ export const createDatabaseTurnHooks = async (
data: worldStateUpdate,
});
if (realtimeBacklogShiftTicks > 0) {
const deltaTicks = BigInt(realtimeBacklogShiftTicks);
const deltaSeconds = (realtimeBacklogShiftTicks / GAME_TICKS_PER_TURN) * state.tickSeconds;
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE general
SET turn_tick = CASE WHEN turn_tick IS NULL THEN NULL ELSE turn_tick + ${deltaTicks} END,
turn_time = turn_time + (${deltaSeconds} * INTERVAL '1 second')
`
);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET close_tick = CASE WHEN close_tick IS NULL THEN NULL ELSE close_tick + ${deltaTicks} END,
close_at = close_at + (${deltaSeconds} * INTERVAL '1 second'),
updated_at = NOW()
WHERE status = 'OPEN'
`
);
}
if (
state.tickSeconds !== persistedTickSeconds &&
commandCompletion?.result.type !== 'updateRuntimeSettings'
@@ -42,6 +42,14 @@ export class InMemoryTurnStateStore implements TurnStateStore {
};
}
async rebaseRealtimeBacklog(wallNow: Date) {
return this.world.rebaseRealtimeBacklog(wallNow);
}
async shouldRebaseRealtimeBacklog(wallNow: Date): Promise<boolean> {
return this.world.shouldRebaseRealtimeBacklog(wallNow);
}
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
this.world.advanceGameClockTo(target, wallNow);
}
+138 -37
View File
@@ -9,7 +9,7 @@ import type {
UnitSetDefinition,
} from '@sammo-ts/logic';
import { getNextTurnAt } from '@sammo-ts/logic';
import { GameClock, type GameClockMode } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type {
@@ -115,6 +115,7 @@ export interface InMemoryGameClockState {
}
export interface TurnWorldChanges {
realtimeBacklogShiftTicks: number;
accessScoreResetGeneralIds: number[];
generals: TurnGeneral[];
cities: City[];
@@ -178,6 +179,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number;
}
export interface InMemoryTurnWorldInspection {
@@ -380,6 +382,38 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
return null;
};
const shiftGameClockMetaDate = (value: unknown, deltaMilliseconds: number): unknown => {
if (typeof value !== 'string' || !value.trim()) {
return value;
}
if (value.includes('T')) {
const shifted = new Date(new Date(value).getTime() + deltaMilliseconds);
return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString();
}
const match = /^(\d{4})-(\d{2})-(\d{2})[ ](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?$/.exec(value);
if (!match) {
return value;
}
const parts = match.slice(1).map(Number);
const shifted = new Date(
Date.UTC(parts[0]!, parts[1]! - 1, parts[2]!, parts[3]!, parts[4]!, parts[5]!) + deltaMilliseconds
);
return (
[
shifted.getUTCFullYear().toString().padStart(4, '0'),
(shifted.getUTCMonth() + 1).toString().padStart(2, '0'),
shifted.getUTCDate().toString().padStart(2, '0'),
].join('-') +
' ' +
[
shifted.getUTCHours().toString().padStart(2, '0'),
shifted.getUTCMinutes().toString().padStart(2, '0'),
shifted.getUTCSeconds().toString().padStart(2, '0'),
].join(':') +
(match[7] ?? '')
);
};
const resolveWorldKillturn = (meta: Record<string, unknown>): number | null => {
const killturn = readMetaNumber(meta, 'killturn');
if (killturn !== null) {
@@ -448,6 +482,7 @@ export class InMemoryTurnWorld {
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig;
private readonly worldConfig: Record<string, unknown>;
private readonly unitSet?: UnitSetDefinition;
@@ -592,7 +627,11 @@ export class InMemoryTurnWorld {
advanceGameClockTo(target: Date, wallNow: Date): void {
const clock = this.getGameClock();
const targetTick = clock.dateToTick(target);
const nextTick = Math.max(clock.tick, targetTick);
// Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거
// target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린
// 시간만큼 게임 시계가 매 pass마다 뒤로 누적됩니다.
const observedTick = clock.mode === 'realtime' ? clock.nowTick(wallNow) : clock.tick;
const nextTick = Math.max(observedTick, targetTick);
this.state = {
...this.state,
clockTick: nextTick,
@@ -600,6 +639,93 @@ export class InMemoryTurnWorld {
};
}
private resolveRealtimeBacklogRebase(wallNow: Date): {
clock: GameClock;
wallAlignedTick: number;
lastTurnTick: number;
skippedTurns: number;
} | null {
const clock = this.getGameClock();
if (clock.mode !== 'realtime') {
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;
}
shouldRebaseRealtimeBacklog(wallNow: Date): boolean {
return this.resolveRealtimeBacklogRebase(wallNow) !== null;
}
rebaseRealtimeBacklog(wallNow: Date): {
skippedTurns: number;
shiftedTicks: number;
lastTurnTime: string;
checkpoint?: TurnCheckpoint;
} | null {
const plan = this.resolveRealtimeBacklogRebase(wallNow);
if (!plan) {
return null;
}
const { clock, wallAlignedTick, lastTurnTick, skippedTurns } = plan;
// Ref realtime clock always projects the current wall time. Core may
// already have accumulated lag from anchoring an overdue target, so a
// long-backlog rebase also repairs that projection without rewinding.
const shiftedTicks = skippedTurns * GAME_TICKS_PER_TURN;
const shiftedMilliseconds = skippedTurns * this.state.tickSeconds * 1_000;
const nextLastTurnTick = clock.addTicks(lastTurnTick, shiftedTicks);
const nextLastTurnTime = clock.tickToDate(nextLastTurnTick);
this.state = {
...this.state,
clockTick: wallAlignedTick,
clockWallAnchor: new Date(wallNow.getTime()),
lastTurnTick: nextLastTurnTick,
lastTurnTime: nextLastTurnTime,
meta: {
...this.state.meta,
lastTurnTime: nextLastTurnTime.toISOString(),
turntime: shiftGameClockMetaDate(this.state.meta.turntime, shiftedMilliseconds),
starttime: shiftGameClockMetaDate(this.state.meta.starttime, shiftedMilliseconds),
},
};
// Ref checkDelay()는 한 번의 UPDATE로 전 장수의 다음 턴만 옮깁니다.
// recent-war를 비롯한 이미 발생한 gameplay 시각은 그대로 보존합니다.
for (const [generalId, general] of this.generals) {
const turnTick = clock.addTicks(general.turnTick ?? clock.dateToTick(general.turnTime), shiftedTicks);
this.generals.set(generalId, {
...general,
turnTick,
turnTime: clock.tickToDate(turnTick),
});
}
if (this.checkpoint) {
const checkpointTick = clock.addTicks(
this.checkpoint.turnTick ?? clock.dateToTick(new Date(this.checkpoint.turnTime)),
shiftedTicks
);
this.checkpoint = {
...this.checkpoint,
turnTick: checkpointTick,
turnTime: clock.tickToDate(checkpointTick).toISOString(),
};
}
this.pendingRealtimeBacklogShiftTicks += shiftedTicks;
return {
skippedTurns,
shiftedTicks,
lastTurnTime: nextLastTurnTime.toISOString(),
checkpoint: this.checkpoint,
};
}
captureState(): InMemoryTurnWorldStateSnapshot {
return structuredClone({
schedule: this.schedule,
@@ -637,6 +763,7 @@ export class InMemoryTurnWorld {
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot);
}
@@ -680,6 +807,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
}
inspectState(): InMemoryTurnWorldInspection {
@@ -1156,38 +1284,6 @@ export class InMemoryTurnWorld {
}
const deltaMs = deltaMinutes * 60_000;
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
const shiftMetaDate = (value: unknown): unknown => {
if (typeof value !== 'string' || !value.trim()) {
return value;
}
if (value.includes('T')) {
const shifted = shiftDate(new Date(value));
return Number.isNaN(shifted.getTime()) ? value : shifted.toISOString();
}
const match = /^(\d{4})-(\d{2})-(\d{2})[ ](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?$/.exec(value);
if (!match) {
return value;
}
const parts = match.slice(1).map(Number);
const shifted = new Date(
Date.UTC(parts[0]!, parts[1]! - 1, parts[2]!, parts[3]!, parts[4]!, parts[5]!) + deltaMs
);
return (
[
shifted.getUTCFullYear().toString().padStart(4, '0'),
(shifted.getUTCMonth() + 1).toString().padStart(2, '0'),
shifted.getUTCDate().toString().padStart(2, '0'),
].join('-') +
' ' +
[
shifted.getUTCHours().toString().padStart(2, '0'),
shifted.getUTCMinutes().toString().padStart(2, '0'),
shifted.getUTCSeconds().toString().padStart(2, '0'),
].join(':') +
(match[7] ?? '')
);
};
const previousClock = this.getGameClock();
const generalTicks = new Map(
Array.from(this.generals.values(), (general) => [
@@ -1212,9 +1308,9 @@ export class InMemoryTurnWorld {
const nextMeta = {
...this.state.meta,
lastTurnTime: nextLastTurnTime.toISOString(),
turntime: shiftMetaDate(this.state.meta.turntime),
starttime: shiftMetaDate(this.state.meta.starttime),
tnmt_time: shiftMetaDate(this.state.meta.tnmt_time),
turntime: shiftGameClockMetaDate(this.state.meta.turntime, deltaMs),
starttime: shiftGameClockMetaDate(this.state.meta.starttime, deltaMs),
tnmt_time: shiftGameClockMetaDate(this.state.meta.tnmt_time, deltaMs),
};
this.state = {
...this.state,
@@ -1615,6 +1711,7 @@ export class InMemoryTurnWorld {
);
return {
realtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
accessScoreResetGeneralIds,
generals,
cities,
@@ -1644,6 +1741,10 @@ export class InMemoryTurnWorld {
}
acknowledgeDirtyState(changes: TurnWorldChanges): void {
this.pendingRealtimeBacklogShiftTicks = Math.max(
0,
this.pendingRealtimeBacklogShiftTicks - changes.realtimeBacklogShiftTicks
);
for (const id of changes.accessScoreResetGeneralIds) this.accessScoreResetGeneralIds.delete(id);
for (const general of changes.generals) this.dirtyGeneralIds.delete(general.id);
for (const city of changes.cities) this.dirtyCityIds.delete(city.id);
@@ -62,6 +62,7 @@ describe('durable read-model change journal mapping', () => {
it('detects troop, leader-turn, and aggregate dashboard dependencies conservatively', () => {
const emptyWorldChanges = {
realtimeBacklogShiftTicks: 0,
accessScoreResetGeneralIds: [],
generals: [],
cities: [],
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
@@ -172,6 +173,119 @@ describe('runtime clock shift', () => {
expect(world.getGeneralById(1)?.turnTick).toBe(beforeTurnTick);
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
});
it.each([
[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 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);
});
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 world = buildWorld({
tickSeconds: 300,
clockBaseTime: wallAnchor,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: wallAnchor,
lastTurnTick: 0,
lastTurnTime: wallAnchor,
});
world.setCheckpoint({
turnTime: '2026-07-30T10:10:00.000Z',
turnTick: 2 * GAME_TICKS_PER_TURN,
generalId: 1,
year: 190,
month: 1,
});
const result = world.rebaseRealtimeBacklog(resumedAt);
expect(result).toMatchObject({
skippedTurns: 7,
shiftedTicks: 7 * GAME_TICKS_PER_TURN,
lastTurnTime: resumedAt.toISOString(),
});
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
expect(world.getState()).toMatchObject({
clockTick: 7 * GAME_TICKS_PER_TURN,
clockWallAnchor: resumedAt,
lastTurnTick: 7 * GAME_TICKS_PER_TURN,
meta: {
turntime: '2026-07-30 10:35:00.123456',
starttime: '2026-07-01 00:35:00',
},
});
expect(world.getGeneralById(1)).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN,
turnTime: new Date('2026-07-30T10:45:00.000Z'),
});
expect(world.getCheckpoint()).toMatchObject({
turnTick: 9 * GAME_TICKS_PER_TURN,
turnTime: '2026-07-30T10:45:00.000Z',
});
expect(world.peekDirtyState()).toMatchObject({
realtimeBacklogShiftTicks: 7 * GAME_TICKS_PER_TURN,
generals: [],
});
});
it('repairs an already accumulated realtime projection lag during a long rebase', () => {
const base = new Date('2026-07-30T10:00:00.000Z');
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
const resumedAt = new Date('2026-07-30T11:50:00.000Z');
const world = buildWorld({
tickSeconds: 300,
clockBaseTime: base,
clockTick: 5 * GAME_TICKS_PER_TURN,
clockMode: 'realtime',
clockWallAnchor: staleAnchor,
lastTurnTick: 0,
lastTurnTime: base,
});
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 22 });
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
});
it('does not lose realtime elapsed time when an overdue target is committed later', () => {
const base = new Date('2026-07-30T10:00:00.000Z');
const world = buildWorld({
tickSeconds: 300,
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: base,
lastTurnTick: 0,
lastTurnTime: base,
});
const completedAt = new Date('2026-07-30T10:07:00.000Z');
world.advanceGameClockTo(new Date('2026-07-30T10:05:00.000Z'), completedAt);
expect(world.getGameNow(completedAt)).toEqual(completedAt);
expect(world.getGameClockState().tick).toBe(50_400_000);
});
});
describe('runtime turn term change', () => {
@@ -19,7 +19,7 @@ const requestId = 'integration:engine:runtime-clock-shift';
const actionId = 'b9f68480-dba9-4e03-a62b-499e6234f18a';
const runtimeSettingsRequestId = 'integration:engine:runtime-game-settings';
const runtimeSettingsActionId = 'c9f68480-dba9-4e03-a62b-499e6234f18a';
const generalIds = [990_301, 990_302, 990_303] as const;
const generalIds = [990_301, 990_302, 990_303, 990_304] as const;
const runtimeSettingsLogText = 'runtime-settings-existing-log';
const buildGeneral = (id: number, turnTime: Date): TurnGeneral =>
@@ -88,7 +88,9 @@ integration('runtime clock shift persistence', () => {
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
await db.worldState.deleteMany({
where: { scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings'] } },
where: {
scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] },
},
});
});
@@ -100,7 +102,9 @@ integration('runtime clock shift persistence', () => {
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
await db.worldState.deleteMany({
where: { scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings'] } },
where: {
scenarioCode: { in: ['runtime-clock-shift', 'runtime-game-settings', 'realtime-backlog-rebase'] },
},
});
await closeDb?.();
});
@@ -299,6 +303,131 @@ integration('runtime clock shift persistence', () => {
await db.worldState.delete({ where: { id: row.id } });
});
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 row = await db.worldState.create({
data: {
scenarioCode: 'realtime-backlog-rebase',
currentYear: 192,
currentMonth: 3,
tickSeconds: 300,
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: base,
lastTurnTick: 0,
config: {},
meta: {
lastTurnTime: base.toISOString(),
turntime: '2099-09-01 00:00:00.123456',
starttime: '2099-08-01 00:00:00',
},
},
});
const general = buildGeneral(generalIds[3], new Date('2099-09-01T00:05:00.000Z'));
await db.general.create({
data: {
id: general.id,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
turnTime: general.turnTime,
turnTick: BigInt(GAME_TICKS_PER_TURN),
},
});
const [openAuction, finishedAuction] = await Promise.all(
(['OPEN', 'FINISHED'] as const).map((status) =>
db.auction.create({
data: {
type: 'BUY_RICE',
hostGeneralId: general.id,
detail: {},
status,
closeAt: new Date('2099-09-01T00:10:00.000Z'),
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
},
})
)
);
const world = new InMemoryTurnWorld(
{
id: row.id,
currentYear: 192,
currentMonth: 3,
tickSeconds: 300,
lastTurnTime: base,
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockWallAnchor: base,
lastTurnTick: 0,
meta: row.meta as Record<string, unknown>,
},
{
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
generals: [general],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
},
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] } }
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 7 });
await hooks.hooks.flushChanges?.({
lastTurnTime: resumedAt.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
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),
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'),
});
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'),
});
expect(await db.auction.findUniqueOrThrow({ where: { id: finishedAuction.id } })).toMatchObject({
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
closeAt: new Date('2099-09-01T00:10:00.000Z'),
});
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
await db.general.delete({ where: { id: general.id } });
await db.worldState.delete({ where: { id: row.id } });
});
it('reprojects tick-owned dates for a live turn-term change without rewriting existing log timestamps', async () => {
const base = new Date('2099-08-01T10:00:00.000Z');
const row = await db.worldState.create({
@@ -14,6 +14,103 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it('durably rebases a long realtime backlog before executing another turn', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime());
const controlQueue = new InMemoryControlQueue();
const processor = { run: vi.fn() };
let needsRebase = true;
const flushChanges = vi.fn(async () => {});
const publishEvents = vi.fn(async () => {
controlQueue.enqueue({ type: 'shutdown', reason: 'rebase verified' });
});
const lifecycle = new TurnDaemonLifecycle(
{
clock,
controlQueue,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
loadLastTurnTime: async () => new Date('2026-08-22T16:35:00.000Z'),
loadNextGeneralTurnTime: async () => new Date('2026-08-22T16:36:00.000Z'),
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({ mode: 'realtime', now: wallNow }),
shouldRebaseRealtimeBacklog: async () => needsRebase,
rebaseRealtimeBacklog: async () => {
needsRebase = false;
return {
skippedTurns: 108,
shiftedTicks: 108 * 36_000_000,
lastTurnTime: '2026-08-23T01:35:00.000Z',
};
},
},
processor,
hooks: { flushChanges, publishEvents },
},
{
profile: 'realtime-resume-rebase',
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
}
);
await lifecycle.start();
expect(flushChanges).toHaveBeenCalledOnce();
expect(publishEvents).toHaveBeenCalledOnce();
expect(processor.run).not.toHaveBeenCalled();
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-23T01:35:00.000Z');
});
it('does not execute overdue turns when the realtime backlog rebase fails to flush', async () => {
const wallNow = new Date('2026-08-23T01:35:00.000Z');
const clock = new ManualClock(wallNow.getTime());
const controlQueue = new InMemoryControlQueue();
const processor = { run: vi.fn() };
const failure = new Error('rebase flush failed');
const onRunError = vi.fn(async () => {
controlQueue.enqueue({ type: 'shutdown', reason: 'failure verified' });
});
const lifecycle = new TurnDaemonLifecycle(
{
clock,
controlQueue,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
loadLastTurnTime: async () => new Date('2026-08-22T16:35:00.000Z'),
loadNextGeneralTurnTime: async () => new Date('2026-08-22T16:36:00.000Z'),
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
loadGameClock: async () => ({ mode: 'realtime', now: wallNow }),
shouldRebaseRealtimeBacklog: async () => true,
rebaseRealtimeBacklog: async () => ({
skippedTurns: 108,
shiftedTicks: 108 * 36_000_000,
lastTurnTime: wallNow.toISOString(),
}),
},
processor,
hooks: {
flushChanges: async () => {
throw failure;
},
onRunError,
},
},
{
profile: 'realtime-resume-rebase-flush-failure',
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
}
);
await lifecycle.start();
expect(onRunError).toHaveBeenCalledWith(failure);
expect(processor.run).not.toHaveBeenCalled();
});
it('does not schedule another processor run after the world reaches a terminal united state', async () => {
const clock = new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime());
const controlQueue = new InMemoryControlQueue();
+13 -5
View File
@@ -31,11 +31,19 @@ Redis 투영은 DB commit 뒤 action ID로 멱등 적용됩니다.
## 중단 후 재개
운영 중단 시간을 따라잡지 않으려면 Gateway의 일정 지연/가속 작업을 사용해
표시 기준시각을 옮깁니다. 이 작업은 `clock_base_time`과 DateTime 투영값을
같이 이동하고, game tick 및 장수 턴 tick은 바꾸지 않습니다. 동시에
`clock_wall_anchor`를 작업 실행 시각으로 다시 고정하므로 장기간 중단 뒤에도
누락된 기간의 턴을 몰아서 실행하지 않습니다.
realtime daemon은 재개할 때 Ref `checkDelay()`와 같은 한도를 적용합니다.
밀린 완전 턴 수가 턴 간격 20분 이상이면 1턴, 10분 이상이면 3턴, 그보다
짧으면 6턴을 초과할 때 장기 중단으로 봅니다. 한도 이내의 짧은 중단은 턴을
순서대로 실행해 따라잡고, 한도를 넘으면 밀린 완전 턴만큼 `last_turn_tick`,
전 장수의 `turn_tick`, 미완료 경매의 `close_tick`과 각각의 DateTime 투영값을
한 transaction에서 옮깁니다. 이 보정은 명령이나 월 이벤트를 실행하지 않으므로
건너뛴 기간의 RNG를 소비하지 않습니다. 이미 처리 중 anchor 갱신으로 표시
시각이 늦어진 상태도 현재 wall time에 맞추되 game tick은 되감지 않습니다.
운영자가 명시적으로 일정을 지연하거나 가속하려면 Gateway 작업을 사용합니다.
이 작업은 `clock_base_time`과 DateTime 투영값을 같이 이동하고, game tick 및
장수 턴 tick은 바꾸지 않습니다. 동시에 `clock_wall_anchor`를 작업 실행
시각으로 다시 고정합니다.
DB migration은 기존 DateTime 값에서 tick을 채웁니다. 새 설치와 migration
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은