refactor engine state transactions

This commit is contained in:
2026-07-28 01:53:48 +00:00
parent d17261f75a
commit 19a80127c6
8 changed files with 673 additions and 12 deletions
+1
View File
@@ -17,6 +17,7 @@ export * from './scenario/scenarioSeeder.js';
export * from './turn/types.js';
export * from './turn/worldLoader.js';
export * from './turn/inMemoryWorld.js';
export * from './turn/engineStateManager.js';
export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
@@ -15,6 +15,7 @@ import type {
TurnDaemonCommandResult,
TurnDaemonCommandExecutionContext,
} from './types.js';
import type { EngineStateManager } from '../turn/engineStateManager.js';
type PendingRun = {
reason: RunReason;
@@ -43,6 +44,7 @@ export interface TurnDaemonLifecycleDeps {
commandHandler?: TurnDaemonCommandHandler;
commandResponder?: TurnDaemonCommandResponder;
pauseGate?: () => Promise<boolean>;
stateManager?: Pick<EngineStateManager, 'transaction'>;
}
export class TurnDaemonLifecycle {
@@ -56,6 +58,7 @@ export class TurnDaemonLifecycle {
private readonly commandHandler?: TurnDaemonCommandHandler;
private readonly commandResponder?: TurnDaemonCommandResponder;
private readonly pauseGate?: () => Promise<boolean>;
private readonly stateManager?: Pick<EngineStateManager, 'transaction'>;
private readonly options: TurnDaemonLifecycleOptions;
private status: TurnDaemonStatus;
@@ -75,6 +78,7 @@ export class TurnDaemonLifecycle {
this.commandHandler = deps.commandHandler;
this.commandResponder = deps.commandResponder;
this.pauseGate = deps.pauseGate;
this.stateManager = deps.stateManager;
this.options = options;
this.status = {
state: 'idle',
@@ -272,15 +276,18 @@ export class TurnDaemonLifecycle {
const executeHandler = async (
context?: TurnDaemonCommandExecutionContext
): Promise<TurnDaemonCommandResult> => {
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
return (
handled ?? {
type: 'commandRejected',
ok: false,
commandType: command.type,
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
}
);
const execute = async (): Promise<TurnDaemonCommandResult> => {
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
return (
handled ?? {
type: 'commandRejected',
ok: false,
commandType: command.type,
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
}
);
};
return this.stateManager ? this.stateManager.transaction(execute) : execute();
};
try {
if (command.requestId && this.hooks?.executeCommand) {
@@ -331,7 +338,8 @@ export class TurnDaemonLifecycle {
let result: TurnRunResult;
try {
result = await this.processor.run(targetTime, budget, checkpoint);
const runProcessor = () => this.processor.run(targetTime, budget, checkpoint);
result = this.stateManager ? await this.stateManager.transaction(runProcessor) : await runProcessor();
} catch (error) {
this.status.running = false;
this.status.state = 'paused';
@@ -0,0 +1,153 @@
export interface EngineStateParticipant<T> {
capture(): T;
restore(snapshot: T): void;
inspect?(): unknown;
}
type RegisteredParticipant = {
capture(): unknown;
restore(snapshot: unknown): void;
inspect?(): unknown;
};
type CapturedParticipant = {
name: string;
snapshot: unknown;
};
export interface EngineStateInspection {
revision: number;
transactionActive: boolean;
participants: Readonly<Record<string, unknown>>;
}
export class EngineStateSavepoint {
readonly revision: number;
readonly participants: readonly CapturedParticipant[];
readonly owner: symbol;
constructor(owner: symbol, revision: number, participants: CapturedParticipant[]) {
this.owner = owner;
this.revision = revision;
this.participants = participants;
}
}
/**
* Coordinates the mutable state owned by the game-engine worker.
*
* The manager deliberately knows nothing about PostgreSQL or Redis. Database
* transactions remain in databaseHooks; this boundary only prevents a failed
* calculation from leaving a partially-mutated in-memory world behind.
*/
export class EngineStateManager {
private readonly owner = Symbol('EngineStateManager');
private readonly participants = new Map<string, RegisteredParticipant>();
private revision = 0;
private transactionActive = false;
register<T>(name: string, participant: EngineStateParticipant<T>): void {
if (this.transactionActive) {
throw new Error('Cannot register engine state while a transaction is active.');
}
if (!name) {
throw new Error('Engine state participant name is required.');
}
if (this.participants.has(name)) {
throw new Error(`Engine state participant is already registered: ${name}`);
}
this.participants.set(name, {
capture: participant.capture,
restore: (snapshot) => participant.restore(snapshot as T),
inspect: participant.inspect,
});
}
getRevision(): number {
return this.revision;
}
isTransactionActive(): boolean {
return this.transactionActive;
}
capture(): EngineStateSavepoint {
const captured: CapturedParticipant[] = [];
for (const [name, participant] of this.participants) {
captured.push({ name, snapshot: participant.capture() });
}
return new EngineStateSavepoint(this.owner, this.revision, captured);
}
restore(savepoint: EngineStateSavepoint): void {
if (this.transactionActive) {
throw new Error('Cannot restore engine state while a transaction is active.');
}
this.assertOwnedSavepoint(savepoint);
this.restoreParticipants(savepoint);
this.revision += 1;
}
inspect(): EngineStateInspection {
const participants: Record<string, unknown> = {};
for (const [name, participant] of this.participants) {
participants[name] = structuredClone(participant.inspect ? participant.inspect() : participant.capture());
}
return {
revision: this.revision,
transactionActive: this.transactionActive,
participants,
};
}
async transaction<T>(operation: () => Promise<T> | T): Promise<T> {
if (this.transactionActive) {
throw new Error('Nested engine state transactions are not supported.');
}
const savepoint = this.capture();
this.transactionActive = true;
try {
const result = await operation();
this.revision += 1;
return result;
} catch (error) {
try {
this.restoreParticipants(savepoint);
} catch (restoreError) {
throw new AggregateError(
[error, restoreError],
'Engine state operation failed and its in-memory state could not be restored.',
{ cause: restoreError }
);
}
throw error;
} finally {
this.transactionActive = false;
}
}
private assertOwnedSavepoint(savepoint: EngineStateSavepoint): void {
if (savepoint.owner !== this.owner) {
throw new Error('Engine state savepoint belongs to a different manager.');
}
}
private restoreParticipants(savepoint: EngineStateSavepoint): void {
this.assertOwnedSavepoint(savepoint);
const currentNames = Array.from(this.participants.keys());
const capturedNames = savepoint.participants.map(({ name }) => name);
if (
currentNames.length !== capturedNames.length ||
currentNames.some((name, index) => name !== capturedNames[index])
) {
throw new Error('Engine state participant set changed after the savepoint was captured.');
}
for (let index = savepoint.participants.length - 1; index >= 0; index -= 1) {
const captured = savepoint.participants[index];
if (!captured) {
continue;
}
this.participants.get(captured.name)?.restore(captured.snapshot);
}
}
}
+149
View File
@@ -119,6 +119,52 @@ export interface TurnWorldChanges {
pendingNationBettingFinishes: PendingNationBettingFinish[];
}
export interface InMemoryTurnWorldStateSnapshot {
schedule: TurnSchedule;
state: TurnWorldState;
checkpoint?: TurnCheckpoint;
generals: Array<[number, TurnGeneral]>;
cities: Array<[number, City]>;
nations: Array<[number, Nation]>;
troops: Array<[number, Troop]>;
diplomacy: Array<[string, TurnDiplomacy]>;
events: Array<[number, TurnEvent]>;
dirtyGeneralIds: number[];
dirtyCityIds: number[];
dirtyNationIds: number[];
dirtyTroopIds: number[];
dirtyDiplomacyKeys: string[];
createdGeneralIds: number[];
createdNationIds: number[];
createdTroopIds: number[];
createdDiplomacyKeys: string[];
createdEventIds: number[];
deletedTroopIds: number[];
deletedGeneralIds: number[];
deletedNationIds: number[];
deletedEventIds: number[];
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
logs: LogEntryDraft[];
messages: MessageDraft[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>;
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
}
export interface InMemoryTurnWorldInspection {
state: TurnWorldState;
checkpoint?: TurnCheckpoint;
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
troops: Troop[];
diplomacy: TurnDiplomacy[];
events: TurnEvent[];
changes: TurnWorldChanges;
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
if (timeDiff !== 0) {
@@ -335,6 +381,91 @@ export class InMemoryTurnWorld {
this.ensureDiplomacyMatrix();
}
captureState(): InMemoryTurnWorldStateSnapshot {
return structuredClone({
schedule: this.schedule,
state: this.state,
checkpoint: this.checkpoint,
generals: Array.from(this.generals.entries()),
cities: Array.from(this.cities.entries()),
nations: Array.from(this.nations.entries()),
troops: Array.from(this.troops.entries()),
diplomacy: Array.from(this.diplomacy.entries()),
events: Array.from(this.events.entries()),
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
dirtyCityIds: Array.from(this.dirtyCityIds),
dirtyNationIds: Array.from(this.dirtyNationIds),
dirtyTroopIds: Array.from(this.dirtyTroopIds),
dirtyDiplomacyKeys: Array.from(this.dirtyDiplomacyKeys),
createdGeneralIds: Array.from(this.createdGeneralIds),
createdNationIds: Array.from(this.createdNationIds),
createdTroopIds: Array.from(this.createdTroopIds),
createdDiplomacyKeys: Array.from(this.createdDiplomacyKeys),
createdEventIds: Array.from(this.createdEventIds),
deletedTroopIds: Array.from(this.deletedTroopIds),
deletedGeneralIds: Array.from(this.deletedGeneralIds),
deletedNationIds: Array.from(this.deletedNationIds),
deletedEventIds: Array.from(this.deletedEventIds),
deletedNationSnapshots: this.deletedNationSnapshots,
logs: this.logs,
messages: this.messages,
lifecycleEvents: this.lifecycleEvents,
pendingNeutralAuctions: this.pendingNeutralAuctions,
inheritancePointAdjustments: this.inheritancePointAdjustments,
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
} satisfies InMemoryTurnWorldStateSnapshot);
}
restoreState(snapshot: InMemoryTurnWorldStateSnapshot): void {
const restored = structuredClone(snapshot);
this.schedule = restored.schedule;
this.state = restored.state;
this.checkpoint = restored.checkpoint;
this.replaceMap(this.generals, restored.generals);
this.replaceMap(this.cities, restored.cities);
this.replaceMap(this.nations, restored.nations);
this.replaceMap(this.troops, restored.troops);
this.replaceMap(this.diplomacy, restored.diplomacy);
this.replaceMap(this.events, restored.events);
this.replaceSet(this.dirtyGeneralIds, restored.dirtyGeneralIds);
this.replaceSet(this.dirtyCityIds, restored.dirtyCityIds);
this.replaceSet(this.dirtyNationIds, restored.dirtyNationIds);
this.replaceSet(this.dirtyTroopIds, restored.dirtyTroopIds);
this.replaceSet(this.dirtyDiplomacyKeys, restored.dirtyDiplomacyKeys);
this.replaceSet(this.createdGeneralIds, restored.createdGeneralIds);
this.replaceSet(this.createdNationIds, restored.createdNationIds);
this.replaceSet(this.createdTroopIds, restored.createdTroopIds);
this.replaceSet(this.createdDiplomacyKeys, restored.createdDiplomacyKeys);
this.replaceSet(this.createdEventIds, restored.createdEventIds);
this.replaceSet(this.deletedTroopIds, restored.deletedTroopIds);
this.replaceSet(this.deletedGeneralIds, restored.deletedGeneralIds);
this.replaceSet(this.deletedNationIds, restored.deletedNationIds);
this.replaceSet(this.deletedEventIds, restored.deletedEventIds);
this.replaceArray(this.deletedNationSnapshots, restored.deletedNationSnapshots);
this.replaceArray(this.logs, restored.logs);
this.replaceArray(this.messages, restored.messages);
this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents);
this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions);
this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments);
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
}
inspectState(): InMemoryTurnWorldInspection {
return structuredClone({
state: this.state,
checkpoint: this.checkpoint,
generals: Array.from(this.generals.values()),
cities: Array.from(this.cities.values()),
nations: Array.from(this.nations.values()),
troops: Array.from(this.troops.values()),
diplomacy: Array.from(this.diplomacy.values()),
events: Array.from(this.events.values()),
changes: this.peekDirtyState(),
} satisfies InMemoryTurnWorldInspection);
}
getState(): TurnWorldState {
return { ...this.state };
}
@@ -1166,4 +1297,22 @@ export class InMemoryTurnWorld {
}
}
}
private replaceMap<K, V>(target: Map<K, V>, entries: Array<[K, V]>): void {
target.clear();
for (const [key, value] of entries) {
target.set(key, value);
}
}
private replaceSet<T>(target: Set<T>, values: T[]): void {
target.clear();
for (const value of values) {
target.add(value);
}
}
private replaceArray<T>(target: T[], values: T[]): void {
target.splice(0, target.length, ...values);
}
}
@@ -94,6 +94,17 @@ export interface ReservedTurnChanges {
nationLeaseKeys: string[];
}
export interface InMemoryReservedTurnStateSnapshot {
generalTurns: Array<[number, ReservedTurnEntry[]]>;
nationTurns: Array<[string, ReservedTurnEntry[]]>;
dirtyGeneralIds: number[];
dirtyNationKeys: string[];
pendingGeneralInitializationIds: number[];
pendingNationInitializationKeys: string[];
leasedGeneralIds: number[];
leasedNationKeys: string[];
}
export class ReservedTurnLeaseConflictError extends Error {
constructor(readonly queueKey: string) {
super(`Reserved turn queue lease conflict: ${queueKey}.`);
@@ -130,6 +141,35 @@ export class InMemoryReservedTurnStore {
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
}
captureState(): InMemoryReservedTurnStateSnapshot {
return structuredClone({
generalTurns: Array.from(this.generalTurns.entries()),
nationTurns: Array.from(this.nationTurns.entries()),
dirtyGeneralIds: Array.from(this.dirtyGeneralIds),
dirtyNationKeys: Array.from(this.dirtyNationKeys),
pendingGeneralInitializationIds: Array.from(this.pendingGeneralInitializationIds),
pendingNationInitializationKeys: Array.from(this.pendingNationInitializationKeys),
leasedGeneralIds: Array.from(this.leasedGeneralIds),
leasedNationKeys: Array.from(this.leasedNationKeys),
} satisfies InMemoryReservedTurnStateSnapshot);
}
restoreState(snapshot: InMemoryReservedTurnStateSnapshot): void {
const restored = structuredClone(snapshot);
this.replaceMap(this.generalTurns, restored.generalTurns);
this.replaceMap(this.nationTurns, restored.nationTurns);
this.replaceSet(this.dirtyGeneralIds, restored.dirtyGeneralIds);
this.replaceSet(this.dirtyNationKeys, restored.dirtyNationKeys);
this.replaceSet(this.pendingGeneralInitializationIds, restored.pendingGeneralInitializationIds);
this.replaceSet(this.pendingNationInitializationKeys, restored.pendingNationInitializationKeys);
this.replaceSet(this.leasedGeneralIds, restored.leasedGeneralIds);
this.replaceSet(this.leasedNationKeys, restored.leasedNationKeys);
}
inspectState(): InMemoryReservedTurnStateSnapshot {
return this.captureState();
}
async loadAll(): Promise<void> {
const [generalRows, nationRows] = await Promise.all([
this.prisma.generalTurn.findMany(),
@@ -164,6 +204,20 @@ export class InMemoryReservedTurnStore {
}
}
private replaceMap<K, V>(target: Map<K, V>, entries: Array<[K, V]>): void {
target.clear();
for (const [key, value] of entries) {
target.set(key, value);
}
}
private replaceSet<T>(target: Set<T>, values: T[]): void {
target.clear();
for (const value of values) {
target.add(value);
}
}
private getLeaseExpiresAt(): Date {
return new Date(Date.now() + this.leaseDurationMs);
}
+36 -1
View File
@@ -75,6 +75,7 @@ import {
} from './monthlyCoreEventAction.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
import { EngineStateManager } from './engineStateManager.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -106,6 +107,7 @@ export interface TurnDaemonRuntime {
world: InMemoryTurnWorld;
controlQueue: TurnDaemonControlQueue;
stateStore: InMemoryTurnStateStore;
stateManager: EngineStateManager;
processor: InMemoryTurnProcessor;
hooks?: TurnDaemonHooks;
close(): Promise<void>;
@@ -475,6 +477,7 @@ const createTurnDaemonRuntimeWithLease = async (
);
let occupiedAuctionUniqueItemKeys: string[] = [];
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
const prefetchedNationTurns = new Set<string>();
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler:
@@ -496,8 +499,38 @@ const createTurnDaemonRuntimeWithLease = async (
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (captured) => world.restoreState(captured),
inspect: () => world.inspectState(),
});
if (reservedTurnStoreHandle) {
stateManager.register('reservedTurns', {
capture: () => reservedTurnStoreHandle.store.captureState(),
restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured),
inspect: () => reservedTurnStoreHandle.store.inspectState(),
});
}
stateManager.register('runtimeCaches', {
capture: () => ({
monthlyNationPowerRollCount,
monthlyTournamentRollConsumed,
occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys],
prefetchedNationTurns: Array.from(prefetchedNationTurns),
}),
restore: (captured) => {
monthlyNationPowerRollCount = captured.monthlyNationPowerRollCount;
monthlyTournamentRollConsumed = captured.monthlyTournamentRollConsumed;
occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys];
prefetchedNationTurns.clear();
for (const key of captured.prefetchedNationTurns) {
prefetchedNationTurns.add(key);
}
},
});
const stateStore = new InMemoryTurnStateStore(world);
const prefetchedNationTurns = new Set<string>();
const processor = new InMemoryTurnProcessor(world, {
tickMinutes,
beforeExecuteGeneral: reservedTurnStoreHandle
@@ -709,6 +742,7 @@ const createTurnDaemonRuntimeWithLease = async (
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
stateManager,
},
{ profile: options.profile, defaultBudget }
);
@@ -749,6 +783,7 @@ const createTurnDaemonRuntimeWithLease = async (
world,
controlQueue: resolvedControlQueue,
stateStore,
stateManager,
processor,
hooks,
close,
@@ -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);
});
});
@@ -3,6 +3,7 @@ import { asRecord } from '@sammo-ts/common';
import {
InMemoryControlQueue,
EngineStateManager,
ManualClock,
TurnDaemonLifecycle,
type TurnDaemonCommandResult,
@@ -253,6 +254,14 @@ describe('input event atomicity', () => {
resolveError = resolve;
});
const publishCommandResult = vi.fn(async () => {});
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
capture: () => structuredClone(engineState),
restore: (snapshot) => {
engineState = snapshot;
},
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
@@ -261,8 +270,12 @@ describe('input event atomicity', () => {
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async () => ({ type: 'vacation', ok: true, generalId: 7 }),
handle: async () => {
engineState.value = 'calculated';
return { type: 'vacation', ok: true, generalId: 7 };
},
},
stateManager,
hooks: {
commitCommand: async () => {
throw new Error('injected commit failure');
@@ -292,6 +305,8 @@ describe('input event atomicity', () => {
lastError: 'injected commit failure',
});
expect(publishCommandResult).not.toHaveBeenCalled();
expect(engineState).toEqual({ value: 'calculated' });
expect(stateManager.getRevision()).toBe(1);
await lifecycle.stop('done');
await loop;
@@ -305,6 +320,14 @@ describe('input event atomicity', () => {
});
const commitCommand = vi.fn(async () => {});
const publishCommandResult = vi.fn(async () => {});
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
capture: () => structuredClone(engineState),
restore: (snapshot) => {
engineState = snapshot;
},
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
@@ -314,9 +337,11 @@ describe('input event atomicity', () => {
processor,
commandHandler: {
handle: async () => {
engineState.value = 'partial';
throw new Error('injected handler failure');
},
},
stateManager,
hooks: {
commitCommand,
onRunError: async () => {
@@ -345,6 +370,8 @@ describe('input event atomicity', () => {
});
expect(commitCommand).not.toHaveBeenCalled();
expect(publishCommandResult).not.toHaveBeenCalled();
expect(engineState).toEqual({ value: 'before' });
expect(stateManager.getRevision()).toBe(0);
await lifecycle.stop('done');
await loop;