@@ -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