Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
72f6325
CG-0MQK0SKBA005QZFR: Fix patron disappearing before animation
Jun 19, 2026
9ae3e91
CG-0MQK7U7PA009XPYW: Fix 3 unprotected scene.sound.play calls in Feud…
Jun 19, 2026
910a4b1
CG-0MQK7U7P2005G44K: Add shared safePlaySound utility to core-engine
Jun 19, 2026
09dd8f4
CG-0MQK7U7PH0072STK: Add ESLint rule no-direct-sound-play and fix 22 …
Jun 19, 2026
83c64d5
CG-0MQK7U7PC008CLHL: Add browser test for missing audio resilience
Jun 19, 2026
87581ee
CG-0MQIS0HQJ000KI8T: Fix player card play animation by replacing brok…
Jun 19, 2026
fd39e06
CG-0MQJYY3X700598XU: Fix discard pile visual state when drawing the l…
Jun 19, 2026
f2a1e83
CG-0MQL21H2W008EIF8: Refine patron animation timing - remove static t…
Jun 19, 2026
efd141f
CG-0MQK14RN4002801F: Fix missing-graphic placeholder during draw-from…
Jun 19, 2026
fccee4b
CG-0MQK16LSV004G98T: Fix Round 0 -> Round 1 in Lost Cities round-comp…
Jun 19, 2026
b2db45d
CG-0MQL8A0CH0017SK1: Core Engine Checkpoint Unit Tests
Jun 19, 2026
951a623
CG-0MQL8CPZS009R74Q: Core Engine Checkpoint Abstraction — add built-i…
Jun 19, 2026
2b4c5ca
CG-0MQL8BEXS003ZNNN: Feudalism Save/Load Integration Tests + SaveLoad…
Jun 20, 2026
a1f1823
CG-0MQL8E5RK0043RAL: Feudalism Save/Load Implementation
Jun 20, 2026
5510dd6
CG-0MQL8E5RK007LEMD: Beleaguered Castle Refactoring — replace inline …
Jun 20, 2026
0b772c6
CG-0MQL8E5RK006ZRMW: Documentation and Cross-Cutting — add Checkpoint…
Jun 20, 2026
46a9f2e
CG-0MQKYJ6J2004G94U: Main Street auto-save with CheckpointManager
Jun 20, 2026
0c61044
CG-0MQM9Z4MY000NOS1: Add full-screen semi-transparent background to d…
Jun 20, 2026
1e92314
Bump version to v0.1.2
Jun 20, 2026
bd3f490
Merge origin/dev into main (automated)
Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
56 changes: 54 additions & 2 deletions docs/DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ src/
│ ├── GameState.ts GameState<T>, 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<T> abstract base class
│ ├── TurnSequencer.ts advanceTurn, getCurrentPlayer, startGame, endGame
│ └── index.ts Barrel file / public API
Expand Down Expand Up @@ -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/` |
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions docs/SFX_CONVENTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
@@ -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)
{
Expand All @@ -16,13 +120,19 @@ module.exports = [
},
plugins: {
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
local: {
rules: {
'no-direct-sound-play': noDirectSoundPlayRule,
},
},
},
rules: {
'no-unused-vars': 'off',
'no-console': ['warn', { allow: ['warn', 'error', 'info', 'debug'] }],
'@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
Expand Down
53 changes: 21 additions & 32 deletions example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -59,6 +61,7 @@ export class BeleagueredCastleScene extends CardGameScene {
private transcript: BCGameTranscript | null = null;

private saveLoadStore!: SaveLoadStore;
private checkpointManager!: CheckpointManager<BeleagueredCastleState, BCSerializedState>;
private transcriptStore!: TranscriptStore;

private bcRenderer!: BeleagueredCastleRenderer;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
Expand All @@ -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),
);
}
Expand Down
Loading
Loading