From 19a80127c6459586afbb91b1464e496e4dfaa29c Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 28 Jul 2026 01:53:48 +0000 Subject: [PATCH] refactor engine state transactions --- app/game-engine/src/index.ts | 1 + .../src/lifecycle/turnDaemonLifecycle.ts | 28 ++- .../src/turn/engineStateManager.ts | 153 ++++++++++++ app/game-engine/src/turn/inMemoryWorld.ts | 149 +++++++++++ app/game-engine/src/turn/reservedTurnStore.ts | 54 ++++ app/game-engine/src/turn/turnDaemon.ts | 37 ++- .../test/engineStateManager.test.ts | 234 ++++++++++++++++++ .../test/inputEventAtomicity.test.ts | 29 ++- 8 files changed, 673 insertions(+), 12 deletions(-) create mode 100644 app/game-engine/src/turn/engineStateManager.ts create mode 100644 app/game-engine/test/engineStateManager.test.ts diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index a8c5e98..1e1885e 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -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'; diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 11b3e9e..13ed93e 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -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; + stateManager?: Pick; } export class TurnDaemonLifecycle { @@ -56,6 +58,7 @@ export class TurnDaemonLifecycle { private readonly commandHandler?: TurnDaemonCommandHandler; private readonly commandResponder?: TurnDaemonCommandResponder; private readonly pauseGate?: () => Promise; + private readonly stateManager?: Pick; 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 => { - 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 => { + 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'; diff --git a/app/game-engine/src/turn/engineStateManager.ts b/app/game-engine/src/turn/engineStateManager.ts new file mode 100644 index 0000000..524f014 --- /dev/null +++ b/app/game-engine/src/turn/engineStateManager.ts @@ -0,0 +1,153 @@ +export interface EngineStateParticipant { + 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>; +} + +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(); + private revision = 0; + private transactionActive = false; + + register(name: string, participant: EngineStateParticipant): 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 = {}; + 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(operation: () => Promise | T): Promise { + 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); + } + } +} diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 26bf7da..dd5b088 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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(target: Map, entries: Array<[K, V]>): void { + target.clear(); + for (const [key, value] of entries) { + target.set(key, value); + } + } + + private replaceSet(target: Set, values: T[]): void { + target.clear(); + for (const value of values) { + target.add(value); + } + } + + private replaceArray(target: T[], values: T[]): void { + target.splice(0, target.length, ...values); + } } diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 9efcb94..31269fe 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -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 { const [generalRows, nationRows] = await Promise.all([ this.prisma.generalTurn.findMany(), @@ -164,6 +204,20 @@ export class InMemoryReservedTurnStore { } } + private replaceMap(target: Map, entries: Array<[K, V]>): void { + target.clear(); + for (const [key, value] of entries) { + target.set(key, value); + } + } + + private replaceSet(target: Set, values: T[]): void { + target.clear(); + for (const value of values) { + target.add(value); + } + } + private getLeaseExpiresAt(): Date { return new Date(Date.now() + this.leaseDurationMs); } diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 7dc898b..49625a3 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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; @@ -475,6 +477,7 @@ const createTurnDaemonRuntimeWithLease = async ( ); let occupiedAuctionUniqueItemKeys: string[] = []; let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; + const prefetchedNationTurns = new Set(); 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(); 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, diff --git a/app/game-engine/test/engineStateManager.test.ts b/app/game-engine/test/engineStateManager.test.ts new file mode 100644 index 0000000..49e2dba --- /dev/null +++ b/app/game-engine/test/engineStateManager.test.ts @@ -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); + }); +}); diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index acd6345..bd14a3e 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -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;