fix(engine): 서버 재개 시 게임 시계를 Ref 기준으로 보정한다
짧은 중단은 순서대로 따라잡고 장기 중단은 턴 간격별 Ref 한도를 넘어선 완전 턴을 일괄 이동한다. 장수와 미완료 경매 시각을 같은 트랜잭션에서 저장하고 표시 시각 지연을 복구한다.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 같은
|
||||
|
||||
Reference in New Issue
Block a user