fix(engine): 서버 재개 시 게임 시계를 Ref 기준으로 보정한다

짧은 중단은 순서대로 따라잡고 장기 중단은 턴 간격별 Ref 한도를 넘어선 완전 턴을 일괄 이동한다. 장수와 미완료 경매 시각을 같은 트랜잭션에서 저장하고 표시 시각 지연을 복구한다.
This commit is contained in:
2026-08-23 02:00:45 +00:00
parent ade543f936
commit eaf7ef8c22
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);