feat: add logical game clock
This commit is contained in:
@@ -257,7 +257,7 @@ describe('EngineStateManager', () => {
|
||||
store.replaceGeneralTurns(1, { action: '훈련', args: { amount: 10 } });
|
||||
const manager = new EngineStateManager();
|
||||
manager.register('reservedTurns', {
|
||||
capture: () => store.captureState(),
|
||||
capture: () => store.captureTransactionState(),
|
||||
restore: (snapshot) => store.restoreState(snapshot),
|
||||
});
|
||||
const before = store.captureState();
|
||||
|
||||
@@ -36,7 +36,7 @@ const buildGeneral = (id: number, turnTime: string): TurnGeneral =>
|
||||
npcState: 0,
|
||||
}) as TurnGeneral;
|
||||
|
||||
const buildWorld = (): InMemoryTurnWorld => {
|
||||
const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 190,
|
||||
@@ -50,6 +50,7 @@ const buildWorld = (): InMemoryTurnWorld => {
|
||||
tnmt_time: '2026-07-30 11:30:00',
|
||||
untouched: 'keep',
|
||||
},
|
||||
...stateOverride,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [buildGeneral(1, '2026-07-30T10:10:00.000Z'), buildGeneral(2, '2026-07-30T10:20:00.000Z')],
|
||||
@@ -134,6 +135,29 @@ describe('runtime clock shift', () => {
|
||||
tnmt_time: '2026-07-30 11:15:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('rebases after two years of downtime without catching up missed turns', () => {
|
||||
const wallAnchor = new Date('2026-07-30T10:00:00.000Z');
|
||||
const resumedAt = new Date('2028-07-29T10:00:00.000Z');
|
||||
const deltaMinutes = 2 * 365 * 24 * 60;
|
||||
const world = buildWorld({
|
||||
clockBaseTime: wallAnchor,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: wallAnchor,
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
|
||||
const beforeTurnTick = world.getGeneralById(1)?.turnTick;
|
||||
world.shiftSchedule(deltaMinutes, resumedAt);
|
||||
|
||||
expect(world.getGameNow(resumedAt).toISOString()).toBe('2028-07-29T10:00:00.000Z');
|
||||
expect(world.getGameNow(new Date(resumedAt.getTime() + 10 * 60_000)).toISOString()).toBe(
|
||||
'2028-07-29T10:10:00.000Z'
|
||||
);
|
||||
expect(world.getGeneralById(1)?.turnTick).toBe(beforeTurnTick);
|
||||
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime clock shift projection', () => {
|
||||
@@ -186,7 +210,9 @@ describe('runtime clock shift projection', () => {
|
||||
),
|
||||
},
|
||||
auction: {
|
||||
findMany: vi.fn(async () => [{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z') }]),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, closeAt: new Date('2026-07-30T11:45:00.000Z'), closeTick: null },
|
||||
]),
|
||||
},
|
||||
} as unknown as GamePrismaClient;
|
||||
const values = new Map<string, string>([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { SystemClock } from '../src/lifecycle/clock.js';
|
||||
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
|
||||
import { getNextTickTime } from '../src/lifecycle/getNextTickTime.js';
|
||||
@@ -73,12 +74,14 @@ integration('runtime clock shift persistence', () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: 'runtime-clock-shift' } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
@@ -90,6 +93,11 @@ integration('runtime clock shift persistence', () => {
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
clockBaseTime: base,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: base,
|
||||
lastTurnTick: 0,
|
||||
config: {},
|
||||
meta: {
|
||||
lastTurnTime: base.toISOString(),
|
||||
@@ -110,6 +118,7 @@ integration('runtime clock shift persistence', () => {
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
turnTime: general.turnTime,
|
||||
turnTick: BigInt((general.id === generalIds[0] ? 1 : 2) * GAME_TICKS_PER_TURN),
|
||||
})),
|
||||
});
|
||||
const auctionRows = await Promise.all(
|
||||
@@ -132,6 +141,11 @@ integration('runtime clock shift persistence', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: base,
|
||||
clockBaseTime: base,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: base,
|
||||
lastTurnTick: 0,
|
||||
meta: row.meta as Record<string, unknown>,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
@@ -229,13 +243,16 @@ integration('runtime clock shift persistence', () => {
|
||||
generalId: 0,
|
||||
});
|
||||
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: row.id } })).meta).toMatchObject({
|
||||
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
||||
expect(storedWorld.meta).toMatchObject({
|
||||
lastTurnTime: '2099-07-30T09:45:00.000Z',
|
||||
starttime: '2099-06-30 23:45:00',
|
||||
});
|
||||
expect((await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } })).turnTime.toISOString()).toBe(
|
||||
'2099-07-30T10:05:00.000Z'
|
||||
);
|
||||
expect(storedWorld.clockTick).toBe(0n);
|
||||
expect(storedWorld.lastTurnTick).toBe(0n);
|
||||
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
|
||||
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
|
||||
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
|
||||
const storedAuctions = await db.auction.findMany({
|
||||
where: { id: { in: auctionRows.map((auction) => auction.id) } },
|
||||
});
|
||||
|
||||
@@ -14,6 +14,173 @@ import {
|
||||
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
|
||||
|
||||
describe('TurnDaemonLifecycle', () => {
|
||||
it('runs manual game time to each monthly snapshot without waiting for wall time', async () => {
|
||||
const wallNow = new Date('2026-01-01T00:00:00.000Z');
|
||||
const operationalClock = new ManualClock(wallNow.getTime());
|
||||
const queue = new InMemoryControlQueue();
|
||||
let lastTurnTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
let gameNow = new Date(lastTurnTime);
|
||||
const targets: string[] = [];
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: operationalClock,
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => lastTurnTime,
|
||||
loadNextGeneralTurnTime: async () => addMinutes(lastTurnTime, 30),
|
||||
saveLastTurnTime: async (value) => {
|
||||
lastTurnTime = value;
|
||||
},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
loadGameClock: async () => ({ mode: 'manual', now: gameNow }),
|
||||
advanceGameClockTo: async (target) => {
|
||||
gameNow = target;
|
||||
},
|
||||
},
|
||||
processor: {
|
||||
run: async (target): Promise<TurnRunResult> => {
|
||||
targets.push(target.toISOString());
|
||||
if (targets.length === 3) {
|
||||
queue.enqueue({ type: 'shutdown', reason: 'verified' });
|
||||
}
|
||||
return {
|
||||
lastTurnTime: target.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: 'manual-clock',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
await lifecycle.start();
|
||||
|
||||
expect(targets).toEqual(['2042-01-01T01:00:00.000Z', '2042-01-01T02:00:00.000Z', '2042-01-01T03:00:00.000Z']);
|
||||
expect(operationalClock.nowMs()).toBe(wallNow.getTime());
|
||||
});
|
||||
|
||||
it('drains restart-overdue generals without advancing or catching up a month', async () => {
|
||||
const gameNow = new Date('2042-01-01T03:00:00.000Z');
|
||||
const queue = new InMemoryControlQueue();
|
||||
const observedTargets: Date[] = [];
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => gameNow,
|
||||
loadNextGeneralTurnTime: async () => addMinutes(gameNow, -30),
|
||||
saveLastTurnTime: async () => {},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
loadGameClock: async () => ({ mode: 'manual', now: gameNow }),
|
||||
advanceGameClockTo: async () => {},
|
||||
},
|
||||
processor: {
|
||||
run: async (target): Promise<TurnRunResult> => {
|
||||
observedTargets.push(target);
|
||||
queue.enqueue({ type: 'shutdown', reason: 'verified' });
|
||||
return {
|
||||
lastTurnTime: gameNow.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: 'manual-overdue',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
await lifecycle.start();
|
||||
|
||||
expect(observedTargets[0]?.toISOString()).toBe('2042-01-01T02:59:59.999Z');
|
||||
});
|
||||
|
||||
it('produces the same command, RNG, and resource state in realtime and manual modes', async () => {
|
||||
const start = new Date('2042-01-01T00:00:00.000Z');
|
||||
const runMode = async (mode: 'realtime' | 'manual') => {
|
||||
const operationalClock = new ManualClock(
|
||||
mode === 'realtime' ? start.getTime() + 3 * 60 * 60_000 : start.getTime()
|
||||
);
|
||||
const queue = new InMemoryControlQueue();
|
||||
let lastTurnTime = new Date(start);
|
||||
let gameNow = new Date(start);
|
||||
let rng = 17;
|
||||
let resource = 100;
|
||||
const commands: string[] = [];
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: operationalClock,
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => lastTurnTime,
|
||||
loadNextGeneralTurnTime: async () => null,
|
||||
saveLastTurnTime: async (value) => {
|
||||
lastTurnTime = value;
|
||||
},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
loadGameClock: async (wallNow) => ({
|
||||
mode,
|
||||
now:
|
||||
mode === 'manual'
|
||||
? gameNow
|
||||
: new Date(start.getTime() + ((wallNow ?? start).getTime() - start.getTime())),
|
||||
}),
|
||||
advanceGameClockTo: async (target) => {
|
||||
gameNow = target;
|
||||
},
|
||||
},
|
||||
processor: {
|
||||
run: async (target): Promise<TurnRunResult> => {
|
||||
while (lastTurnTime.getTime() < target.getTime()) {
|
||||
lastTurnTime = addMinutes(lastTurnTime, 60);
|
||||
rng = (rng * 48_271) % 2_147_483_647;
|
||||
const command = rng % 2 === 0 ? 'develop' : 'train';
|
||||
commands.push(command);
|
||||
resource += command === 'develop' ? 7 : -3;
|
||||
}
|
||||
if (commands.length >= 3) {
|
||||
queue.enqueue({ type: 'shutdown', reason: `${mode} verified` });
|
||||
}
|
||||
return {
|
||||
lastTurnTime: lastTurnTime.toISOString(),
|
||||
processedGenerals: commands.length,
|
||||
processedTurns: commands.length,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profile: `${mode}-equivalence`,
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 10 },
|
||||
}
|
||||
);
|
||||
|
||||
await lifecycle.start();
|
||||
return { commands, rng, resource, lastTurnTime: lastTurnTime.toISOString() };
|
||||
};
|
||||
|
||||
expect(await runMode('manual')).toEqual(await runMode('realtime'));
|
||||
});
|
||||
|
||||
it('restores engine state when a scheduled calculation throws', async () => {
|
||||
const now = new Date('2026-01-01T00:10:00.000Z');
|
||||
const queue = new InMemoryControlQueue();
|
||||
|
||||
@@ -170,6 +170,13 @@ describe('InMemoryTurnProcessor ordering', () => {
|
||||
expect(world.getNextGeneralId()).toBe(4);
|
||||
expect(world.getNextGeneralId()).toBe(5);
|
||||
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
|
||||
|
||||
const overdue = world.getGeneralById(1);
|
||||
expect(overdue).toBeDefined();
|
||||
overdue!.turnTime = addMinutes(baseTime, 5);
|
||||
const overdueResult = await processor.run(addMinutes(baseTime, 5), budget);
|
||||
expect(overdueResult.processedGenerals).toBe(1);
|
||||
expect(executed.at(-1)).toBe(1);
|
||||
});
|
||||
|
||||
it('stops catch-up immediately after a calendar handler finalizes unification', async () => {
|
||||
|
||||
Reference in New Issue
Block a user