Skip to content

Commit 586dcc5

Browse files
committed
CG-0MM0GQFZA1WQKILP: Implement Lost Cities replay adapter and scene replay support
- Create LostCitiesReplayAdapter implementing ReplayAdapter interface - Detects transcripts via gameType: 'lost-cities' - Flattens multi-round actions into linear sequence for replay - Full snapshot injection (no delta reconstruction needed) - Add replay mode support to LostCitiesScene - replayMode flag from ?mode=replay URL param - loadBoardState() injects board/table state from transcript snapshots - emitStateSettled() signals visual stability for screenshots - Expose __GAME_EVENTS__ on window for replay tool - Skip help panel, sound system, settings in replay mode - Move GameEventEmitter creation before conditional UI setup - Register LostCitiesReplayAdapter in adapter registry (between BC and Golf)
1 parent be9773e commit 586dcc5

3 files changed

Lines changed: 509 additions & 7 deletions

File tree

example-games/lost-cities/scenes/LostCitiesScene.ts

Lines changed: 149 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ export class LostCitiesScene extends Phaser.Scene {
240240
private turnPhase: SceneTurnPhase = 'waiting-for-card-select';
241241
private selectedCardIndex: number = -1;
242242

243+
/** When true, the scene suppresses all input and AI turns for replay use. */
244+
private replayMode: boolean = false;
245+
243246
// Graphics layer for section boxes
244247
private gfx!: Phaser.GameObjects.Graphics;
245248

@@ -361,6 +364,18 @@ export class LostCitiesScene extends Phaser.Scene {
361364
this.selectionHighlight = null;
362365
this.tooltipContainer = null;
363366

367+
// Check for replay mode via URL parameter (?mode=replay)
368+
this.replayMode =
369+
new URLSearchParams(window.location.search).get('mode') === 'replay';
370+
371+
// Event system: create emitter and bridge to Phaser scene events.
372+
// Must be created before help/sound systems and exposed on window
373+
// for the replay tool.
374+
this.gameEvents = new GameEventEmitter();
375+
this.eventBridge = new PhaserEventBridge(this.gameEvents, this.events);
376+
(window as unknown as Record<string, unknown>).__GAME_EVENTS__ =
377+
this.gameEvents;
378+
364379
// Initialize game state
365380
this.session = setupLostCitiesGame({
366381
playerNames: ['You', 'AI'],
@@ -381,12 +396,22 @@ export class LostCitiesScene extends Phaser.Scene {
381396
this.createDiscardZones();
382397
this.createRightColumn();
383398
this.createInstructionBar();
384-
this.createHelpPanel();
385-
this.createSoundSystem();
399+
if (!this.replayMode) {
400+
this.createHelpPanel();
401+
this.createSoundSystem();
402+
}
386403

387404
// Initial render
388405
this.refreshAll();
389-
this.setPhase('waiting-for-card-select');
406+
407+
if (this.replayMode) {
408+
// In replay mode: clear instruction text and emit state-settled
409+
// so the replay tool knows the scene is ready for state injection.
410+
this.instructionText.setText('');
411+
this.emitStateSettled();
412+
} else {
413+
this.setPhase('waiting-for-card-select');
414+
}
390415
}
391416

392417
// ── Section box helpers ─────────────────────────────────
@@ -598,10 +623,6 @@ export class LostCitiesScene extends Phaser.Scene {
598623
}
599624

600625
private createSoundSystem(): void {
601-
// Event system
602-
this.gameEvents = new GameEventEmitter();
603-
this.eventBridge = new PhaserEventBridge(this.gameEvents, this.events);
604-
605626
// Sound system
606627
const phaserSound = this.sound;
607628
const player: SoundPlayer = {
@@ -626,6 +647,127 @@ export class LostCitiesScene extends Phaser.Scene {
626647
this.settingsButton = new SettingsButton(this, this.settingsPanel);
627648
}
628649

650+
// ── Replay API ──────────────────────────────────────────
651+
652+
/**
653+
* Inject an arbitrary board state from transcript snapshot data and
654+
* refresh the visual display. Intended for use by the replay tool
655+
* via `page.evaluate()`.
656+
*
657+
* Only operational in replay mode (?mode=replay). Throws if called
658+
* outside of replay mode.
659+
*
660+
* After updating the internal state and refreshing all sprites,
661+
* emits a `state-settled` event so the caller can synchronize
662+
* screenshot capture.
663+
*
664+
* @param boardStates Per-player board snapshots (hand + expeditions).
665+
* @param tableState Table state (discard tops + draw pile size).
666+
*/
667+
loadBoardState(
668+
boardStates: [
669+
{ hand: Array<{ id: number; color: string; type: string; rank: number; faceUp: boolean }>;
670+
expeditions: Record<string, Array<{ id: number; color: string; type: string; rank: number; faceUp: boolean }>> },
671+
{ hand: Array<{ id: number; color: string; type: string; rank: number; faceUp: boolean }>;
672+
expeditions: Record<string, Array<{ id: number; color: string; type: string; rank: number; faceUp: boolean }>> },
673+
],
674+
tableState: {
675+
discardTops: Record<string, { id: number; color: string; type: string; rank: number; faceUp: boolean } | null>;
676+
drawPileSize: number;
677+
},
678+
): void {
679+
if (!this.replayMode) {
680+
throw new Error(
681+
'loadBoardState() is only available in replay mode (?mode=replay)',
682+
);
683+
}
684+
685+
// Reconstruct each player's hand and expeditions from snapshot data
686+
for (let p = 0; p < 2; p++) {
687+
const snapshot = boardStates[p];
688+
689+
// Rebuild hand
690+
this.session.players[p as 0 | 1].hand = snapshot.hand.map(
691+
(cs) => this.snapshotToCard(cs),
692+
);
693+
694+
// Rebuild expeditions
695+
const expeditions = this.session.players[p as 0 | 1].expeditions;
696+
for (const color of EXPEDITION_COLORS) {
697+
const cards = (snapshot.expeditions[color] ?? []).map(
698+
(cs) => this.snapshotToCard(cs),
699+
);
700+
expeditions.set(color, cards);
701+
}
702+
}
703+
704+
// Rebuild discard piles (only top card available from transcript)
705+
for (const color of EXPEDITION_COLORS) {
706+
const topSnap = tableState.discardTops[color];
707+
if (topSnap) {
708+
const card = this.snapshotToCard(topSnap);
709+
card.faceUp = true;
710+
this.session.round.discardPiles.set(color, [card]);
711+
} else {
712+
this.session.round.discardPiles.set(color, []);
713+
}
714+
}
715+
716+
// Rebuild draw pile (fill with dummy face-down cards since we don't
717+
// have actual draw pile data — only the size is recorded)
718+
this.session.round.drawPile.length = 0;
719+
for (let i = 0; i < tableState.drawPileSize; i++) {
720+
this.session.round.drawPile.push({
721+
id: -1,
722+
color: 'yellow' as ExpeditionColor,
723+
type: 'numbered',
724+
rank: 2 as 2,
725+
faceUp: false,
726+
});
727+
}
728+
729+
// Refresh all visual elements
730+
this.refreshAll();
731+
732+
// Signal that the board is visually stable and ready for screenshot
733+
this.emitStateSettled();
734+
}
735+
736+
/**
737+
* Convert a card snapshot (from the transcript) into a LostCitiesCard.
738+
*/
739+
private snapshotToCard(
740+
cs: { id: number; color: string; type: string; rank: number; faceUp: boolean },
741+
): LostCitiesCard {
742+
if (cs.type === 'investment') {
743+
return {
744+
id: cs.id,
745+
color: cs.color as ExpeditionColor,
746+
type: 'investment',
747+
investmentIndex: cs.rank as 1 | 2 | 3,
748+
faceUp: cs.faceUp,
749+
};
750+
}
751+
return {
752+
id: cs.id,
753+
color: cs.color as ExpeditionColor,
754+
type: 'numbered',
755+
rank: cs.rank as 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10,
756+
faceUp: cs.faceUp,
757+
};
758+
}
759+
760+
/**
761+
* Emit state-settled when the board is visually stable and safe
762+
* to screenshot. Called after animations complete and display is refreshed.
763+
*/
764+
private emitStateSettled(): void {
765+
this.gameEvents.emit('state-settled', {
766+
turnNumber: this.session.round.turnNumber,
767+
phase: this.session.matchPhase === 'playing' ? 'playing' : 'ended',
768+
});
769+
}
770+
629771
// ── Phase management ────────────────────────────────────
630772
private setPhase(phase: SceneTurnPhase): void {
631773
this.turnPhase = phase;

0 commit comments

Comments
 (0)