From a531332668c9c86e643d0b2c4bdd16d83fda4653 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 28 Jul 2026 01:58:52 +0000 Subject: [PATCH] test scheduled state rollback boundary --- .../src/turn/engineStateManager.ts | 28 ++++++--- .../test/turnDaemonLifecycle.test.ts | 62 +++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/app/game-engine/src/turn/engineStateManager.ts b/app/game-engine/src/turn/engineStateManager.ts index 524f014..54e8f80 100644 --- a/app/game-engine/src/turn/engineStateManager.ts +++ b/app/game-engine/src/turn/engineStateManager.ts @@ -15,6 +15,13 @@ type CapturedParticipant = { snapshot: unknown; }; +type EngineStateSavepointData = { + owner: symbol; + participants: readonly CapturedParticipant[]; +}; + +const savepointData = new WeakMap(); + export interface EngineStateInspection { revision: number; transactionActive: boolean; @@ -23,13 +30,10 @@ export interface EngineStateInspection { 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; + savepointData.set(this, { owner, participants }); } } @@ -57,9 +61,9 @@ export class EngineStateManager { throw new Error(`Engine state participant is already registered: ${name}`); } this.participants.set(name, { - capture: participant.capture, + capture: () => participant.capture(), restore: (snapshot) => participant.restore(snapshot as T), - inspect: participant.inspect, + inspect: participant.inspect ? () => participant.inspect?.() : undefined, }); } @@ -127,23 +131,27 @@ export class EngineStateManager { } private assertOwnedSavepoint(savepoint: EngineStateSavepoint): void { - if (savepoint.owner !== this.owner) { + if (savepointData.get(savepoint)?.owner !== this.owner) { throw new Error('Engine state savepoint belongs to a different manager.'); } } private restoreParticipants(savepoint: EngineStateSavepoint): void { this.assertOwnedSavepoint(savepoint); + const capturedParticipants = savepointData.get(savepoint)?.participants; + if (!capturedParticipants) { + throw new Error('Engine state savepoint data is unavailable.'); + } const currentNames = Array.from(this.participants.keys()); - const capturedNames = savepoint.participants.map(({ name }) => name); + const capturedNames = capturedParticipants.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]; + for (let index = capturedParticipants.length - 1; index >= 0; index -= 1) { + const captured = capturedParticipants[index]; if (!captured) { continue; } diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 0eb0eeb..1577f87 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { InMemoryControlQueue, + EngineStateManager, ManualClock, TurnDaemonLifecycle, getNextTickTime, @@ -13,6 +14,67 @@ import { const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); describe('TurnDaemonLifecycle', () => { + it('restores engine state when a scheduled calculation throws', async () => { + const now = new Date('2026-01-01T00:10:00.000Z'); + const queue = new InMemoryControlQueue(); + let engineState = { value: 'before' }; + const stateManager = new EngineStateManager(); + stateManager.register('test', { + capture: () => structuredClone(engineState), + restore: (snapshot) => { + engineState = snapshot; + }, + }); + let resolveError: (() => void) | undefined; + const errorObserved = new Promise((resolve) => { + resolveError = resolve; + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new ManualClock(now.getTime()), + controlQueue: queue, + getNextTickTime: () => new Date('2026-01-01T00:05:00.000Z'), + stateStore: { + loadLastTurnTime: async () => new Date('2026-01-01T00:00:00.000Z'), + loadNextGeneralTurnTime: async () => null, + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => undefined, + saveCheckpoint: async () => {}, + }, + processor: { + run: async () => { + engineState.value = 'partial'; + throw new Error('scheduled calculation failed'); + }, + }, + stateManager, + hooks: { + onRunError: async () => { + resolveError?.(); + }, + }, + }, + { + profile: 'test', + defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 }, + } + ); + + const loop = lifecycle.start(); + await errorObserved; + + expect(engineState).toEqual({ value: 'before' }); + expect(stateManager.getRevision()).toBe(0); + expect(lifecycle.getStatus()).toMatchObject({ + state: 'paused', + paused: true, + lastError: 'scheduled calculation failed', + }); + + await lifecycle.stop('done'); + await loop; + }); + it('runs scheduled turn based on queue front and checkpoint context', async () => { const turnTermMinutes = 10; const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);