v0.9.11 — NAVIGATION AI unlocks two-hop jumps (#35) #40

Merged
kamilkrzywda merged 1 commits from navigation-ai into master 2026-09-29 16:57:44 +02:00
10 changed files with 167 additions and 26 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## v0.9.11 — 2026-09-29 14:49
- NAVIGATION AI now enables multi-system jumps (#35): once the existing root tech is researched, `/api/jump` accepts targets up to two hops away (BFS over jump routes) and a multi-hop jump costs one turn per hop — single-hop jumps keep their existing cost. On the WORLD MAP chart, two-hop systems become clickable JUMP fields with a distinct `// JUMP ×2` sublabel; without the tech the range stays adjacent-only as before.
- e2e: new spec researches NAVIGATION AI (scan loop + SKILL TREE UI) and jumps to a two-hop system in one action, asserting the world state and the +2 turn cost; the world-map button-count spec now derives its expected count from the save's tech state so it holds across retries on the shared CI DB.
## v0.9.10 — 2026-09-29 12:57 ## v0.9.10 — 2026-09-29 12:57
- The WORLD MAP now fills the entire available area of its tab (#36): in wide mode (pinned viewport, #21) the OPERATIONS Tabs are a full-height flex column and the world-map panel flexes to the bottom of the pane — the chart SVG sizes to the measured container instead of the old fixed 380px height, so there is no internal scrollbar on this tab at any window size. The scene keeps its viewBox and scales with the pane on resizes (pan/zoom stay accurate; re-fitting after a resize is intentionally not done). Narrow mode keeps the fixed fallback height since the page scrolls naturally there. - The WORLD MAP now fills the entire available area of its tab (#36): in wide mode (pinned viewport, #21) the OPERATIONS Tabs are a full-height flex column and the world-map panel flexes to the bottom of the pane — the chart SVG sizes to the measured container instead of the old fixed 380px height, so there is no internal scrollbar on this tab at any window size. The scene keeps its viewBox and scales with the pane on resizes (pan/zoom stay accurate; re-fitting after a resize is intentionally not done). Narrow mode keeps the fixed fallback height since the page scrolls naturally there.
- Follow-up to that layout change (CI run 382): the OPERATIONS Tabs root uses `min-height: 100%` instead of `height: 100%`. With a capped height, a tall tab's content overflowed the flex column and shrank items down to their automatic minimum — the tablist's `overflow-x: auto` zeroes that minimum per CSS spec, so the bar collapsed to ~0px with its buttons clipped out of hit-testing (every subsequent tab click failed with "div intercepts pointer events"); a capped box also released the sticky pin on deep scrolls. With min-height the box grows to fit tall tabs — the bar keeps its natural height and stays pinned across the whole scroll range, while WORLD MAP still fills the pane exactly. - Follow-up to that layout change (CI run 382): the OPERATIONS Tabs root uses `min-height: 100%` instead of `height: 100%`. With a capped height, a tall tab's content overflowed the flex column and shrank items down to their automatic minimum — the tablist's `overflow-x: auto` zeroes that minimum per CSS spec, so the bar collapsed to ~0px with its buttons clipped out of hit-testing (every subsequent tab click failed with "div intercepts pointer events"); a capped box also released the sticky pin on deep scrolls. With min-height the box grows to fit tall tabs — the bar keeps its natural height and stays pinned across the whole scroll range, while WORLD MAP still fills the pane exactly.
+2 -1
View File
@@ -130,6 +130,7 @@ export function MissionControl() {
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS // Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
// tab via the same pure helpers the API enforces (lib/game/tech.ts). // tab via the same pure helpers the API enforces (lib/game/tech.ts).
const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug)) const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug))
const navAi = researchedSlugs.has('NAV_AI') // #35 — two-hop systems become jumpable chart fields
return ( return (
<div <div
@@ -421,7 +422,7 @@ export function MissionControl() {
value="world-map" value="world-map"
style={{ margin: 0, padding: '1rem', ...(!narrow ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : {}) }} style={{ margin: 0, padding: '1rem', ...(!narrow ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : {}) }}
> >
<WorldMapPanel height={380} narrow={narrow} onJumped={refreshSystem} /> <WorldMapPanel height={380} narrow={narrow} navAi={navAi} onJumped={refreshSystem} />
</TabsContent> </TabsContent>
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */} {/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
+12 -11
View File
@@ -6,10 +6,12 @@ import type { SimulationNodeDatum } from 'd3-force'
import { select } from 'd3-selection' import { select } from 'd3-selection'
import { zoom, zoomIdentity } from 'd3-zoom' import { zoom, zoomIdentity } from 'd3-zoom'
import type { WorldState } from '@/lib/game/save' import type { WorldState } from '@/lib/game/save'
import { reachableWithin } from '@/lib/game/world'
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with // WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there // pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one jumps
// (same POST /api/jump flow as before); the current and unreachable systems are dimmed and not // there (same POST /api/jump flow as before; two hops away once NAVIGATION AI is researched,
// #35); the current and unreachable systems are dimmed and not
// clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount, // clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
// refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab // refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab
// pane (no internal scrollbar); narrow keeps a fixed fallback height since the page scrolls naturally there. // pane (no internal scrollbar); narrow keeps a fixed fallback height since the page scrolls naturally there.
@@ -17,6 +19,7 @@ import type { WorldState } from '@/lib/game/save'
interface WorldMapPanelProps { interface WorldMapPanelProps {
height?: number // fallback chart height when the pane gives no definite space (narrow mode) height?: number // fallback chart height when the pane gives no definite space (narrow mode)
narrow?: boolean narrow?: boolean
navAi?: boolean // #35 — NAVIGATION AI researched: two-hop systems are jumpable fields too
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
} }
@@ -46,7 +49,7 @@ const STATUS_COLOR: Record<NodeStatus, string> = {
offline: 'var(--border)', offline: 'var(--border)',
} }
export function WorldMapPanel({ height = 380, narrow = false, onJumped }: WorldMapPanelProps) { export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJumped }: WorldMapPanelProps) {
// Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36). // Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36).
const filling = !narrow const filling = !narrow
const [world, setWorld] = useState<WorldState | null>(null) const [world, setWorld] = useState<WorldState | null>(null)
@@ -108,19 +111,17 @@ export function WorldMapPanel({ height = 380, narrow = false, onJumped }: WorldM
const W = container.clientWidth || 800 const W = container.clientWidth || 800
const H = container.clientHeight || height const H = container.clientHeight || height
const current = world.systems.find((s) => s.current)! const current = world.systems.find((s) => s.current)!
// Systems one jump away — the only clickable chart fields (the same set as before). // Clickable chart fields: one hop away, or two hops once NAVIGATION AI is researched (#35).
const reachableIds = new Set( const hops = reachableWithin(world.edges, current.id, navAi ? 2 : 1)
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
)
const nodes: ChartNode[] = world.systems.map((s) => ({ const nodes: ChartNode[] = world.systems.map((s) => ({
id: s.id, id: s.id,
name: s.name.toUpperCase(), name: s.name.toUpperCase(),
status: s.current ? 'current' : reachableIds.has(s.id) ? 'jumpable' : 'offline', status: s.current ? 'current' : hops.has(s.id) ? 'jumpable' : 'offline',
sublabel: s.current sublabel: s.current
? 'CURRENT' ? 'CURRENT'
: reachableIds.has(s.id) : hops.has(s.id)
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') ? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hops.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
: s.discovered ? 'DISCOVERED' : 'UNKNOWN', : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
y: (s.y / 100) * H, y: (s.y / 100) * H,
@@ -245,7 +246,7 @@ export function WorldMapPanel({ height = 380, narrow = false, onJumped }: WorldM
simulation.stop() simulation.stop()
svg.remove() svg.remove()
} }
}, [world, jumpingId, jump, height]) }, [world, jumpingId, jump, height, navAi])
if (error && !world) { if (error && !world) {
return ( return (
+69 -3
View File
@@ -117,16 +117,21 @@ test('tab bar stays pinned while panel content scrolls (#31)', async ({ page })
test('world map shows the generated starting system and its neighbours', async ({ page }) => { test('world map shows the generated starting system and its neighbours', async ({ page }) => {
const world = (await (await page.request.get('/api/world')).json()) as WorldState; const world = (await (await page.request.get('/api/world')).json()) as WorldState;
// #35 — once NAVIGATION AI is researched the chart also offers two-hop systems, so the expected
// button count depends on the save's tech state (the CI DB persists across retries).
const techs = (await (await page.request.get('/api/tech')).json()) as { slug: string; state: string }[];
const maxHops = techs.some((t) => t.slug === 'NAV_AI' && t.state === 'RESEARCHED') ? 2 : 1;
await page.goto('/'); await page.goto('/');
// Live chart title carries the turn counter. // Live chart title carries the turn counter.
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible(); await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
// The current system is marked, and every adjacent system is a clickable chart field (role=button). // The current system is marked, and every in-range system is a clickable chart field (role=button).
const current = world.systems.find((s) => s.current)!; const current = world.systems.find((s) => s.current)!;
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible(); await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
const panel = page.getByRole('tabpanel'); const panel = page.getByRole('tabpanel');
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save // Six on a fresh save without NAV AI; the two-hop ring joins once it is researched.
await expect(panel.getByRole('button')).toHaveCount(adjacent.length); const jumpable = world.systems.filter((s) => s.id !== current.id && shortestJumpPath(current.id, s.id, world.edges).length <= maxHops);
await expect(panel.getByRole('button')).toHaveCount(jumpable.length);
}); });
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => { test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
@@ -364,6 +369,67 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
await expect(weaponsCard.getByText('LV 1')).toBeVisible(); await expect(weaponsCard.getByText('LV 1')).toBeVisible();
}); });
test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', async ({ page }) => {
// Research costs two resources (70% of scans drop one), so a short scan loop makes it affordable;
// jumping keeps it going if a system exhausts. Idempotent: a retry finds it already RESEARCHED.
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
const navAiState = () => techs.find((t) => t.slug === 'NAV_AI')!.state;
if (navAiState() !== 'RESEARCHED') {
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
let iterations = 0;
while (totalQty(ship.inventory, 'resource') < 2 && iterations < 40) {
iterations++;
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
const unexplored = sys.planets.filter((p) => !p.explored);
if (unexplored.length > 0) {
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
} else {
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
}
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
}
expect(totalQty(ship.inventory, 'resource')).toBeGreaterThanOrEqual(2);
await page.goto('/');
// SKILL TREE tab: research NAVIGATION AI (two resources) — the card flips to RESEARCHED.
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
const techCard = page.getByRole('heading', { name: 'NAVIGATION AI' }).locator('xpath=ancestor::div[3]');
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // resources ≥ 2
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
}
// Pick a system exactly two hops from the current one. A completely fresh save has none (the start
// region is only one hop deep), so jump once first — that expansion creates the two-hop territory.
let world = (await (await page.request.get('/api/world')).json()) as WorldState;
const findTwoHop = () => {
const currentId = world.systems.find((s) => s.current)!.id;
return world.systems.find((s) => shortestJumpPath(currentId, s.id, world.edges).length === 2);
};
let guard = 0;
while (!findTwoHop() && guard < 5) {
await jumpToFreshSystem(page); // +1 turn — jumping into undiscovered territory always expands the chart
world = (await (await page.request.get('/api/world')).json()) as WorldState;
guard++;
}
const target = findTwoHop()!;
expect(target).toBeDefined();
await page.goto('/'); // fresh chart with NAV AI researched — two-hop fields are clickable now
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
const panel = page.getByRole('tabpanel');
const turnBefore = world.turn;
// The two-hop systems render as distinct chart fields (// JUMP ×2 sublabel), and the target is one of them.
await expect(panel.getByText(/(DISCOVERED|UNKNOWN) \/\/ JUMP ×2/)).toBeVisible();
await panel.getByRole('button', { name: target.name.toUpperCase() }).click();
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // world state updated in one action
expect(after.turn).toBe(turnBefore + 2); // the turn cost scales with hop count (#35)
});
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => { test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up
+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 { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions' import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { TECHS } from './tech' 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. // 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 // 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') if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes) const routes = await db.db.select().from(schema.jumpRoutes)
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id) // #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop jump
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`) // 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: 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)) 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' }, 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})` }, 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})` }, 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)` }, 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})` }, 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 { 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', () => { describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42)) const region = generateStartRegion(mulberry32(42))
@@ -145,3 +145,37 @@ describe('rollExploration', () => {
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--'])) 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) } 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 ------------------------------- // --- Bodies (planets + moons) and exploration -------------------------------
export interface WorldMoon { export interface WorldMoon {
+4 -4
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: { info: {
title: 'SpaceWanderer API', title: 'SpaceWanderer API',
description: description:
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between adjacent solar systems.', 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between solar systems within range (adjacent by default; two hops once NAVIGATION AI is researched).',
// Sourced from package.json so it stays in sync with every version bump. // Sourced from package.json so it stays in sync with every version bump.
version: pkg.version, version: pkg.version,
}, },
@@ -52,9 +52,9 @@ export const openApiSpec = {
}, },
'/api/jump': { '/api/jump': {
post: { post: {
summary: 'Jump to an adjacent solar system', summary: 'Jump to a solar system within range',
description: description:
'Validates that the target is a jump route away from the current system, sets it as current (discovering it if new and generating its neighbours), increments the turn counter and writes a jump event. Returns the fresh world state.', 'Validates that the target is within jump range of the current system — one hop away, or two hops once NAVIGATION AI is researched (#35) — sets it as current (discovering it if new and generating its neighbours), advances the turn counter by the hop count and writes a jump event. Returns the fresh world state.',
operationId: 'jumpToSystem', operationId: 'jumpToSystem',
requestBody: { requestBody: {
required: true, required: true,
@@ -90,7 +90,7 @@ export const openApiSpec = {
}, },
}, },
409: { 409: {
description: 'Target is not adjacent to the current system (or is the current system itself)', description: 'Target is outside jump range of the current system — more than one hop away, or more than two hops without NAVIGATION AI researched (or is the current system itself)',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/Error' }, schema: { $ref: '#/components/schemas/Error' },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.9.10", "version": "0.9.11",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",