Skip to content

Commit 28e1995

Browse files
committed
Add checkpoint-aware AI navigation for Grand Tour race
Teach the AI to navigate multi-body race scenarios: - Pick nearest unvisited checkpoint body as navigation target - Divert to nearest base for refueling when fuel is low - Disable overloads in non-combat races to avoid gravity crashes - Smart gravity well avoidance: check if corrective burns exist - Speed penalties near body surfaces to prevent fatal approaches - Deceleration scoring near targets for safe landing approaches
1 parent 1112b09 commit 28e1995

File tree

1 file changed

+176
-16
lines changed

1 file changed

+176
-16
lines changed

src/shared/ai.ts

Lines changed: 176 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,28 @@ export function aiAstrogation(
4242
const targetBody = player.targetBody;
4343
const escapeWins = player.escapeWins;
4444

45-
// Find target hex (center of target body)
46-
let targetHex: { q: number; r: number } | null = null;
45+
// Default navigation target (non-checkpoint scenarios)
46+
let defaultTargetHex: { q: number; r: number } | null = null;
4747
if (targetBody) {
48-
for (const [key, hex] of map.hexes) {
49-
if (hex.body?.name === targetBody) {
50-
targetHex = parseHexKey(key);
48+
for (const body of map.bodies) {
49+
if (body.name === targetBody) {
50+
defaultTargetHex = body.center;
5151
break;
5252
}
5353
}
5454
}
5555

56+
const checkpoints = state.scenarioRules.checkpointBodies;
57+
5658
// Find enemy ships for combat positioning
5759
const enemyShips = state.ships.filter(s => s.owner !== playerId && !s.destroyed);
5860

61+
// Precompute base positions for fuel-seeking
62+
const basePosMap = new Map<string, { q: number; r: number }>();
63+
for (const baseKey of player.bases) {
64+
basePosMap.set(baseKey, parseHexKey(baseKey));
65+
}
66+
5967
for (const ship of state.ships) {
6068
if (ship.owner !== playerId) continue;
6169

@@ -74,17 +82,57 @@ export function aiAstrogation(
7482
continue;
7583
}
7684

85+
// Per-ship checkpoint target or default target
86+
let shipTargetHex = defaultTargetHex;
87+
let shipTargetBody = targetBody;
88+
let seekingFuel = false;
89+
if (checkpoints && player.visitedBodies) {
90+
const nextBody = pickNextCheckpoint(player, checkpoints, map, ship.position) ?? '';
91+
shipTargetBody = nextBody;
92+
shipTargetHex = null;
93+
if (nextBody) {
94+
for (const body of map.bodies) {
95+
if (body.name === nextBody) {
96+
shipTargetHex = body.center;
97+
break;
98+
}
99+
}
100+
}
101+
102+
// Refuel strategy: divert to nearest base when fuel won't reach the target
103+
if (shipTargetHex && !ship.landed) {
104+
const distToTarget = hexDistance(ship.position, shipTargetHex);
105+
const speed = hexVecLength(ship.velocity);
106+
// Need fuel to navigate: roughly distance/3 for accel + distance/3 for decel + margin
107+
const fuelForTrip = Math.ceil(distToTarget * 2 / 3) + speed + 1;
108+
if (ship.fuel < fuelForTrip) {
109+
const basePos = findNearestBase(ship.position, player.bases, map);
110+
if (basePos) {
111+
const baseDist = hexDistance(ship.position, basePos);
112+
// Only divert if base is reasonably close and reachable
113+
if (baseDist < distToTarget && baseDist <= ship.fuel + speed + 2) {
114+
shipTargetHex = basePos;
115+
shipTargetBody = ''; // Landing at base is good
116+
seekingFuel = true;
117+
}
118+
}
119+
}
120+
}
121+
}
122+
77123
let bestBurn: number | null = null;
78124
let bestOverload: number | null = null;
79125
let bestScore = -Infinity;
80126

81127
const stats = SHIP_STATS[ship.type];
82128
const canBurnFuel = ship.fuel > 0;
83129
// Easy AI never overloads; Normal/Hard can overload warships with enough fuel
130+
// No overloads in non-combat races — too risky near gravity wells
84131
const canOverload = difficulty !== 'easy'
85132
&& stats?.canOverload
86133
&& ship.fuel >= 2
87-
&& !ship.overloadUsed;
134+
&& !ship.overloadUsed
135+
&& !state.scenarioRules.combatDisabled;
88136

89137
// Build list of (burn, overload) pairs to evaluate
90138
type BurnOption = { burn: number | null; overload: number | null; weakGravityChoices?: Record<string, boolean> };
@@ -112,16 +160,52 @@ export function aiAstrogation(
112160
// Skip crashed courses entirely
113161
if (course.crashed) continue;
114162

115-
// Look ahead: skip courses whose resulting velocity will crash next turn
163+
// Look ahead: skip courses that will inevitably crash.
164+
let gravityRiskPenalty = 0;
116165
if (!course.landedAt) {
117166
const simShip = { ...ship, position: course.destination, velocity: course.newVelocity, pendingGravityEffects: course.enteredGravityEffects };
118-
const nextCourse = computeCourse(simShip, null, map, { destroyedBases: state.destroyedBases });
119-
if (nextCourse.crashed) continue;
167+
const fuelAfter = ship.fuel - course.fuelSpent;
168+
const driftCourse = computeCourse(simShip, null, map, { destroyedBases: state.destroyedBases });
169+
if (driftCourse.crashed) {
170+
if (!checkpoints) {
171+
// Combat scenarios: simple hard reject if drifting crashes
172+
continue;
173+
}
174+
// Race mode: check if any burn next turn avoids crash
175+
if (fuelAfter <= 0) continue;
176+
let canSurvive = false;
177+
for (let d2 = 0; d2 < 6; d2++) {
178+
const escape = computeCourse(simShip, d2, map, { destroyedBases: state.destroyedBases });
179+
if (escape.crashed) continue;
180+
// Also check the turn after the escape burn
181+
if (!escape.landedAt && fuelAfter > 1) {
182+
const sim2 = { ...simShip, position: escape.destination, velocity: escape.newVelocity, pendingGravityEffects: escape.enteredGravityEffects };
183+
const drift2 = computeCourse(sim2, null, map, { destroyedBases: state.destroyedBases });
184+
if (drift2.crashed) {
185+
let canSurvive2 = false;
186+
for (let d3 = 0; d3 < 6; d3++) {
187+
const esc2 = computeCourse(sim2, d3, map, { destroyedBases: state.destroyedBases });
188+
if (!esc2.crashed) { canSurvive2 = true; break; }
189+
}
190+
if (!canSurvive2) continue; // Escape leads to another trap
191+
}
192+
}
193+
canSurvive = true;
194+
break;
195+
}
196+
if (!canSurvive) continue; // No escape, hard reject
197+
gravityRiskPenalty = -20; // Survivable but needs corrective burns
198+
}
120199
}
121200

122201
let score = scoreCourse(
123-
ship, course, targetHex, targetBody, escapeWins, enemyShips, difficulty,
124-
);
202+
ship, course, shipTargetHex, shipTargetBody, escapeWins, enemyShips, difficulty, map, !!checkpoints,
203+
) + gravityRiskPenalty;
204+
205+
// Fuel-seeking: big bonus for landing at any body (base refuel)
206+
if (seekingFuel && course.landedAt) {
207+
score += 800;
208+
}
125209

126210
// Fuel efficiency: slight preference for conserving fuel
127211
if (opt.burn === null) {
@@ -145,7 +229,7 @@ export function aiAstrogation(
145229
const nextAlt = computeCourse(simShip2, null, map, { destroyedBases: state.destroyedBases });
146230
if (nextAlt.crashed) continue;
147231
}
148-
const altScore = scoreCourse(ship, altCourse, targetHex, targetBody, escapeWins, enemyShips, difficulty);
232+
const altScore = scoreCourse(ship, altCourse, shipTargetHex, shipTargetBody, escapeWins, enemyShips, difficulty, map, !!checkpoints);
149233
if (altScore > score) {
150234
score = altScore;
151235
bestLocalWG = wgChoices;
@@ -419,6 +503,8 @@ function scoreCourse(
419503
escapeWins: boolean,
420504
enemyShips: Ship[],
421505
difficulty: AIDifficulty,
506+
map?: SolarSystemMap,
507+
isRace?: boolean,
422508
): number {
423509
let score = 0;
424510

@@ -444,10 +530,14 @@ function scoreCourse(
444530
score += (currentDist - newDist) * 20 * mult;
445531

446532
// Bonus for landing on target body (not home!)
447-
if (course.landedAt === targetBody) {
533+
if (targetBody && course.landedAt === targetBody) {
448534
score += 1000;
535+
} else if (course.landedAt && !targetBody) {
536+
// Fuel-seeking: landing at any base is great
537+
score += 500;
449538
} else if (course.landedAt) {
450-
// Landing at wrong body — bad, we need to keep moving
539+
// Landing at wrong body — generally bad, but in checkpoint races
540+
// any base landing provides a refuel opportunity
451541
score -= 30 * mult;
452542
}
453543

@@ -464,10 +554,29 @@ function scoreCourse(
464554
score -= velDist * 2 * mult;
465555

466556
// Penalty for overshooting (velocity too high near target)
467-
if (newDist < 5) {
557+
if (newDist < 8) {
468558
const speed = hexVecLength(course.newVelocity);
469559
if (speed > newDist + 1) {
470-
score -= (speed - newDist) * 10 * mult;
560+
score -= (speed - newDist) * 15 * mult;
561+
}
562+
}
563+
}
564+
565+
// Race mode: penalize high speed near bodies (gravity well danger)
566+
if (isRace && map && !course.landedAt) {
567+
const speed = hexVecLength(course.newVelocity);
568+
for (const body of map.bodies) {
569+
const bodyDist = hexDistance(course.destination, body.center);
570+
const dangerZone = body.surfaceRadius + 5;
571+
if (bodyDist < dangerZone && speed > Math.max(1, bodyDist - body.surfaceRadius)) {
572+
score -= (speed - bodyDist + body.surfaceRadius + 1) * 15;
573+
}
574+
}
575+
// Must be nearly stopped to land
576+
if (targetHex) {
577+
const newDist2 = hexDistance(course.destination, targetHex);
578+
if (newDist2 < 3 && speed > 1) {
579+
score -= speed * 25;
471580
}
472581
}
473582
}
@@ -551,6 +660,57 @@ function scoreCourse(
551660
return score;
552661
}
553662

663+
/**
664+
* Pick the next checkpoint body to visit, or homeBody if all visited.
665+
* Uses nearest-neighbor heuristic from the player's ship position.
666+
*/
667+
function pickNextCheckpoint(
668+
player: { visitedBodies?: string[]; homeBody: string },
669+
checkpoints: string[],
670+
map: SolarSystemMap,
671+
shipPos?: { q: number; r: number },
672+
): string | null {
673+
const visited = new Set(player.visitedBodies ?? []);
674+
const unvisited = checkpoints.filter(b => !visited.has(b));
675+
if (unvisited.length === 0) return player.homeBody;
676+
if (!shipPos) return unvisited[0];
677+
678+
// Find nearest unvisited body
679+
let bestBody = unvisited[0];
680+
let bestDist = Infinity;
681+
for (const name of unvisited) {
682+
const body = map.bodies.find(b => b.name === name);
683+
if (!body) continue;
684+
const dist = hexDistance(shipPos, body.center);
685+
if (dist < bestDist) {
686+
bestDist = dist;
687+
bestBody = name;
688+
}
689+
}
690+
return bestBody;
691+
}
692+
693+
/**
694+
* Find the nearest base hex the player controls that has fuel.
695+
*/
696+
function findNearestBase(
697+
shipPos: { q: number; r: number },
698+
playerBases: string[],
699+
map: SolarSystemMap,
700+
): { q: number; r: number } | null {
701+
let bestPos: { q: number; r: number } | null = null;
702+
let bestDist = Infinity;
703+
for (const baseKey of playerBases) {
704+
const pos = parseHexKey(baseKey);
705+
const dist = hexDistance(shipPos, pos);
706+
if (dist < bestDist) {
707+
bestDist = dist;
708+
bestPos = pos;
709+
}
710+
}
711+
return bestPos;
712+
}
713+
554714
function parseHexKey(key: string): { q: number; r: number } {
555715
const [q, r] = key.split(',').map(Number);
556716
return { q, r };

0 commit comments

Comments
 (0)