235 lines
7.9 KiB
TypeScript
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);
|
|
});
|
|
});
|