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