diff --git a/README.md b/README.md index 0d7e51cc..5f479461 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,9 @@ tableau-card-engine/ |------|----------|-------------| | Gym (demo suite) | `example-games/gym/` | Interactive demo scenes for every core-engine feature: deck lifecycle, hand/pile interactions, undo/redo, overlays, SLL composition, audio feedback, transcript recording, save/load | | 9-Card Golf | `example-games/golf/` | Single-round 9-Card Golf (human vs. AI) with card flip animations, greedy/random AI strategies, and JSON game transcripts | -| Beleaguered Castle | `example-games/beleaguered-castle/` | Open solitaire with drag-and-drop, click-to-move, undo/redo, auto-move to foundations, auto-complete, win/loss detection, help panel, and JSON game transcripts | +| Beleaguered Castle | `example-games/beleaguered-castle/` | Open solitaire with drag-and-drop, click-to-move, undo/redo, auto-move to foundations, auto-complete, win/loss detection, help panel, JSON game transcripts, and checkpoint autosave after each move with startup recovery | | Sushi Go! | `example-games/sushi-go/` | Card drafting game (human vs. AI). Pick and pass hands over 3 rounds, collect sets of sushi dishes, and score the most points | -| Feudalism | `example-games/feudalism/` | Engine-building card game (human vs. AI). Collect gem tokens, purchase development cards for bonuses, attract nobles, and reach 15 prestige to win | +| Feudalism | `example-games/feudalism/` | Engine-building card game (human vs. AI). Collect gem tokens, purchase development cards for bonuses, attract nobles, and reach 15 prestige to win. Checkpoint autosaves after each turn (human + AI) with startup recovery | | Lost Cities | `example-games/lost-cities/` | Two-player expedition card game (human vs. AI). Bet on up to 5 colored expeditions across a 3-round match with investment multipliers, ascending-play rules, and cumulative scoring | | Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns. Tutorial overlay zones are defined in a separate SLL layout file (`main-street-tutorial.layout.json`) composed with the base layout. | | Scenario: Tutorial | `example-games/main-street/scenes/MainStreetTutorialScene.ts` | Guided introduction to Main Street. Non-interactive tutorial overlays walk through the market, street placement, synergies, events, and scoring. Easy difficulty, 25 turns. Accessible from the Game Selector. | diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 86d38d02..303e4175 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -212,6 +212,8 @@ src/ │ ├── GameState.ts GameState, createGameState (deprecated for setup — use SetupOptions) │ ├── SetupOptions.ts BaseSetupOptions, MultiplayerSetupOptions, resolveSetupOptions │ ├── SeededRng.ts createSeededRng — deterministic PRNG (LCG) for shuffles and AI +│ ├── CheckpointManager.ts Checkpoint save-and-resume abstraction (save, load, clear, checkAndResume) +│ ├── CheckpointResumeOverlay.ts Built-in default resume overlay component │ ├── TranscriptRecorder.ts BaseTranscript interface, TranscriptRecorderBase abstract base class │ ├── TurnSequencer.ts advanceTurn, getCurrentPlayer, startGame, endGame │ └── index.ts Barrel file / public API @@ -490,9 +492,9 @@ Open `http://localhost:3000` and click the desired game card. Each game also has | Game | Location | Key engine features demonstrated | Tests | |------|----------|--------------------------------|-------| | 9-Card Golf | `example-games/golf/` | Card/Deck/Pile abstractions, GameState/TurnSequencer, scoring rules (A=1, 2=-2, K=0, column-of-three=0), Random/Greedy AI strategies, transcript recording, Phaser UI with 3x3 grid | `tests/golf/` (8 files) | -| Beleaguered Castle | `example-games/beleaguered-castle/` | Single-player solitaire, UndoRedoManager (Command pattern), drag-and-drop + click-to-move, auto-move heuristics, auto-complete, win/loss detection, HelpPanel component | `tests/beleaguered-castle/` (2 files) | +| Beleaguered Castle | `example-games/beleaguered-castle/` | Single-player solitaire, UndoRedoManager (Command pattern), drag-and-drop + click-to-move, auto-move heuristics, auto-complete, win/loss detection, HelpPanel component, checkpoint autosave after each move with startup recovery | `tests/beleaguered-castle/` (2 files) | | Sushi Go! | `example-games/sushi-go/` | Card drafting (pick-and-pass hands), custom card types with set-collection scoring, multi-round match, procedural card-back textures | `tests/sushi-go/` (4 files) | -| Feudalism | `example-games/feudalism/` | Resource management (gem tokens), tiered development cards with costs/bonuses, noble attraction, multi-action turns (take/reserve/purchase) | `tests/feudalism/` (3 files) | +| Feudalism | `example-games/feudalism/` | Resource management (gem tokens), tiered development cards with costs/bonuses, noble attraction, multi-action turns (take/reserve/purchase), checkpoint autosave after each turn (human + AI) with startup recovery | `tests/feudalism/` (4 files) | | Lost Cities | `example-games/lost-cities/` | Two-player expeditions, two-phase turn model (play/discard then draw), ascending-play rules, investment multipliers (x2/x3/x4), multi-round match scoring, procedurally generated SVG card assets | `tests/lost-cities/` (6 files) | | The Mind | `example-games/the-mind/` | Cooperative real-time game, event-based transcript, timing-based AI, level progression (1-100 cards, 8 levels), headless AI-vs-AI runner for fixture generation | `tests/the-mind/` (7 files) | | Main Street | `example-games/main-street/` | Single-player tableau builder, responsive 2x5 grid layout, SLL integration, ToneForge audio adapter, Monte Carlo balance testing, tutorial scene | `tests/main-street/` | @@ -563,6 +565,56 @@ All example games have fixture transcripts checked into version control: These are generated by game-specific fixture generator scripts (e.g. `scripts/generate-golf-fixture-transcript.ts`) that run deterministic AI-vs-AI games using a fixed seed. +## Checkpoint Save and Resume + +Games can auto-save their run state after each turn and offer a resume/fresh-game +choice on startup via the shared `CheckpointManager` in `@core-engine`. This +pattern is game-agnostic — the manager works with any game state type and +`SaveSerializer`. + +### CheckpointManager API + +```typescript +import { CheckpointManager } from '@core-engine'; + +const manager = new CheckpointManager(store, 'my-game', 'run-checkpoint', mySerializer); +``` + +| Method | Description | +|--------|-------------| +| `save(state)` | Fire-and-forget checkpoint save after each game state change. | +| `load()` | Returns the saved state, or `null` if none exists. | +| `clear()` | Removes the checkpoint (e.g. on game end or New Game). | +| `checkAndResume(freshStartFn, resumeFn, createResumeOverlay?)` | Checks for a saved checkpoint. If found, shows a resume overlay via the optional callback. If not, calls `freshStartFn` immediately. | + +### Resume overlay + +The `createResumeOverlay` callback lets each game provide its own overlay UI. +A built-in `createDefaultResumeOverlay` is also available for quick integration: + +```typescript +import { createDefaultResumeOverlay } from '@core-engine'; + +manager.checkAndResume( + () => startFreshGame(), + (state) => restoreFromCheckpoint(state), + (state, onResume, onNewGame) => + createDefaultResumeOverlay(scene, state, onResume, onNewGame), +); +``` + +### Games using CheckpointManager + +| Game | When checkpoint is saved | Startup behaviour | +|------|--------------------------|-------------------| +| Beleaguered Castle | After deal completes + after each player move | Shows "Resume Saved Game?" overlay with [Resume] and [New Game] buttons | +| Feudalism | After each human turn + after each AI turn | Shows resume overlay with [Resume] and [New Game] buttons | +| Main Street | _(planned)_ | _(planned)_ | + +The `CheckpointManager` delegates all storage to `SaveLoadStore` (IndexedDB +with localStorage fallback). See `src/core-engine/CheckpointManager.ts` for +full API documentation. + ## Replay Tool The replay tool (`scripts/replay.ts`) replays a fixture transcript through the game's Phaser scene in a headless browser, capturing per-turn screenshots. It is the foundation for thumbnail generation and visual regression testing. diff --git a/docs/SFX_CONVENTION.md b/docs/SFX_CONVENTION.md index dc58e1f4..d60f2bd8 100644 --- a/docs/SFX_CONVENTION.md +++ b/docs/SFX_CONVENTION.md @@ -69,6 +69,38 @@ import { audioPathWithFallback } from '@ui/CardGameScene'; this.load.audio('golf:sfx-card-draw', audioPathWithFallback('golf', 'card-draw.wav')); ``` +## Safe Sound Playback (Overlay Helpers) + +Overlay helper classes (e.g., `FeudalismOverlays`, `LostCitiesOverlays`) often do not have access to the namespaced `SoundManager` instance. In these cases, calling `this.scene.sound.play()` directly bypasses the namespace resolution and can crash the game loop if the audio key is not found. + +### Recommended approach: `safePlaySound()` + +For overlay helpers, use the exported `safePlaySound()` utility from `@core-engine/SoundManager`: + +```ts +import { safePlaySound } from '@core-engine/SoundManager'; + +// Inside an overlay helper: +safePlaySound(this.scene, SFX_KEYS.GAME_END); +``` + +The function wraps `scene.sound.play?.()` in try/catch, gracefully handling: +- Missing audio keys (the audio file was not loaded) +- Null sound manager (e.g., during scene transitions) +- Any other playback errors + +### Legacy fallback: inline try/catch + +If you prefer not to import the utility, always wrap `scene.sound.play` calls in try/catch: + +```ts +try { this.scene.sound.play?.(SFX_KEYS.XXX); } catch { /* ignore */ } +``` + +### Prohibition + +Direct calls to `this.scene.sound.play()` or `this.sound.play()` without try/catch protection **must not** be committed. A custom ESLint rule (`no-direct-sound-play`) enforces this at build time. + ## Collision Protection To prevent Phaser audio key collisions when multiple games are loaded: diff --git a/eslint.config.cjs b/eslint.config.cjs index 94064d7f..b5d0be34 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -1,3 +1,107 @@ +// ── Custom ESLint rule: no-direct-sound-play ────────────── +// +// Flags direct scene.sound.play() calls that are NOT inside a try/catch +// block. These calls bypass the namespaced SoundManager and can crash +// the Phaser game loop when audio keys are missing from the cache. +// +// Allowed patterns: +// try { scene.sound.play?.(key); } catch { /* ignore */ } +// safePlaySound(scene, key); // preferred utility +// typeof scene.sound.play === 'function' // type check, not a play call +// soundManager.play(key); // correct namespace-aware usage + +const noDirectSoundPlayRule = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow direct scene.sound.play() calls without try/catch ' + + 'protection to prevent game-loop crashes from missing audio keys.', + }, + schema: [], + messages: { + unprotectedCall: + 'Direct scene.sound.play() call must be wrapped in try/catch or ' + + 'use safePlaySound() to prevent game-loop crashes. ' + + 'See docs/SFX_CONVENTION.md for details.', + }, + }, + create(context) { + /** + * Check if a node is inside a try/catch block by traversing parent nodes. + */ + function isInsideTryCatch(node) { + let current = node; + while (current) { + if (current.type === 'TryStatement') { + return true; + } + current = current.parent; + } + return false; + } + + /** + * Extract the member expression chain as an array of name strings. + * e.g. this.scene.sound.play -> ['this', 'scene', 'sound', 'play'] + * Returns null if the chain cannot be extracted. + */ + function extractMemberChain(node) { + const parts = []; + let current = node; + while (current.type === 'MemberExpression') { + if (current.computed) return null; // Skip computed properties like foo[bar] + if (current.property.type !== 'Identifier') return null; + parts.unshift(current.property.name); + current = current.object; + } + if (current.type === 'Identifier') { + parts.unshift(current.name); + } else if (current.type === 'ThisExpression') { + parts.unshift('this'); + } else { + return null; // Unsupported expression type + } + return parts; + } + + return { + CallExpression(node) { + const callee = node.callee; + + // Must be a member expression call (something.play()) + if (callee.type !== 'MemberExpression') return; + + // Extract the member chain + const chain = extractMemberChain(callee); + if (!chain) return; + + // Must end with '.play' + const methodName = chain[chain.length - 1]; + if (methodName !== 'play') return; + + // Must have a 'sound' member in the chain (e.g. scene.sound.play) + // but NOT at the root (i.e. a variable named 'sound' calling .play) + const soundIndex = chain.lastIndexOf('sound'); + if (soundIndex < 0) return; // No 'sound' in chain + if (soundIndex === 0) return; // Root 'sound' variable — not our concern + + // Must have at least something before 'sound' (e.g. scene.sound) + if (soundIndex < 1) return; + + // Check if this call is inside a try/catch block + if (isInsideTryCatch(node)) return; + + // Report the violation + context.report({ + node, + messageId: 'unprotectedCall', + }); + }, + }; + }, +}; + module.exports = [ // Ignore patterns (replaces .eslintignore) { @@ -16,6 +120,11 @@ module.exports = [ }, plugins: { '@typescript-eslint': require('@typescript-eslint/eslint-plugin'), + local: { + rules: { + 'no-direct-sound-play': noDirectSoundPlayRule, + }, + }, }, rules: { 'no-unused-vars': 'off', @@ -23,6 +132,7 @@ module.exports = [ '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/ban-ts-comment': 'warn', + 'local/no-direct-sound-play': 'error', }, }, // Allow console uses in scripts/tools diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts index 84866df5..2e52c184 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts @@ -46,7 +46,9 @@ import { import { createHudText } from '../../../src/ui/Renderer/adapters/BeleagueredCastleAdapter'; import { SaveLoadStore } from '../../../src/core-engine'; import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; -import { saveBCSnapshot, loadBCSnapshot, clearBCSnapshot } from '../BeleagueredCastleSaveLoad'; +import { CheckpointManager } from '../../../src/core-engine'; +import { bcStateSerializer } from '../BeleagueredCastleSaveLoad'; +import type { BCSerializedState } from '../BeleagueredCastleSaveLoad'; export class BeleagueredCastleScene extends CardGameScene { private gameState!: BeleagueredCastleState; @@ -59,6 +61,7 @@ export class BeleagueredCastleScene extends CardGameScene { private transcript: BCGameTranscript | null = null; private saveLoadStore!: SaveLoadStore; + private checkpointManager!: CheckpointManager; private transcriptStore!: TranscriptStore; private bcRenderer!: BeleagueredCastleRenderer; @@ -117,6 +120,7 @@ export class BeleagueredCastleScene extends CardGameScene { const recorder = new BCTranscriptRecorder(this.seed, this.gameState); this.saveLoadStore = new SaveLoadStore(); + this.checkpointManager = new CheckpointManager(this.saveLoadStore, 'beleaguered-castle', 'run-checkpoint', bcStateSerializer); this.transcriptStore = new TranscriptStore(); this.bcRenderer = new BeleagueredCastleRenderer(this, this.gameState); @@ -487,26 +491,20 @@ export class BeleagueredCastleScene extends CardGameScene { * restarts fresh. */ private checkForSavedCheckpoint(): void { - loadBCSnapshot(this.saveLoadStore).then((savedState) => { - if (savedState) { - // Checkpoint found — show the resume overlay. No deal animation - // will play until the user picks an option. - this.showResumeOverlay(savedState); - } else { - // No checkpoint — start a fresh game with the deal animation. - this.startFreshGame(); - } - }).catch((err) => { - console.warn('[BeleagueredCastle] Error loading checkpoint:', err); - // On error, fall through to a fresh deal so the game is still playable. - this.startFreshGame(); - }); + this.checkpointManager.checkAndResume( + () => this.startFreshGame(), + (state) => this.restoreFromCheckpoint(state), + (state, onResume) => this.showResumeOverlay(state, onResume), + ); } /** * Show a "Resume Saved Game?" overlay with Resume and New Game options. */ - private showResumeOverlay(savedState: BeleagueredCastleState): void { + private showResumeOverlay( + _savedState: BeleagueredCastleState, + onResume: () => void, + ): void { const BUTTON_DEPTH = OVERLAY_DEPTH + 1; this.overlayManager.showOverlay({ @@ -531,14 +529,18 @@ export class BeleagueredCastleScene extends CardGameScene { const resumeBtn = createOverlayButton(this, GAME_W / 2 - RESUME_BUTTON_SPACING, GAME_H / 2 + RESUME_BUTTON_Y_OFFSET, '[ Resume ]', BUTTON_DEPTH); resumeBtn.on('pointerdown', () => { this.overlayManager.dismiss(); - this.restoreFromCheckpoint(savedState); + onResume(); }); this.overlayManager.add(resumeBtn); const newGameBtn = createOverlayButton(this, GAME_W / 2 + RESUME_BUTTON_SPACING, GAME_H / 2 + RESUME_BUTTON_Y_OFFSET, '[ New Game ]', BUTTON_DEPTH); newGameBtn.on('pointerdown', () => { this.overlayManager.dismiss(); - this.clearCheckpointAndStartFresh(); + this.checkpointManager.clear().then(() => { + this.scene.restart(); + }).catch(() => { + this.scene.restart(); + }); }); this.overlayManager.add(newGameBtn); } @@ -602,19 +604,6 @@ export class BeleagueredCastleScene extends CardGameScene { this.setupKeyboard(); } - /** - * Delete the saved checkpoint and restart the scene for a fresh game. - * Used when the user clicks "New Game" on the resume overlay. - */ - private clearCheckpointAndStartFresh(): void { - clearBCSnapshot(this.saveLoadStore).then(() => { - this.scene.restart(); - }).catch((err) => { - console.warn('[BeleagueredCastle] Failed to clear checkpoint:', err); - this.scene.restart(); - }); - } - /** * Start a fresh game (no saved checkpoint). * Runs the deal animation and wires up interactions as normal. @@ -632,7 +621,7 @@ export class BeleagueredCastleScene extends CardGameScene { * Fire-and-forget (not awaited) to avoid blocking the input handler. */ private saveCheckpoint(): void { - saveBCSnapshot(this.saveLoadStore, this.gameState).catch((err) => + this.checkpointManager.save(this.gameState).catch((err) => console.warn('[BeleagueredCastle] Failed to save checkpoint:', err), ); } diff --git a/example-games/feudalism/FeudalismGame.ts b/example-games/feudalism/FeudalismGame.ts index 627cc8c3..bf2fb8d8 100644 --- a/example-games/feudalism/FeudalismGame.ts +++ b/example-games/feudalism/FeudalismGame.ts @@ -28,7 +28,7 @@ import { } from './FeudalismCards'; import type { MultiplayerSetupOptions } from '../../src/core-engine/SetupOptions'; -import { resolveSetupOptions } from '../../src/core-engine/SetupOptions'; +import { resolveSetupOptions, createSeededRng } from '../../src/core-engine'; import { getCurrentPlayer } from '../../src/core-engine/TurnSequencer'; import type { LegalityResult } from '../../src/rule-engine/index'; @@ -69,6 +69,8 @@ export interface FeudalismSession { startingPlayerIndex: number; /** Index of the player who first reached WIN_THRESHOLD, or -1. */ triggerPlayerIndex: number; + /** The numeric seed used for RNG creation, for checkpoint serialization. */ + seed: number; rng: () => number; } @@ -127,14 +129,35 @@ export interface TurnResult { // Setup options // --------------------------------------------------------------------------- -export type FeudalismSetupOptions = MultiplayerSetupOptions; +export interface FeudalismSetupOptions extends MultiplayerSetupOptions { + /** + * Numeric seed for deterministic RNG. + * + * When provided, a seeded RNG is created automatically (overriding any + * `rng` in the base options). The seed is stored in the session so it + * can be serialized for checkpoint save/load. + * + * When omitted, the RNG from `MultiplayerSetupOptions.rng` is used + * (defaulting to `Math.random`), and the session seed is set to 0. + */ + seed?: number; +} // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- export function setupFeudalismGame(options?: FeudalismSetupOptions): FeudalismSession { - const { players: playerInfos, rng } = resolveSetupOptions(options ?? {}); + // Resolve seed: if explicitly provided, create seeded RNG; otherwise use + // the RNG from options (or a default). Track the seed for serialization. + const seed = options?.seed ?? 0; + const setupOptions: MultiplayerSetupOptions = { + ...options, + rng: options?.seed !== undefined + ? createSeededRng(options.seed) + : options?.rng, + }; + const { players: playerInfos, rng } = resolveSetupOptions(setupOptions); const playerCount = playerInfos.length; if (playerCount < 2 || playerCount > 4) { @@ -173,6 +196,7 @@ export function setupFeudalismGame(options?: FeudalismSetupOptions): FeudalismSe currentPlayerIndex: 0, startingPlayerIndex: 0, triggerPlayerIndex: -1, + seed, rng, }; } diff --git a/example-games/feudalism/FeudalismSaveLoad.ts b/example-games/feudalism/FeudalismSaveLoad.ts new file mode 100644 index 00000000..be0aff29 --- /dev/null +++ b/example-games/feudalism/FeudalismSaveLoad.ts @@ -0,0 +1,293 @@ +/** + * Feudalism save/load adapter. + * + * Provides state serialization/deserialization and checkpoint helpers + * compatible with `SaveLoadStore` and `CheckpointManager`, following + * the pattern established by `BeleagueredCastleSaveLoad.ts` and + * `MainStreetSaveLoad.ts`. + * + * ## Checkpoint strategy + * + * - A single slot (`FEUDALISM_RUN_SLOT`) is reused for all checkpoints. + * The latest checkpoint always reflects the most recent save point. + * - The RNG seed is tracked in the serialized state so that the game + * can be reconstructed deterministically on restore. + */ + +import { createSeededRng, type SaveSerializer, SaveLoadStore, deserializeWithVersion } from '../../src/core-engine'; +import type { VersionedPayload } from '../../src/core-engine'; +import type { FeudalismSession, FeudalismPlayerState, FeudalismPhase } from './FeudalismGame'; +import type { DevelopmentCard, PatronTile, Tier, ResourceTokens } from './FeudalismCards'; + + +// ── Constants ─────────────────────────────────────────────── + +/** Schema version for Feudalism run checkpoints. */ +export const FEUDALISM_SAVE_SCHEMA_VERSION = 1; + +/** Game type identifier used in SaveLoadStore keys. */ +export const FEUDALISM_GAME_TYPE = 'feudalism'; + +/** Slot ID for run checkpoints (reused for turn-by-turn saves). */ +export const FEUDALISM_RUN_SLOT = 'run-checkpoint'; + +// ── Serialized state shape ────────────────────────────────── + +/** + * JSON-safe serialized form of a {@link FeudalismSession}. + * + * Cards and patron tiles are referenced by their `id` fields. + * The RNG function is not serializable; instead the numeric seed + * is stored and a new RNG is created on deserialization. + */ +export interface FeudalismSerializedState { + /** Serialized player states. */ + players: Array<{ + name: string; + isAI: boolean; + tokens: ResourceTokens; + /** IDs of purchased development cards, in order. */ + purchasedCardIds: number[]; + /** IDs of reserved development cards. */ + reservedCardIds: number[]; + /** IDs of earned patron tiles. */ + patronIds: number[]; + }>; + /** Market rows per tier, with card IDs for visible cards and deck. */ + market: Record; + /** Token supply counts. */ + tokenSupply: ResourceTokens; + /** IDs of patron tiles remaining in the pool. */ + patronIds: number[]; + /** Current game phase. */ + phase: FeudalismPhase; + /** Index of the current player. */ + currentPlayerIndex: number; + /** Index of the player who started the game. */ + startingPlayerIndex: number; + /** Index of the player who triggered end-game, or -1. */ + triggerPlayerIndex: number; + /** The numeric seed for RNG reconstruction. */ + seed: number; +} + +// ── Serialization helpers ─────────────────────────────────── + +/** + * Serialize a {@link FeudalismSession} to a JSON-safe object. + * + * Cards and patrons are referenced by ID. The RNG function is not + * serialized; the caller must provide the original seed. + * + * @param session - The current game session. + * @param seed - The numeric RNG seed used to set up the session. + * @returns A serializable state object. + */ +export function serializeFeudalismState( + session: FeudalismSession, + seed: number, +): FeudalismSerializedState { + return { + players: session.players.map((p) => ({ + name: p.name, + isAI: p.isAI, + tokens: { ...p.tokens }, + purchasedCardIds: p.purchasedCards.map((c) => c.id), + reservedCardIds: p.reservedCards.map((c) => c.id), + patronIds: p.patrons.map((pt) => pt.id), + })), + market: { + 1: serializeMarketRow(session.market[1]), + 2: serializeMarketRow(session.market[2]), + 3: serializeMarketRow(session.market[3]), + }, + tokenSupply: { ...session.tokenSupply }, + patronIds: session.patrons.map((pt) => pt.id), + phase: session.phase, + currentPlayerIndex: session.currentPlayerIndex, + startingPlayerIndex: session.startingPlayerIndex, + triggerPlayerIndex: session.triggerPlayerIndex, + seed, + }; +} + +function serializeMarketRow(row: { visible: (DevelopmentCard | null)[]; deck: DevelopmentCard[] }): FeudalismSerializedState['market'][Tier] { + return { + visible: row.visible.map((c) => c?.id ?? null), + deckIds: row.deck.map((c) => c.id), + }; +} + +/** + * Deserialize a {@link FeudalismSerializedState} back into a + * {@link FeudalismSession}. + * + * Cards and patrons are resolved by ID using the reference arrays. + * A new RNG is created from the stored seed. + * + * @param saved - The serialized state to restore. + * @returns A fully reconstructed game session (ready for play). + */ +export function deserializeFeudalismState( + saved: FeudalismSerializedState, +): FeudalismSession { + // Recreate the RNG from the stored seed + const rng = createSeededRng(saved.seed); + + // Reconstruct players + const players: FeudalismPlayerState[] = saved.players.map((sp) => ({ + name: sp.name, + isAI: sp.isAI, + tokens: { ...sp.tokens }, + purchasedCards: sp.purchasedCardIds.map((id) => resolveCardById(id)), + reservedCards: sp.reservedCardIds.map((id) => resolveCardById(id)), + patrons: sp.patronIds.map((id) => resolvePatronById(id)), + })); + + // Reconstruct market + const market = { + 1: deserializeMarketRow(saved.market[1]), + 2: deserializeMarketRow(saved.market[2]), + 3: deserializeMarketRow(saved.market[3]), + } as FeudalismSession['market']; + + return { + players, + market, + tokenSupply: { ...saved.tokenSupply }, + patrons: saved.patronIds.map((id) => resolvePatronById(id)), + phase: saved.phase, + currentPlayerIndex: saved.currentPlayerIndex, + startingPlayerIndex: saved.startingPlayerIndex, + triggerPlayerIndex: saved.triggerPlayerIndex, + seed: saved.seed, + rng, + }; +} + +function deserializeMarketRow( + saved: FeudalismSerializedState['market'][Tier], +): { visible: (DevelopmentCard | null)[]; deck: DevelopmentCard[] } { + return { + visible: saved.visible.map((id) => id !== null ? resolveCardById(id) : null), + deck: saved.deckIds.map((id) => resolveCardById(id)), + }; +} + +// ── Card and Patron resolution ────────────────────────────── + +import { + ALL_DEVELOPMENT_CARDS, + ALL_PATRONS, +} from './FeudalismCards'; + +/** + * Resolve a development card by its ID from the master list. + * Throws if the ID is not found. + */ +function resolveCardById(id: number): DevelopmentCard { + const card = ALL_DEVELOPMENT_CARDS.find((c) => c.id === id); + if (!card) { + throw new Error(`[FeudalismSaveLoad] Unknown development card ID: ${id}`); + } + return card; +} + +/** + * Resolve a patron tile by its ID from the master list. + * Throws if the ID is not found. + */ +function resolvePatronById(id: number): PatronTile { + const patron = ALL_PATRONS.find((p) => p.id === id); + if (!patron) { + throw new Error(`[FeudalismSaveLoad] Unknown patron tile ID: ${id}`); + } + return patron; +} + +// ── SaveSerializer ────────────────────────────────────────── + +/** + * Factory function to create a SaveSerializer for Feudalism. + * + * Returns a serializer that captures both the session and the seed. + * + * @param seed - The numeric RNG seed used for this game session. + * @returns A SaveSerializer matching the in-memory and serialized state types. + */ +export function createFeudalismSerializer( + seed: number, +): SaveSerializer { + return { + schemaVersion: FEUDALISM_SAVE_SCHEMA_VERSION, + serialize: (session: FeudalismSession) => serializeFeudalismState(session, seed), + deserialize: deserializeFeudalismState, + }; +} + +// ── Checkpoint helpers ────────────────────────────────────── + +/** + * Save a snapshot of the current game session as a run checkpoint. + * + * Fire-and-forget in production. The seed is pulled from the session + * directly for serialization. + * + * @param store - Initialized SaveLoadStore instance. + * @param session - Current game session. + * @param seed - Numeric RNG seed used for this session. + * @param slotId - Optional slot identifier (defaults to FEUDALISM_RUN_SLOT). + */ +export async function saveFeudalismCheckpoint( + store: SaveLoadStore, + session: FeudalismSession, + seed: number, + slotId: string = FEUDALISM_RUN_SLOT, +): Promise { + const serializer = createFeudalismSerializer(seed); + await store.saveRunCheckpoint(FEUDALISM_GAME_TYPE, slotId, serializer, session); +} + +/** + * Load the most recently saved run checkpoint. + * + * Since the serializer requires the seed (stored in the state), we load + * the raw stored data, extract the seed, then deserialize with the + * proper serializer. + * + * @param store - Initialized SaveLoadStore instance. + * @param slotId - Optional slot identifier (defaults to FEUDALISM_RUN_SLOT). + * @returns The restored game session, or null if no checkpoint exists. + */ +export async function loadFeudalismCheckpoint( + store: SaveLoadStore, + slotId: string = FEUDALISM_RUN_SLOT, +): Promise { + const stored = await store.load>( + 'run-checkpoint', FEUDALISM_GAME_TYPE, slotId, + ); + if (!stored) return null; + + const seed = stored.payload.data.seed; + const serializer = createFeudalismSerializer(seed); + return deserializeWithVersion(serializer, stored.payload); +} + +/** + * Delete the saved checkpoint so the next boot starts a fresh game. + * Safe to call even if no checkpoint exists. + * + * @param store - Initialized SaveLoadStore instance. + * @param slotId - Optional slot identifier (defaults to FEUDALISM_RUN_SLOT). + */ +export async function clearFeudalismCheckpoint( + store: SaveLoadStore, + slotId: string = FEUDALISM_RUN_SLOT, +): Promise { + await store.remove('run-checkpoint', FEUDALISM_GAME_TYPE, slotId); +} diff --git a/example-games/feudalism/scenes/FeudalismAnimator.ts b/example-games/feudalism/scenes/FeudalismAnimator.ts index 2ee0baf8..ae231ad0 100644 --- a/example-games/feudalism/scenes/FeudalismAnimator.ts +++ b/example-games/feudalism/scenes/FeudalismAnimator.ts @@ -96,6 +96,7 @@ export class FeudalismAnimator { playerIndex: number, onAllComplete: () => void, onRefreshMarket: () => void, + onBeforePatronAnimation: () => void, onRefreshPatronsAndPlayer: () => void, ): void { const flyingCard = this.createFlyingCard(sourcePos.x, sourcePos.y, card); @@ -110,10 +111,10 @@ export class FeudalismAnimator { flyingCard.destroy(); if (marketSlot) { this.playMarketRefillAnimation(marketSlot.tier, marketSlot.col, () => { - this.chainPatronAnimation(patronVisit, patronSourceIndex, playerIndex, onAllComplete, onRefreshPatronsAndPlayer); + this.chainPatronAnimation(patronVisit, patronSourceIndex, playerIndex, onAllComplete, onBeforePatronAnimation, onRefreshPatronsAndPlayer); }, onRefreshMarket); } else { - this.chainPatronAnimation(patronVisit, patronSourceIndex, playerIndex, onAllComplete, onRefreshPatronsAndPlayer); + this.chainPatronAnimation(patronVisit, patronSourceIndex, playerIndex, onAllComplete, onBeforePatronAnimation, onRefreshPatronsAndPlayer); } }, }); @@ -148,12 +149,18 @@ export class FeudalismAnimator { patronSourceIndex: number, playerIndex: number, onComplete: () => void, + onBeforePatronAnimation: () => void, onRefreshPatronsAndPlayer: () => void, ): void { if (!patronVisit || patronSourceIndex < 0) { onComplete(); return; } + + // Remove the static patron tile from the Patrons section before the + // flying patron appears, so there is only one visible patron during flight. + onBeforePatronAnimation(); + const patronSource = this.getPatronCenter(patronSourceIndex); const patronDest = this.getPlayerPatronDest(playerIndex); const flyingPatron = this.createFlyingPatron(patronSource.x, patronSource.y, patronVisit); diff --git a/example-games/feudalism/scenes/FeudalismOverlays.ts b/example-games/feudalism/scenes/FeudalismOverlays.ts index ac83e4f3..40f500a4 100644 --- a/example-games/feudalism/scenes/FeudalismOverlays.ts +++ b/example-games/feudalism/scenes/FeudalismOverlays.ts @@ -29,7 +29,7 @@ export class FeudalismOverlayHelper { } showGameOverOverlay(recorder: FeudalismTranscriptRecorder | null, onRestart: () => void): void { - this.scene.sound.play?.(SFX_KEYS.GAME_END); + try { this.scene.sound.play?.(SFX_KEYS.GAME_END); } catch { /* ignore */ } const winnerIdx = getWinnerIndex(this.session); if (recorder && !recorder.isSealed()) { @@ -69,7 +69,7 @@ export class FeudalismOverlayHelper { const playBtn = createOverlayButton(this.scene, GAME_W / 2 - 80, GAME_H / 2 + 110, '[ Play Again ]'); playBtn.on('pointerdown', () => { - this.scene.sound.play?.(SFX_KEYS.UI_CLICK); + try { this.scene.sound.play?.(SFX_KEYS.UI_CLICK); } catch { /* ignore */ } this.dismiss(); onRestart(); }); @@ -124,7 +124,7 @@ export class FeudalismOverlayHelper { const cancelBtn = createOverlayButton(this.scene, bx, GAME_H / 2 + 40, '[ Cancel ]'); cancelBtn.on('pointerdown', () => { - this.scene.sound.play?.(SFX_KEYS.UI_CLICK); + try { this.scene.sound.play?.(SFX_KEYS.UI_CLICK); } catch { /* ignore */ } this.dismiss(); onCancel(); }); diff --git a/example-games/feudalism/scenes/FeudalismRenderer.ts b/example-games/feudalism/scenes/FeudalismRenderer.ts index 3df0eeea..eed1a973 100644 --- a/example-games/feudalism/scenes/FeudalismRenderer.ts +++ b/example-games/feudalism/scenes/FeudalismRenderer.ts @@ -2,7 +2,7 @@ * FeudalismRenderer — UI creation and refresh logic for Feudalism. */ import Phaser from 'phaser'; -import type { ResourceType, ResourceOrWild, DevelopmentCard, Tier } from '../FeudalismCards'; +import type { ResourceType, ResourceOrWild, DevelopmentCard, PatronTile, Tier } from '../FeudalismCards'; import { RESOURCE_TYPES, tokenCount, @@ -88,6 +88,10 @@ export class FeudalismRenderer { discardSelection: Partial> = {}; discardNeeded = 0; + // Patron animation cache: keep a patron visible in the patron column + // during card-purchase/reserve animation before its own fly animation starts. + private patronAnimationCache: { tile: PatronTile; index: number } | null = null; + constructor(scene: Phaser.Scene, session: FeudalismSession) { this.scene = scene; this.session = session; @@ -96,6 +100,11 @@ export class FeudalismRenderer { // ── Getters ───────────────────────────────────────────── get instruction(): Phaser.GameObjects.Text { return this.instructionText; } get selectedCardId(): number | null { return this.selectedMarketCardId; } + + /** Cache a patron that should remain visible during animation. */ + cachePatronForAnimation(patron: PatronTile | null, index: number): void { + this.patronAnimationCache = patron ? { tile: patron, index } : null; + } get marketContainers(): Map { return this.marketCardContainerById; } get marketSelections(): Map { return this.marketSelectionByCardId; } get marketMgr(): SingleSelectionManager { return this.marketSelectionManager; } @@ -337,41 +346,53 @@ export class FeudalismRenderer { this.patronContainer.removeAll(true); for (let i = 0; i < this.session.patrons.length; i++) { const patron = this.session.patrons[i]; - const y = MARKET_Y + i * (MARKET_CARD_H + MARKET_TIER_GAP); + this.renderPatronTile(i, patron); + } - const bg = this.scene.add.rectangle(PATRON_X + PATRON_W / 2, y + PATRON_H / 2, PATRON_W, PATRON_H, 0x6633aa, 0.7); - bg.setStrokeStyle(1, 0x9966cc); - this.patronContainer.add(bg); + // If a patron is being animated, keep it rendered at its original position + // (it was removed from session.patrons by executeTurn but the fly animation + // hasn't started yet). + if (this.patronAnimationCache) { + this.renderPatronTile(this.patronAnimationCache.index, this.patronAnimationCache.tile); + } + } - const pts = this.scene.add.text(PATRON_X + PATRON_W / 2, y + 20, '3 pt', { - fontSize: '20px', fontStyle: 'bold', color: '#ffdd44', fontFamily: FONT_FAMILY, - }).setOrigin(0.5); - this.patronContainer.add(pts); + /** Render a single patron tile at the given row index. */ + private renderPatronTile(i: number, patron: PatronTile): void { + const y = MARKET_Y + i * (MARKET_CARD_H + MARKET_TIER_GAP); - const patronLabel = this.scene.add.text(PATRON_X + PATRON_W / 2, y + 42, 'Patron', { - fontSize: '13px', color: '#ccaaee', fontFamily: FONT_FAMILY, - }).setOrigin(0.5); - this.patronContainer.add(patronLabel); + const bg = this.scene.add.rectangle(PATRON_X + PATRON_W / 2, y + PATRON_H / 2, PATRON_W, PATRON_H, 0x6633aa, 0.7); + bg.setStrokeStyle(1, 0x9966cc); + this.patronContainer.add(bg); - const reqs: { color: ResourceType; count: number }[] = []; - for (const c of RESOURCE_TYPES) { - const n = patron.requirements[c] ?? 0; - if (n > 0) reqs.push({ color: c, count: n }); - } - const chipSpacing = 30; - const reqStartX = PATRON_X + PATRON_W / 2 - (reqs.length - 1) * chipSpacing / 2; - for (let j = 0; j < reqs.length; j++) { - const rx = reqStartX + j * chipSpacing; - const ry = y + PATRON_H - 26; - const chip = this.scene.add.circle(rx, ry, 13, RESOURCE_FILL[reqs[j].color], 0.9); - chip.setStrokeStyle(1, 0x888888); - this.patronContainer.add(chip); - const ct = this.scene.add.text(rx, ry, `${reqs[j].count}`, { - fontSize: '15px', fontStyle: 'bold', - color: RESOURCE_TEXT_COLOR[reqs[j].color], fontFamily: FONT_FAMILY, - }).setOrigin(0.5); - this.patronContainer.add(ct); - } + const pts = this.scene.add.text(PATRON_X + PATRON_W / 2, y + 20, '3 pt', { + fontSize: '20px', fontStyle: 'bold', color: '#ffdd44', fontFamily: FONT_FAMILY, + }).setOrigin(0.5); + this.patronContainer.add(pts); + + const patronLabel = this.scene.add.text(PATRON_X + PATRON_W / 2, y + 42, 'Patron', { + fontSize: '13px', color: '#ccaaee', fontFamily: FONT_FAMILY, + }).setOrigin(0.5); + this.patronContainer.add(patronLabel); + + const reqs: { color: ResourceType; count: number }[] = []; + for (const c of RESOURCE_TYPES) { + const n = patron.requirements[c] ?? 0; + if (n > 0) reqs.push({ color: c, count: n }); + } + const chipSpacing = 30; + const reqStartX = PATRON_X + PATRON_W / 2 - (reqs.length - 1) * chipSpacing / 2; + for (let j = 0; j < reqs.length; j++) { + const rx = reqStartX + j * chipSpacing; + const ry = y + PATRON_H - 26; + const chip = this.scene.add.circle(rx, ry, 13, RESOURCE_FILL[reqs[j].color], 0.9); + chip.setStrokeStyle(1, 0x888888); + this.patronContainer.add(chip); + const ct = this.scene.add.text(rx, ry, `${reqs[j].count}`, { + fontSize: '15px', fontStyle: 'bold', + color: RESOURCE_TEXT_COLOR[reqs[j].color], fontFamily: FONT_FAMILY, + }).setOrigin(0.5); + this.patronContainer.add(ct); } } diff --git a/example-games/feudalism/scenes/FeudalismScene.ts b/example-games/feudalism/scenes/FeudalismScene.ts index be9e7254..948a5764 100644 --- a/example-games/feudalism/scenes/FeudalismScene.ts +++ b/example-games/feudalism/scenes/FeudalismScene.ts @@ -8,6 +8,14 @@ import { setupFeudalismGame } from '../FeudalismGame'; import { FeudalismAiPlayer, GreedyStrategy } from '../AiStrategy'; import { FeudalismTranscriptRecorder } from '../GameTranscript'; import type { EventSoundMapping } from '../../../src/core-engine/SoundManager'; +import { SaveLoadStore, CheckpointManager, createDefaultResumeOverlay } from '../../../src/core-engine'; +import { + createFeudalismSerializer, + FEUDALISM_GAME_TYPE, + FEUDALISM_RUN_SLOT, + clearFeudalismCheckpoint, + type FeudalismSerializedState, +} from '../FeudalismSaveLoad'; import { CardGameScene, GAME_W, GAME_H, @@ -47,6 +55,8 @@ export class FeudalismScene extends CardGameScene { private turnController!: FeudalismTurnController; private replayController!: FeudalismReplayController; + private saveLoadStore!: SaveLoadStore; + private checkpointManager!: CheckpointManager; private replayStepIndex: number = -1; constructor() { @@ -90,10 +100,15 @@ export class FeudalismScene extends CardGameScene { }; this.initSoundSystem(Object.values(SFX_KEYS), mapping, { namespace: 'feudalism' }); + this.saveLoadStore = new SaveLoadStore(); + + // Use a random seed for new games (Date.now() so each game is unique) + const gameSeed = Date.now(); this.session = setupFeudalismGame({ playerCount: 2, playerNames: ['You', 'AI'], isAI: [false, true], + seed: gameSeed, }); this.aiPlayer = new FeudalismAiPlayer(GreedyStrategy); this.recorder = new FeudalismTranscriptRecorder(this.session); @@ -102,13 +117,26 @@ export class FeudalismScene extends CardGameScene { this.animator = new FeudalismAnimator(this, this.session); this.overlayManager = new OverlayManager(this); this.overlayHelper = new FeudalismOverlayHelper(this, this.overlayManager, this.session); + // Wire up checkpoint saving after each turn + this.checkpointManager = new CheckpointManager( + this.saveLoadStore, + FEUDALISM_GAME_TYPE, + FEUDALISM_RUN_SLOT, + createFeudalismSerializer(gameSeed), + ); + const checkpointManager = this.checkpointManager; + this.turnController = new FeudalismTurnController(this.session, this.aiPlayer, this.animator, { onPhaseChange: (phase) => this.setPhase(phase), onRefreshAll: () => this.refreshAll(), onShowToast: (msg) => this.feudRenderer.showToast(msg), onShowDiscardDialog: (excess) => this.showDiscardDialog(excess), - onShowGameOver: () => this.overlayHelper.showGameOverOverlay(this.recorder, () => this.scene.restart()), + onShowGameOver: () => { + clearFeudalismCheckpoint(this.saveLoadStore).catch(() => {}); + this.overlayHelper.showGameOverOverlay(this.recorder, () => this.scene.restart()); + }, onPlaySound: (key) => this.soundManager?.play(key), + onSetPatronAnimationCache: (patron, index) => this.feudRenderer.cachePatronForAnimation(patron, index), onEmitTurnStarted: () => { this.gameEvents.emit('turn-started', { turnNumber: 0, @@ -123,6 +151,9 @@ export class FeudalismScene extends CardGameScene { winnerIndex: winnerIdx, }); }, + onSaveCheckpoint: () => { + checkpointManager.save(this.session).catch(() => {}); + }, }); this.turnController.setRecorder(this.recorder); @@ -137,6 +168,9 @@ export class FeudalismScene extends CardGameScene { this.refreshAll(); this.turnController.setPhase('player-turn'); + + // Async checkpoint check (deferred to next frame for scene readiness) + this.time.delayedCall(0, () => this.checkForSavedCheckpoint()); } private createHeader(): void { @@ -386,6 +420,106 @@ export class FeudalismScene extends CardGameScene { return this.turnController.phase; } + // ── Checkpoint / Resume ───────────────────────────────── + + /** + * Asynchronously check for a saved checkpoint on startup. + * + * If found, shows a resume overlay with [Resume] and [New Game] buttons. + * If not found, the normal fresh game flow continues. + */ + private checkForSavedCheckpoint(): void { + if (!this.checkpointManager) return; + + this.checkpointManager.checkAndResume( + // No checkpoint — start fresh (already done in create()) + () => { + // Already set up in create(); nothing extra to do + }, + // Resume from checkpoint + (savedState) => { + this.restoreFromCheckpoint(savedState); + }, + // Custom resume overlay matching Feudalism visual style + (state, onResume, onNewGame) => { + // Use the built-in default overlay from core engine + createDefaultResumeOverlay(this, state, onResume, onNewGame); + }, + ).catch(() => { + // On error (e.g., storage unavailable), continue with fresh game + }); + } + + /** + * Restore the game state from a saved checkpoint. + * Rebuilds the turn controller, renderer, and interactions. + */ + private restoreFromCheckpoint(savedState: FeudalismSession): void { + this.session = savedState; + this.aiPlayer = new FeudalismAiPlayer(GreedyStrategy); + this.recorder = new FeudalismTranscriptRecorder(this.session); + + this.feudRenderer = new FeudalismRenderer(this, this.session); + this.animator = new FeudalismAnimator(this, this.session); + + const checkpointManager = new CheckpointManager( + this.saveLoadStore, + FEUDALISM_GAME_TYPE, + FEUDALISM_RUN_SLOT, + createFeudalismSerializer(savedState.seed), + ); + + this.turnController = new FeudalismTurnController(this.session, this.aiPlayer, this.animator, { + onPhaseChange: (phase) => this.setPhase(phase), + onRefreshAll: () => this.refreshAll(), + onShowToast: (msg) => this.feudRenderer.showToast(msg), + onShowDiscardDialog: (excess) => this.showDiscardDialog(excess), + onShowGameOver: () => { + clearFeudalismCheckpoint(this.saveLoadStore).catch(() => {}); + this.overlayHelper.showGameOverOverlay(this.recorder, () => this.scene.restart()); + }, + onPlaySound: (key) => this.soundManager?.play(key), + onSetPatronAnimationCache: (patron, index) => this.feudRenderer.cachePatronForAnimation(patron, index), + onEmitTurnStarted: () => { + this.gameEvents.emit('turn-started', { + turnNumber: 0, + playerIndex: 0, + playerName: 'You', + isAI: false, + }); + }, + onEmitGameEnded: (winnerIdx) => { + this.gameEvents.emit('game-ended', { + finalTurnNumber: 0, + winnerIndex: winnerIdx, + }); + }, + onSaveCheckpoint: () => { + checkpointManager.save(this.session).catch(() => {}); + }, + }); + + this.turnController.setRecorder(this.recorder); + + this.feudRenderer.createContainers(); + this.feudRenderer.createInstructions(); + this.feudRenderer.createInfluenceDisplay(); + this.refreshAll(); + this.turnController.setPhase('player-turn'); + + // Wire up interactions for the restored state + this.feudRenderer.refreshAll({ + onMarketCardClick: (card) => this.onMarketCardClick(card), + onReserveDeck: (tier) => this.onReserveDeck(tier), + onSupplyTokenClick: (color) => this.onSupplyTokenClick(color), + onTakeTokens: () => this.onTakeTokens(), + onTakeSame: (color) => this.turnController.executeTakeSame(color), + onConfirmDifferent: () => this.onConfirmDifferent(), + onCancelSelection: () => this.onCancelSelection(), + onReservedCardClick: (card) => this.onReservedCardClick(card), + }); + } + // ── Cleanup ───────────────────────────────────────────── shutdown(): void { this.overlayManager.dismiss(); diff --git a/example-games/feudalism/scenes/FeudalismTurnController.ts b/example-games/feudalism/scenes/FeudalismTurnController.ts index d9d81034..10ad1866 100644 --- a/example-games/feudalism/scenes/FeudalismTurnController.ts +++ b/example-games/feudalism/scenes/FeudalismTurnController.ts @@ -1,7 +1,7 @@ /** * FeudalismTurnController — turn flow, action execution, and AI scheduling. */ -import type { ResourceType, DevelopmentCard, Tier } from '../FeudalismCards'; +import type { PatronTile, ResourceType, DevelopmentCard, Tier } from '../FeudalismCards'; import { resourceAbbrev, resourceDisplayName } from '../FeudalismCards'; import type { FeudalismSession, TurnAction, TurnResult } from '../FeudalismGame'; import { executeTurn, discardTokens, isGameOver } from '../FeudalismGame'; @@ -21,6 +21,10 @@ export interface TurnControllerCallbacks { onPlaySound: (key: string) => void; onEmitTurnStarted: () => void; onEmitGameEnded: (winnerIdx: number) => void; + /** Cache a patron to keep it visible in the patron column during animation. */ + onSetPatronAnimationCache: (patron: PatronTile | null, index: number) => void; + /** Callback after each complete turn (human or AI) to save a checkpoint. */ + onSaveCheckpoint?: () => void; } export class FeudalismTurnController { @@ -148,6 +152,11 @@ export class FeudalismTurnController { ? this.animator.getPlayerCardDest(playerIndex) : this.animator.getPlayerReserveDest(playerIndex); + // Cache the patron so refreshPatrons keeps it visible during animation + if (result.patronVisit) { + this.callbacks.onSetPatronAnimationCache(result.patronVisit, patronSourceIndex); + } + this.setPhase('animating'); this.callbacks.onRefreshAll(); @@ -156,7 +165,19 @@ export class FeudalismTurnController { patronSourceIndex, playerIndex, () => this.afterTurnComplete(result), () => this.callbacks.onRefreshAll(), - () => this.callbacks.onRefreshAll(), + () => { + // Clear cache before patron fly animation starts so the static + // patron tile is removed from the Patrons section, leaving only + // the flying patron visible during its flight. + this.callbacks.onSetPatronAnimationCache(null, -1); + this.callbacks.onRefreshAll(); + }, + () => { + // Patron has flown and been destroyed; do a full refresh to + // show the patron in the player area and update the Patrons + // section to reflect remaining available patrons. + this.callbacks.onRefreshAll(); + }, ); } else { this.afterTurnComplete(result); @@ -191,6 +212,9 @@ export class FeudalismTurnController { this.setPhase('animating'); this.callbacks.onRefreshAll(); + // Save checkpoint after every completed turn (human or AI) + this.callbacks.onSaveCheckpoint?.(); + if (result.gameOver) { (this.animator as any).scene.time.delayedCall(ANIM_DURATION, () => { this.callbacks.onShowGameOver(); @@ -296,13 +320,30 @@ export class FeudalismTurnController { ? this.animator.getPlayerCardDest(aiIndex) : this.animator.getPlayerReserveDest(aiIndex); + // Cache the patron so refreshPatrons keeps it visible during animation + if (result.patronVisit) { + this.callbacks.onSetPatronAnimationCache(result.patronVisit, patronSourceIndex); + } + this.callbacks.onRefreshAll(); this.animator.playCardAnimation( sourcePos, destPos, card, marketSlot, result.patronVisit, patronSourceIndex, aiIndex, afterAnim, () => this.callbacks.onRefreshAll(), - () => this.callbacks.onRefreshAll(), + () => { + // Clear cache before patron fly animation starts so the static + // patron tile is removed from the Patrons section, leaving only + // the flying patron visible during its flight. + this.callbacks.onSetPatronAnimationCache(null, -1); + this.callbacks.onRefreshAll(); + }, + () => { + // Patron has flown and been destroyed; do a full refresh to + // show the patron in the player area and update the Patrons + // section to reflect remaining available patrons. + this.callbacks.onRefreshAll(); + }, ); } else { this.callbacks.onRefreshAll(); diff --git a/example-games/golf/scenes/GolfAnimator.ts b/example-games/golf/scenes/GolfAnimator.ts index 3e7b1eb6..d3ea58d4 100644 --- a/example-games/golf/scenes/GolfAnimator.ts +++ b/example-games/golf/scenes/GolfAnimator.ts @@ -237,7 +237,13 @@ export class GolfAnimator { updateDiscardPileAfterDraw(): void { const pile = this.session.shared.discardPile; if (pile.size() <= 1) { - this.renderer.showDiscardPlaceholder(); + // Pile will be empty after the draw — show the ghosted card-back + // placeholder. Direct sprite manipulation here (matching the else + // branch) because the card hasn't been popped from the pile yet + // (that happens later in executeTurn()). + this.renderer.discardSprite.setTexture('card_back'); + this.renderer.discardSprite.setAlpha(0.25); + this.renderer.discardSprite.setVisible(true); } else { const arr = pile.toArray(); const nextTop = arr[arr.length - 2]; diff --git a/example-games/lost-cities/scenes/LostCitiesAnimator.ts b/example-games/lost-cities/scenes/LostCitiesAnimator.ts index 13dca8b2..ebc4dd47 100644 --- a/example-games/lost-cities/scenes/LostCitiesAnimator.ts +++ b/example-games/lost-cities/scenes/LostCitiesAnimator.ts @@ -3,7 +3,7 @@ */ import Phaser from 'phaser'; import type { Phase1Action, Phase2Action } from '../LostCitiesRules'; -import { cardAssetKey } from '../LostCitiesCards'; +import { cardAssetKey, compactAssetKey } from '../LostCitiesCards'; import type { LostCitiesSession } from '../LostCitiesGame'; import { EXPEDITION_COLORS } from '../LostCitiesCards'; import { getLcTextureKey, getLcBackFallbackKey, getLcFaceKey } from '../LostCitiesTextureHelpers'; @@ -46,32 +46,14 @@ export class LostCitiesAnimator { this.renderer = renderer; } - animatePhase1(action: Phase1Action, onComplete: () => void): void { + animatePhase1(action: Phase1Action, handIndex: number, onComplete: () => void): void { const handSprites = this.renderer.handSpriteList; - if (handSprites.length === 0) { + if (handSprites.length === 0 || handIndex < 0 || handIndex >= handSprites.length) { onComplete(); return; } - // Use DPR-aware key for sprite lookup (hand sprites use CARD_W x CARD_H). - const targetTemplateId = cardAssetKey(action.card); - const targetKey = getLcTextureKey(targetTemplateId, CARD_W, CARD_H); - let spriteIdx = -1; - for (let i = 0; i < handSprites.length; i++) { - const spriteKey = handSprites[i].texture.key; - // Match DPR-aware keys; fall back to template ID for legacy compatibility. - if (spriteKey === targetKey || spriteKey === targetTemplateId) { - spriteIdx = i; - break; - } - } - - if (spriteIdx < 0) { - onComplete(); - return; - } - - const sprite = handSprites[spriteIdx]; + const sprite = handSprites[handIndex]; sprite.setDepth(100); let targetX: number; @@ -126,9 +108,11 @@ export class LostCitiesAnimator { const colorIdx = EXPEDITION_COLORS.indexOf(action.color); sourceX = laneX(colorIdx); sourceY = DISCARD_Y + DISCARD_CARD_H / 2; - // Use DPR-aware key for the drawn card texture. - const templateId = cardAssetKey(drawnCard); - textureKey = getLcTextureKey(templateId, DISCARD_CARD_W, DISCARD_CARD_H); + // Use compact template ID (with -sm suffix) + getLcFaceKey so the + // texture matches what prewarmTextures() creates, and getLcFaceKey + // provides fallback to card-back if synchronous rasterisation fails. + const templateId = compactAssetKey(drawnCard); + textureKey = getLcFaceKey(this.scene, templateId, DISCARD_CARD_W, DISCARD_CARD_H); } const tempSprite = this.scene.add.image(sourceX, sourceY, textureKey); diff --git a/example-games/lost-cities/scenes/LostCitiesOverlays.ts b/example-games/lost-cities/scenes/LostCitiesOverlays.ts index d895d8bf..ce8b2603 100644 --- a/example-games/lost-cities/scenes/LostCitiesOverlays.ts +++ b/example-games/lost-cities/scenes/LostCitiesOverlays.ts @@ -48,7 +48,11 @@ export class LostCitiesOverlayHelper { const cx = GAME_W / 2; const topY = GAME_H / 2 - 200; - const title = createLcHudText(this.scene, cx, topY, `Round ${this.session.roundNumber - 1} Complete`, '#f0c040', { + // roundNumber is already 1-based and is NOT incremented before the overlay + // is rendered — advanceMatch/startNextRound happens *after* the overlay is + // dismissed (the 'round-over' pause phase preserves round-final state). + // Using roundNumber directly (not roundNumber - 1) gives the correct display. + const title = createLcHudText(this.scene, cx, topY, `Round ${this.session.roundNumber} Complete`, '#f0c040', { fontSize: '28px', originX: 0.5, originY: 0, diff --git a/example-games/lost-cities/scenes/LostCitiesTurnController.ts b/example-games/lost-cities/scenes/LostCitiesTurnController.ts index bc97c997..32675159 100644 --- a/example-games/lost-cities/scenes/LostCitiesTurnController.ts +++ b/example-games/lost-cities/scenes/LostCitiesTurnController.ts @@ -193,6 +193,10 @@ export class LostCitiesTurnController { private executePlayerPhase1(action: Phase1Action): void { this.setPhase('animating'); this.renderer.clearSelectionHighlight(); + // Save the selected card index before clearing (needed by animatePhase1 + // to find the correct hand sprite to animate — after executeAction() the + // sprite list is still unchanged since refreshAll() hasn't been called yet). + const savedCardIndex = this.selectedCardIndex; this.selectedCardIndex = -1; if (action.kind === 'play-to-expedition') { @@ -205,7 +209,7 @@ export class LostCitiesTurnController { const result = executeAction(this.session, action); this.recorder.recordAction(this.session, result, action, phase); - this.animator.animatePhase1(action, () => { + this.animator.animatePhase1(action, savedCardIndex, () => { this.callbacks.onRefreshAll(); this.setPhase('waiting-for-draw'); }); diff --git a/example-games/main-street/MainStreetSaveLoad.ts b/example-games/main-street/MainStreetSaveLoad.ts index e8dbaf8b..2be1069b 100644 --- a/example-games/main-street/MainStreetSaveLoad.ts +++ b/example-games/main-street/MainStreetSaveLoad.ts @@ -1,5 +1,6 @@ import { SaveLoadStore, + CheckpointManager, type SaveSerializer, } from '../../src/core-engine'; import { @@ -71,6 +72,43 @@ export function createDefaultCampaignProgress(): MainStreetCampaignProgress { }; } +/** + * Create a canonical {@link CheckpointManager} for Main Street run checkpoints. + * + * Uses the existing {@link mainStreetStateSerializer} and the shared + * `MAIN_STREET_GAME_TYPE` / `MAIN_STREET_RUN_SLOT` constants so that all + * checkpoint operations follow the same pattern as Feudalism and + * Beleaguered Castle. + * + * @param store - Initialized SaveLoadStore instance. + * @returns A new CheckpointManager bound to Main Street's slot and serializer. + */ +export function createMainStreetCheckpointManager( + store: SaveLoadStore, +): CheckpointManager { + return new CheckpointManager( + store, + MAIN_STREET_GAME_TYPE, + MAIN_STREET_RUN_SLOT, + mainStreetStateSerializer, + ); +} + +/** + * Remove the saved turn-start checkpoint. + * + * Safe to call even when no checkpoint exists. Delegates to + * {@link CheckpointManager.clear}. + * + * @param store - Initialized SaveLoadStore instance. + */ +export async function clearTurnStartCheckpoint( + store: SaveLoadStore, +): Promise { + const mgr = createMainStreetCheckpointManager(store); + await mgr.clear(); +} + /** * Evaluates tier unlocks and updates campaign progress after a completed run. * @@ -142,25 +180,18 @@ export async function updateCampaignAfterRun( export async function saveTurnStartCheckpoint( store: SaveLoadStore, state: MainStreetState, - slotId: string = MAIN_STREET_RUN_SLOT, + _slotId: string = MAIN_STREET_RUN_SLOT, ): Promise { - await store.saveRunCheckpoint( - MAIN_STREET_GAME_TYPE, - slotId, - mainStreetStateSerializer, - state, - ); + const mgr = createMainStreetCheckpointManager(store); + await mgr.save(state); } export async function loadTurnStartCheckpoint( store: SaveLoadStore, - slotId: string = MAIN_STREET_RUN_SLOT, + _slotId: string = MAIN_STREET_RUN_SLOT, ): Promise { - return store.loadRunCheckpoint( - MAIN_STREET_GAME_TYPE, - slotId, - mainStreetStateSerializer, - ); + const mgr = createMainStreetCheckpointManager(store); + return mgr.load(); } export async function saveCampaignProgress( diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index acc33b9a..45bee98d 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -1,7 +1,7 @@ import { setupMainStreetGame, deserializeMainStreetState } from '../MainStreetState'; -import { createDefaultCampaignProgress, loadCampaignProgress, updateCampaignAfterRun, saveCampaignProgress } from '../MainStreetSaveLoad'; +import { createDefaultCampaignProgress, loadCampaignProgress, updateCampaignAfterRun, saveCampaignProgress, createMainStreetCheckpointManager } from '../MainStreetSaveLoad'; import { DIFFICULTY_NAMES } from '../MainStreetDifficulty'; -import { SaveLoadStore, markSceneValid, markSceneInvalid, createTfPlayer, UndoRedoManager } from '../../../src/core-engine'; +import { SaveLoadStore, createDefaultResumeOverlay, markSceneValid, markSceneInvalid, createTfPlayer, UndoRedoManager } from '../../../src/core-engine'; import { createSingleSelectionManager, TooltipManager } from '../../../src/ui'; import type { HelpSection } from '../../../src/ui'; import { MAIN_STREET_TF_SFX_MAPPING } from '../sfx-tf-mapping'; @@ -240,6 +240,22 @@ export class MainStreetLifecycleManager { // Game setup -- load campaign for tier-filtered deck building s.saveStore = new SaveLoadStore(); + s.checkpointManager = createMainStreetCheckpointManager(s.saveStore); + + // Wire checkpoint callbacks to the turn controller + s.msTurnController.onSaveCheckpoint = () => { + if (s.state) { + s.checkpointManager.save(s.state).catch((_err: unknown) => { + console.warn('[MainStreet] Failed to save checkpoint:', _err); + }); + } + }; + s.msTurnController.onGameEnd = () => { + s.checkpointManager.clear().catch((_err: unknown) => { + console.warn('[MainStreet] Failed to clear checkpoint:', _err); + }); + }; + this.loadCampaignAndSetup(); // Undo/Redo manager (per-scene) @@ -695,12 +711,17 @@ export class MainStreetLifecycleManager { // the market is empty, making interactive tutorial steps impossible. try { s.startDayPhase(); } catch (_) { /* ignore */ } } - // After attempting to load (saved or not), show the tutorial offer modal - // if eligibility checks pass (Milestone 5 onboarding flow). + // Check for a saved run checkpoint. If one exists, the resume overlay + // takes priority over the tutorial offer modal. try { - const legacySeen = s.campaign ? (s.campaign as any).tutorialSeen : undefined; - (s as any).tutorialOfferModal?.showIfEligible(tutorialOpts, legacySeen); - } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] tutorial offer check failed', e); } + s.checkForSavedCheckpoint(tutorialOpts); + } catch (e) { + // If checkpoint check fails, fall through to tutorial offer + try { + const legacySeen = s.campaign ? (s.campaign as any).tutorialSeen : undefined; + (s as any).tutorialOfferModal?.showIfEligible(tutorialOpts, legacySeen); + } catch (_) { /* ignore */ } + } return saved; }).catch(() => { // If load fails, continue with defaults and show offer modal @@ -797,4 +818,49 @@ export class MainStreetLifecycleManager { // Signal board is visually stable s.emitStateSettled(stepIdx, 'playing'); } + + /** + * Check for a saved run checkpoint on startup. + * + * If a checkpoint exists, shows a resume overlay with [Resume] and + * [New Game] buttons (takes priority over the tutorial offer). + * If no checkpoint exists, the tutorial offer modal is shown. + * + * @param tutorialOpts Options for the tutorial offer modal (shown if no checkpoint). + */ + public checkForSavedCheckpoint(tutorialOpts: TutorialVisibilityOptions): void { + const s = this.scene; + if (!s.checkpointManager) return; + + s.checkpointManager.checkAndResume( + // No checkpoint — show tutorial offer (if eligible) + () => { + try { + const legacySeen = s.campaign ? (s.campaign as any).tutorialSeen : undefined; + (s as any).tutorialOfferModal?.showIfEligible(tutorialOpts, legacySeen); + } catch (_) { /* ignore */ } + }, + // Resume from checkpoint — replace state and rebuild UI + (savedState: any) => { + s.state = savedState; + // Mark tutorial as seen (resumed game means player already played) + if (s.campaign) { + s.campaign.tutorialSeen = true; + } + // Rebuild renderer and start day phase from checkpoint state + try { s.refreshAll(); } catch (_) { /* ignore */ } + try { s.startDayPhase(); } catch (_) { /* ignore */ } + }, + // Resume overlay callback — use built-in default overlay + (state: any, onResume: () => void, onNewGame: () => void) => { + createDefaultResumeOverlay(s, state, onResume, onNewGame); + }, + ).catch(() => { + // On error (e.g., storage unavailable), show tutorial offer + try { + const legacySeen = s.campaign ? (s.campaign as any).tutorialSeen : undefined; + (s as any).tutorialOfferModal?.showIfEligible(tutorialOpts, legacySeen); + } catch (_) { /* ignore */ } + }); + } } diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index d41b6b12..fa161544 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -9,8 +9,9 @@ import { TooltipManager, } from '../../../src/ui'; import type { SelectionController, SingleSelectionManager } from '../../../src/ui'; -import { SaveLoadStore } from '../../../src/core-engine'; +import { SaveLoadStore, CheckpointManager } from '../../../src/core-engine'; import { UndoRedoManager } from '../../../src/core-engine'; +import type { MainStreetSerializedState } from '../MainStreetState'; import { MainStreetRenderer } from './MainStreetRenderer'; import { MainStreetAnimator } from './MainStreetAnimator'; import { MainStreetTurnController } from './MainStreetTurnController'; @@ -49,6 +50,9 @@ export class MainStreetScene extends CardGameScene { public campaign: MainStreetCampaignProgress | null = null; public saveStore: SaveLoadStore | null = null; + // Checkpoint (auto-save/resume after each turn) + public checkpointManager!: CheckpointManager; + // Selected difficulty (persisted across replays) public selectedDifficulty: DifficultyName = 'Medium'; @@ -191,6 +195,15 @@ export class MainStreetScene extends CardGameScene { return (this.msLifecycleManager as any).loadCampaignAndSetup.apply(this.msLifecycleManager, args); } + /** + * Check for a saved run checkpoint on startup. + * If found, shows a resume overlay with [Resume] and [New Game] buttons. + * Delegates to MainStreetLifecycleManager.checkForSavedCheckpoint(). + */ + public checkForSavedCheckpoint(...args: any[]): any { + return (this.msLifecycleManager as any).checkForSavedCheckpoint.apply(this.msLifecycleManager, args); + } + /** * Updates campaign progress after a completed run (win or loss). * Evaluates tier unlocks and persists the updated campaign. diff --git a/example-games/main-street/scenes/MainStreetTurnController.ts b/example-games/main-street/scenes/MainStreetTurnController.ts index 30ed1b50..8e214c59 100644 --- a/example-games/main-street/scenes/MainStreetTurnController.ts +++ b/example-games/main-street/scenes/MainStreetTurnController.ts @@ -19,6 +19,18 @@ import { getCurrentStep, type TutorialActionType } from '../TutorialFlow'; export class MainStreetTurnController { constructor(private readonly scene: any) {} + /** + * Callback invoked after each completed turn (when game is still playing). + * Used by the lifecycle manager to save a checkpoint via CheckpointManager. + */ + public onSaveCheckpoint: (() => void) | null = null; + + /** + * Callback invoked on game end (win/loss/bankruptcy). + * Used by the lifecycle manager to clear the checkpoint. + */ + public onGameEnd: (() => void) | null = null; + public startDayPhase(): void { const s = this.scene; // Execute DayStart (refills market, transitions to MarketPhase) @@ -76,6 +88,9 @@ export class MainStreetTurnController { return; } + // Save checkpoint after each completed turn (fire-and-forget) + try { this.onSaveCheckpoint?.(); } catch (e) { /* ignore */ } + // Clear undo stack on end-of-turn (per acceptance criteria) try { s.undoManager.clear(); } catch (e) { /* ignore */ } @@ -104,6 +119,8 @@ export class MainStreetTurnController { // by the lifecycle manager. s.updateStats(result.gameResult, result.finalScore); + // Clear checkpoint on game end + try { this.onGameEnd?.(); } catch (e) { /* ignore */ } s.updateCampaignProgress().then(() => { const tiersAfter = s.campaign ? s.campaign.unlockedTiers diff --git a/package.json b/package.json index 406e9710..d79f23a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tableau-card-engine", - "version": "0.1.1", + "version": "0.1.2", "description": "Tableau Card Engine (TCE) -- a modular game engine for building single-player tableau card games using Phaser 4 RC and TypeScript", "private": true, "type": "module", diff --git a/src/core-engine/CheckpointManager.ts b/src/core-engine/CheckpointManager.ts new file mode 100644 index 00000000..0d0d5018 --- /dev/null +++ b/src/core-engine/CheckpointManager.ts @@ -0,0 +1,215 @@ +/** + * CheckpointManager — reusable checkpoint save-and-resume abstraction. + * + * Provides a game-agnostic API for saving, loading, clearing, and restoring + * game checkpoints via the existing {@link SaveLoadStore}. Supports both a + * default built-in resume overlay and a callback-based overlay for games + * that need custom UI. + * + * ## Usage + * + * ```ts + * const manager = new CheckpointManager(store, 'feudalism', 'run-checkpoint', feudalismSerializer); + * + * // After each turn: + * manager.save(gameState); + * + * // On startup: + * manager.checkAndResume( + * () => startFreshGame(), + * (state) => restoreFromCheckpoint(state), + * (state, onResume, onNewGame) => showCustomOverlay(state, onResume, onNewGame), + * ); + * ``` + * + * ## Overlay support + * + * - **Callback-based (preferred for games):** Provide a `createResumeOverlay` + * callback. The manager will not create any overlay; the game renders its own. + * - **Built-in default:** Import `createDefaultResumeOverlay` from + * `@core-engine` and pass it as the `createResumeOverlay` callback. This + * renders a standard "Resume Saved Game?" overlay with [Resume] and + * [New Game] buttons. + * + * If no overlay callback is provided at all, the manager falls through to + * `freshStartFn()` (no overlay is shown). Games should always provide an + * overlay callback — either the built-in default or a custom one. + * + * @module core-engine/CheckpointManager + */ + +import type { SaveLoadStore, SaveSerializer } from './SaveLoad'; + +// ── Types ────────────────────────────────────────────────── + +/** + * Options for the overlay behaviour of {@link CheckpointManager.checkAndResume}. + * + * @typeParam TState - The in-memory game state type. + */ +export interface CheckpointManagerOverlayOptions { + /** + * Optional callback to create a custom resume overlay. + * + * If provided, the manager will call this function when a checkpoint exists + * instead of using any built-in overlay. The callback receives: + * + * @param state - The loaded checkpoint state (for display/info). + * @param onResume - Call when the user clicks "Resume". + * @param onNewGame - Call when the user clicks "New Game". + */ + createResumeOverlay?: ( + state: TState, + onResume: () => void, + onNewGame: () => void, + ) => void; +} + +// ── CheckpointManager class ──────────────────────────────── + +/** + * Manages checkpoint save, load, clear, and resume workflow for a single game. + * + * Delegates all storage to the provided {@link SaveLoadStore}, which handles + * IndexedDB with localStorage fallback. Each instance is bound to a specific + * game type and slot, providing isolation between games. + * + * @typeParam TState - The in-memory game state type. + * @typeParam TSerialized - The serialized/wire format (typically a JSON-safe + * subset of `TState`). + */ +export class CheckpointManager { + /** + * @param store - The shared SaveLoadStore instance. + * @param gameType - Game identifier string (e.g. `'feudalism'`). + * @param slotId - Slot identifier (e.g. `'run-checkpoint'`). + * @param serializer - Game-specific SaveSerializer for state conversion. + */ + constructor( + private readonly store: SaveLoadStore, + private readonly gameType: string, + private readonly slotId: string, + private readonly serializer: SaveSerializer, + ) {} + + /** + * Save a checkpoint of the current game state. + * + * Fire-and-forget: the returned promise resolves once the save completes, + * but callers typically do not await it to avoid input lag on slow storage. + * + * @param state - The current game state to persist. + */ + async save(state: TState): Promise { + try { + await this.store.saveRunCheckpoint( + this.gameType, + this.slotId, + this.serializer, + state, + ); + } catch (err) { + console.warn(`[CheckpointManager:${this.gameType}] Failed to save checkpoint:`, err); + } + } + + /** + * Load the most recently saved checkpoint. + * + * @returns The restored game state, or `null` if no checkpoint exists or + * storage is unavailable. + */ + async load(): Promise { + try { + return await this.store.loadRunCheckpoint( + this.gameType, + this.slotId, + this.serializer, + ); + } catch (err) { + console.warn(`[CheckpointManager:${this.gameType}] Failed to load checkpoint:`, err); + return null; + } + } + + /** + * Remove the saved checkpoint. + * + * Safe to call even when no checkpoint exists. + */ + async clear(): Promise { + try { + await this.store.remove('run-checkpoint', this.gameType, this.slotId); + } catch (err) { + console.warn(`[CheckpointManager:${this.gameType}] Failed to clear checkpoint:`, err); + } + } + + /** + * Check for a saved checkpoint and either start fresh or offer resume. + * + * - **No checkpoint:** Calls `freshStartFn()` immediately. + * - **Checkpoint found + `createResumeOverlay` provided:** Calls + * `createResumeOverlay(state, onResume, onNewGame)`. The overlay renders + * the choice; the manager wires the callbacks. Use the exported + * {@link createDefaultResumeOverlay} for a built-in Phaser-compatible + * default overlay, or provide a game-specific callback. + * - **Checkpoint found + no overlay callback:** Calls `freshStartFn()` + * (no overlay is shown). Games should always provide an overlay callback + * when they want resume UI. + * - **Storage error:** Falls through to `freshStartFn()` so the game is + * still playable. + * + * @param freshStartFn - Called when no checkpoint exists or on error. + * @param resumeFn - Called with the restored state when user picks Resume. + * @param createResumeOverlay - Optional callback for custom overlay rendering. + */ + async checkAndResume( + freshStartFn: () => void, + resumeFn: (state: TState) => void, + createResumeOverlay?: ( + state: TState, + onResume: () => void, + onNewGame: () => void, + ) => void, + ): Promise { + let savedState: TState | null; + + try { + savedState = await this.load(); + } catch { + // On error, fall through to fresh start so the game is still playable + freshStartFn(); + return; + } + + if (!savedState) { + // No checkpoint — start fresh + freshStartFn(); + return; + } + + // Checkpoint exists + if (createResumeOverlay) { + // Game provides its own overlay + const self = this; + createResumeOverlay( + savedState, + // On resume + () => { + resumeFn(savedState); + }, + // On new game — await clear before starting fresh + async () => { + await self.clear(); + freshStartFn(); + }, + ); + } else { + // No overlay callback provided — fall through to freshStartFn. + // Games should provide a createResumeOverlay callback (either the + // built-in createDefaultResumeOverlay or a custom one). + freshStartFn(); + } + } +} diff --git a/src/core-engine/CheckpointResumeOverlay.ts b/src/core-engine/CheckpointResumeOverlay.ts new file mode 100644 index 00000000..4cf3f723 --- /dev/null +++ b/src/core-engine/CheckpointResumeOverlay.ts @@ -0,0 +1,220 @@ +/** + * Default resume overlay for the CheckpointManager. + * + * Provides a Phaser-compatible "Resume Saved Game?" overlay with + * [Resume] and [New Game] buttons. Uses a minimal scene interface + * so the core engine module stays headless-compatible (no Phaser + * import at compile time). + * + * ## Usage + * + * ```ts + * import { CheckpointManager, createDefaultResumeOverlay } from '@core-engine'; + * + * const manager = new CheckpointManager(store, 'my-game', 'run-checkpoint', serializer); + * + * manager.checkAndResume( + * () => startFreshGame(), + * (state) => restoreFromCheckpoint(state), + * (state, onResume, onNewGame) => + * createDefaultResumeOverlay(scene, state, onResume, onNewGame), + * ); + * ``` + * + * @module core-engine/CheckpointResumeOverlay + */ + +// ── Minimal Phaser-compatible interfaces ──────────────────── + +/** + * Minimal subset of the Phaser.GameObjects.Text API. + * Avoids importing Phaser at compile time. + */ +interface OverlayText { + setOrigin(x: number, y: number): this; + setDepth(depth: number): this; + setInteractive(useHandCursor?: PhaserLikeInteractiveOptions): this; + on(event: 'pointerover' | 'pointerout' | 'pointerdown', fn: (...args: unknown[]) => void): this; + setColor(color: string): this; + destroy(): void; +} + +/** + * Interactive options for Phaser game objects. + */ +interface PhaserLikeInteractiveOptions { + useHandCursor?: boolean; +} + +/** + * Minimal subset of the Phaser.GameObjects.Rectangle API. + * Avoids importing Phaser at compile time. + */ +interface OverlayRect { + setDepth(depth: number): this; + setInteractive(useHandCursor?: PhaserLikeInteractiveOptions): this; + destroy(): void; +} + +/** + * Minimal subset of a Phaser.Scene that the default overlay needs. + * Avoids importing Phaser at compile time while remaining compatible + * with any Phaser scene at runtime. + */ +export interface ResumeOverlayScene { + add: { + text( + x: number, + y: number, + text: string, + style?: Record, + ): OverlayText; + rectangle( + x: number, + y: number, + width: number, + height: number, + fillColor?: number, + fillAlpha?: number, + ): OverlayRect; + }; +} + +// ── Constants ─────────────────────────────────────────────── + +/** Display depth for the overlay background. */ +const BACKGROUND_DEPTH = 2000; + +/** Display depth for overlay text and buttons (above background). */ +const TEXT_DEPTH = 2001; + +/** Y-offset for the title text from center. */ +const TITLE_Y_OFFSET = -120; + +/** Y-offset for the info text from center. */ +const INFO_Y_OFFSET = -60; + +/** Y-offset for the button row from center. */ +const BUTTON_Y_OFFSET = 20; + +/** Horizontal spacing between buttons. */ +const BUTTON_SPACING = 120; + +/** Default font family. */ +const DEFAULT_FONT_FAMILY = 'Arial, sans-serif'; + +// ── Default Overlay Factory ───────────────────────────────── + +/** + * Create the built-in default "Resume Saved Game?" overlay. + * + * Renders a title, info text, [Resume] button, and [New Game] button + * centered on the screen. The overlay is created directly on the scene + * as visible game objects. + * + * Games may use this function as the `createResumeOverlay` callback + * in {@link CheckpointManager.checkAndResume}, or provide their own + * custom overlay. + * + * @param scene - A Phaser scene (or any object matching the minimal + * {@link ResumeOverlayScene} interface). + * @param _state - The loaded checkpoint state (unused by default overlay, + * but received for compatibility with the callback signature). + * @param onResume - Callback when the user clicks [Resume]. + * @param onNewGame - Callback when the user clicks [New Game]. + */ +export function createDefaultResumeOverlay( + scene: ResumeOverlayScene, + _state: TState, + onResume: () => void, + onNewGame: () => void, +): void { + const centerX = 640; // GAME_W / 2 + const centerY = 360; // GAME_H / 2 + + // Full-screen semi-transparent dark background to make overlay readable + const background: OverlayRect = scene.add.rectangle( + centerX, centerY, + 1280, 720, // GAME_W, GAME_H + 0x000000, 0.75, + ); + background.setDepth(BACKGROUND_DEPTH); + background.setInteractive(); + + // Title: "Resume Saved Game?" + const title: OverlayText = scene.add.text(centerX, centerY + TITLE_Y_OFFSET, 'Resume Saved Game?', { + fontSize: '22px', + color: '#ffcc00', + fontFamily: DEFAULT_FONT_FAMILY, + fontStyle: 'bold', + }); + title.setOrigin(0.5, 0.5).setDepth(TEXT_DEPTH); + + // Info text + const infoText: OverlayText = scene.add.text( + centerX, + centerY + INFO_Y_OFFSET, + 'A checkpoint was found from a previous game.\nResume where you left off or start fresh.', + { + fontSize: '14px', + color: '#cccccc', + fontFamily: DEFAULT_FONT_FAMILY, + align: 'center', + }, + ); + infoText.setOrigin(0.5, 0.5).setDepth(TEXT_DEPTH); + + // [ Resume ] button + const resumeBtn: OverlayText = scene.add.text( + centerX - BUTTON_SPACING, + centerY + BUTTON_Y_OFFSET, + '[ Resume ]', + { + fontSize: '14px', + color: '#88ff88', + fontFamily: DEFAULT_FONT_FAMILY, + }, + ); + resumeBtn.setOrigin(0.5, 0.5); + resumeBtn.setDepth(TEXT_DEPTH); + resumeBtn.setInteractive({ useHandCursor: true }); + + resumeBtn.on('pointerover', () => resumeBtn.setColor('#aaffaa')); + resumeBtn.on('pointerout', () => resumeBtn.setColor('#88ff88')); + resumeBtn.on('pointerdown', () => { + // Clean up all overlay objects including background + background.destroy(); + title.destroy(); + infoText.destroy(); + resumeBtn.destroy(); + newGameBtn.destroy(); + onResume(); + }); + + // [ New Game ] button + const newGameBtn: OverlayText = scene.add.text( + centerX + BUTTON_SPACING, + centerY + BUTTON_Y_OFFSET, + '[ New Game ]', + { + fontSize: '14px', + color: '#88ff88', + fontFamily: DEFAULT_FONT_FAMILY, + }, + ); + newGameBtn.setOrigin(0.5, 0.5); + newGameBtn.setDepth(TEXT_DEPTH); + newGameBtn.setInteractive({ useHandCursor: true }); + + newGameBtn.on('pointerover', () => newGameBtn.setColor('#aaffaa')); + newGameBtn.on('pointerout', () => newGameBtn.setColor('#88ff88')); + newGameBtn.on('pointerdown', () => { + // Clean up all overlay objects including background + background.destroy(); + title.destroy(); + infoText.destroy(); + resumeBtn.destroy(); + newGameBtn.destroy(); + onNewGame(); + }); +} diff --git a/src/core-engine/SoundManager.ts b/src/core-engine/SoundManager.ts index 9107db4c..cb4035a6 100644 --- a/src/core-engine/SoundManager.ts +++ b/src/core-engine/SoundManager.ts @@ -407,3 +407,33 @@ export class SoundManager { } } } + +// ── Shared safe-play utility ────────────────────────── + +/** + * Safely play a sound on a scene-like object with a `sound.play` method, + * ignoring any errors (e.g. missing audio key, null sound manager). + * + * This is a drop-in replacement for bare `this.scene.sound.play?.()` calls + * in overlay helpers that don't have access to the namespaced SoundManager. + * It preserves the optional-chaining behaviour (no-op if `sound` is null). + * + * @param scene A Phaser Scene (or any object with a `sound.play?` method). + * @param key The audio key to play. + * + * @example + * ```ts + * import { safePlaySound } from '@core-engine/SoundManager'; + * safePlaySound(this.scene, SFX_KEYS.GAME_END); + * ``` + */ +export function safePlaySound( + scene: { sound: { play?: (key: string) => void } | null } | null, + key: string, +): void { + try { + scene?.sound?.play?.(key); + } catch { + /* ignore — missing audio keys must not crash the game loop */ + } +} diff --git a/src/core-engine/index.ts b/src/core-engine/index.ts index 6a530528..49cea607 100644 --- a/src/core-engine/index.ts +++ b/src/core-engine/index.ts @@ -64,6 +64,22 @@ export { deserializeWithVersion, } from './SaveLoad'; +// Checkpoint save-and-resume abstraction +// @module CheckpointManager +// @since 0.1.0 +export type { + CheckpointManagerOverlayOptions, +} from './CheckpointManager'; +export { + CheckpointManager, +} from './CheckpointManager'; +export type { + ResumeOverlayScene, +} from './CheckpointResumeOverlay'; +export { + createDefaultResumeOverlay, +} from './CheckpointResumeOverlay'; + // Game event system export type { TurnStartedPayload, @@ -99,7 +115,7 @@ export { PhaserEventBridge } from './PhaserEventBridge'; // Sound management export type { SoundPlayer, EventSoundMapping, StorageLike, SoundManagerOptions, CommonSfxKey } from './SoundManager'; -export { SoundManager, COMMON_SFX_KEYS } from './SoundManager'; +export { SoundManager, COMMON_SFX_KEYS, safePlaySound } from './SoundManager'; // ToneForge runtime adapter export type { diff --git a/src/ui/dealCard.ts b/src/ui/dealCard.ts index c1aa9b86..c8fd961b 100644 --- a/src/ui/dealCard.ts +++ b/src/ui/dealCard.ts @@ -185,7 +185,7 @@ export function dealCard(opts: DealCardOptions): Phaser.Tweens.Tween { onStart: () => { if (sfx?.start) { if (soundManager) soundManager.play(sfx.start); - else scene.sound?.play(sfx.start); + else { try { scene.sound?.play(sfx.start); } catch { /* ignore */ } } } if (sfx?.move) { @@ -197,7 +197,7 @@ export function dealCard(opts: DealCardOptions): Phaser.Tweens.Tween { } catch { loopSound = null; } } else { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = Date.now(); } } @@ -208,7 +208,7 @@ export function dealCard(opts: DealCardOptions): Phaser.Tweens.Tween { const now = Date.now(); if (now - lastMovePlay >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = now; } }, @@ -230,7 +230,7 @@ export function dealCard(opts: DealCardOptions): Phaser.Tweens.Tween { const now = Date.now(); if (now - lastMovePlay >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = now; } }, @@ -238,7 +238,7 @@ export function dealCard(opts: DealCardOptions): Phaser.Tweens.Tween { if (loopSound) { try { loopSound.stop(); } catch {} loopSound = null; } if (sfx?.end) { if (soundManager) soundManager.play(sfx.end); - else scene.sound?.play(sfx.end); + else { try { scene.sound?.play(sfx.end); } catch { /* ignore */ } } } if (gameEvents && cardId) { gameEvents.emit('card:dealt', { cardId, playerIndex }); diff --git a/src/ui/discardCard.ts b/src/ui/discardCard.ts index a874ad1d..43b67292 100644 --- a/src/ui/discardCard.ts +++ b/src/ui/discardCard.ts @@ -168,7 +168,7 @@ export function discardCard(opts: DiscardCardOptions): Phaser.Tweens.Tween { onStart: () => { if (sfx?.start) { if (soundManager) soundManager.play(sfx.start); - else scene.sound?.play(sfx.start); + else { try { scene.sound?.play(sfx.start); } catch { /* ignore */ } } } if (sfx?.move) { if (sfx.moveLoop && scene.sound && typeof scene.sound.add === 'function') { @@ -179,7 +179,7 @@ export function discardCard(opts: DiscardCardOptions): Phaser.Tweens.Tween { } catch { loopSound = null; } } else { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = Date.now(); } } @@ -190,7 +190,7 @@ export function discardCard(opts: DiscardCardOptions): Phaser.Tweens.Tween { const now = Date.now(); if (now - lastMovePlay >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = now; } }, @@ -201,7 +201,7 @@ export function discardCard(opts: DiscardCardOptions): Phaser.Tweens.Tween { if (loopSound) { try { loopSound.stop(); } catch {} loopSound = null; } if (sfx?.end) { if (soundManager) soundManager.play(sfx.end); - else scene.sound?.play(sfx.end); + else { try { scene.sound?.play(sfx.end); } catch { /* ignore */ } } } if (destroyAfter) { target.destroy(); diff --git a/src/ui/flipCard.ts b/src/ui/flipCard.ts index be08ede5..588744f4 100644 --- a/src/ui/flipCard.ts +++ b/src/ui/flipCard.ts @@ -122,7 +122,7 @@ export function flipCard(opts: FlipCardOptions): Phaser.Tweens.Tween { onStart: () => { if (sfx?.start) { if (soundManager) soundManager.play(sfx.start); - else scene.sound?.play(sfx.start); + else { try { scene.sound?.play(sfx.start); } catch { /* ignore */ } } } if (sfx?.move) { @@ -134,7 +134,7 @@ export function flipCard(opts: FlipCardOptions): Phaser.Tweens.Tween { } catch { loopSound = null; } } else { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } (closeConfig as any).__lastMovePlay = Date.now(); } } @@ -146,7 +146,7 @@ export function flipCard(opts: FlipCardOptions): Phaser.Tweens.Tween { const now = Date.now(); if (now - last >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } (closeConfig as any).__lastMovePlay = now; } }, @@ -180,7 +180,7 @@ export function flipCard(opts: FlipCardOptions): Phaser.Tweens.Tween { const now = Date.now(); if (now - last >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } (openConfig as any).__lastMovePlay = now; } }, @@ -192,7 +192,7 @@ export function flipCard(opts: FlipCardOptions): Phaser.Tweens.Tween { if (sfx?.end) { if (soundManager) soundManager.play(sfx.end); - else scene.sound?.play(sfx.end); + else { try { scene.sound?.play(sfx.end); } catch { /* ignore */ } } } if (onComplete) onComplete(); diff --git a/src/ui/moveGameObject.ts b/src/ui/moveGameObject.ts index 810a214f..500ff10a 100644 --- a/src/ui/moveGameObject.ts +++ b/src/ui/moveGameObject.ts @@ -100,7 +100,7 @@ export function moveGameObject(opts: MoveGameObjectOptions): Phaser.Tweens.Tween // Play start SFX (prefers SoundManager, fall back to scene.sound) if (sfx?.start) { if (soundManager) soundManager.play(sfx.start); - else scene.sound?.play(sfx.start); + else { try { scene.sound?.play(sfx.start); } catch { /* ignore */ } } } if (sfx?.move) { @@ -118,7 +118,7 @@ export function moveGameObject(opts: MoveGameObjectOptions): Phaser.Tweens.Tween if (soundManager) { soundManager.play(sfx.move); } else { - scene.sound?.play(sfx.move); + try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = Date.now(); } @@ -131,7 +131,7 @@ export function moveGameObject(opts: MoveGameObjectOptions): Phaser.Tweens.Tween const now = Date.now(); if (now - lastMovePlay >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = now; } }, @@ -144,7 +144,7 @@ export function moveGameObject(opts: MoveGameObjectOptions): Phaser.Tweens.Tween if (sfx?.end) { if (soundManager) soundManager.play(sfx.end); - else scene.sound?.play(sfx.end); + else { try { scene.sound?.play(sfx.end); } catch { /* ignore */ } } } onComplete?.(); }, diff --git a/src/ui/placeCard.ts b/src/ui/placeCard.ts index c1d68b6f..6b291d9c 100644 --- a/src/ui/placeCard.ts +++ b/src/ui/placeCard.ts @@ -178,7 +178,7 @@ export function placeCard(opts: PlaceCardOptions): Phaser.Tweens.Tween { onStart: () => { if (sfx?.start) { if (soundManager) soundManager.play(sfx.start); - else scene.sound?.play(sfx.start); + else { try { scene.sound?.play(sfx.start); } catch { /* ignore */ } } } if (sfx?.move) { @@ -190,7 +190,7 @@ export function placeCard(opts: PlaceCardOptions): Phaser.Tweens.Tween { } catch { loopSound = null; } } else { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = Date.now(); } } @@ -201,7 +201,7 @@ export function placeCard(opts: PlaceCardOptions): Phaser.Tweens.Tween { const now = Date.now(); if (now - lastMovePlay >= moveInterval) { if (soundManager) soundManager.play(sfx.move); - else scene.sound?.play(sfx.move); + else { try { scene.sound?.play(sfx.move); } catch { /* ignore */ } } lastMovePlay = now; } }, @@ -220,7 +220,7 @@ export function placeCard(opts: PlaceCardOptions): Phaser.Tweens.Tween { } if (sfx?.end) { if (soundManager) soundManager.play(sfx.end); - else scene.sound?.play(sfx.end); + else { try { scene.sound?.play(sfx.end); } catch { /* ignore */ } } } // Emit event after animation completes if (gameEvents && cardId) { diff --git a/tests/core-engine/SoundManager.test.ts b/tests/core-engine/SoundManager.test.ts index b7794549..92f597f9 100644 --- a/tests/core-engine/SoundManager.test.ts +++ b/tests/core-engine/SoundManager.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SoundManager, COMMON_SFX_KEYS, + safePlaySound, type SoundPlayer, type StorageLike, } from '../../src/core-engine/SoundManager'; @@ -417,5 +418,81 @@ describe('SoundManager', () => { const mod = await import('../../src/core-engine/index'); expect(mod.SoundManager).toBeDefined(); }); + + it('should export safePlaySound from core-engine index', async () => { + const mod = await import('../../src/core-engine/index'); + expect(mod.safePlaySound).toBeDefined(); + expect(typeof mod.safePlaySound).toBe('function'); + }); + }); + + // ── safePlaySound utility ─────────────────────────────── + + describe('safePlaySound', () => { + it('should not throw when given a valid scene and key', () => { + const play = vi.fn(); + const scene = { sound: { play } }; + + expect(() => safePlaySound(scene as any, 'sfx-test')).not.toThrow(); + expect(play).toHaveBeenCalledWith('sfx-test'); + }); + + it('should not throw when sound.play throws', () => { + const play = vi.fn(() => { throw new Error('Audio key not found'); }); + const scene = { sound: { play } }; + + expect(() => safePlaySound(scene as any, 'sfx-missing')).not.toThrow(); + }); + + it('should not throw when sound is null', () => { + const scene = { sound: null }; + + expect(() => safePlaySound(scene as any, 'sfx-test')).not.toThrow(); + }); + + it('should not throw when scene is null', () => { + expect(() => safePlaySound(null, 'sfx-test')).not.toThrow(); + }); + + it('should not throw when sound.play is undefined', () => { + const scene = { sound: {} }; + + expect(() => safePlaySound(scene as any, 'sfx-test')).not.toThrow(); + }); + + it('should preserve optional-chaining behaviour (no-op if sound is null)', () => { + const scene = { sound: null }; + // Should complete without error and without calling any function + safePlaySound(scene as any, 'sfx-test'); + }); + + it('should not produce console errors when audio key is missing', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const play = vi.fn(() => { throw new Error('Audio key not found'); }); + const scene = { sound: { play } }; + + safePlaySound(scene as any, 'sfx-missing'); + + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('should work with Phaser-like scene structure', () => { + // Phaser.Scene has this.sound.play(key) — simulate the interface + const play = vi.fn(); + const scene = { + sound: { + play, + // Phaser sound manager has many other methods + stop: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + setVolume: vi.fn(), + }, + }; + + expect(() => safePlaySound(scene as any, 'sfx-ui-click')).not.toThrow(); + expect(play).toHaveBeenCalledWith('sfx-ui-click'); + }); }); }); diff --git a/tests/core-engine/checkpoint-manager.test.ts b/tests/core-engine/checkpoint-manager.test.ts new file mode 100644 index 00000000..433c750a --- /dev/null +++ b/tests/core-engine/checkpoint-manager.test.ts @@ -0,0 +1,425 @@ +/** + * Unit tests for the CheckpointManager core engine abstraction. + * + * Exercises: + * - CheckpointManager.save/load/clear round-trip + * - checkAndResume() workflow (no checkpoint → freshStart, checkpoint exists → overlay) + * - Error handling (storage unavailable, corrupt data) + * - Both built-in overlay and callback-based overlay approaches + * - Fire-and-forget save behaviour + * + * Test-first: defines the API contract that Feature 3 (Core Engine Checkpoint + * Abstraction, CG-0MQL8CPZS009R74Q) must implement. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + SaveLoadStore, + type SaveSerializer, +} from '../../src/core-engine/SaveLoad'; +import { + CheckpointManager, + type CheckpointManagerOverlayOptions, +} from '../../src/core-engine/CheckpointManager'; + +// ── Test helpers ──────────────────────────────────────────── + +function createLocalStorageMock(): Storage { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value); }, + removeItem: (key: string) => { data.delete(key); }, + clear: () => data.clear(), + get length() { return data.size; }, + key: (index: number) => [...data.keys()][index] ?? null, + }; +} + +/** A simple game state type for testing. */ +interface TestState { + score: number; + level: number; + items: string[]; +} + +/** A simple serializer for the test state. */ +const testSerializer: SaveSerializer = { + schemaVersion: 1, + serialize: (state) => ({ ...state }), + deserialize: (data) => ({ ...data }), +}; + +/** Another serializer with a different version for version-mismatch tests. */ +const serializerV2: SaveSerializer = { + schemaVersion: 2, + serialize: (state) => ({ ...state }), + deserialize: (data) => ({ ...data }), +}; + +function createTestState(overrides: Partial = {}): TestState { + return { + score: 100, + level: 3, + items: ['sword', 'shield'], + ...overrides, + }; +} + +function createTestState2(): TestState { + return { + score: 200, + level: 5, + items: ['potion', 'map', 'key'], + }; +} + +/** Summary of a checkpoint for equality comparison. */ +function summarizeState(state: TestState): Record { + return { ...state, items: [...state.items] }; +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('CheckpointManager', () => { + let store: SaveLoadStore; + let manager: CheckpointManager; + + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', createLocalStorageMock()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + store = new SaveLoadStore(); + manager = new CheckpointManager(store, 'test-game', 'test-slot', testSerializer); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + // ── Constructor ───────────────────────────────────────── + + it('constructor accepts SaveLoadStore, gameType, slotId, and SaveSerializer', () => { + expect(manager).toBeInstanceOf(CheckpointManager); + }); + + // ── Save / Load / Clear round-trip ────────────────────── + + it('save persists state that can be loaded back', async () => { + const state = createTestState(); + + await manager.save(state); + + const loaded = await manager.load(); + expect(loaded).not.toBeNull(); + expect(summarizeState(loaded!)).toEqual(summarizeState(state)); + }); + + it('load returns null when no checkpoint has been saved', async () => { + const loaded = await manager.load(); + expect(loaded).toBeNull(); + }); + + it('load returns the most recently saved state (overwrites previous)', async () => { + const state1 = createTestState({ score: 100 }); + const state2 = createTestState2(); + + await manager.save(state1); + await manager.save(state2); + + const loaded = await manager.load(); + expect(loaded).not.toBeNull(); + expect(loaded!.score).toBe(200); + expect(loaded!.level).toBe(5); + expect(loaded!.items).toEqual(['potion', 'map', 'key']); + }); + + it('clear removes an existing checkpoint', async () => { + await manager.save(createTestState()); + expect(await manager.load()).not.toBeNull(); + + await manager.clear(); + expect(await manager.load()).toBeNull(); + }); + + it('clear is safe to call when no checkpoint exists', async () => { + // Should not throw + await expect(manager.clear()).resolves.toBeUndefined(); + expect(await manager.load()).toBeNull(); + }); + + it('save/load round-trip preserves full state fidelity', async () => { + const original = createTestState2(); + + await manager.save(original); + const restored = await manager.load(); + + expect(restored).not.toBeNull(); + expect(restored!.score).toBe(original.score); + expect(restored!.level).toBe(original.level); + expect(restored!.items).toEqual(original.items); + }); + + it('multiple round-trips work correctly', async () => { + for (let i = 0; i < 5; i++) { + const state = createTestState({ score: i * 100, level: i }); + await manager.save(state); + + const loaded = await manager.load(); + expect(loaded).not.toBeNull(); + expect(loaded!.score).toBe(i * 100); + expect(loaded!.level).toBe(i); + + await manager.clear(); + expect(await manager.load()).toBeNull(); + } + }); + + // ── Fire-and-forget save ──────────────────────────────── + + it('save is fire-and-forget (does not block the caller)', async () => { + const state = createTestState(); + // Should resolve without error + await expect(manager.save(state)).resolves.toBeUndefined(); + }); + + it('save and immediate load works (fire-and-forget completes before next tick)', async () => { + const state = createTestState({ score: 999 }); + await manager.save(state); + const loaded = await manager.load(); + expect(loaded).not.toBeNull(); + expect(loaded!.score).toBe(999); + }); + + // ── checkAndResume: no checkpoint ─────────────────────── + + it('checkAndResume calls freshStartFn when no checkpoint exists', async () => { + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + + await manager.checkAndResume(freshStartFn, resumeFn); + + expect(freshStartFn).toHaveBeenCalledTimes(1); + expect(resumeFn).not.toHaveBeenCalled(); + }); + + it('checkAndResume does not show an overlay when no checkpoint exists', async () => { + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + const createOverlay = vi.fn(); + + await manager.checkAndResume(freshStartFn, resumeFn, createOverlay); + + expect(createOverlay).not.toHaveBeenCalled(); + expect(freshStartFn).toHaveBeenCalledTimes(1); + }); + + // ── checkAndResume: with checkpoint ───────────────────── + + it('checkAndResume calls createResumeOverlay callback when a checkpoint exists', async () => { + const state = createTestState(); + await manager.save(state); + + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + const createOverlay = vi.fn(); + + await manager.checkAndResume(freshStartFn, resumeFn, createOverlay); + + expect(freshStartFn).not.toHaveBeenCalled(); + expect(createOverlay).toHaveBeenCalledTimes(1); + expect(resumeFn).not.toHaveBeenCalled(); // resume only called after user action + }); + + it('checkAndResume passes the saved state to createResumeOverlay', async () => { + const state = createTestState({ score: 777 }); + await manager.save(state); + + const createOverlay = vi.fn(); + + await manager.checkAndResume(vi.fn(), vi.fn(), createOverlay); + + // The overlay callback receives (state, onResume, onNewGame) + expect(createOverlay).toHaveBeenCalledWith( + expect.objectContaining({ score: 777 }), + expect.any(Function), + expect.any(Function), + ); + }); + + it('checkAndResume resumeFn is called when onResume is triggered', async () => { + const state = createTestState({ score: 555 }); + await manager.save(state); + + const resumeFn = vi.fn(); + let triggerResume: (() => void) | undefined; + + const createOverlay: CheckpointManagerOverlayOptions['createResumeOverlay'] = ( + _state, onResume, _onNewGame, + ) => { + triggerResume = onResume; + }; + + await manager.checkAndResume(vi.fn(), resumeFn, createOverlay); + + expect(triggerResume).toBeDefined(); + + // Simulate user clicking Resume + triggerResume!(); + + expect(resumeFn).toHaveBeenCalledTimes(1); + expect(resumeFn).toHaveBeenCalledWith(state); + }); + + it('checkAndResume clears checkpoint and starts fresh when onNewGame is triggered', async () => { + const state = createTestState(); + await manager.save(state); + + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + let triggerNewGame: (() => void) | undefined; + + const createOverlay: CheckpointManagerOverlayOptions['createResumeOverlay'] = ( + _state, _onResume, onNewGame, + ) => { + triggerNewGame = onNewGame; + }; + + await manager.checkAndResume(freshStartFn, resumeFn, createOverlay); + // (Note: resumeFn is a dummy — the fresh game path clears checkpoint) + + expect(triggerNewGame).toBeDefined(); + + // Simulate user clicking New Game + triggerNewGame!(); + + // Flush microtasks so the async clear() completes + await new Promise(resolve => setTimeout(resolve, 0)); + + // Checkpoint should be cleared + const loaded = await manager.load(); + expect(loaded).toBeNull(); + + // Fresh start should have been triggered + expect(freshStartFn).toHaveBeenCalledTimes(1); + }); + + // ── Built-in overlay ──────────────────────────────────── + + it('checkAndResume works without createOverlay callback (uses built-in default)', async () => { + // This test verifies the default path — no custom overlay needed. + // When no checkpoint exists, freshStartFn is called. + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + + await manager.checkAndResume(freshStartFn, resumeFn); + + expect(freshStartFn).toHaveBeenCalledTimes(1); + expect(resumeFn).not.toHaveBeenCalled(); + }); + + it('checkAndResume with no overlay callback falls through to freshStartFn when checkpoint exists', async () => { + const state = createTestState({ score: 333 }); + await manager.save(state); + + // Without an overlay callback, the core engine cannot show a resume + // overlay (no Phaser dependency), so it falls through to freshStartFn. + // Games should always provide a createResumeOverlay callback to get + // a resume dialog. + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + + await manager.checkAndResume(freshStartFn, resumeFn); + + // Without overlay callback, freshStartFn is called + expect(freshStartFn).toHaveBeenCalledTimes(1); + expect(resumeFn).not.toHaveBeenCalled(); + }); + + // ── Error handling ────────────────────────────────────── + + it('save does not throw when storage is unavailable', async () => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', undefined); + + const noopStore = new SaveLoadStore(); + const noopManager = new CheckpointManager(noopStore, 'test-game', 'test-slot', testSerializer); + + // Should not throw + await expect(noopManager.save(createTestState())).resolves.toBeUndefined(); + // Should return null (no storage) + const loaded = await noopManager.load(); + expect(loaded).toBeNull(); + }); + + it('load returns null when storage backend is unavailable', async () => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', undefined); + + const noopStore = new SaveLoadStore(); + const noopManager = new CheckpointManager(noopStore, 'test-game', 'test-slot', testSerializer); + + const loaded = await noopManager.load(); + expect(loaded).toBeNull(); + }); + + it('checkAndResume falls through to freshStartFn when storage fails', async () => { + // Simulate a storage error by making load fail + const freshStartFn = vi.fn(); + const resumeFn = vi.fn(); + + // With no storage at all + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', undefined); + + const brokenStore = new SaveLoadStore(); + const brokenManager = new CheckpointManager(brokenStore, 'test-game', 'test-slot', testSerializer); + + // Should fall through to freshStart without throwing + await expect( + brokenManager.checkAndResume(freshStartFn, resumeFn), + ).resolves.toBeUndefined(); + + expect(freshStartFn).toHaveBeenCalledTimes(1); + }); + + // ── Schema version handling ───────────────────────────── + + it('save/load returns null for incompatible schema version', async () => { + const state = createTestState(); + + // Save with version 1 serializer + await manager.save(state); + + // Load with version 2 serializer (wrong version) + const v2Manager = new CheckpointManager(store, 'test-game', 'test-slot', serializerV2); + + // CheckpointManager defensive error handling returns null + // (consistent with "no valid checkpoint" semantics) + const result = await v2Manager.load(); + expect(result).toBeNull(); + }); + + // ── Game type isolation ───────────────────────────────── + + it('different game types have isolated checkpoints', async () => { + const store2 = new SaveLoadStore(); + const game1 = new CheckpointManager(store2, 'game-a', 'slot-1', testSerializer); + const game2 = new CheckpointManager(store2, 'game-b', 'slot-1', testSerializer); + + await game1.save(createTestState({ score: 111 })); + await game2.save(createTestState({ score: 222 })); + + const loaded1 = await game1.load(); + const loaded2 = await game2.load(); + + expect(loaded1).not.toBeNull(); + expect(loaded2).not.toBeNull(); + expect(loaded1!.score).toBe(111); + expect(loaded2!.score).toBe(222); + }); +}); diff --git a/tests/core-engine/checkpoint-resume-overlay.test.ts b/tests/core-engine/checkpoint-resume-overlay.test.ts new file mode 100644 index 00000000..1136b11b --- /dev/null +++ b/tests/core-engine/checkpoint-resume-overlay.test.ts @@ -0,0 +1,261 @@ +/** + * Unit tests for createDefaultResumeOverlay (CheckpointResumeOverlay.ts). + * + * Exercises: + * - Background rectangle creation with correct dimensions/color/alpha + * - Background input blocking (setInteractive) + * - All overlay objects destroyed on Resume click + * - All overlay objects destroyed on New Game click + * - Text and buttons rendered with correct content + * - Existing text/button behavior preserved + * + * Test-first: defines the API contract that this work item + * (CG-0MQM9Z4MY000NOS1) must implement. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createDefaultResumeOverlay, + type ResumeOverlayScene, +} from '../../src/core-engine/CheckpointResumeOverlay'; + +// ── Mock helpers ──────────────────────────────────────────── + +/** Create a mock rectangle object matching the minimal OverlayRect interface. */ +function mockRect() { + return { + setDepth: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }; +} + +/** Create a mock text object matching the OverlayText interface. */ +function mockText() { + const handlers: Record = {}; + return { + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + setColor: vi.fn().mockReturnThis(), + on: vi.fn((event: string, handler: Function) => { + handlers[event] = handler; + return { _handlers: handlers }; + }), + destroy: vi.fn(), + _handlers: handlers, + }; +} + +/** Create a minimal mock scene matching ResumeOverlayScene. */ +function mockScene(): ResumeOverlayScene { + return { + add: { + rectangle: vi.fn(() => mockRect() as any), + text: vi.fn(() => mockText() as any), + }, + } as unknown as ResumeOverlayScene; +} + +/** Capture all text objects created during a call to createDefaultResumeOverlay. */ +function getTexts(scene: ResumeOverlayScene): ReturnType[] { + const results = (scene.add.text as any).mock.results; + return results.map((r: any) => r.value); +} + +/** Capture the rectangle object created during a call to createDefaultResumeOverlay. */ +function getRect(scene: ResumeOverlayScene): ReturnType { + const results = (scene.add.rectangle as any).mock.results; + return results[0].value; +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('createDefaultResumeOverlay', () => { + // ── Background rectangle ─────────────────────────────── + + it('creates a full-screen background rectangle with standard overlay style', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + expect(scene.add.rectangle).toHaveBeenCalledWith( + // Center of game viewport (1280x720) + 640, 360, + // Full-screen dimensions + 1280, 720, + // Standard overlay color and alpha + 0x000000, 0.75, + ); + }); + + it('sets background rectangle depth below text depth', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const rect = getRect(scene); + expect(rect.setDepth).toHaveBeenCalledWith(expect.any(Number)); + + // Background depth should be less than text depth (2001) + const depthArg = (rect.setDepth as ReturnType).mock.calls[0][0]; + expect(depthArg).toBeLessThan(2001); + }); + + it('makes the background rectangle interactive to block pointer events', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const rect = getRect(scene); + expect(rect.setInteractive).toHaveBeenCalled(); + }); + + // ── Text and buttons ─────────────────────────────────── + + it('creates title, info text, resume button, and new game button', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + expect(scene.add.text).toHaveBeenCalledTimes(4); + }); + + it('renders the resume title text', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const titleCall = (scene.add.text as any).mock.calls[0]; + expect(titleCall[2]).toBe('Resume Saved Game?'); + }); + + it('renders the info text', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const infoCall = (scene.add.text as ReturnType).mock.calls[1]; + expect(infoCall[2]).toContain('A checkpoint was found'); + }); + + it('renders the Resume button', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const resumeCall = (scene.add.text as ReturnType).mock.calls[2]; + expect(resumeCall[2]).toBe('[ Resume ]'); + }); + + it('renders the New Game button', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const newGameCall = (scene.add.text as ReturnType).mock.calls[3]; + expect(newGameCall[2]).toBe('[ New Game ]'); + }); + + it('sets all text objects to depth 2001', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const texts = getTexts(scene); + for (const t of texts) { + expect(t.setDepth).toHaveBeenCalledWith(2001); + } + }); + + it('makes buttons interactive with hand cursor', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const texts = getTexts(scene); + // Resume button (index 2) and New Game button (index 3) should be interactive + expect(texts[2].setInteractive).toHaveBeenCalledWith({ useHandCursor: true }); + expect(texts[3].setInteractive).toHaveBeenCalledWith({ useHandCursor: true }); + }); + + // ── Destruction on Resume ────────────────────────────── + + it('destroys background and all text objects when Resume is clicked', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const texts = getTexts(scene); + const rect = getRect(scene); + + // Trigger pointerdown on the Resume button (third text = index 2) + texts[2]._handlers['pointerdown'](); + + expect(rect.destroy).toHaveBeenCalledTimes(1); + for (const t of texts) { + expect(t.destroy).toHaveBeenCalledTimes(1); + } + }); + + it('calls onResume callback when Resume is clicked', () => { + const scene = mockScene(); + const onResume = vi.fn(); + createDefaultResumeOverlay(scene, null, onResume, vi.fn()); + + const texts = getTexts(scene); + texts[2]._handlers['pointerdown'](); + + expect(onResume).toHaveBeenCalledTimes(1); + }); + + // ── Destruction on New Game ──────────────────────────── + + it('destroys background and all text objects when New Game is clicked', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const texts = getTexts(scene); + const rect = getRect(scene); + + // Trigger pointerdown on the New Game button (fourth text = index 3) + texts[3]._handlers['pointerdown'](); + + expect(rect.destroy).toHaveBeenCalledTimes(1); + for (const t of texts) { + expect(t.destroy).toHaveBeenCalledTimes(1); + } + }); + + it('calls onNewGame callback when New Game is clicked', () => { + const scene = mockScene(); + const onNewGame = vi.fn(); + createDefaultResumeOverlay(scene, null, vi.fn(), onNewGame); + + const texts = getTexts(scene); + texts[3]._handlers['pointerdown'](); + + expect(onNewGame).toHaveBeenCalledTimes(1); + }); + + // ── Hover effects ────────────────────────────────────── + + it('registers hover effects on the Resume button', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const resumeBtn = (scene.add.text as ReturnType).mock.results[2].value; + + expect(resumeBtn.on).toHaveBeenCalledWith('pointerover', expect.any(Function)); + expect(resumeBtn.on).toHaveBeenCalledWith('pointerout', expect.any(Function)); + }); + + it('registers hover effects on the New Game button', () => { + const scene = mockScene(); + createDefaultResumeOverlay(scene, null, vi.fn(), vi.fn()); + + const newGameBtn = (scene.add.text as ReturnType).mock.results[3].value; + + expect(newGameBtn.on).toHaveBeenCalledWith('pointerover', expect.any(Function)); + expect(newGameBtn.on).toHaveBeenCalledWith('pointerout', expect.any(Function)); + }); + + // ── State parameter ──────────────────────────────────── + + it('accepts and ignores the _state parameter (compatibility)', () => { + const scene = mockScene(); + // Should not throw regardless of state value + expect(() => { + createDefaultResumeOverlay(scene, { game: 'test' }, vi.fn(), vi.fn()); + }).not.toThrow(); + }); +}); diff --git a/tests/feudalism/FeudalismAudioResilience.browser.test.ts b/tests/feudalism/FeudalismAudioResilience.browser.test.ts new file mode 100644 index 00000000..f6a6448d --- /dev/null +++ b/tests/feudalism/FeudalismAudioResilience.browser.test.ts @@ -0,0 +1,180 @@ +/** + * Feudalism audio resilience browser test. + * + * Verifies that missing audio keys do not crash the game: + * - `safePlaySound()` (or try/catch-protected calls) gracefully handle + * missing keys without throwing + * - Direct unprotected `scene.sound.play()` calls DO throw (demonstrating + * why the protection is necessary) + * - The game scene remains responsive after safe sound calls + * - The game-over overlay renders correctly even with missing audio keys + * + * NOTE: Each test boots a fresh Phaser game which creates a WebGL context. + * Browsers limit concurrent WebGL contexts (~8-16). We keep total boots + * per file <= 6 to avoid context exhaustion. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import Phaser from 'phaser'; +import { safePlaySound } from '../../src/core-engine/SoundManager'; +import { waitForScene } from '../helpers/waitForScene'; + +// ── Helpers ───────────────────────────────────────────────── + +async function bootGame(): Promise { + let container = document.getElementById('game-container'); + if (container) container.remove(); + container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + const { createFeudalismGame } = await import( + '../../example-games/feudalism/createFeudalismGame' + ); + const game = createFeudalismGame({ type: Phaser.CANVAS }); + await waitForScene(game, 'FeudalismScene'); + return game; +} + +function destroyGame(game: Phaser.Game | null): void { + if (game) { + game.destroy(true, false); + } + const container = document.getElementById('game-container'); + if (container) container.remove(); +} + +function waitFrames(n: number, fallbackMs = 3000): Promise { + return new Promise((resolve) => { + let settled = false; + let left = n; + const finish = () => { if (settled) return; settled = true; resolve(); }; + const fallback = setTimeout(finish, fallbackMs); + const tick = () => { + if (settled) return; + left -= 1; + if (left <= 0) { clearTimeout(fallback); finish(); } + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +/** + * Get a typed reference to the active FeudalismScene. + */ +function getScene(game: Phaser.Game): Phaser.Scene { + const scene = game.scene.getScene('FeudalismScene'); + if (!scene) throw new Error('FeudalismScene not found'); + return scene; +} + +/** + * Check if the scene is still active by verifying its systems are running. + */ +function isSceneActive(scene: Phaser.Scene): boolean { + return scene.sys.isActive(); +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('Feudalism audio resilience', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + destroyGame(game); + game = null; + }); + + it('safePlaySound does not throw for a missing audio key', async () => { + game = await bootGame(); + const scene = getScene(game); + + // This key was never loaded into Phaser's audio cache + const MISSING_KEY = '__test-nonexistent-audio-key__'; + + let error: Error | null = null; + try { + safePlaySound(scene, MISSING_KEY); + } catch (e) { + error = e as Error; + } + + expect(error).toBeNull(); + }); + + it('safePlaySound does not throw when sound is null', async () => { + game = await bootGame(); + + let error: Error | null = null; + try { + safePlaySound(null, 'any-key'); + } catch (e) { + error = e as Error; + } + + expect(error).toBeNull(); + }); + + it('scene remains active after safePlaySound calls with missing keys', async () => { + game = await bootGame(); + const scene = getScene(game); + + // Make multiple safe play attempts with various missing keys + safePlaySound(scene, '__test-nonexistent-key-1__'); + safePlaySound(scene, '__test-nonexistent-key-2__'); + safePlaySound(scene, '__test-nonexistent-key-3__'); + + // Wait a few frames for any deferred error propagation + await waitFrames(5); + + // Scene must still be active + expect(isSceneActive(scene)).toBe(true); + }); + + it('direct scene.sound.play with missing key throws an error', async () => { + game = await bootGame(); + const scene = getScene(game); + + const MISSING_KEY = '__test-nonexistent-audio-key__'; + + // Phaser's sound.play() throws when the key is not in the audio cache. + // This test proves WHY the try/catch protection is necessary. + expect(() => { + scene.sound.play(MISSING_KEY); + }).toThrow(); + }); + + it('game-over overlay renders and scene remains active after sound calls', async () => { + game = await bootGame(); + const scene = getScene(game); + + // Simulate missing-key sound playback via safePlaySound (as overlays do) + safePlaySound(scene, '__test-missing-sfx__'); + safePlaySound(scene, '__test-another-missing__'); + + // Wait a few frames for any error propagation + await waitFrames(5); + + // Scene must still be active (the game loop was not crashed) + expect(isSceneActive(scene)).toBe(true); + + // Verify the Phaser game is still running + expect(game.isRunning).toBe(true); + }); + + it('protects against null scene or missing sound manager', async () => { + game = await bootGame(); + + // Null scene + expect(() => safePlaySound(null, 'sfx-test')).not.toThrow(); + + // Scene with null sound + const nullSoundScene = { sound: null }; + expect(() => safePlaySound(nullSoundScene as any, 'sfx-test')).not.toThrow(); + + // Scene with sound that has no play method + const noPlayScene = { sound: {} }; + expect(() => safePlaySound(noPlayScene as any, 'sfx-test')).not.toThrow(); + }); +}); diff --git a/tests/feudalism/save-load.test.ts b/tests/feudalism/save-load.test.ts new file mode 100644 index 00000000..b36aead0 --- /dev/null +++ b/tests/feudalism/save-load.test.ts @@ -0,0 +1,516 @@ +/** + * Integration tests for Feudalism save/load checkpoint round-trip. + * + * Exercises: + * - serialize/deserialize round-trip for full FeudalismSession state + * - Checkpoint save after human turn and after AI turn + * - Game state equality before save and after load/restore + * - RNG seed serialization and reconstruction + * - Market deck contents (all 3 tiers) faithfully serialized and restored + * - Checkpoint cleared on game end + * + * Follows the pattern in tests/beleaguered-castle/save-load-autosave.test.ts + * and satisfies: CG-0MQL8BEXS003ZNNN + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SaveLoadStore, CheckpointManager } from '../../src/core-engine'; +import { + setupFeudalismGame, + executeTurn, + type FeudalismSession, + type TurnAction, +} from '../../example-games/feudalism/FeudalismGame'; +import { + serializeFeudalismState, + deserializeFeudalismState, + createFeudalismSerializer, + saveFeudalismCheckpoint, + loadFeudalismCheckpoint, + clearFeudalismCheckpoint, + FEUDALISM_GAME_TYPE, +} from '../../example-games/feudalism/FeudalismSaveLoad'; +import { createSeededRng } from '../../src/core-engine'; + +// ── Test helpers ──────────────────────────────────────────── + +function createLocalStorageMock(): Storage { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value); }, + removeItem: (key: string) => { data.delete(key); }, + clear: () => data.clear(), + get length() { return data.size; }, + key: (index: number) => [...data.keys()][index] ?? null, + }; +} + +/** Default seed for test games. */ +const TEST_SEED = 42; + +/** + * Set up a 2-player Feudalism game with a deterministic seed. + * Player 0 is human, Player 1 is AI. + */ +function setupTestSession(): FeudalismSession { + return setupFeudalismGame({ + playerCount: 2, + playerNames: ['Human', 'AI'], + isAI: [false, true], + rng: createSeededRng(TEST_SEED), + }); +} + +/** + * Execute a simple legal turn for the current player. + * Tries 'take-different' first if available, then 'take-same', then reserves. + * Returns the action taken, or null if no action was possible. + */ +function executeSimpleTurn(session: FeudalismSession): TurnAction | null { + // Try take-different with first 3 available resources + const availColors = (['oats', 'flax', 'wheat', 'barley', 'turnip'] as const) + .filter((c) => (session.tokenSupply[c] ?? 0) > 0); + + let action: TurnAction; + + if (availColors.length >= 3) { + action = { type: 'take-different', colors: [availColors[0], availColors[1], availColors[2]] }; + } else if (availColors.length >= 1) { + // Take as many different as available + action = { type: 'take-different', colors: availColors }; + } else if ((session.tokenSupply['oats'] ?? 0) >= 4) { + action = { type: 'take-same', color: 'oats' }; + } else { + // Try reserve + for (const tier of [1, 2, 3] as const) { + for (const card of session.market[tier].visible) { + if (card) { + action = { type: 'reserve', cardId: card.id }; + try { + executeTurn(session, action); + return action; + } catch { + continue; + } + } + } + } + return null; + } + + try { + executeTurn(session, action); + return action; + } catch { + return null; + } +} + +/** + * Create a summary of the session state for equality comparison. + * Excludes the rng function (not comparable) and uses card IDs + * for market and player card references. + */ +function summarizeSession(session: FeudalismSession): Record { + return { + phase: session.phase, + currentPlayerIndex: session.currentPlayerIndex, + startingPlayerIndex: session.startingPlayerIndex, + triggerPlayerIndex: session.triggerPlayerIndex, + playerCount: session.players.length, + playerNames: session.players.map((p) => p.name), + playerAI: session.players.map((p) => p.isAI), + playerTokenCounts: session.players.map((p) => ({ ...p.tokens })), + purchasedCardCounts: session.players.map((p) => p.purchasedCards.length), + reservedCardCounts: session.players.map((p) => p.reservedCards.length), + patronCounts: session.players.map((p) => p.patrons.length), + tokenSupply: { ...session.tokenSupply }, + marketTier1Visible: session.market[1].visible.map((c) => c?.id ?? null), + marketTier2Visible: session.market[2].visible.map((c) => c?.id ?? null), + marketTier3Visible: session.market[3].visible.map((c) => c?.id ?? null), + marketTier1DeckSize: session.market[1].deck.length, + marketTier2DeckSize: session.market[2].deck.length, + marketTier3DeckSize: session.market[3].deck.length, + poolPatronCount: session.patrons.length, + }; +} + +/** + * Create a detailed comparison of two sessions for deep equality checking. + * Compares individual fields that should match exactly. + */ +function expectSessionsEqual( + actual: FeudalismSession, + expected: FeudalismSession, +): void { + // Phase and index fields + expect(actual.phase).toBe(expected.phase); + expect(actual.currentPlayerIndex).toBe(expected.currentPlayerIndex); + expect(actual.startingPlayerIndex).toBe(expected.startingPlayerIndex); + expect(actual.triggerPlayerIndex).toBe(expected.triggerPlayerIndex); + + // Player states + expect(actual.players.length).toBe(expected.players.length); + for (let i = 0; i < actual.players.length; i++) { + expect(actual.players[i].name).toBe(expected.players[i].name); + expect(actual.players[i].isAI).toBe(expected.players[i].isAI); + expect(actual.players[i].tokens).toEqual(expected.players[i].tokens); + expect(actual.players[i].purchasedCards.map((c) => c.id)).toEqual( + expected.players[i].purchasedCards.map((c) => c.id), + ); + expect(actual.players[i].reservedCards.map((c) => c.id)).toEqual( + expected.players[i].reservedCards.map((c) => c.id), + ); + expect(actual.players[i].patrons.map((pt) => pt.id)).toEqual( + expected.players[i].patrons.map((pt) => pt.id), + ); + } + + // Token supply + expect(actual.tokenSupply).toEqual(expected.tokenSupply); + + // Market decks and visible cards + for (const tier of [1, 2, 3] as const) { + expect(actual.market[tier].visible.map((c) => c?.id ?? null)).toEqual( + expected.market[tier].visible.map((c) => c?.id ?? null), + ); + expect(actual.market[tier].deck.map((c) => c.id)).toEqual( + expected.market[tier].deck.map((c) => c.id), + ); + } + + // Pool patrons + expect(actual.patrons.map((pt) => pt.id)).toEqual( + expected.patrons.map((pt) => pt.id), + ); + + // RNG should be a function on both + expect(typeof actual.rng).toBe('function'); + expect(typeof expected.rng).toBe('function'); +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('Feudalism save/load integration (CG-0MQL8BEXS003ZNNN)', () => { + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', createLocalStorageMock()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + // ── Serializer round-trip ───────────────────────────────── + + it('serialize/deserialize round-trip preserves full session state', () => { + const session = setupTestSession(); + + // Play a few turns to create non-trivial state + for (let i = 0; i < 4; i++) { + const action = executeSimpleTurn(session); + if (!action) break; + } + + const summaryBefore = summarizeSession(session); + + // Serialize and deserialize + const serialized = serializeFeudalismState(session, TEST_SEED); + const restored = deserializeFeudalismState(serialized); + + const summaryAfter = summarizeSession(restored); + expect(summaryAfter).toEqual(summaryBefore); + expectSessionsEqual(restored, session); + }); + + it('serialize/deserialize round-trip works on fresh session (no turns played)', () => { + const session = setupTestSession(); + const summaryBefore = summarizeSession(session); + + const serialized = serializeFeudalismState(session, TEST_SEED); + const restored = deserializeFeudalismState(serialized); + + expectSessionsEqual(restored, session); + expect(summarizeSession(restored)).toEqual(summaryBefore); + }); + + it('serializer has correct schema version', () => { + const serializer = createFeudalismSerializer(TEST_SEED); + expect(serializer.schemaVersion).toBe(1); + }); + + // ── Save/Load round-trip via SaveLoadStore ──────────────── + + it('save/load round-trip: checkpoint preserves full state', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Play a few turns + for (let i = 0; i < 5; i++) { + const action = executeSimpleTurn(session); + if (!action) break; + } + + const summaryBefore = summarizeSession(session); + + // Save checkpoint + await saveFeudalismCheckpoint(store, session, TEST_SEED); + + // Load and verify + const restored = await loadFeudalismCheckpoint(store); + expect(restored).not.toBeNull(); + expectSessionsEqual(restored!, session); + expect(summarizeSession(restored!)).toEqual(summaryBefore); + }); + + it('save/load round-trip: multiple saves overwrite correctly', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Save initial state + const summaryInitial = summarizeSession(session); + await saveFeudalismCheckpoint(store, session, TEST_SEED); + + // Play a turn and save again + executeSimpleTurn(session); + const summaryAfterTurn = summarizeSession(session); + await saveFeudalismCheckpoint(store, session, TEST_SEED); + + // Load should return the latest (after turn), not initial + const restored = await loadFeudalismCheckpoint(store); + expect(restored).not.toBeNull(); + expect(summarizeSession(restored!)).toEqual(summaryAfterTurn); + expect(summarizeSession(restored!)).not.toEqual(summaryInitial); + }); + + it('save/load round-trip: serializer works with CheckpointManager', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Play a couple turns + executeSimpleTurn(session); + executeSimpleTurn(session); + + const summaryBefore = summarizeSession(session); + const serializer = createFeudalismSerializer(TEST_SEED); + const manager = new CheckpointManager(store, FEUDALISM_GAME_TYPE, 'test-slot', serializer); + + // Save via CheckpointManager + await manager.save(session); + + // Load via CheckpointManager + const loaded = await manager.load(); + expect(loaded).not.toBeNull(); + + // RNG is recreated, so the restored session has a new rng function + // But the state should match + const summaryAfter = summarizeSession(loaded!); + expect(summaryAfter).toEqual(summaryBefore); + }); + + // ── Human and AI turns ─────────────────────────────────── + + it('checkpoint saves after human turn (player 0)', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Player 0 (human) takes a turn + expect(session.currentPlayerIndex).toBe(0); + executeSimpleTurn(session); + + // Save after human turn + await saveFeudalismCheckpoint(store, session, TEST_SEED); + + // Load and verify state + const loaded = await loadFeudalismCheckpoint(store); + expect(loaded).not.toBeNull(); + expect(loaded!.currentPlayerIndex).not.toBe(0); // Should have advanced to next player + expect(summarizeSession(loaded!)).toEqual(summarizeSession(session)); + }); + + it('checkpoint saves after AI turn (player 1)', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Player 0 takes a turn (advances to player 1) + executeSimpleTurn(session); + + // Now player 1 (AI) should be current + expect(session.currentPlayerIndex).toBe(1); + + // AI takes a turn + executeSimpleTurn(session); + + // Save after AI turn + await saveFeudalismCheckpoint(store, session, TEST_SEED); + + // Load and verify + const loaded = await loadFeudalismCheckpoint(store); + expect(loaded).not.toBeNull(); + expect(summarizeSession(loaded!)).toEqual(summarizeSession(session)); + }); + + it('checkpoint survives multiple human+AI turns', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Play 6 turns (3 rounds for 2 players) + for (let i = 0; i < 6; i++) { + const action = executeSimpleTurn(session); + if (!action) break; + } + + const summaryBefore = summarizeSession(session); + await saveFeudalismCheckpoint(store, session, TEST_SEED); + + const restored = await loadFeudalismCheckpoint(store); + expect(restored).not.toBeNull(); + expect(summarizeSession(restored!)).toEqual(summaryBefore); + }); + + // ── RNG seed serialization ────────────────────────────── + + it('RNG seed is serialized and RNG is reconstructed on deserialize', () => { + const session = setupTestSession(); + + const serialized = serializeFeudalismState(session, TEST_SEED); + expect(serialized.seed).toBe(TEST_SEED); + + const restored = deserializeFeudalismState(serialized); + // The restored session should have a working RNG + expect(typeof restored.rng).toBe('function'); + + // Deterministic check: two restores from same serialized state + // should produce RNGs with the same first value + const restored2 = deserializeFeudalismState(serialized); + expect(restored.rng()).toBe(restored2.rng()); + }); + + it('different seeds produce different restored sessions', () => { + // Set up two sessions with different seeds + const session1 = setupTestSession(); // seed 42 + + const session2 = setupFeudalismGame({ + playerCount: 2, + rng: createSeededRng(99), + }); + + const serialized1 = serializeFeudalismState(session1, TEST_SEED); + const serialized2 = serializeFeudalismState(session2, 99); + + // Different seeds -> different market layouts + expect(serialized1.market[1].visible).not.toEqual(serialized2.market[1].visible); + }); + + // ── Market deck contents ──────────────────────────────── + + it('market deck contents (all 3 tiers) faithfully serialized and restored', () => { + const session = setupTestSession(); + + // Play a few turns that consume some market cards via reserve/purchase + for (let i = 0; i < 6; i++) { + executeSimpleTurn(session); + } + + // Check market state before serialization + const marketBefore = [ + { visible: session.market[1].visible.map((c) => c?.id ?? null), deckIds: session.market[1].deck.map((c) => c.id) }, + { visible: session.market[2].visible.map((c) => c?.id ?? null), deckIds: session.market[2].deck.map((c) => c.id) }, + { visible: session.market[3].visible.map((c) => c?.id ?? null), deckIds: session.market[3].deck.map((c) => c.id) }, + ]; + + const serialized = serializeFeudalismState(session, TEST_SEED); + const restored = deserializeFeudalismState(serialized); + + // Verify market contents match exactly + for (const tier of [1, 2, 3] as const) { + const idx = tier - 1; + expect(restored.market[tier].visible.map((c) => c?.id ?? null)) + .toEqual(marketBefore[idx].visible); + expect(restored.market[tier].deck.map((c) => c.id)) + .toEqual(marketBefore[idx].deckIds); + } + }); + + it('deck ordering is preserved through save/load', () => { + const session = setupTestSession(); + + // Check deck ordering is preserved + const deck1Before = session.market[1].deck.map((c) => c.id); + + const serialized = serializeFeudalismState(session, TEST_SEED); + const restored = deserializeFeudalismState(serialized); + + const deck1After = restored.market[1].deck.map((c) => c.id); + expect(deck1After).toEqual(deck1Before); + }); + + // ── Game end / checkpoint clear ───────────────────────── + + it('clear checkpoint removes saved data', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + // Save checkpoint + await saveFeudalismCheckpoint(store, session, TEST_SEED); + expect(await loadFeudalismCheckpoint(store)).not.toBeNull(); + + // Clear it + await clearFeudalismCheckpoint(store); + + // Verify it's gone + const loaded = await loadFeudalismCheckpoint(store); + expect(loaded).toBeNull(); + }); + + it('clear is safe to call when no checkpoint exists', async () => { + const store = new SaveLoadStore(); + await expect(clearFeudalismCheckpoint(store)).resolves.toBeUndefined(); + expect(await loadFeudalismCheckpoint(store)).toBeNull(); + }); + + it('checkpoint persists across separate store instances', async () => { + const store1 = new SaveLoadStore(); + const session = setupTestSession(); + + // Play a few turns + for (let i = 0; i < 3; i++) { + executeSimpleTurn(session); + } + + const expectedSummary = summarizeSession(session); + + // Save with one store instance + await saveFeudalismCheckpoint(store1, session, TEST_SEED); + + // Load with a different store instance (same localStorage backend) + const store2 = new SaveLoadStore(); + const loaded = await loadFeudalismCheckpoint(store2); + expect(loaded).not.toBeNull(); + expect(summarizeSession(loaded!)).toEqual(expectedSummary); + }); + + // ── Checkpoint lifecycle ───────────────────────────────── + + it('load returns null when no checkpoint has been saved', async () => { + const store = new SaveLoadStore(); + const result = await loadFeudalismCheckpoint(store); + expect(result).toBeNull(); + }); + + it('clear then load returns null', async () => { + const store = new SaveLoadStore(); + const session = setupTestSession(); + + await saveFeudalismCheckpoint(store, session, TEST_SEED); + expect(await loadFeudalismCheckpoint(store)).not.toBeNull(); + + await clearFeudalismCheckpoint(store); + expect(await loadFeudalismCheckpoint(store)).toBeNull(); + }); +}); diff --git a/tests/golf/GolfInteraction.browser.test.ts b/tests/golf/GolfInteraction.browser.test.ts index 97ed89b6..62a8b85d 100644 --- a/tests/golf/GolfInteraction.browser.test.ts +++ b/tests/golf/GolfInteraction.browser.test.ts @@ -243,7 +243,12 @@ describe('GolfScene interaction tests', () => { expect(internals.phaseManager.current).toBe('waiting-for-draw'); - // Click the discard pile + // The discard pile starts with exactly 1 face-up card + expect(internals.session.shared.discardPile.size()).toBe(1); + const initialDiscardTextureKey = internals.discardSprite.texture.key; + expect(initialDiscardTextureKey).not.toBe('card_back'); + + // Click the discard pile to draw the only card clickGameObject(internals.discardSprite); await nextFrame(); @@ -251,17 +256,15 @@ describe('GolfScene interaction tests', () => { expect(internals.drawnCard).not.toBeNull(); expect(internals.drawSource).toBe('discard'); - // The sprite key should still be defined after draw - expect(internals.discardSprite.texture.key).toBeDefined(); + // The card is peeked but NOT popped yet (pop happens later in executeTurn). + // The discard pile model still shows 1 card. + expect(internals.session.shared.discardPile.size()).toBe(1); - // Verify the discard pile peek returns a card or undefined - // (depending on whether it's now empty after the draw) - const discardTop = internals.session.shared.discardPile.peek(); - if (discardTop) { - expect( - (discardTop as { faceUp: boolean }).faceUp, - ).toBe(true); - } + // But the discard pile sprite should have updated to the empty-pile + // placeholder (ghosted card-back) to visually preview the post-draw state. + expect(internals.discardSprite.texture.key).toBe('card_back'); + expect(internals.discardSprite.visible).toBe(true); + expect(internals.discardSprite.alpha).toBeCloseTo(0.25, 1); }); // ── Test 4: Swap move ────────────────────────────────── diff --git a/tests/lost-cities/LostCitiesRoundEnd.browser.test.ts b/tests/lost-cities/LostCitiesRoundEnd.browser.test.ts index 6ed78933..b6f89e74 100644 --- a/tests/lost-cities/LostCitiesRoundEnd.browser.test.ts +++ b/tests/lost-cities/LostCitiesRoundEnd.browser.test.ts @@ -230,7 +230,7 @@ describe('Lost Cities round-end overlay tests', () => { // Phase 1: Play it to the card's own color expedition (it's 'blue') internals.turnController.onExpeditionClick(); - await wait(200); // Allow animation to start + await wait(400); // Allow 300ms animation to complete // The turn controller should now be in 'waiting-for-draw' phase expect(internals.turnController.phase).toBe('waiting-for-draw'); @@ -240,10 +240,10 @@ describe('Lost Cities round-end overlay tests', () => { await wait(800); // Allow animation + overlay to render // After the draw, the round should have ended and the overlay should appear - // Look for text containing "Round" (from "Round 1 Complete") - const overlayText = findOverlayText(scene, 'Round'); + // Look for text containing "Round 1 Complete" (setupPlayerLastCard sets roundNumber = 1) + const overlayText = findOverlayText(scene, 'Round 1 Complete'); expect(overlayText).toBeDefined(); - expect(overlayText!.text).toContain('Round'); + expect(overlayText!.text).toContain('Round 1 Complete'); // Also verify session state reflects the round-end const session = internals.session; @@ -288,7 +288,7 @@ describe('Lost Cities round-end overlay tests', () => { internals.turnController.onHandCardClick(0); await wait(50); internals.turnController.onExpeditionClick(); - await wait(200); + await wait(400); // Allow 300ms animation to complete expect(internals.turnController.phase).toBe('waiting-for-draw'); internals.turnController.onDrawPileClick(); await wait(800); @@ -317,7 +317,7 @@ describe('Lost Cities round-end overlay tests', () => { internals.turnController.onHandCardClick(0); await wait(50); internals.turnController.onExpeditionClick(); - await wait(200); + await wait(400); // Allow 300ms animation to complete internals.turnController.onDrawPileClick(); await wait(800); @@ -409,7 +409,7 @@ describe('Lost Cities round-end overlay tests', () => { internals.turnController.onHandCardClick(0); await wait(50); internals.turnController.onExpeditionClick(); - await wait(150); + await wait(400); // Allow 300ms animation to complete expect(internals.turnController.phase).toBe('waiting-for-draw'); internals.turnController.onDrawPileClick(); await wait(150); @@ -424,9 +424,10 @@ describe('Lost Cities round-end overlay tests', () => { expect(session.roundScores).toHaveLength(1); // Overlay should show round summary - const overlayText = findOverlayText(scene, 'Round'); + // setupAiLastCard sets roundNumber = 1, so we expect "Round 1 Complete" + const overlayText = findOverlayText(scene, 'Round 1 Complete'); expect(overlayText).toBeDefined(); - expect(overlayText!.text).toContain('Round'); + expect(overlayText!.text).toContain('Round 1 Complete'); }); // ═══════════════════════════════════════════════════════════ diff --git a/tests/main-street/checkpoint-manager.test.ts b/tests/main-street/checkpoint-manager.test.ts new file mode 100644 index 00000000..5ed7e35c --- /dev/null +++ b/tests/main-street/checkpoint-manager.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for Main Street CheckpointManager integration. + * + * These tests verify that: + * - The `createMainStreetCheckpointManager` factory creates a valid CheckpointManager. + * - `saveTurnStartCheckpoint` / `loadTurnStartCheckpoint` delegate to CheckpointManager + * internally while preserving the existing public API surface. + * - `clearTurnStartCheckpoint` removes the saved checkpoint. + * - All existing save-load tests continue to pass with the refactored internals. + * + * @module tests/main-street/checkpoint-manager + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SaveLoadStore, CheckpointManager } from '../../src/core-engine'; +import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState'; + +import { + executeDayStart, + executeAction, + processEndOfTurn, +} from '../../example-games/main-street/MainStreetEngine'; +import { + saveTurnStartCheckpoint, + loadTurnStartCheckpoint, + createMainStreetCheckpointManager, + clearTurnStartCheckpoint, +} from '../../example-games/main-street/MainStreetSaveLoad'; + +function createLocalStorageMock(): Storage { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + clear: () => data.clear(), + get length() { + return data.size; + }, + key: (index: number) => [...data.keys()][index] ?? null, + }; +} + +describe('Main Street CheckpointManager integration', () => { + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', createLocalStorageMock()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + // ── Factory / construction ─────────────────────────────── + + it('createMainStreetCheckpointManager returns a valid CheckpointManager instance', () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + expect(mgr).toBeInstanceOf(CheckpointManager); + }); + + it('createMainStreetCheckpointManager uses correct game type and slot', () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + expect(mgr).toBeInstanceOf(CheckpointManager); + // The CheckpointManager doesn't expose gameType/slotId publicly, + // but we verify it works by saving and loading through it. + }); + + // ── Save / Load / Clear via CheckpointManager ──────────── + + it('saves and loads a checkpoint via CheckpointManager', async () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + const state = setupMainStreetGame({ seed: 'cm-test-save-load' }); + + await mgr.save(state); + const loaded = await mgr.load(); + expect(loaded).not.toBeNull(); + expect(loaded!.seed).toBe(state.seed); + expect(loaded!.turn).toBe(state.turn); + }); + + it('load returns null when no checkpoint exists', async () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + const loaded = await mgr.load(); + expect(loaded).toBeNull(); + }); + + it('clear removes a saved checkpoint', async () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + const state = setupMainStreetGame({ seed: 'cm-test-clear' }); + + await mgr.save(state); + expect(await mgr.load()).not.toBeNull(); + + await mgr.clear(); + expect(await mgr.load()).toBeNull(); + }); + + it('clear is safe when no checkpoint exists', async () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + // Should not throw + await expect(mgr.clear()).resolves.toBeUndefined(); + }); + + // ── Existing public API (saveTurnStartCheckpoint / loadTurnStartCheckpoint) ── + + it('saveTurnStartCheckpoint persists state via the refactored CheckpointManager path', async () => { + const store = new SaveLoadStore(); + const state = setupMainStreetGame({ seed: 'existing-api-save' }); + + await saveTurnStartCheckpoint(store, state); + const loaded = await loadTurnStartCheckpoint(store); + expect(loaded).not.toBeNull(); + expect(loaded!.seed).toBe(state.seed); + expect(loaded!.turn).toBe(state.turn); + }); + + it('loadTurnStartCheckpoint returns null when no checkpoint saved', async () => { + const store = new SaveLoadStore(); + const loaded = await loadTurnStartCheckpoint(store); + expect(loaded).toBeNull(); + }); + + it('save/load round-trip preserves turn-start state deterministically', async () => { + const store = new SaveLoadStore(); + const state = setupMainStreetGame({ seed: 'existing-api-det-roundtrip' }); + + executeDayStart(state); + const card = state.market.development[0]; + executeAction(state, { type: 'buy-business', cardId: card.id, slotIndex: 0 }); + processEndOfTurn(state); + + await saveTurnStartCheckpoint(store, state); + const restored = await loadTurnStartCheckpoint(store); + expect(restored).not.toBeNull(); + + const expected = setupMainStreetGame({ seed: 'existing-api-det-roundtrip' }); + executeDayStart(expected); + executeAction(expected, { type: 'buy-business', cardId: card.id, slotIndex: 0 }); + processEndOfTurn(expected); + + expect(restored!.turn).toBe(expected.turn); + expect(restored!.resourceBank).toEqual(expected.resourceBank); + expect(restored!.phase).toBe(expected.phase); + expect(restored!.streetGrid.map((b) => b?.id ?? null)).toEqual( + expected.streetGrid.map((b) => b?.id ?? null), + ); + }); + + it('clearing via CheckpointManager makes loadTurnStartCheckpoint return null', async () => { + const store = new SaveLoadStore(); + const mgr = createMainStreetCheckpointManager(store); + const state = setupMainStreetGame({ seed: 'clear-checkpoint' }); + + await saveTurnStartCheckpoint(store, state); + expect(await loadTurnStartCheckpoint(store)).not.toBeNull(); + + await mgr.clear(); + expect(await loadTurnStartCheckpoint(store)).toBeNull(); + }); + + it('clearTurnStartCheckpoint removes the saved checkpoint', async () => { + const store = new SaveLoadStore(); + const state = setupMainStreetGame({ seed: 'clear-turn-start' }); + + await saveTurnStartCheckpoint(store, state); + expect(await loadTurnStartCheckpoint(store)).not.toBeNull(); + + await clearTurnStartCheckpoint(store); + expect(await loadTurnStartCheckpoint(store)).toBeNull(); + }); + + it('clearTurnStartCheckpoint is safe when no checkpoint exists', async () => { + const store = new SaveLoadStore(); + await expect(clearTurnStartCheckpoint(store)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/main-street/save-load.test.ts b/tests/main-street/save-load.test.ts index 4e23db23..56e044f3 100644 --- a/tests/main-street/save-load.test.ts +++ b/tests/main-street/save-load.test.ts @@ -80,7 +80,7 @@ describe('Main Street save/load integration', () => { ); }); - it('rejects incompatible checkpoint version', async () => { + it('rejects incompatible checkpoint version (returns null via CheckpointManager)', async () => { const store = new SaveLoadStore(); const state = setupMainStreetGame({ seed: 'save-load-version-mismatch' }); const payload = { @@ -90,7 +90,9 @@ describe('Main Street save/load integration', () => { await store.save('run-checkpoint', 'main-street', 'turn-start', payload.schemaVersion, payload); - await expect(loadTurnStartCheckpoint(store)).rejects.toThrow(/Incompatible save version/); + // CheckpointManager.load() catches schema version mismatch and returns null + const result = await loadTurnStartCheckpoint(store); + expect(result).toBeNull(); }); it('persists and restores campaign progression separately from run checkpoint', async () => {