Skip to content

Commit a83dfe3

Browse files
committed
Decompose game-engine.ts into focused modules
Extract 4 modules from the 1957-line game-engine.ts: - engine-util.ts: shared predicates and helpers - engine-victory.ts: victory conditions, turn advancement, detection - engine-ordnance.ts: ordnance movement, detonation, emplacement - engine-combat.ts: combat phase orchestration game-engine.ts is now a ~530-line orchestrator with re-exports for backward compatibility (no test import changes needed). Add map-data.test.ts covering map builder, body gravity, base placement, and all scenario definitions. Add engine-ordnance.test.ts covering processEmplacement validation and success paths. 493 tests pass (up from 456), all simulations clean.
1 parent fc79be3 commit a83dfe3

File tree

7 files changed

+1649
-1307
lines changed

7 files changed

+1649
-1307
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { processEmplacement } from '../engine-ordnance';
3+
import { createGame } from '../game-engine';
4+
import { buildSolarSystemMap, SCENARIOS, findBaseHex } from '../map-data';
5+
import { hexKey, hexVecLength } from '../hex';
6+
import { ORBITAL_BASE_MASS } from '../constants';
7+
import type { GameState, SolarSystemMap, Ship } from '../types';
8+
9+
let map: SolarSystemMap;
10+
11+
function createConvoyGame(): GameState {
12+
return createGame(SCENARIOS.convoy, map, 'TEST', findBaseHex);
13+
}
14+
15+
function makeTransportWithBase(state: GameState, playerId: number, position: { q: number; r: number }, velocity: { dq: number; dr: number }): Ship {
16+
const ship: Ship = {
17+
id: `test-transport-${state.ships.length}`,
18+
type: 'transport',
19+
owner: playerId,
20+
position: { ...position },
21+
velocity: { ...velocity },
22+
fuel: 10,
23+
cargoUsed: ORBITAL_BASE_MASS,
24+
resuppliedThisTurn: false,
25+
landed: false,
26+
destroyed: false,
27+
detected: true,
28+
carryingOrbitalBase: true,
29+
pendingGravityEffects: [],
30+
damage: { disabledTurns: 0 },
31+
};
32+
state.ships.push(ship);
33+
return ship;
34+
}
35+
36+
beforeEach(() => {
37+
map = buildSolarSystemMap();
38+
});
39+
40+
describe('processEmplacement', () => {
41+
it('rejects emplacement outside ordnance phase', () => {
42+
const state = createConvoyGame();
43+
state.phase = 'astrogation';
44+
const ship = makeTransportWithBase(state, 0, { q: -9, r: -6 }, { dq: 0, dr: 0 });
45+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
46+
expect('error' in result).toBe(true);
47+
});
48+
49+
it('rejects emplacement from wrong player', () => {
50+
const state = createConvoyGame();
51+
state.phase = 'ordnance';
52+
state.activePlayer = 0;
53+
const ship = makeTransportWithBase(state, 0, { q: -9, r: -6 }, { dq: 0, dr: 0 });
54+
const result = processEmplacement(state, 1, [{ shipId: ship.id }], map);
55+
expect('error' in result).toBe(true);
56+
});
57+
58+
it('rejects ship not carrying orbital base', () => {
59+
const state = createConvoyGame();
60+
state.phase = 'ordnance';
61+
state.activePlayer = 0;
62+
const ship = makeTransportWithBase(state, 0, { q: -9, r: -6 }, { dq: 0, dr: 0 });
63+
ship.carryingOrbitalBase = false;
64+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
65+
expect('error' in result).toBe(true);
66+
});
67+
68+
it('rejects emplacement when ship is not in orbit (speed !== 1)', () => {
69+
const state = createConvoyGame();
70+
state.phase = 'ordnance';
71+
state.activePlayer = 0;
72+
// Place at a gravity hex but with speed 0 (not orbiting)
73+
const marsGravityHex = { q: -9, r: -6 }; // Mars gravity ring
74+
const ship = makeTransportWithBase(state, 0, marsGravityHex, { dq: 0, dr: 0 });
75+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
76+
expect('error' in result).toBe(true);
77+
});
78+
79+
it('successfully emplaces orbital base when ship is in orbit (speed 1)', () => {
80+
const state = createConvoyGame();
81+
state.phase = 'ordnance';
82+
state.activePlayer = 0;
83+
// Place at a gravity hex with speed 1 (orbiting)
84+
const marsGravityHex = { q: -9, r: -6 };
85+
const ship = makeTransportWithBase(state, 0, marsGravityHex, { dq: 1, dr: 0 });
86+
const shipsBefore = state.ships.length;
87+
88+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
89+
expect('error' in result).toBe(false);
90+
expect(state.ships.length).toBe(shipsBefore + 1);
91+
92+
const base = state.ships[state.ships.length - 1];
93+
expect(base.type).toBe('orbitalBase');
94+
expect(base.owner).toBe(0);
95+
expect(base.emplaced).toBe(true);
96+
expect(base.position).toEqual(marsGravityHex);
97+
expect(base.velocity).toEqual({ dq: 1, dr: 0 });
98+
});
99+
100+
it('clears carryingOrbitalBase and reduces cargo after emplacement', () => {
101+
const state = createConvoyGame();
102+
state.phase = 'ordnance';
103+
state.activePlayer = 0;
104+
const marsGravityHex = { q: -9, r: -6 };
105+
const ship = makeTransportWithBase(state, 0, marsGravityHex, { dq: 1, dr: 0 });
106+
ship.cargoUsed = ORBITAL_BASE_MASS + 10;
107+
108+
processEmplacement(state, 0, [{ shipId: ship.id }], map);
109+
expect(ship.carryingOrbitalBase).toBe(false);
110+
expect(ship.cargoUsed).toBe(10);
111+
});
112+
113+
it('rejects emplacement by a destroyed ship', () => {
114+
const state = createConvoyGame();
115+
state.phase = 'ordnance';
116+
state.activePlayer = 0;
117+
const ship = makeTransportWithBase(state, 0, { q: -9, r: -6 }, { dq: 1, dr: 0 });
118+
ship.destroyed = true;
119+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
120+
expect('error' in result).toBe(true);
121+
});
122+
123+
it('rejects emplacement during resupply turn', () => {
124+
const state = createConvoyGame();
125+
state.phase = 'ordnance';
126+
state.activePlayer = 0;
127+
const ship = makeTransportWithBase(state, 0, { q: -9, r: -6 }, { dq: 1, dr: 0 });
128+
ship.resuppliedThisTurn = true;
129+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
130+
expect('error' in result).toBe(true);
131+
});
132+
133+
it('allows emplacement when ship is landed on a world hex', () => {
134+
const state = createConvoyGame();
135+
state.phase = 'ordnance';
136+
state.activePlayer = 0;
137+
// Mars surface hex in gravity field, landed
138+
const marsGravityHex = { q: -9, r: -6 };
139+
const ship = makeTransportWithBase(state, 0, marsGravityHex, { dq: 0, dr: 0 });
140+
ship.landed = true;
141+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
142+
expect('error' in result).toBe(false);
143+
});
144+
145+
it('rejects non-transport/packet ship types', () => {
146+
const state = createConvoyGame();
147+
state.phase = 'ordnance';
148+
state.activePlayer = 0;
149+
const ship = makeTransportWithBase(state, 0, { q: -9, r: -6 }, { dq: 1, dr: 0 });
150+
ship.type = 'corvette';
151+
const result = processEmplacement(state, 0, [{ shipId: ship.id }], map);
152+
expect('error' in result).toBe(true);
153+
});
154+
});
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { buildSolarSystemMap, SCENARIOS, findBaseHex, findBaseHexes, bodyHasGravity } from '../map-data';
3+
import { hexKey } from '../hex';
4+
import type { SolarSystemMap } from '../types';
5+
6+
let map: SolarSystemMap;
7+
8+
beforeEach(() => {
9+
map = buildSolarSystemMap();
10+
});
11+
12+
describe('buildSolarSystemMap', () => {
13+
it('produces a non-empty hex map', () => {
14+
expect(map.hexes.size).toBeGreaterThan(0);
15+
});
16+
17+
it('has valid bounds', () => {
18+
expect(map.bounds.minQ).toBeLessThan(map.bounds.maxQ);
19+
expect(map.bounds.minR).toBeLessThan(map.bounds.maxR);
20+
});
21+
22+
it('includes all expected celestial bodies', () => {
23+
const names = map.bodies.map(b => b.name);
24+
expect(names).toContain('Sol');
25+
expect(names).toContain('Mercury');
26+
expect(names).toContain('Venus');
27+
expect(names).toContain('Terra');
28+
expect(names).toContain('Luna');
29+
expect(names).toContain('Mars');
30+
expect(names).toContain('Ceres');
31+
expect(names).toContain('Jupiter');
32+
expect(names).toContain('Io');
33+
expect(names).toContain('Callisto');
34+
expect(names).toContain('Ganymede');
35+
expect(names.length).toBe(11);
36+
});
37+
38+
it('marks Sol surface as destructive', () => {
39+
const solCenter = map.bodies.find(b => b.name === 'Sol')!.center;
40+
const hex = map.hexes.get(hexKey(solCenter))!;
41+
expect(hex.terrain).toBe('sunSurface');
42+
expect(hex.body?.destructive).toBe(true);
43+
});
44+
45+
it('marks planet surfaces as non-destructive', () => {
46+
const mars = map.bodies.find(b => b.name === 'Mars')!;
47+
const hex = map.hexes.get(hexKey(mars.center))!;
48+
expect(hex.terrain).toBe('planetSurface');
49+
expect(hex.body?.destructive).toBe(false);
50+
});
51+
52+
it('includes asteroid hexes', () => {
53+
const asteroids = [...map.hexes.entries()].filter(([, h]) => h.terrain === 'asteroid');
54+
expect(asteroids.length).toBeGreaterThan(10);
55+
});
56+
});
57+
58+
describe('bodyHasGravity', () => {
59+
it('returns true for bodies with gravity rings', () => {
60+
expect(bodyHasGravity('Sol', map)).toBe(true);
61+
expect(bodyHasGravity('Mercury', map)).toBe(true);
62+
expect(bodyHasGravity('Venus', map)).toBe(true);
63+
expect(bodyHasGravity('Terra', map)).toBe(true);
64+
expect(bodyHasGravity('Mars', map)).toBe(true);
65+
expect(bodyHasGravity('Jupiter', map)).toBe(true);
66+
});
67+
68+
it('returns true for moons with weak gravity', () => {
69+
expect(bodyHasGravity('Luna', map)).toBe(true);
70+
expect(bodyHasGravity('Io', map)).toBe(true);
71+
expect(bodyHasGravity('Callisto', map)).toBe(true);
72+
expect(bodyHasGravity('Ganymede', map)).toBe(true);
73+
});
74+
75+
it('returns false for Ceres (no gravity rings)', () => {
76+
expect(bodyHasGravity('Ceres', map)).toBe(false);
77+
});
78+
79+
it('returns false for non-existent bodies', () => {
80+
expect(bodyHasGravity('Pluto', map)).toBe(false);
81+
});
82+
});
83+
84+
describe('findBaseHex / findBaseHexes', () => {
85+
it('finds bases for Mercury (2 bases)', () => {
86+
const bases = findBaseHexes(map, 'Mercury');
87+
expect(bases.length).toBe(2);
88+
});
89+
90+
it('finds bases for Venus (6 bases)', () => {
91+
const bases = findBaseHexes(map, 'Venus');
92+
expect(bases.length).toBe(6);
93+
});
94+
95+
it('finds bases for Terra (6 bases)', () => {
96+
const bases = findBaseHexes(map, 'Terra');
97+
expect(bases.length).toBe(6);
98+
});
99+
100+
it('finds bases for Luna (6 bases)', () => {
101+
const bases = findBaseHexes(map, 'Luna');
102+
expect(bases.length).toBe(6);
103+
});
104+
105+
it('finds bases for Mars (6 bases)', () => {
106+
const bases = findBaseHexes(map, 'Mars');
107+
expect(bases.length).toBe(6);
108+
});
109+
110+
it('finds a single base for Ceres', () => {
111+
const bases = findBaseHexes(map, 'Ceres');
112+
expect(bases.length).toBe(1);
113+
});
114+
115+
it('finds a single base for Io', () => {
116+
const bases = findBaseHexes(map, 'Io');
117+
expect(bases.length).toBe(1);
118+
});
119+
120+
it('finds a single base for Callisto', () => {
121+
const bases = findBaseHexes(map, 'Callisto');
122+
expect(bases.length).toBe(1);
123+
});
124+
125+
it('returns no bases for Jupiter (no base directions)', () => {
126+
const bases = findBaseHexes(map, 'Jupiter');
127+
expect(bases.length).toBe(0);
128+
});
129+
130+
it('returns no bases for Ganymede (no base directions)', () => {
131+
const bases = findBaseHexes(map, 'Ganymede');
132+
expect(bases.length).toBe(0);
133+
});
134+
135+
it('findBaseHex returns first base or null', () => {
136+
expect(findBaseHex(map, 'Mars')).not.toBeNull();
137+
expect(findBaseHex(map, 'Jupiter')).toBeNull();
138+
});
139+
140+
it('base hexes are on gravity rings, not surfaces', () => {
141+
for (const body of ['Venus', 'Terra', 'Mars', 'Mercury']) {
142+
const bases = findBaseHexes(map, body);
143+
for (const base of bases) {
144+
const hex = map.hexes.get(hexKey(base))!;
145+
expect(hex.terrain).not.toBe('planetSurface');
146+
expect(hex.base).toBeDefined();
147+
expect(hex.base!.bodyName).toBe(body);
148+
}
149+
}
150+
});
151+
});
152+
153+
describe('SCENARIOS', () => {
154+
it('all scenarios have valid player definitions', () => {
155+
for (const [name, scenario] of Object.entries(SCENARIOS)) {
156+
expect(scenario.players.length).toBeGreaterThanOrEqual(2);
157+
expect(scenario.name).toBeTruthy();
158+
expect(scenario.description).toBeTruthy();
159+
}
160+
});
161+
162+
it('biplanetary has 2 corvettes with target bodies', () => {
163+
const s = SCENARIOS.biplanetary;
164+
expect(s.players[0].ships.length).toBe(1);
165+
expect(s.players[0].ships[0].type).toBe('corvette');
166+
expect(s.players[0].targetBody).toBe('Venus');
167+
expect(s.players[1].ships.length).toBe(1);
168+
expect(s.players[1].ships[0].type).toBe('corvette');
169+
expect(s.players[1].targetBody).toBe('Mars');
170+
});
171+
172+
it('escape has 3 transports vs 2 enforcers', () => {
173+
const s = SCENARIOS.escape;
174+
expect(s.players[0].ships.length).toBe(3);
175+
expect(s.players[0].ships.every(sh => sh.type === 'transport')).toBe(true);
176+
expect(s.players[0].escapeWins).toBe(true);
177+
expect(s.players[1].ships.length).toBe(2);
178+
expect(s.rules?.hiddenIdentityInspection).toBe(true);
179+
expect(s.rules?.escapeEdge).toBe('north');
180+
});
181+
182+
it('fleet-building scenarios have startingCredits and empty ship lists', () => {
183+
for (const name of ['interplanetaryWar', 'fleetAction']) {
184+
const s = SCENARIOS[name];
185+
expect(s.startingCredits).toBeDefined();
186+
expect(s.availableShipTypes).toBeDefined();
187+
expect(s.availableShipTypes!.length).toBeGreaterThan(0);
188+
for (const p of s.players) {
189+
expect(p.ships.length).toBe(0);
190+
}
191+
}
192+
});
193+
194+
it('convoy has a tanker with frigate escort', () => {
195+
const s = SCENARIOS.convoy;
196+
const shipTypes = s.players[0].ships.map(sh => sh.type);
197+
expect(shipTypes).toContain('tanker');
198+
expect(shipTypes).toContain('frigate');
199+
});
200+
});

0 commit comments

Comments
 (0)