Files
core2026/app/game-engine/test/engineStateManager.test.ts

235 lines
7.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { LogCategory, LogScope } from '@sammo-ts/logic';
const buildWorld = (): InMemoryTurnWorld => {
const baseTime = new Date('0189-01-01T00:00:00Z');
const general: TurnGeneral = {
id: 1,
name: 'General_1',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
turnTime: baseTime,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, nested: { value: 1 } },
officerLevel: 5,
experience: 0,
dedication: 0,
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
};
const snapshot: TurnWorldSnapshot = {
generals: [general],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test_map', unitSet: 'default' },
},
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: baseTime,
meta: { killturn: 24 },
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
describe('EngineStateManager', () => {
it('commits all registered state participants as one revision', async () => {
const manager = new EngineStateManager();
let worldValue = 1;
let queueValue = 10;
manager.register('world', {
capture: () => worldValue,
restore: (value) => {
worldValue = value;
},
});
manager.register('queue', {
capture: () => queueValue,
restore: (value) => {
queueValue = value;
},
});
const result = await manager.transaction(() => {
worldValue = 2;
queueValue = 20;
return 'committed';
});
expect(result).toBe('committed');
expect({ worldValue, queueValue }).toEqual({ worldValue: 2, queueValue: 20 });
expect(manager.getRevision()).toBe(1);
});
it('restores every participant and keeps the revision when a calculation fails', async () => {
const manager = new EngineStateManager();
let worldValue = { value: 1 };
let queueValue = ['before'];
manager.register('world', {
capture: () => structuredClone(worldValue),
restore: (value) => {
worldValue = value;
},
});
manager.register('queue', {
capture: () => structuredClone(queueValue),
restore: (value) => {
queueValue = value;
},
});
await expect(
manager.transaction(() => {
worldValue.value = 2;
queueValue.push('partial');
throw new Error('calculation failed');
})
).rejects.toThrow('calculation failed');
expect(worldValue).toEqual({ value: 1 });
expect(queueValue).toEqual(['before']);
expect(manager.getRevision()).toBe(0);
expect(manager.isTransactionActive()).toBe(false);
});
it('restores the world entities, nested metadata, checkpoint and dirty journal', async () => {
const world = buildWorld();
const manager = new EngineStateManager();
manager.register('world', {
capture: () => world.captureState(),
restore: (snapshot) => world.restoreState(snapshot),
inspect: () => world.inspectState(),
});
const before = manager.inspect();
await expect(
manager.transaction(() => {
const general = world.getGeneralById(1);
if (!general) {
throw new Error('fixture general missing');
}
world.updateGeneral(1, {
gold: 25,
meta: { ...general.meta, nested: { value: 2 } },
});
world.addEvent({
id: 10,
targetCode: 'test',
priority: 1,
condition: [],
action: [],
meta: { phase: 'partial' },
});
world.setCheckpoint({
turnTime: new Date('0189-01-01T00:10:00Z').toISOString(),
generalId: 1,
year: 189,
month: 1,
});
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: 1,
text: 'partial',
});
throw new Error('handler failed');
})
).rejects.toThrow('handler failed');
expect(manager.inspect()).toEqual(before);
expect(world.peekDirtyState().logs).toEqual([]);
expect(world.listEvents()).toEqual([]);
});
it('creates reusable test savepoints without sharing mutable inspection data', async () => {
const manager = new EngineStateManager();
let state = { nested: { value: 1 } };
manager.register('world', {
capture: () => structuredClone(state),
restore: (snapshot) => {
state = snapshot;
},
inspect: () => state,
});
const baseline = manager.capture();
await manager.transaction(() => {
state.nested.value = 2;
});
const inspected = manager.inspect();
(inspected.participants.world as typeof state).nested.value = 99;
expect(state.nested.value).toBe(2);
manager.restore(baseline);
expect(state.nested.value).toBe(1);
expect(manager.getRevision()).toBe(2);
});
it('rolls the reserved-turn queue and its dirty journal back with the world', async () => {
const store = new InMemoryReservedTurnStore(
{
generalTurn: { findMany: async () => [] },
nationTurn: { findMany: async () => [] },
} as never,
{ maxGeneralTurns: 2, maxNationTurns: 1 }
);
store.replaceGeneralTurns(1, { action: '훈련', args: { amount: 10 } });
const manager = new EngineStateManager();
manager.register('reservedTurns', {
capture: () => store.captureState(),
restore: (snapshot) => store.restoreState(snapshot),
});
const before = store.captureState();
await expect(
manager.transaction(() => {
store.shiftGeneralTurns(1, -1);
store.ensureGeneralTurns(2);
throw new Error('turn calculation failed');
})
).rejects.toThrow('turn calculation failed');
expect(store.captureState()).toEqual(before);
});
});