11import type { GameState , S2C , AstrogationOrder , OrdnanceLaunch , CombatAttack } from '../shared/types' ;
22import { canAttack } from '../shared/combat' ;
3- import { getSolarSystemMap } from '../shared/map-data' ;
3+ import { getSolarSystemMap , SCENARIOS , findBaseHex } from '../shared/map-data' ;
44import { SHIP_STATS , ORDNANCE_MASS } from '../shared/constants' ;
5+ import { createGame , processAstrogation , processOrdnance , skipOrdnance , processCombat , skipCombat } from '../shared/game-engine' ;
6+ import { aiAstrogation , aiOrdnance , aiCombat } from '../shared/ai' ;
57import { Renderer } from './renderer' ;
68import { InputHandler } from './input' ;
79import { UIManager } from './ui' ;
@@ -22,9 +24,10 @@ class GameClient {
2224 private state : ClientState = 'menu' ;
2325 private ws : WebSocket | null = null ;
2426 private playerId = - 1 ;
25- private gameCode = '' ;
27+ private gameCode : string | null = null ;
2628 private scenario = 'biplanetary' ;
2729 private gameState : GameState | null = null ;
30+ private isLocalGame = false ; // true for single player vs AI
2831
2932 private canvas : HTMLCanvasElement ;
3033 renderer : Renderer ;
@@ -43,6 +46,7 @@ class GameClient {
4346
4447 // Wire UI callbacks
4548 this . ui . onSelectScenario = ( scenario ) => this . createGame ( scenario ) ;
49+ this . ui . onSinglePlayer = ( ) => this . startLocalGame ( 'biplanetary' ) ;
4650 this . ui . onJoin = ( code ) => this . joinGame ( code ) ;
4751 this . ui . onUndo = ( ) => this . undoSelectedShipBurn ( ) ;
4852 this . ui . onConfirm = ( ) => this . confirmOrders ( ) ;
@@ -109,7 +113,7 @@ class GameClient {
109113 break ;
110114
111115 case 'waitingForOpponent' :
112- this . ui . showWaiting ( this . gameCode ) ;
116+ this . ui . showWaiting ( this . gameCode ?? '' ) ;
113117 break ;
114118
115119 case 'playing_astrogation' :
@@ -191,6 +195,25 @@ class GameClient {
191195 }
192196 }
193197
198+ private startLocalGame ( scenario : string ) {
199+ this . isLocalGame = true ;
200+ this . playerId = 0 ;
201+ this . renderer . setPlayerId ( 0 ) ;
202+ this . input . setPlayerId ( 0 ) ;
203+
204+ const scenarioDef = SCENARIOS [ scenario ] ?? SCENARIOS . biplanetary ;
205+ this . gameState = createGame ( scenarioDef , this . map , 'LOCAL' , findBaseHex ) ;
206+ this . renderer . setGameState ( this . gameState ) ;
207+ this . input . setGameState ( this . gameState ) ;
208+
209+ if ( this . gameState . activePlayer === this . playerId ) {
210+ this . setState ( 'playing_astrogation' ) ;
211+ } else {
212+ this . setState ( 'playing_opponentTurn' ) ;
213+ this . runAITurn ( ) ;
214+ }
215+ }
216+
194217 private joinGame ( code : string ) {
195218 this . gameCode = code ;
196219 history . replaceState ( null , '' , `/?code=${ code } ` ) ;
@@ -372,7 +395,11 @@ class GameClient {
372395 }
373396
374397 playConfirm ( ) ;
375- this . send ( { type : 'astrogation' , orders } ) ;
398+ if ( this . isLocalGame ) {
399+ this . localProcessAstrogation ( orders ) ;
400+ } else {
401+ this . send ( { type : 'astrogation' , orders } ) ;
402+ }
376403 }
377404
378405 private onAnimationComplete ( ) {
@@ -397,6 +424,10 @@ class GameClient {
397424 playPhaseChange ( ) ;
398425 } else {
399426 this . setState ( 'playing_opponentTurn' ) ;
427+ // In local game, trigger AI turn
428+ if ( this . isLocalGame && this . gameState . activePlayer !== this . playerId ) {
429+ setTimeout ( ( ) => this . runAITurn ( ) , 500 ) ;
430+ }
400431 }
401432 }
402433
@@ -412,7 +443,11 @@ class GameClient {
412443 if ( attackerIds . length === 0 ) return ;
413444
414445 const attacks : CombatAttack [ ] = [ { attackerIds, targetId } ] ;
415- this . send ( { type : 'combat' , attacks } ) ;
446+ if ( this . isLocalGame ) {
447+ this . localProcessCombat ( attacks ) ;
448+ } else {
449+ this . send ( { type : 'combat' , attacks } ) ;
450+ }
416451 }
417452
418453 private combatWatchInterval : number | null = null ;
@@ -448,31 +483,239 @@ class GameClient {
448483 launch . torpedoAccel = this . renderer . planningState . torpedoAccel ?? null ;
449484 }
450485
451- this . send ( { type : 'ordnance' , launches : [ launch ] } ) ;
486+ if ( this . isLocalGame ) {
487+ this . localProcessOrdnance ( [ launch ] ) ;
488+ } else {
489+ this . send ( { type : 'ordnance' , launches : [ launch ] } ) ;
490+ }
452491 }
453492
454493 private sendSkipOrdnance ( ) {
455494 if ( ! this . gameState || this . state !== 'playing_ordnance' ) return ;
456- this . send ( { type : 'skipOrdnance' } ) ;
495+ if ( this . isLocalGame ) {
496+ this . localSkipOrdnance ( ) ;
497+ } else {
498+ this . send ( { type : 'skipOrdnance' } ) ;
499+ }
457500 }
458501
459502 private sendSkipCombat ( ) {
460503 if ( ! this . gameState || this . state !== 'playing_combat' ) return ;
461- this . send ( { type : 'skipCombat' } ) ;
504+ if ( this . isLocalGame ) {
505+ this . localSkipCombat ( ) ;
506+ } else {
507+ this . send ( { type : 'skipCombat' } ) ;
508+ }
462509 }
463510
464511 private sendRematch ( ) {
512+ if ( this . isLocalGame ) {
513+ this . startLocalGame ( this . scenario ) ;
514+ return ;
515+ }
465516 this . send ( { type : 'rematch' } ) ;
466517 }
467518
468519 private exitToMenu ( ) {
469520 this . ws ?. close ( ) ;
470521 this . ws = null ;
471522 this . gameState = null ;
523+ this . isLocalGame = false ;
472524 history . replaceState ( null , '' , '/' ) ;
473525 this . setState ( 'menu' ) ;
474526 }
475527
528+ // --- Local game (single player) ---
529+
530+ private localProcessAstrogation ( orders : AstrogationOrder [ ] ) {
531+ if ( ! this . gameState ) return ;
532+ const result = processAstrogation ( this . gameState , this . playerId , orders , this . map ) ;
533+ if ( 'error' in result ) {
534+ console . error ( 'Local astrogation error:' , result . error ) ;
535+ return ;
536+ }
537+ this . gameState = result . state ;
538+ this . renderer . setGameState ( this . gameState ) ;
539+ this . input . setGameState ( this . gameState ) ;
540+ this . setState ( 'playing_movementAnim' ) ;
541+ playThrust ( ) ;
542+ if ( result . events . length > 0 ) {
543+ this . renderer . showMovementEvents ( result . events ) ;
544+ if ( result . events . some ( e => e . damageType === 'eliminated' || e . type === 'crash' ) ) {
545+ setTimeout ( ( ) => playExplosion ( ) , 500 ) ;
546+ }
547+ }
548+ this . renderer . animateMovements ( result . movements , result . ordnanceMovements , ( ) => {
549+ this . localCheckGameEnd ( ) ;
550+ this . onAnimationComplete ( ) ;
551+ } ) ;
552+ }
553+
554+ private localProcessOrdnance ( launches : OrdnanceLaunch [ ] ) {
555+ if ( ! this . gameState ) return ;
556+ const result = processOrdnance ( this . gameState , this . playerId , launches , this . map ) ;
557+ if ( 'error' in result ) {
558+ console . error ( 'Local ordnance error:' , result . error ) ;
559+ return ;
560+ }
561+ this . gameState = result . state ;
562+ this . renderer . setGameState ( this . gameState ) ;
563+ this . input . setGameState ( this . gameState ) ;
564+ this . localCheckGameEnd ( ) ;
565+ this . transitionToPhase ( ) ;
566+ }
567+
568+ private localSkipOrdnance ( ) {
569+ if ( ! this . gameState ) return ;
570+ const result = skipOrdnance ( this . gameState , this . playerId ) ;
571+ if ( 'error' in result ) return ;
572+ this . gameState = result . state ;
573+ this . renderer . setGameState ( this . gameState ) ;
574+ this . input . setGameState ( this . gameState ) ;
575+ this . transitionToPhase ( ) ;
576+ }
577+
578+ private localProcessCombat ( attacks : CombatAttack [ ] ) {
579+ if ( ! this . gameState ) return ;
580+ const result = processCombat ( this . gameState , this . playerId , attacks , this . map ) ;
581+ if ( 'error' in result ) return ;
582+ this . gameState = result . state ;
583+ this . renderer . setGameState ( this . gameState ) ;
584+ this . input . setGameState ( this . gameState ) ;
585+ this . renderer . showCombatResults ( result . results ) ;
586+ this . renderer . planningState . combatTargetId = null ;
587+ playCombat ( ) ;
588+ if ( result . results . some ( r => r . damageType === 'eliminated' ) ) {
589+ setTimeout ( ( ) => playExplosion ( ) , 300 ) ;
590+ }
591+ this . localCheckGameEnd ( ) ;
592+ this . transitionToPhase ( ) ;
593+ }
594+
595+ private localSkipCombat ( ) {
596+ if ( ! this . gameState ) return ;
597+ const result = skipCombat ( this . gameState , this . playerId , this . map ) ;
598+ if ( 'error' in result ) return ;
599+ this . gameState = result . state ;
600+ this . renderer . setGameState ( this . gameState ) ;
601+ this . input . setGameState ( this . gameState ) ;
602+ if ( result . baseDefenseResults && result . baseDefenseResults . length > 0 ) {
603+ this . renderer . showCombatResults ( result . baseDefenseResults ) ;
604+ }
605+ this . localCheckGameEnd ( ) ;
606+ this . transitionToPhase ( ) ;
607+ }
608+
609+ private localCheckGameEnd ( ) {
610+ if ( ! this . gameState || this . gameState . phase !== 'gameOver' ) return ;
611+ this . setState ( 'gameOver' ) ;
612+ this . ui . showGameOver (
613+ this . gameState . winner === this . playerId ,
614+ this . gameState . winReason ?? '' ,
615+ ) ;
616+ if ( this . gameState . winner === this . playerId ) {
617+ playVictory ( ) ;
618+ } else {
619+ playDefeat ( ) ;
620+ }
621+ }
622+
623+ private runAITurn ( ) {
624+ if ( ! this . gameState || this . gameState . phase === 'gameOver' ) return ;
625+ const aiPlayer = this . gameState . activePlayer ;
626+ if ( aiPlayer === this . playerId ) return ; // Not AI's turn
627+
628+ // Astrogation phase
629+ if ( this . gameState . phase === 'astrogation' ) {
630+ const orders = aiAstrogation ( this . gameState , aiPlayer , this . map ) ;
631+ const result = processAstrogation ( this . gameState , aiPlayer , orders , this . map ) ;
632+ if ( 'error' in result ) {
633+ console . error ( 'AI astrogation error:' , result . error ) ;
634+ return ;
635+ }
636+ this . gameState = result . state ;
637+ this . renderer . setGameState ( this . gameState ) ;
638+ this . input . setGameState ( this . gameState ) ;
639+ this . setState ( 'playing_movementAnim' ) ;
640+ playThrust ( ) ;
641+ if ( result . events . length > 0 ) {
642+ this . renderer . showMovementEvents ( result . events ) ;
643+ if ( result . events . some ( e => e . damageType === 'eliminated' || e . type === 'crash' ) ) {
644+ setTimeout ( ( ) => playExplosion ( ) , 500 ) ;
645+ }
646+ }
647+ this . renderer . animateMovements ( result . movements , result . ordnanceMovements , ( ) => {
648+ this . localCheckGameEnd ( ) ;
649+ this . continueAIAfterAstrogation ( aiPlayer ) ;
650+ } ) ;
651+ return ;
652+ }
653+
654+ // If we get here with ordnance/combat phase, process them directly
655+ this . processAIPhases ( aiPlayer ) ;
656+ }
657+
658+ private continueAIAfterAstrogation ( aiPlayer : number ) {
659+ if ( ! this . gameState || this . gameState . phase === 'gameOver' ) return ;
660+ // Process ordnance and combat phases for AI
661+ this . processAIPhases ( aiPlayer ) ;
662+ }
663+
664+ private processAIPhases ( aiPlayer : number ) {
665+ if ( ! this . gameState || this . gameState . phase === 'gameOver' ) return ;
666+
667+ // Ordnance phase
668+ if ( this . gameState . phase === 'ordnance' && this . gameState . activePlayer === aiPlayer ) {
669+ const launches = aiOrdnance ( this . gameState , aiPlayer , this . map ) ;
670+ if ( launches . length > 0 ) {
671+ const result = processOrdnance ( this . gameState , aiPlayer , launches , this . map ) ;
672+ if ( ! ( 'error' in result ) ) {
673+ this . gameState = result . state ;
674+ }
675+ } else {
676+ const result = skipOrdnance ( this . gameState , aiPlayer ) ;
677+ if ( ! ( 'error' in result ) ) {
678+ this . gameState = result . state ;
679+ }
680+ }
681+ this . renderer . setGameState ( this . gameState ) ;
682+ this . input . setGameState ( this . gameState ) ;
683+ }
684+
685+ // Combat phase
686+ if ( this . gameState . phase === 'combat' && this . gameState . activePlayer === aiPlayer ) {
687+ const attacks = aiCombat ( this . gameState , aiPlayer ) ;
688+ if ( attacks . length > 0 ) {
689+ const result = processCombat ( this . gameState , aiPlayer , attacks , this . map ) ;
690+ if ( ! ( 'error' in result ) ) {
691+ this . gameState = result . state ;
692+ this . renderer . showCombatResults ( result . results ) ;
693+ playCombat ( ) ;
694+ if ( result . results . some ( r => r . damageType === 'eliminated' ) ) {
695+ setTimeout ( ( ) => playExplosion ( ) , 300 ) ;
696+ }
697+ }
698+ } else {
699+ const result = skipCombat ( this . gameState , aiPlayer , this . map ) ;
700+ if ( ! ( 'error' in result ) ) {
701+ this . gameState = result . state ;
702+ if ( result . baseDefenseResults && result . baseDefenseResults . length > 0 ) {
703+ this . renderer . showCombatResults ( result . baseDefenseResults ) ;
704+ }
705+ }
706+ }
707+ this . renderer . setGameState ( this . gameState ) ;
708+ this . input . setGameState ( this . gameState ) ;
709+ }
710+
711+ this . localCheckGameEnd ( ) ;
712+
713+ // Transition to the next phase (should be player's turn now)
714+ if ( this . gameState . phase !== 'gameOver' ) {
715+ this . transitionToPhase ( ) ;
716+ }
717+ }
718+
476719 private cycleShip ( direction : number ) {
477720 if ( ! this . gameState ) return ;
478721 const myShips = this . gameState . ships . filter ( s => s . owner === this . playerId && ! s . destroyed ) ;
0 commit comments