refactor engine state transactions
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user