Skip to content

Commit cfe4b7f

Browse files
committed
Extract client AI phase planning helpers
1 parent 1cad36f commit cfe4b7f

File tree

3 files changed

+340
-62
lines changed

3 files changed

+340
-62
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { buildSolarSystemMap } from '../shared/map-data';
4+
import type {
5+
AstrogationOrder,
6+
CombatAttack,
7+
GameState,
8+
OrdnanceLaunch,
9+
PlayerState,
10+
Ship,
11+
} from '../shared/types';
12+
import { deriveAIActionPlan } from './game-client-ai-flow';
13+
14+
function createShip(overrides: Partial<Ship> = {}): Ship {
15+
return {
16+
id: 'ship-1',
17+
type: 'packet',
18+
owner: 1,
19+
position: { q: 0, r: 0 },
20+
velocity: { dq: 0, dr: 0 },
21+
fuel: 10,
22+
cargoUsed: 0,
23+
nukesLaunchedSinceResupply: 0,
24+
resuppliedThisTurn: false,
25+
landed: false,
26+
destroyed: false,
27+
detected: true,
28+
damage: { disabledTurns: 0 },
29+
...overrides,
30+
};
31+
}
32+
33+
function createPlayers(): [PlayerState, PlayerState] {
34+
return [
35+
{ connected: true, ready: true, targetBody: 'Mars', homeBody: 'Terra', bases: [], escapeWins: false },
36+
{ connected: true, ready: true, targetBody: 'Terra', homeBody: 'Mars', bases: [], escapeWins: false },
37+
];
38+
}
39+
40+
function createState(overrides: Partial<GameState> = {}): GameState {
41+
return {
42+
gameId: 'AI',
43+
scenario: 'test',
44+
scenarioRules: {},
45+
escapeMoralVictoryAchieved: false,
46+
turnNumber: 2,
47+
phase: 'astrogation',
48+
activePlayer: 1,
49+
ships: [
50+
createShip({ id: 'player-ship', owner: 0, position: { q: 3, r: 0 } }),
51+
createShip({ id: 'ai-ship', owner: 1 }),
52+
],
53+
ordnance: [],
54+
pendingAstrogationOrders: null,
55+
pendingAsteroidHazards: [],
56+
destroyedAsteroids: [],
57+
destroyedBases: [],
58+
players: createPlayers(),
59+
winner: null,
60+
winReason: null,
61+
...overrides,
62+
};
63+
}
64+
65+
describe('game-client-ai-flow', () => {
66+
it('returns none when there is no active AI turn', () => {
67+
const map = buildSolarSystemMap();
68+
69+
expect(deriveAIActionPlan(null, 0, map, 'normal')).toEqual({ kind: 'none' });
70+
expect(deriveAIActionPlan(createState({ activePlayer: 0 }), 0, map, 'normal')).toEqual({ kind: 'none' });
71+
expect(deriveAIActionPlan(createState({ phase: 'gameOver' }), 0, map, 'normal')).toEqual({ kind: 'none' });
72+
});
73+
74+
it('derives astrogation actions from the injected generator', () => {
75+
const map = buildSolarSystemMap();
76+
const orders: AstrogationOrder[] = [{ shipId: 'ai-ship', burn: 2 }];
77+
const astrogation = vi.fn(() => orders);
78+
79+
expect(deriveAIActionPlan(createState({ phase: 'astrogation' }), 0, map, 'normal', {
80+
astrogation,
81+
ordnance: vi.fn(() => []),
82+
combat: vi.fn(() => []),
83+
})).toEqual({
84+
kind: 'astrogation',
85+
aiPlayer: 1,
86+
orders,
87+
errorPrefix: 'AI astrogation error:',
88+
});
89+
});
90+
91+
it('derives ordnance actions, skip behavior, and log entries', () => {
92+
const map = buildSolarSystemMap();
93+
const launches: OrdnanceLaunch[] = [{ shipId: 'ai-ship', ordnanceType: 'mine' }];
94+
95+
expect(deriveAIActionPlan(createState({ phase: 'ordnance' }), 0, map, 'normal', {
96+
astrogation: vi.fn(() => []),
97+
ordnance: vi.fn(() => launches),
98+
combat: vi.fn(() => []),
99+
})).toEqual({
100+
kind: 'ordnance',
101+
aiPlayer: 1,
102+
launches,
103+
logEntries: ['AI: Packet launched mine'],
104+
skip: false,
105+
errorPrefix: 'AI ordnance error:',
106+
});
107+
108+
expect(deriveAIActionPlan(createState({ phase: 'ordnance' }), 0, map, 'normal', {
109+
astrogation: vi.fn(() => []),
110+
ordnance: vi.fn(() => []),
111+
combat: vi.fn(() => []),
112+
})).toEqual({
113+
kind: 'ordnance',
114+
aiPlayer: 1,
115+
launches: [],
116+
logEntries: [],
117+
skip: true,
118+
errorPrefix: 'AI skip ordnance error:',
119+
});
120+
});
121+
122+
it('derives combat actions, including pending-hazard start and skip paths', () => {
123+
const map = buildSolarSystemMap();
124+
const attacks: CombatAttack[] = [{ attackerIds: ['ai-ship'], targetId: 'player-ship' }];
125+
126+
expect(deriveAIActionPlan(createState({
127+
phase: 'combat',
128+
pendingAsteroidHazards: [{ shipId: 'ai-ship', hex: { q: 0, r: 0 } }],
129+
}), 0, map, 'normal')).toEqual({
130+
kind: 'beginCombat',
131+
aiPlayer: 1,
132+
errorPrefix: 'AI combat start error:',
133+
});
134+
135+
expect(deriveAIActionPlan(createState({ phase: 'combat' }), 0, map, 'normal', {
136+
astrogation: vi.fn(() => []),
137+
ordnance: vi.fn(() => []),
138+
combat: vi.fn(() => attacks),
139+
})).toEqual({
140+
kind: 'combat',
141+
aiPlayer: 1,
142+
attacks,
143+
skip: false,
144+
errorPrefix: 'AI combat error:',
145+
});
146+
147+
expect(deriveAIActionPlan(createState({ phase: 'combat' }), 0, map, 'normal', {
148+
astrogation: vi.fn(() => []),
149+
ordnance: vi.fn(() => []),
150+
combat: vi.fn(() => []),
151+
})).toEqual({
152+
kind: 'combat',
153+
aiPlayer: 1,
154+
attacks: [],
155+
skip: true,
156+
errorPrefix: 'AI skip combat error:',
157+
});
158+
});
159+
160+
it('falls back to transition for non-action AI phases', () => {
161+
const map = buildSolarSystemMap();
162+
163+
expect(deriveAIActionPlan(createState({ phase: 'resupply' }), 0, map, 'normal')).toEqual({
164+
kind: 'transition',
165+
aiPlayer: 1,
166+
});
167+
});
168+
});

