@@ -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+
554714function parseHexKey ( key : string ) : { q : number ; r : number } {
555715 const [ q , r ] = key . split ( ',' ) . map ( Number ) ;
556716 return { q, r } ;
0 commit comments