Skip to content

Commit 0ad2f84

Browse files
committed
Add Grand Tour race scenario
Checkpoint-based race where each player's corvette must pass through gravity hexes of all 8 major bodies (Sol, Mercury, Venus, Terra, Mars, Jupiter, Io, Callisto) and return home. No combat allowed; shared fuel bases on Terra, Venus, Mars, and Callisto. New engine features: visitedBodies/totalFuelSpent on PlayerState, combatDisabled/checkpointBodies/sharedBases on ScenarioRules, updateCheckpoints() helper in engine-victory, combat gating in engine-combat. 10 new tests covering the full scenario lifecycle.
1 parent e5c866a commit 0ad2f84

File tree

8 files changed

+296
-2
lines changed

8 files changed

+296
-2
lines changed

src/client/game-client-helpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ function getSelectedShip(state: GameState, playerId: number, selectedId: string
4141

4242
function getObjective(state: GameState, playerId: number): string {
4343
const player = state.players[playerId];
44+
if (state.scenarioRules.checkpointBodies) {
45+
const visited = player.visitedBodies?.length ?? 0;
46+
const total = state.scenarioRules.checkpointBodies.length;
47+
if (visited >= total) return `⬡ Return to ${player.homeBody}`;
48+
return `⬡ Tour: ${visited}/${total} bodies visited`;
49+
}
4450
const hasFugitiveShip = state.ships.some((ship) => ship.owner === playerId && ship.hasFugitives);
4551
const facingFugitives = state.scenarioRules.hiddenIdentityInspection;
4652
if (player.escapeWins) {
@@ -146,6 +152,12 @@ export function getScenarioBriefingLines(state: GameState, playerId: number): st
146152
const myShips = state.ships.filter((ship) => ship.owner === playerId);
147153
const shipNames = myShips.map((ship) => SHIP_STATS[ship.type]?.name ?? ship.type).join(', ');
148154
const lines = [`Your fleet: ${shipNames}`];
155+
if (state.scenarioRules.checkpointBodies) {
156+
lines.push(`Objective: Visit all ${state.scenarioRules.checkpointBodies.length} major bodies, then land on ${player.homeBody}`);
157+
lines.push('No combat — race only');
158+
lines.push('Press ? for controls help');
159+
return lines;
160+
}
149161
const hasFugitiveShip = myShips.some((ship) => ship.hasFugitives);
150162
const facingFugitives = state.scenarioRules.hiddenIdentityInspection;
151163
if (hasFugitiveShip) {

src/shared/__tests__/game-engine.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { buildSolarSystemMap, SCENARIOS, findBaseHex, findBaseHexes } from '../m
44
import { SHIP_STATS, ORDNANCE_MASS } from '../constants';
55
import { hexKey, hexEqual, hexDistance } from '../hex';
66
import { resolveBaseDefense } from '../combat';
7+
import { updateCheckpoints, checkImmediateVictory } from '../engine-victory';
78
import type { GameState, ScenarioDefinition, SolarSystemMap, AstrogationOrder, OrdnanceLaunch, Ordnance, Ship } from '../types';
89

910
let map: SolarSystemMap;
@@ -2480,3 +2481,147 @@ describe('fleet building (MegaCredit economy)', () => {
24802481
}
24812482
});
24822483
});
2484+
2485+
describe('Grand Tour', () => {
2486+
let tourState: GameState;
2487+
2488+
beforeEach(() => {
2489+
tourState = createGame(SCENARIOS.grandTour, map, 'TOUR1', findBaseHex);
2490+
});
2491+
2492+
it('initializes checkpoint tracking', () => {
2493+
expect(tourState.scenarioRules.checkpointBodies).toEqual(
2494+
['Sol', 'Mercury', 'Venus', 'Terra', 'Mars', 'Jupiter', 'Io', 'Callisto'],
2495+
);
2496+
expect(tourState.scenarioRules.combatDisabled).toBe(true);
2497+
expect(tourState.players[0].visitedBodies).toBeDefined();
2498+
expect(tourState.players[0].totalFuelSpent).toBe(0);
2499+
expect(tourState.players[1].visitedBodies).toBeDefined();
2500+
expect(tourState.players[1].totalFuelSpent).toBe(0);
2501+
});
2502+
2503+
it('pre-marks starting body as visited', () => {
2504+
// Player 0 starts at Terra, Player 1 at Mars
2505+
expect(tourState.players[0].visitedBodies).toContain('Terra');
2506+
expect(tourState.players[1].visitedBodies).toContain('Mars');
2507+
});
2508+
2509+
it('gives both players shared bases for fuel', () => {
2510+
// Both players should have bases on Terra, Venus, Mars, and Callisto
2511+
const terraBaseKeys = findBaseHexes(map, 'Terra').map(h => hexKey(h));
2512+
const venusBaseKeys = findBaseHexes(map, 'Venus').map(h => hexKey(h));
2513+
const marsBaseKeys = findBaseHexes(map, 'Mars').map(h => hexKey(h));
2514+
const callistoBaseKeys = findBaseHexes(map, 'Callisto').map(h => hexKey(h));
2515+
2516+
for (const key of [...terraBaseKeys, ...venusBaseKeys, ...marsBaseKeys, ...callistoBaseKeys]) {
2517+
expect(tourState.players[0].bases).toContain(key);
2518+
expect(tourState.players[1].bases).toContain(key);
2519+
}
2520+
});
2521+
2522+
it('updates checkpoints when path crosses gravity hexes', () => {
2523+
// Find a Mercury gravity hex
2524+
const mercuryGravityHex = [...map.hexes.entries()]
2525+
.find(([, hex]) => hex.gravity?.bodyName === 'Mercury');
2526+
expect(mercuryGravityHex).toBeDefined();
2527+
2528+
const [keyStr] = mercuryGravityHex!;
2529+
const [q, r] = keyStr.split(',').map(Number);
2530+
2531+
updateCheckpoints(tourState, 0, [{ q, r }], map);
2532+
expect(tourState.players[0].visitedBodies).toContain('Mercury');
2533+
// Player 1 should be unaffected
2534+
expect(tourState.players[1].visitedBodies).not.toContain('Mercury');
2535+
});
2536+
2537+
it('does not duplicate visited bodies', () => {
2538+
const mercuryGravityHex = [...map.hexes.entries()]
2539+
.find(([, hex]) => hex.gravity?.bodyName === 'Mercury');
2540+
const [keyStr] = mercuryGravityHex!;
2541+
const [q, r] = keyStr.split(',').map(Number);
2542+
2543+
updateCheckpoints(tourState, 0, [{ q, r }], map);
2544+
updateCheckpoints(tourState, 0, [{ q, r }], map);
2545+
const count = tourState.players[0].visitedBodies!.filter(b => b === 'Mercury').length;
2546+
expect(count).toBe(1);
2547+
});
2548+
2549+
it('wins when all checkpoints visited and landed at home', () => {
2550+
// Mark all checkpoints as visited for player 0
2551+
tourState.players[0].visitedBodies = [...tourState.scenarioRules.checkpointBodies!];
2552+
2553+
// Land the ship at a Terra base
2554+
const ship = tourState.ships.find(s => s.owner === 0)!;
2555+
const terraBase = findBaseHexes(map, 'Terra')[0];
2556+
ship.position = terraBase;
2557+
ship.landed = true;
2558+
ship.destroyed = false;
2559+
2560+
// Run astrogation with no burns (ship is already landed)
2561+
// Directly test checkImmediateVictory by importing it
2562+
checkImmediateVictory(tourState, map);
2563+
2564+
expect(tourState.winner).toBe(0);
2565+
expect(tourState.winReason).toContain('Grand Tour complete');
2566+
});
2567+
2568+
it('does not win with incomplete checkpoints', () => {
2569+
// Mark only some checkpoints as visited
2570+
tourState.players[0].visitedBodies = ['Terra', 'Mercury', 'Venus'];
2571+
2572+
const ship = tourState.ships.find(s => s.owner === 0)!;
2573+
const terraBase = findBaseHexes(map, 'Terra')[0];
2574+
ship.position = terraBase;
2575+
ship.landed = true;
2576+
ship.destroyed = false;
2577+
2578+
checkImmediateVictory(tourState, map);
2579+
2580+
expect(tourState.winner).toBeNull();
2581+
});
2582+
2583+
it('does not win at wrong home body', () => {
2584+
// All checkpoints visited but landed at Mars (not home for player 0)
2585+
tourState.players[0].visitedBodies = [...tourState.scenarioRules.checkpointBodies!];
2586+
2587+
const ship = tourState.ships.find(s => s.owner === 0)!;
2588+
const marsBase = findBaseHexes(map, 'Mars')[0];
2589+
ship.position = marsBase;
2590+
ship.landed = true;
2591+
ship.destroyed = false;
2592+
2593+
checkImmediateVictory(tourState, map);
2594+
2595+
expect(tourState.winner).toBeNull();
2596+
});
2597+
2598+
it('skips combat phase when combatDisabled', () => {
2599+
// Move player 0's ship next to player 1's ship
2600+
const ship0 = tourState.ships.find(s => s.owner === 0)!;
2601+
const ship1 = tourState.ships.find(s => s.owner === 1)!;
2602+
ship0.position = { q: 0, r: 0 };
2603+
ship0.landed = false;
2604+
ship0.velocity = { dq: 0, dr: 0 };
2605+
ship1.position = { q: 1, r: 0 };
2606+
ship1.landed = false;
2607+
ship1.velocity = { dq: 0, dr: 0 };
2608+
2609+
// Process a turn — should skip combat
2610+
const result = processAstrogation(tourState, 0, [{ shipId: ship0.id, burn: null }], map);
2611+
// After movement, phase should not be 'combat'
2612+
expect(tourState.phase).not.toBe('combat');
2613+
});
2614+
2615+
it('tracks fuel consumption', () => {
2616+
const ship = tourState.ships.find(s => s.owner === 0)!;
2617+
ship.landed = false;
2618+
ship.velocity = { dq: 0, dr: 0 };
2619+
tourState.phase = 'astrogation';
2620+
tourState.activePlayer = 0;
2621+
2622+
const initialFuel = tourState.players[0].totalFuelSpent;
2623+
resolveAstrogationMovement(tourState, 0, [{ shipId: ship.id, burn: 0 }]);
2624+
2625+
expect(tourState.players[0].totalFuelSpent).toBe(initialFuel! + 1);
2626+
});
2627+
});

src/shared/engine-combat.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ export function processCombat(
7878
return { results, state };
7979
}
8080

81+
if (state.scenarioRules.combatDisabled && attacks.length > 0) {
82+
return { error: 'Combat is not allowed in this scenario' };
83+
}
84+
8185
const committedAttackers = new Map<string, string>();
8286
const committedTargets = new Set<string>();
8387
const attackGroups = new Map<string, {
@@ -260,6 +264,9 @@ export function shouldEnterCombatPhase(state: GameState, map: SolarSystemMap): b
260264
return true;
261265
}
262266

267+
// No gun/base combat in race scenarios (asteroid hazards still resolve above)
268+
if (state.scenarioRules.combatDisabled) return false;
269+
263270
if (isPlanetaryDefenseEnabled(state) && hasBaseDefenseTargets(state, map)) {
264271
return true;
265272
}
@@ -274,6 +281,7 @@ function shouldRemainInCombatPhase(state: GameState, map?: SolarSystemMap): bool
274281
})) {
275282
return true;
276283
}
284+
if (state.scenarioRules.combatDisabled) return false;
277285
if (!map) {
278286
return hasAnyEnemyShips(state);
279287
}