src/client/game-client-ai-flow.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { aiAstrogation, aiCombat, aiOrdnance, type AIDifficulty } from '../shared/ai';
2+
import { SHIP_STATS } from '../shared/constants';
3+
import { filterStateForPlayer } from '../shared/game-engine';
4+
import type {
5+
AstrogationOrder,
6+
CombatAttack,
7+
GameState,
8+
OrdnanceLaunch,
9+
SolarSystemMap,
10+
} from '../shared/types';
11+
import { hasOwnedPendingAsteroidHazards } from './game-client-local';
12+
13+
export interface AIDecisionGenerators {
14+
astrogation: typeof aiAstrogation;
15+
ordnance: typeof aiOrdnance;
16+
combat: typeof aiCombat;
17+
}
18+
19+
export type AIActionPlan =
20+
| { kind: 'none' }
21+
| {
22+
kind: 'astrogation';
23+
aiPlayer: number;
24+
orders: AstrogationOrder[];
25+
errorPrefix: 'AI astrogation error:';
26+
}
27+
| {
28+
kind: 'ordnance';
29+
aiPlayer: number;
30+
launches: OrdnanceLaunch[];
31+
logEntries: string[];
32+
skip: boolean;
33+
errorPrefix: 'AI ordnance error:' | 'AI skip ordnance error:';
34+
}
35+
| {
36+
kind: 'beginCombat';
37+
aiPlayer: number;
38+
errorPrefix: 'AI combat start error:';
39+
}
40+
| {
41+
kind: 'combat';
42+
aiPlayer: number;
43+
attacks: CombatAttack[];
44+
skip: boolean;
45+
errorPrefix: 'AI combat error:' | 'AI skip combat error:';
46+
}
47+
| {
48+
kind: 'transition';
49+
aiPlayer: number;
50+
};
51+
52+
function buildAIOrdnanceLogEntries(state: GameState, launches: OrdnanceLaunch[]): string[] {
53+
return launches.map((launch) => {
54+
const ship = state.ships.find((candidate) => candidate.id === launch.shipId);
55+
const name = ship ? (SHIP_STATS[ship.type]?.name ?? ship.type) : launch.shipId;
56+
return `AI: ${name} launched ${launch.ordnanceType}`;
57+
});
58+
}
59+
60+
export function deriveAIActionPlan(
61+
state: GameState | null,
62+
playerId: number,
63+
map: SolarSystemMap,
64+
difficulty: AIDifficulty,
65+
generators: AIDecisionGenerators = {
66+
astrogation: aiAstrogation,
67+
ordnance: aiOrdnance,
68+
combat: aiCombat,
69+
},
70+
): AIActionPlan {
71+
if (!state || state.phase === 'gameOver') {
72+
return { kind: 'none' };
73+
}
74+
75+
const aiPlayer = state.activePlayer;
76+
if (aiPlayer === playerId) {
77+
return { kind: 'none' };
78+
}
79+
80+
if (state.phase === 'astrogation') {
81+
return {
82+
kind: 'astrogation',
83+
aiPlayer,
84+
orders: generators.astrogation(filterStateForPlayer(state, aiPlayer), aiPlayer, map, difficulty),
85+
errorPrefix: 'AI astrogation error:',
86+
};
87+
}
88+
89+
if (state.phase === 'ordnance') {
90+
const launches = generators.ordnance(filterStateForPlayer(state, aiPlayer), aiPlayer, map, difficulty);
91+
return {
92+
kind: 'ordnance',
93+
aiPlayer,
94+
launches,
95+
logEntries: buildAIOrdnanceLogEntries(state, launches),
96+
skip: launches.length === 0,
97+
errorPrefix: launches.length > 0 ? 'AI ordnance error:' : 'AI skip ordnance error:',
98+
};
99+
}
100+
101+
if (state.phase === 'combat') {
102+
if (hasOwnedPendingAsteroidHazards(state, aiPlayer)) {
103+
return {
104+
kind: 'beginCombat',
105+
aiPlayer,
106+
errorPrefix: 'AI combat start error:',
107+
};
108+
}
109+
110+
const attacks = generators.combat(filterStateForPlayer(state, aiPlayer), aiPlayer, map, difficulty);
111+
return {
112+
kind: 'combat',
113+
aiPlayer,
114+
attacks,
115+
skip: attacks.length === 0,
116+
errorPrefix: attacks.length > 0 ? 'AI combat error:' : 'AI skip combat error:',
117+
};
118+
}
119+
120+
return {
121+
kind: 'transition',
122+
aiPlayer,
123+
};
124+
}

0 commit comments

Comments
 (0)