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