feat(game): NAVIGATION AI unlocks two-hop jumps in one action (#35)
CI / build + unit + e2e (pull_request) Failing after 3m11s

This commit is contained in:
2026-09-29 14:53:11 +00:00
parent d083b5b708
commit d14ffdcf2f
10 changed files with 167 additions and 26 deletions
+7 -4
View File
@@ -6,7 +6,7 @@ import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
// Single-player by design — exactly one save row ever exists, so every query below is
@@ -217,10 +217,13 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes)
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id)
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop jump
// costs one turn per hop, so single-hop jumps keep their existing cost.
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
const turn = save.turn + 1
const turn = save.turn + hopCount
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
+3 -1
View File
@@ -37,7 +37,9 @@ export const EFFECTS: Record<string, TechEffect & { description: string }> = {
BUILD_WEAPONS: { kind: 'unlock_build', shipType: 'WEAPONS', description: 'Unlocks building the WEAPONS system' },
REACTOR_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'REACTOR', bonus: 2, description: `Raises REACTOR max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
HULL_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'HULL', bonus: 3, description: `Raises HULL max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
// #35 — the researched state of this tech also extends the jump range to 2 hops (enforced in
// lib/game/save.ts, which keys off the NAV_AI slug); the description is what the SKILL TREE card shows.
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2}) and enables multi-system jumps (up to 2 hops in one action)` },
WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` },
REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
}
+35 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, rollExploration } from './world'
describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42))
@@ -145,3 +145,37 @@ describe('rollExploration', () => {
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--']))
})
})
describe('reachableWithin (#35 jump range)', () => {
// Chain 1—2—3—4 with a branch off 2: 2—5. Distances from 1: 2→1, 3/5→2, 4→3.
const edges: [number, number][] = [[1, 2], [2, 3], [3, 4], [2, 5]]
it('maxHops=1 returns only direct neighbours at distance 1 (the pre-NAV-AI range)', () => {
expect(reachableWithin(edges, 1, 1)).toEqual(new Map([[2, 1]]))
})
it('maxHops=2 adds the two-hop systems and stops before three', () => {
expect(reachableWithin(edges, 1, 2)).toEqual(new Map([ [2, 1], [3, 2], [5, 2] ]))
})
it('larger maxHops keeps walking until the graph is exhausted', () => {
expect(reachableWithin(edges, 1, 5)).toEqual(new Map([ [2, 1], [3, 2], [5, 2], [4, 3] ]))
})
it('never includes the origin and handles empty/isolated input', () => {
expect(reachableWithin(edges, 1, 3).has(1)).toBe(false)
expect(reachableWithin([], 1, 2)).toEqual(new Map())
expect(reachableWithin([[9, 10]], 1, 2)).toEqual(new Map())
})
it('accepts double-directed route rows (the save.ts input shape) identically', () => {
const directed: [number, number][] = [[1, 2], [2, 1], [2, 3], [3, 2]]
expect(reachableWithin(directed, 1, 2)).toEqual(new Map([ [2, 1], [3, 2] ]))
})
it('on a fresh start region every system is one hop away — two-hop targets only exist after an expansion', () => {
const region = generateStartRegion(mulberry32(42)) // edges index into [start, ...neighbors], start = 0
expect(reachableWithin(region.edges, 0, 1).size).toBe(region.neighbors.length)
expect(reachableWithin(region.edges, 0, 2).size).toBe(region.neighbors.length)
})
})
+30
View File
@@ -136,6 +136,36 @@ export function generateRing(
return { systems, edges: ringEdges(systems.length) }
}
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
// `edges` may be undirected pairs (WorldState.edges) or double-directed route rows — duplicates are
// harmless because a visited system is never re-queued. fromId itself is not in the result.
export function reachableWithin(edges: [number, number][], fromId: number, maxHops: number): Map<number, number> {
const adj = new Map<number, number[]>()
for (const [a, b] of edges) {
if (!adj.has(a)) adj.set(a, [])
if (!adj.has(b)) adj.set(b, [])
adj.get(a)!.push(b)
adj.get(b)!.push(a)
}
const dist = new Map<number, number>()
const seen = new Set([fromId]) // the origin is never re-queued or reported
let frontier = [fromId]
for (let hop = 1; hop <= maxHops; hop++) {
const next: number[] = []
for (const id of frontier) {
for (const nb of adj.get(id) ?? []) {
if (!seen.has(nb)) {
seen.add(nb)
dist.set(nb, hop)
next.push(nb)
}
}
}
frontier = next
}
return dist
}
// --- Bodies (planets + moons) and exploration -------------------------------
export interface WorldMoon {