Skip to content

Commit 6eb6c78

Browse files
committed
CG-0MM0GQ9JO1MH50R9: Complete BC transcript-to-thumbnail pipeline
- Add replayMode, loadBoardState(), and state-settled event to BeleagueredCastleScene - Implement full BeleagueredCastleReplayAdapter with move-based state reconstruction - Update adapter tests from stub expectations to implementation verification - Fix makeBCTranscript fixture to use correct TranscriptEntry format - Add fixture transcript at tests/fixtures/transcripts/beleaguered-castle/ - Add placeholder thumbnail at public/assets/games/beleaguered-castle/
1 parent 40bf42d commit 6eb6c78

5 files changed

Lines changed: 720 additions & 118 deletions

File tree

example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts

Lines changed: 135 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
*/
1515

1616
import Phaser from 'phaser';
17-
import type { Suit } from '../../../src/card-system/Card';
17+
import type { Rank, Suit } from '../../../src/card-system/Card';
18+
import { createCard, RANKS } from '../../../src/card-system/Card';
1819
import type { BeleagueredCastleState, BCMove } from '../BeleagueredCastleState';
1920
import { FOUNDATION_COUNT, TABLEAU_COUNT, FOUNDATION_SUITS } from '../BeleagueredCastleState';
2021
import {
@@ -33,7 +34,7 @@ import {
3334
import type { Command } from '../../../src/core-engine/UndoRedoManager';
3435
import { UndoRedoManager, CompoundCommand } from '../../../src/core-engine/UndoRedoManager';
3536
import { BCTranscriptRecorder } from '../GameTranscript';
36-
import type { BCGameTranscript } from '../GameTranscript';
37+
import type { BCGameTranscript, BoardSnapshot } from '../GameTranscript';
3738
import {
3839
HelpPanel, HelpButton,
3940
SettingsPanel, SettingsButton,
@@ -207,6 +208,9 @@ export class BeleagueredCastleScene extends Phaser.Scene {
207208
private seed: number = Date.now();
208209
private undoManager!: UndoRedoManager;
209210

211+
/** When true, the scene suppresses all input and sound for replay use. */
212+
private replayMode: boolean = false;
213+
210214
// Whether the deal animation has finished (interactions blocked until then)
211215
private dealComplete: boolean = false;
212216

@@ -305,6 +309,10 @@ export class BeleagueredCastleScene extends Phaser.Scene {
305309
const seedParam = params.get('seed');
306310
this.seed = seedParam ? parseInt(seedParam, 10) : Date.now();
307311

312+
// Check for replay mode via URL parameter (?mode=replay)
313+
this.replayMode =
314+
params.get('mode') === 'replay';
315+
308316
// Deal the game
309317
this.gameState = deal(this.seed);
310318
this.undoManager = new UndoRedoManager();
@@ -334,60 +342,75 @@ export class BeleagueredCastleScene extends Phaser.Scene {
334342
this.createFoundationSlots();
335343
this.createTableauDropZones();
336344
this.createHUD();
337-
this.createHelpPanel();
345+
if (!this.replayMode) {
346+
this.createHelpPanel();
347+
}
338348

339349
// Sound system: event emitter, bridge, sound manager, settings
340350
this.gameEvents = new GameEventEmitter();
341351
this.eventBridge = new PhaserEventBridge(this.gameEvents, this.events);
352+
(window as unknown as Record<string, unknown>).__GAME_EVENTS__ =
353+
this.gameEvents;
354+
355+
if (!this.replayMode) {
356+
const phaserSound = this.sound;
357+
const player: SoundPlayer = {
358+
play: (key: string) => { phaserSound.play(key); },
359+
stop: (key: string) => { phaserSound.stopByKey(key); },
360+
setVolume: (v: number) => { phaserSound.volume = v; },
361+
setMute: (m: boolean) => { phaserSound.mute = m; },
362+
};
363+
this.soundManager = new SoundManager(player);
342364

343-
const phaserSound = this.sound;
344-
const player: SoundPlayer = {
345-
play: (key: string) => { phaserSound.play(key); },
346-
stop: (key: string) => { phaserSound.stopByKey(key); },
347-
setVolume: (v: number) => { phaserSound.volume = v; },
348-
setMute: (m: boolean) => { phaserSound.mute = m; },
349-
};
350-
this.soundManager = new SoundManager(player);
351-
352-
// Register all SFX keys
353-
for (const sfxKey of Object.values(SFX_KEYS)) {
354-
this.soundManager.register(sfxKey);
355-
}
365+
// Register all SFX keys
366+
for (const sfxKey of Object.values(SFX_KEYS)) {
367+
this.soundManager.register(sfxKey);
368+
}
356369

357-
// Declarative event-to-sound mapping
358-
const mapping: EventSoundMapping = {
359-
'card-pickup': SFX_KEYS.CARD_PICKUP,
360-
'card-to-foundation': SFX_KEYS.CARD_TO_FOUNDATION,
361-
'card-to-tableau': SFX_KEYS.CARD_TO_TABLEAU,
362-
'card-snap-back': SFX_KEYS.CARD_SNAP_BACK,
363-
'deal-card': SFX_KEYS.DEAL_CARD,
364-
'game-ended': SFX_KEYS.LOSS_SOUND, // default; win overrides with direct play
365-
'auto-complete-start': SFX_KEYS.AUTO_COMPLETE_START,
366-
'auto-complete-card': SFX_KEYS.AUTO_COMPLETE_CARD,
367-
'undo': SFX_KEYS.UNDO,
368-
'redo': SFX_KEYS.REDO,
369-
'card-selected': SFX_KEYS.CARD_SELECT,
370-
'card-deselected': SFX_KEYS.CARD_DESELECT,
371-
'ui-interaction': SFX_KEYS.UI_CLICK,
372-
};
373-
this.soundManager.connectToEvents(this.gameEvents, mapping);
374-
375-
this.createSettingsPanel();
370+
// Declarative event-to-sound mapping
371+
const mapping: EventSoundMapping = {
372+
'card-pickup': SFX_KEYS.CARD_PICKUP,
373+
'card-to-foundation': SFX_KEYS.CARD_TO_FOUNDATION,
374+
'card-to-tableau': SFX_KEYS.CARD_TO_TABLEAU,
375+
'card-snap-back': SFX_KEYS.CARD_SNAP_BACK,
376+
'deal-card': SFX_KEYS.DEAL_CARD,
377+
'game-ended': SFX_KEYS.LOSS_SOUND, // default; win overrides with direct play
378+
'auto-complete-start': SFX_KEYS.AUTO_COMPLETE_START,
379+
'auto-complete-card': SFX_KEYS.AUTO_COMPLETE_CARD,
380+
'undo': SFX_KEYS.UNDO,
381+
'redo': SFX_KEYS.REDO,
382+
'card-selected': SFX_KEYS.CARD_SELECT,
383+
'card-deselected': SFX_KEYS.CARD_DESELECT,
384+
'ui-interaction': SFX_KEYS.UI_CLICK,
385+
};
386+
this.soundManager.connectToEvents(this.gameEvents, mapping);
387+
388+
this.createSettingsPanel();
389+
}
376390

377391
// Render foundations (aces already placed)
378392
this.refreshFoundations();
379393

380-
// Deal cards to tableau with animation
381-
this.dealTableauAnimated();
394+
if (this.replayMode) {
395+
// In replay mode: skip deal animation, skip input handlers,
396+
// mark deal as complete, and emit state-settled for the replay tool.
397+
this.dealComplete = true;
398+
this.refreshTableau();
399+
this.refreshHUD();
400+
this.emitStateSettled();
401+
} else {
402+
// Deal cards to tableau with animation
403+
this.dealTableauAnimated();
382404

383-
// Setup drag-and-drop event handlers
384-
this.setupDragAndDrop();
405+
// Setup drag-and-drop event handlers
406+
this.setupDragAndDrop();
385407

386-
// Setup click-to-move event handlers
387-
this.setupClickToMove();
408+
// Setup click-to-move event handlers
409+
this.setupClickToMove();
388410

389-
// Setup keyboard shortcuts
390-
this.setupKeyboard();
411+
// Setup keyboard shortcuts
412+
this.setupKeyboard();
413+
}
391414
}
392415

393416
// ── UI creation ─────────────────────────────────────────
@@ -1701,6 +1724,75 @@ export class BeleagueredCastleScene extends Phaser.Scene {
17011724
return this.recorder;
17021725
}
17031726

1727+
// ── Replay API ──────────────────────────────────────────
1728+
1729+
/**
1730+
* Inject an arbitrary board state from a transcript snapshot and
1731+
* refresh the visual display. Intended for use by the replay tool
1732+
* via `page.evaluate()`.
1733+
*
1734+
* Only operational in replay mode (?mode=replay). Throws if called
1735+
* outside of replay mode.
1736+
*
1737+
* After updating the internal state and refreshing all sprites,
1738+
* emits a `state-settled` event so the caller can synchronize
1739+
* screenshot capture.
1740+
*
1741+
* @param snapshot A BoardSnapshot containing foundations and tableau state.
1742+
*/
1743+
loadBoardState(snapshot: BoardSnapshot): void {
1744+
if (!this.replayMode) {
1745+
throw new Error(
1746+
'loadBoardState() is only available in replay mode (?mode=replay)',
1747+
);
1748+
}
1749+
1750+
// Rebuild foundation piles from the snapshot
1751+
for (let i = 0; i < FOUNDATION_COUNT; i++) {
1752+
const fs = snapshot.foundations[i];
1753+
this.gameState.foundations[i].clear();
1754+
// Push cards A through topRank for this foundation's suit
1755+
if (fs.size > 0 && fs.topRank !== null) {
1756+
const topIdx = RANKS.indexOf(fs.topRank);
1757+
for (let ri = 0; ri <= topIdx; ri++) {
1758+
this.gameState.foundations[i].push(
1759+
createCard(RANKS[ri], fs.suit, true),
1760+
);
1761+
}
1762+
}
1763+
}
1764+
1765+
// Rebuild tableau piles from the snapshot
1766+
for (let col = 0; col < TABLEAU_COUNT; col++) {
1767+
this.gameState.tableau[col].clear();
1768+
const cs = snapshot.tableau[col];
1769+
for (const cardSnap of cs.cards) {
1770+
this.gameState.tableau[col].push(
1771+
createCard(cardSnap.rank as Rank, cardSnap.suit as Suit, true),
1772+
);
1773+
}
1774+
}
1775+
1776+
// Refresh all visual elements
1777+
this.refreshFoundations();
1778+
this.refreshTableau();
1779+
this.refreshHUD();
1780+
1781+
// Signal that the board is visually stable and ready for screenshot
1782+
this.emitStateSettled();
1783+
}
1784+
1785+
/**
1786+
* Emit state-settled when the board is visually stable and safe
1787+
* to screenshot. Called after state injection and display refresh.
1788+
*/
1789+
private emitStateSettled(): void {
1790+
this.gameEvents.emit('state-settled', {
1791+
turnNumber: this.gameState.moveCount,
1792+
phase: (this.gameEnded ? 'ended' : 'playing') as 'setup' | 'playing' | 'ended',
1793+
});
1794+
}
1795+
17041796
// ── Cleanup ─────────────────────────────────────────────
17051797

17061798
shutdown(): void {
247 Bytes
Loading

0 commit comments

Comments
 (0)