feat(game): NAVIGATION AI unlocks two-hop jumps in one action (#35)
CI / build + unit + e2e (pull_request) Failing after 3m11s
CI / build + unit + e2e (pull_request) Failing after 3m11s
This commit is contained in:
+7
-4
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user