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
+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 }) => {
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('/');
// Live chart title carries the turn counter.
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)!;
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
const panel = page.getByRole('tabpanel');
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save
await expect(panel.getByRole('button')).toHaveCount(adjacent.length);
// Six on a fresh save without NAV AI; the two-hop ring joins once it is researched.
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 }) => {
@@ -364,6 +369,67 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
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 }) => {
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