src/shared/engine-victory.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GameState, Ship, SolarSystemMap, MovementEvent } from './types';
2-
import { hexKey, hexVecLength, hexDistance, hexEqual } from './hex';
2+
import { type HexCoord, hexKey, hexVecLength, hexDistance, hexEqual } from './hex';
33
import { SHIP_STATS, SHIP_DETECTION_RANGE, BASE_DETECTION_RANGE } from './constants';
44
import { rollD6, lookupOtherDamage, applyDamage } from './combat';
55
import {
@@ -28,12 +28,51 @@ export function advanceTurn(state: GameState): void {
2828
state.phase = 'astrogation';
2929
}
3030

31+
/**
32+
* Update checkpoint body visits for race scenarios.
33+
* Checks each hex in the path for gravity or surface belonging to a checkpoint body.
34+
*/
35+
export function updateCheckpoints(state: GameState, playerId: number, path: HexCoord[], map: SolarSystemMap): void {
36+
const checkpoints = state.scenarioRules.checkpointBodies;
37+
const visited = state.players[playerId].visitedBodies;
38+
if (!checkpoints || !visited) return;
39+
40+
for (const hex of path) {
41+
const mapHex = map.hexes.get(hexKey(hex));
42+
if (!mapHex) continue;
43+
const bodyName = mapHex.gravity?.bodyName ?? mapHex.body?.name;
44+
if (bodyName && checkpoints.includes(bodyName) && !visited.includes(bodyName)) {
45+
visited.push(bodyName);
46+
}
47+
}
48+
}
49+
3150
/**
3251
* Check immediate movement-based victory conditions.
3352
*/
3453
export function checkImmediateVictory(state: GameState, map?: SolarSystemMap): void {
3554
if (!map) return;
3655

56+
// Checkpoint race victory: all bodies visited + landed at home
57+
if (state.scenarioRules.checkpointBodies) {
58+
for (const ship of state.ships) {
59+
if (ship.destroyed || !ship.landed) continue;
60+
const player = state.players[ship.owner];
61+
if (!player.visitedBodies) continue;
62+
const allVisited = state.scenarioRules.checkpointBodies.every(
63+
b => player.visitedBodies!.includes(b),
64+
);
65+
if (!allVisited) continue;
66+
const hex = map.hexes.get(hexKey(ship.position));
67+
if (hex?.base?.bodyName === player.homeBody || hex?.body?.name === player.homeBody) {
68+
state.winner = ship.owner;
69+
state.winReason = `Grand Tour complete! Visited all ${state.scenarioRules.checkpointBodies.length} bodies.`;
70+
state.phase = 'gameOver';
71+
return;
72+
}
73+
}
74+
}
75+
3776
for (const ship of state.ships) {
3877
if (ship.destroyed || !ship.landed) continue;
3978
const targetBody = state.players[ship.owner].targetBody;

src/shared/game-engine.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from './engine-util';
1616
import {
1717
advanceTurn, checkGameEnd, checkImmediateVictory,
18-
updateEscapeMoralVictory,
18+
updateEscapeMoralVictory, updateCheckpoints,
1919
checkRamming, checkInspection, checkCapture,
2020
checkOrbitalBaseResupply, applyResupply, updateDetection,
2121
} from './engine-victory';
@@ -70,6 +70,17 @@ function getScenarioStartingCredits(scenario: ScenarioDefinition, playerId: numb
7070
: scenario.startingCredits;
7171
}
7272

73+
function getStartingVisitedBodies(ships: Ship[], playerId: number, map: SolarSystemMap): string[] {
74+
const visited = new Set<string>();
75+
for (const ship of ships) {
76+
if (ship.owner !== playerId) continue;
77+
const hex = map.hexes.get(hexKey(ship.position));
78+
if (hex?.gravity?.bodyName) visited.add(hex.gravity.bodyName);
79+
if (hex?.body?.name) visited.add(hex.body.name);
80+
}
81+
return [...visited];
82+
}
83+
7384
function assertScenarioPlayerCount(scenario: ScenarioDefinition): void {
7485
if (scenario.players.length !== 2) {
7586
throw new Error(`Scenario must define exactly 2 players, got ${scenario.players.length}`);
@@ -145,6 +156,21 @@ export function createGame(
145156
const ships: Ship[] = [];
146157
const playerBases = scenario.players.map(player => resolveControlledBases(player, map));
147158

159+
// Shared bases: add fuel-body bases to both players (Grand Tour race)
160+
if (scenario.rules?.sharedBases) {
161+
const sharedBaseKeys = new Set<string>();
162+
for (const [key, hex] of map.hexes) {
163+
if (hex.base && scenario.rules.sharedBases.includes(hex.base.bodyName)) {
164+
sharedBaseKeys.add(key);
165+
}
166+
}
167+
for (const bases of playerBases) {
168+
for (const key of sharedBaseKeys) {
169+
if (!bases.includes(key)) bases.push(key);
170+
}
171+
}
172+
}
173+
148174
for (let p = 0; p < scenario.players.length; p++) {
149175
for (let s = 0; s < scenario.players[p].ships.length; s++) {
150176
const def = scenario.players[p].ships[s];
@@ -214,6 +240,13 @@ export function createGame(
214240
planetaryDefenseEnabled: scenario.rules?.planetaryDefenseEnabled ?? true,
215241
hiddenIdentityInspection: scenario.rules?.hiddenIdentityInspection ?? false,
216242
escapeEdge: scenario.rules?.escapeEdge ?? 'any',
243+
combatDisabled: scenario.rules?.combatDisabled,
244+
checkpointBodies: scenario.rules?.checkpointBodies
245+
? [...scenario.rules.checkpointBodies]
246+
: undefined,
247+
sharedBases: scenario.rules?.sharedBases
248+
? [...scenario.rules.sharedBases]
249+
: undefined,
217250
},
218251
escapeMoralVictoryAchieved: false,
219252
turnNumber: 1,
@@ -234,6 +267,10 @@ export function createGame(
234267
bases: playerBases[0],
235268
escapeWins: scenario.players[0].escapeWins,
236269
credits: getScenarioStartingCredits(scenario, 0),
270+
...(scenario.rules?.checkpointBodies ? {
271+
visitedBodies: getStartingVisitedBodies(ships, 0, map),
272+
totalFuelSpent: 0,
273+
} : {}),
237274
},
238275
{
239276
connected: true,
@@ -243,6 +280,10 @@ export function createGame(
243280
bases: playerBases[1],
244281
escapeWins: scenario.players[1].escapeWins,
245282
credits: getScenarioStartingCredits(scenario, 1),
283+
...(scenario.rules?.checkpointBodies ? {
284+
visitedBodies: getStartingVisitedBodies(ships, 1, map),
285+
totalFuelSpent: 0,
286+
} : {}),
246287
},
247288
],
248289
winner: null,
@@ -512,6 +553,19 @@ function resolveMovementPhase(
512553
}
513554
}
514555

556+
// Track checkpoint visits and fuel for race scenarios
557+
if (state.scenarioRules.checkpointBodies) {
558+
for (const m of movements) {
559+
const ship = state.ships.find(s => s.id === m.shipId);
560+
if (ship && !ship.destroyed) {
561+
updateCheckpoints(state, ship.owner, m.path, map);
562+
if (state.players[ship.owner].totalFuelSpent !== undefined) {
563+
state.players[ship.owner].totalFuelSpent! += m.fuelSpent;
564+
}
565+
}
566+
}
567+
}
568+
515569
checkOrbitalBaseResupply(state, playerId);
516570
checkInspection(state, playerId);
517571
checkCapture(state, playerId, events);

0 commit comments

Comments
 (0)