feat: add logical game clock

This commit is contained in:
2026-08-04 02:51:27 +00:00
parent 87965a39d6
commit a26031dc3f
51 changed files with 1605 additions and 398 deletions
@@ -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();
+28 -2
View File
@@ -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();
+7
View File
@@ -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 () => {