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/types.js';
|
||||||
export * from './turn/worldLoader.js';
|
export * from './turn/worldLoader.js';
|
||||||
export * from './turn/inMemoryWorld.js';
|
export * from './turn/inMemoryWorld.js';
|
||||||
|
export * from './turn/engineStateManager.js';
|
||||||
export * from './turn/inMemoryStateStore.js';
|
export * from './turn/inMemoryStateStore.js';
|
||||||
export * from './turn/inMemoryTurnProcessor.js';
|
export * from './turn/inMemoryTurnProcessor.js';
|
||||||
export * from './turn/databaseHooks.js';
|
export * from './turn/databaseHooks.js';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
TurnDaemonCommandResult,
|
TurnDaemonCommandResult,
|
||||||
TurnDaemonCommandExecutionContext,
|
TurnDaemonCommandExecutionContext,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import type { EngineStateManager } from '../turn/engineStateManager.js';
|
||||||
|
|
||||||
type PendingRun = {
|
type PendingRun = {
|
||||||
reason: RunReason;
|
reason: RunReason;
|
||||||
@@ -43,6 +44,7 @@ export interface TurnDaemonLifecycleDeps {
|
|||||||
commandHandler?: TurnDaemonCommandHandler;
|
commandHandler?: TurnDaemonCommandHandler;
|
||||||
commandResponder?: TurnDaemonCommandResponder;
|
commandResponder?: TurnDaemonCommandResponder;
|
||||||
pauseGate?: () => Promise<boolean>;
|
pauseGate?: () => Promise<boolean>;
|
||||||
|
stateManager?: Pick<EngineStateManager, 'transaction'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TurnDaemonLifecycle {
|
export class TurnDaemonLifecycle {
|
||||||
@@ -56,6 +58,7 @@ export class TurnDaemonLifecycle {
|
|||||||
private readonly commandHandler?: TurnDaemonCommandHandler;
|
private readonly commandHandler?: TurnDaemonCommandHandler;
|
||||||
private readonly commandResponder?: TurnDaemonCommandResponder;
|
private readonly commandResponder?: TurnDaemonCommandResponder;
|
||||||
private readonly pauseGate?: () => Promise<boolean>;
|
private readonly pauseGate?: () => Promise<boolean>;
|
||||||
|
private readonly stateManager?: Pick<EngineStateManager, 'transaction'>;
|
||||||
private readonly options: TurnDaemonLifecycleOptions;
|
private readonly options: TurnDaemonLifecycleOptions;
|
||||||
|
|
||||||
private status: TurnDaemonStatus;
|
private status: TurnDaemonStatus;
|
||||||
@@ -75,6 +78,7 @@ export class TurnDaemonLifecycle {
|
|||||||
this.commandHandler = deps.commandHandler;
|
this.commandHandler = deps.commandHandler;
|
||||||
this.commandResponder = deps.commandResponder;
|
this.commandResponder = deps.commandResponder;
|
||||||
this.pauseGate = deps.pauseGate;
|
this.pauseGate = deps.pauseGate;
|
||||||
|
this.stateManager = deps.stateManager;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.status = {
|
this.status = {
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
@@ -272,15 +276,18 @@ export class TurnDaemonLifecycle {
|
|||||||
const executeHandler = async (
|
const executeHandler = async (
|
||||||
context?: TurnDaemonCommandExecutionContext
|
context?: TurnDaemonCommandExecutionContext
|
||||||
): Promise<TurnDaemonCommandResult> => {
|
): Promise<TurnDaemonCommandResult> => {
|
||||||
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
|
const execute = async (): Promise<TurnDaemonCommandResult> => {
|
||||||
return (
|
const handled = this.commandHandler ? await this.commandHandler.handle(command, context) : null;
|
||||||
handled ?? {
|
return (
|
||||||
type: 'commandRejected',
|
handled ?? {
|
||||||
ok: false,
|
type: 'commandRejected',
|
||||||
commandType: command.type,
|
ok: false,
|
||||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
commandType: command.type,
|
||||||
}
|
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return this.stateManager ? this.stateManager.transaction(execute) : execute();
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (command.requestId && this.hooks?.executeCommand) {
|
if (command.requestId && this.hooks?.executeCommand) {
|
||||||
@@ -331,7 +338,8 @@ export class TurnDaemonLifecycle {
|
|||||||
let result: TurnRunResult;
|
let result: TurnRunResult;
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
this.status.running = false;
|
this.status.running = false;
|
||||||
this.status.state = 'paused';
|
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[];
|
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 compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
||||||
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
|
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
|
||||||
if (timeDiff !== 0) {
|
if (timeDiff !== 0) {
|
||||||
@@ -335,6 +381,91 @@ export class InMemoryTurnWorld {
|
|||||||
this.ensureDiplomacyMatrix();
|
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 {
|
getState(): TurnWorldState {
|
||||||
return { ...this.state };
|
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[];
|
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 {
|
export class ReservedTurnLeaseConflictError extends Error {
|
||||||
constructor(readonly queueKey: string) {
|
constructor(readonly queueKey: string) {
|
||||||
super(`Reserved turn queue lease conflict: ${queueKey}.`);
|
super(`Reserved turn queue lease conflict: ${queueKey}.`);
|
||||||
@@ -130,6 +141,35 @@ export class InMemoryReservedTurnStore {
|
|||||||
this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
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> {
|
async loadAll(): Promise<void> {
|
||||||
const [generalRows, nationRows] = await Promise.all([
|
const [generalRows, nationRows] = await Promise.all([
|
||||||
this.prisma.generalTurn.findMany(),
|
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 {
|
private getLeaseExpiresAt(): Date {
|
||||||
return new Date(Date.now() + this.leaseDurationMs);
|
return new Date(Date.now() + this.leaseDurationMs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
} from './monthlyCoreEventAction.js';
|
} from './monthlyCoreEventAction.js';
|
||||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||||
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
||||||
|
import { EngineStateManager } from './engineStateManager.js';
|
||||||
|
|
||||||
export interface TurnDaemonRuntimeOptions {
|
export interface TurnDaemonRuntimeOptions {
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -106,6 +107,7 @@ export interface TurnDaemonRuntime {
|
|||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
controlQueue: TurnDaemonControlQueue;
|
controlQueue: TurnDaemonControlQueue;
|
||||||
stateStore: InMemoryTurnStateStore;
|
stateStore: InMemoryTurnStateStore;
|
||||||
|
stateManager: EngineStateManager;
|
||||||
processor: InMemoryTurnProcessor;
|
processor: InMemoryTurnProcessor;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
@@ -475,6 +477,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
);
|
);
|
||||||
let occupiedAuctionUniqueItemKeys: string[] = [];
|
let occupiedAuctionUniqueItemKeys: string[] = [];
|
||||||
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
|
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
|
||||||
|
const prefetchedNationTurns = new Set<string>();
|
||||||
const worldOptions: InMemoryTurnWorldOptions = {
|
const worldOptions: InMemoryTurnWorldOptions = {
|
||||||
schedule,
|
schedule,
|
||||||
generalTurnHandler:
|
generalTurnHandler:
|
||||||
@@ -496,8 +499,38 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
|
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
|
||||||
worldRef = world;
|
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 stateStore = new InMemoryTurnStateStore(world);
|
||||||
const prefetchedNationTurns = new Set<string>();
|
|
||||||
const processor = new InMemoryTurnProcessor(world, {
|
const processor = new InMemoryTurnProcessor(world, {
|
||||||
tickMinutes,
|
tickMinutes,
|
||||||
beforeExecuteGeneral: reservedTurnStoreHandle
|
beforeExecuteGeneral: reservedTurnStoreHandle
|
||||||
@@ -709,6 +742,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
|
pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
|
||||||
commandHandler,
|
commandHandler,
|
||||||
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
|
||||||
|
stateManager,
|
||||||
},
|
},
|
||||||
{ profile: options.profile, defaultBudget }
|
{ profile: options.profile, defaultBudget }
|
||||||
);
|
);
|
||||||
@@ -749,6 +783,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
world,
|
world,
|
||||||
controlQueue: resolvedControlQueue,
|
controlQueue: resolvedControlQueue,
|
||||||
stateStore,
|
stateStore,
|
||||||
|
stateManager,
|
||||||
processor,
|
processor,
|
||||||
hooks,
|
hooks,
|
||||||
close,
|
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 {
|
import {
|
||||||
InMemoryControlQueue,
|
InMemoryControlQueue,
|
||||||
|
EngineStateManager,
|
||||||
ManualClock,
|
ManualClock,
|
||||||
TurnDaemonLifecycle,
|
TurnDaemonLifecycle,
|
||||||
type TurnDaemonCommandResult,
|
type TurnDaemonCommandResult,
|
||||||
@@ -253,6 +254,14 @@ describe('input event atomicity', () => {
|
|||||||
resolveError = resolve;
|
resolveError = resolve;
|
||||||
});
|
});
|
||||||
const publishCommandResult = 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(
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
{
|
{
|
||||||
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
|
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
|
||||||
@@ -261,8 +270,12 @@ describe('input event atomicity', () => {
|
|||||||
stateStore: createStateStore(),
|
stateStore: createStateStore(),
|
||||||
processor,
|
processor,
|
||||||
commandHandler: {
|
commandHandler: {
|
||||||
handle: async () => ({ type: 'vacation', ok: true, generalId: 7 }),
|
handle: async () => {
|
||||||
|
engineState.value = 'calculated';
|
||||||
|
return { type: 'vacation', ok: true, generalId: 7 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
stateManager,
|
||||||
hooks: {
|
hooks: {
|
||||||
commitCommand: async () => {
|
commitCommand: async () => {
|
||||||
throw new Error('injected commit failure');
|
throw new Error('injected commit failure');
|
||||||
@@ -292,6 +305,8 @@ describe('input event atomicity', () => {
|
|||||||
lastError: 'injected commit failure',
|
lastError: 'injected commit failure',
|
||||||
});
|
});
|
||||||
expect(publishCommandResult).not.toHaveBeenCalled();
|
expect(publishCommandResult).not.toHaveBeenCalled();
|
||||||
|
expect(engineState).toEqual({ value: 'calculated' });
|
||||||
|
expect(stateManager.getRevision()).toBe(1);
|
||||||
|
|
||||||
await lifecycle.stop('done');
|
await lifecycle.stop('done');
|
||||||
await loop;
|
await loop;
|
||||||
@@ -305,6 +320,14 @@ describe('input event atomicity', () => {
|
|||||||
});
|
});
|
||||||
const commitCommand = vi.fn(async () => {});
|
const commitCommand = vi.fn(async () => {});
|
||||||
const publishCommandResult = 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(
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
{
|
{
|
||||||
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
|
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
|
||||||
@@ -314,9 +337,11 @@ describe('input event atomicity', () => {
|
|||||||
processor,
|
processor,
|
||||||
commandHandler: {
|
commandHandler: {
|
||||||
handle: async () => {
|
handle: async () => {
|
||||||
|
engineState.value = 'partial';
|
||||||
throw new Error('injected handler failure');
|
throw new Error('injected handler failure');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
stateManager,
|
||||||
hooks: {
|
hooks: {
|
||||||
commitCommand,
|
commitCommand,
|
||||||
onRunError: async () => {
|
onRunError: async () => {
|
||||||
@@ -345,6 +370,8 @@ describe('input event atomicity', () => {
|
|||||||
});
|
});
|
||||||
expect(commitCommand).not.toHaveBeenCalled();
|
expect(commitCommand).not.toHaveBeenCalled();
|
||||||
expect(publishCommandResult).not.toHaveBeenCalled();
|
expect(publishCommandResult).not.toHaveBeenCalled();
|
||||||
|
expect(engineState).toEqual({ value: 'before' });
|
||||||
|
expect(stateManager.getRevision()).toBe(0);
|
||||||
|
|
||||||
await lifecycle.stop('done');
|
await lifecycle.stop('done');
|
||||||
await loop;
|
await loop;
|
||||||
|
|||||||
Reference in New Issue
Block a user