Skip to content

Commit 2817a8e

Browse files
committed
Add single-player AI mode with client-side heuristic opponent
Rule-based AI runs entirely in-browser (zero server cost): - aiAstrogation: evaluates all 7 burn options per ship, scoring by distance to target, velocity alignment, crash avoidance, combat positioning, and fuel efficiency - aiOrdnance: launches torpedoes at nearby enemies, drops mines - aiCombat: concentrates fire on weakest/closest target, skips bad odds - Local game loop in main.ts bypasses WebSocket for single-player - 19 new AI tests (157 total)
1 parent 5850f61 commit 2817a8e

File tree

5 files changed

+796
-8
lines changed

5 files changed

+796
-8
lines changed

src/client/main.ts

Lines changed: 251 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { GameState, S2C, AstrogationOrder, OrdnanceLaunch, CombatAttack } from '../shared/types';
22
import { canAttack } from '../shared/combat';
3-
import { getSolarSystemMap } from '../shared/map-data';
3+
import { getSolarSystemMap, SCENARIOS, findBaseHex } from '../shared/map-data';
44
import { 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';
57
import { Renderer } from './renderer';
68
import { InputHandler } from './input';
79
import { 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);

src/client/ui.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class UIManager {
1111

1212
// Callbacks
1313
onSelectScenario: ((scenario: string) => void) | null = null;
14+
onSinglePlayer: (() => void) | null = null;
1415
onJoin: ((code: string) => void) | null = null;
1516
onUndo: (() => void) | null = null;
1617
onConfirm: (() => void) | null = null;
@@ -35,6 +36,10 @@ export class UIManager {
3536
this.showScenarioSelect();
3637
});
3738

39+
document.getElementById('singlePlayerBtn')!.addEventListener('click', () => {
40+
this.onSinglePlayer?.();
41+
});
42+
3843
// Scenario buttons
3944
document.querySelectorAll('.btn-scenario').forEach((btn) => {
4045
btn.addEventListener('click', () => {

0 commit comments

Comments
 (0)