import { expect, test, type Page } from '@playwright/test'; import { jumpToFreshSystem, resolveAction } from './helpers'; // Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained. // Since Task 6 both snapshots also carry the save's task queue (ACTIVE block first, then PENDING). interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean } // The subset of QueueDto the spec reads: the task type and its live duration (`remaining`). interface QueueTaskDto { type: string; remaining: number } interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number; tasks: QueueTaskDto[] } interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface InventoryItemDto { kind: string; name: string; qty: number } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskDto[] } interface FoundItem { kind: string; name: string; qty: number } interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number } interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string } const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); // BFS over the undirected jump routes — [next, ..., target], empty when already there. const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][]): number[] => { const adj = new Map(); 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 prev = new Map(); const seen = new Set([fromId]); const queue = [fromId]; while (queue.length > 0 && !seen.has(toId)) { const cur = queue.shift()!; for (const next of adj.get(cur) ?? []) { if (!seen.has(next)) { seen.add(next); prev.set(next, cur); queue.push(next); } } } expect(seen.has(toId)).toBe(true); // the world graph is connected by construction const path: number[] = []; for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) { path.unshift(cur); } return path; }; // --- PR A's UI flow (Task 8's team builder + Task 7's NEXT TURN button) ----------------- // The team-builder half of every action panel: pick the default one-person crew in the mounted // builder — the first lead radio, the same default member (the roster is in join order) that // resolveAction queues with, no helpers — and click DISPATCH. Returns the queue POST's response: // 200, no turn bump, the effect not yet applied. Its `tasks` array (where the snapshot carries // one) holds the new task's `remaining` — the team's live duration N (Task 6). async function dispatchDefaultCrew(page: Page, path: string) { await page.getByRole('tabpanel').locator('input[type="radio"][name="lead"]').first().check(); const [res] = await Promise.all([ page.waitForResponse((r) => r.request().method() === 'POST' && r.url().endsWith(path)), page.getByRole('button', { name: 'DISPATCH' }).click(), ]); return res; } // One NEXT TURN click (Task 7) = one POST /api/turn. The button drops a click while a tick is // in flight, so each click waits for the button's own TURN span (the shell's ship snapshot — // refreshed on every tick) to show the next turn before the following click. async function tickTo(page: Page, toTurn: number) { await page.getByRole('button', { name: 'NEXT TURN' }).click(); await expect(page.getByText(`TURN ${toTurn}`, { exact: true })).toBeVisible(); } // The N for a ship or research action, read from the crew snapshot: the queueing routes for // /api/ship/* and /api/research return a bare ShipState / TechDto[] with no `tasks` field of // their own (unlike the world/system snapshots), but the queue itself is the same one GET // /api/crew exposes (JC-7). Every test ticks its actions to completion, so at most one task of // a given type is ever in the queue when this reads it. async function crewTasks(page: Page): Promise { const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; return tasks; } test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); }); test('game template tabs render (scificn-ui component layer wired in)', async ({ page }) => { await page.goto('/'); // Stat card + footer badge prove the layout shell; Radix Tabs.Trigger renders role="tab". await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible(); await expect(page.getByRole('tab', { name: 'WORLD MAP' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'CREW' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'SKILL TREE' })).toBeVisible(); await expect(page.getByText('SYS ONLINE')).toBeVisible(); }); test('tabs switch panels client-side (event log terminal)', async ({ page }) => { await page.goto('/'); await page.getByRole('tab', { name: 'EVENT LOG' }).click(); // The placeholder feed was replaced by the real log (v0.9.0): at least one T // line // must render — every save has the turn-0 new_game event, so this holds on a fresh stack and across retries. await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible(); }); test('active tab survives a page reload (#27)', async ({ page }) => { await page.goto('/'); // Fresh context → nothing stored yet, so CREW starts unselected (the default is WORLD MAP). await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'false'); await page.getByRole('tab', { name: 'CREW' }).click(); await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true'); await page.reload(); // The tab is restored from localStorage on mount — no API state involved, so this holds across retries. await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true'); }); test('tab bar stays pinned while panel content scrolls (#31)', async ({ page }) => { await page.goto('/'); // Shrink the viewport so the center pane's content overflows its scroll area regardless of save state. await page.setViewportSize({ width: 1280, height: 560 }); // SHIP SYSTEMS always renders six stacked system cards (seeded at start, never removed) — tall // enough to overflow the shrunk pane on any save state, deterministic across retries. (#36 made // WORLD MAP fill its pane instead of overflowing it, so it can no longer force the scroll.) await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); await expect(page.getByRole('heading', { name: 'HULL' })).toBeVisible(); const tablist = page.getByRole('tablist'); await expect(tablist).toBeVisible(); const before = await tablist.boundingBox(); expect(before).not.toBeNull(); // Scroll the nearest scroll container above the tab list all the way down. const scrolled = await page.evaluate(() => { let el = document.querySelector('[role="tablist"]')?.parentElement ?? null; while (el && !['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) el = el.parentElement; if (!el) throw new Error('no scroll container above the tab list'); el.scrollTop = el.scrollHeight; return el.scrollTop; }); expect(scrolled).toBeGreaterThan(50); // content really overflowed — guards against a vacuous pass await expect(tablist).toBeVisible(); const after = await tablist.boundingBox(); expect(after).not.toBeNull(); // Pinned: the tab bar's viewport position is unchanged even though the content scrolled away (#31). expect(Math.abs(after!.y - before!.y)).toBeLessThan(2); }); 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 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'); // 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 }) => { const before = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = before.systems.find((s) => s.current)!.id; const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!; const targetId = edge[0] === currentId ? edge[1] : edge[0]; const targetName = before.systems.find((s) => s.id === targetId)!.name; await page.goto('/'); // Click the target's chart field — it ARMS the node: the crew team builder floats over the chart. await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click(); // Dispatch with the default one-person crew — the POST only QUEUES the JUMP: 200, no turn bump, // and the ship has not moved yet (the origin is still `current` in the queue response). const res = await dispatchDefaultCrew(page, '/api/jump'); expect(res.status()).toBe(200); const queued = (await res.json()) as WorldState; expect(queued.turn).toBe(before.turn); expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); // no instant landing const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // N = the team's live duration (Task 6/8) // The tick, not the queue, advances the counter: N NEXT TURN clicks, one turn each. for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k); const after = (await (await page.request.get('/api/world')).json()) as WorldState; expect(after.turn).toBe(before.turn + n); expect(after.systems.find((s) => s.current)?.id).toBe(targetId); }); test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => { const before = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = before.systems.find((s) => s.current)!.id; const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!; const targetId = edge[0] === currentId ? edge[1] : edge[0]; const targetName = before.systems.find((s) => s.id === targetId)!.name; await page.goto('/'); // Jump through the UI: click the target's chart field on WORLD MAP (the default tab) — it arms // the node and floats the crew team builder over the chart. await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click(); const res = await dispatchDefaultCrew(page, '/api/jump'); expect(res.status()).toBe(200); // The queue response is applied to the chart — but the turn is still the origin's: no landing, // no counter bump until the ticks. const queued = (await res.json()) as WorldState; expect(queued.turn).toBe(before.turn); expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // Wait until the ticks are done: the tick, not the queue, advances the counter (the button's // TURN span is the shell's live counter; the last click lands the jump and shows TURN before+N). for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k); const after = (await (await page.request.get('/api/world')).json()) as WorldState; const newCurrent = after.systems.find((s) => s.current)!; expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed, on the Nth tick // Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30): // the shell re-fetches /api/system on every tick, so its header carries the final turn. await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible(); }); test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => { const sys = (await (await page.request.get('/api/system')).json()) as SystemState; await page.goto('/'); await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); // Header carries the real system name + turn counter. await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible(); // One EXPLORE button per unexplored body (planets and moons alike). const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ }); await expect(exploreButtons.first()).toBeVisible(); await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length); }); test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => { const before = (await (await page.request.get('/api/system')).json()) as SystemState; const target = before.planets.find((p) => !p.explored)!; await page.goto('/'); await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); // The row's button ARMS the planet (it flips to PICK TEAM); the crew team builder mounts above the list. await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click(); // Dispatch with the default one-person crew — the POST only QUEUES the scan: 200, no turn bump, // the body still unexplored in the response and its `outcome` empty until the scan resolves (JC-2). const res = await dispatchDefaultCrew(page, '/api/explore'); expect(res.status()).toBe(200); const queued = (await res.json()) as ExploreResult; expect(queued.turn).toBe(before.turn); expect(queued.planets.find((p) => p.id === target.id)?.explored).toBe(false); expect(queued.outcome).toEqual([]); expect(queued.crewFound).toBeNull(); const n = queued.tasks.find((t) => t.type === 'EXPLORE')!.remaining; // The ticks: the scan resolves (explored flips, the haul lands) on the Nth one. for (let k = 1; k <= n; k++) await tickTo(page, before.turn + k); const after = (await (await page.request.get('/api/system')).json()) as SystemState; expect(after.turn).toBe(before.turn + n); expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true); // UI reflects it after the ticks: the row badge flipped to SCANNED and its EXPLORE button is gone. // (The SCAN RESULT block already showed the queue response's empty outcome — NOTHING FOUND — as // soon as the scan was dispatched; the haul itself only ever lands on the tick, into the hold.) await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0); await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible(); }); test('exploration yields finds that accumulate in the inventory', async ({ page }) => { // Scan bodies until one yields something (most scans do; jumping keeps it going if a whole // system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible. const found = new Map(); // kind|name -> qty let iterations = 0; while (found.size === 0 && iterations < 60) { 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) { // Queue + tick the scan (resolveAction). The queue response's `outcome` is empty until the // tick (JC-2), so the haul is read from the post-tick /api/system snapshot it returns: // the inventory delta of this scan (the queue is clean at the start of every iteration). const after = (await resolveAction(page, '/api/explore', { planetId: unexplored[0].id })) as SystemState; const before = new Map(sys.inventory.map((i) => [`${i.kind}|${i.name}`, i.qty])); for (const item of after.inventory) { const qty = item.qty - (before.get(`${item.kind}|${item.name}`) ?? 0); if (qty > 0) found.set(`${item.kind}|${item.name}`, qty); } } else { // Current system fully scanned — jump somewhere (queued, then ticked) and keep scanning. const world = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = world.systems.find((s) => s.current)!.id; const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; const targetId = edge[0] === currentId ? edge[1] : edge[0]; await resolveAction(page, '/api/jump', { systemId: targetId }); } } expect(found.size).toBeGreaterThan(0); // Every find landed in the inventory with at least that qty. const sys = (await (await page.request.get('/api/system')).json()) as SystemState; for (const [key, qty] of found) { const [kind, name] = key.split('|'); expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty); } }); test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => { // GET /api/crew is the {crew, tasks} object since Task 6 (JC-18) — the roster is its crew field. const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; expect(crew.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded await page.goto('/'); await page.getByRole('tab', { name: 'CREW' }).click(); for (const member of crew) { const heading = page.getByRole('heading', { name: member.name }); await expect(heading).toBeVisible(); // The card is the third div ancestor of the title (flex row → CardHeader → Card). const card = heading.locator('xpath=ancestor::div[3]'); await expect(card.getByText(member.role)).toBeVisible(); await expect(card.getByText(member.traitGood)).toBeVisible(); await expect(card.getByText(member.traitBad)).toBeVisible(); await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible(); } }); test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => { // Scan bodies until something is affordable: an upgrade costs `level` resources (at least one), // a build costs three parts. Every scan/jump is queued with a default crew and ticked to // completion (resolveAction) — the hold only changes once the task resolves on a tick. Jumping // keeps it going if a whole system is exhausted. let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level); // WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by // the research test below, so this one only targets ungated types. const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built && s.type !== 'WEAPONS') : undefined); let iterations = 0; while (!upgradeTarget() && !buildTarget() && iterations < 60) { 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) { await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } else { const world = (await (await page.request.get('/api/world')).json()) as WorldState; const currentId = world.systems.find((s) => s.current)!.id; const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; const targetId = edge[0] === currentId ? edge[1] : edge[0]; await resolveAction(page, '/api/jump', { systemId: targetId }); } ship = (await (await page.request.get('/api/ship')).json()) as ShipState; } const upgrade = upgradeTarget(); const build = !upgrade ? buildTarget() : undefined; expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on await page.goto('/'); await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); if (upgrade) { // The UPGRADE button ARMS the action — the crew team builder mounts above the grid. const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]'); await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled(); await card.getByRole('button', { name: 'UPGRADE' }).click(); // Dispatch with the default one-person crew — the POST only QUEUES the upgrade: 200, no turn // bump, the cost (`level` resources) already deducted from the hold, the level NOT yet up. const res = await dispatchDefaultCrew(page, '/api/ship/upgrade'); expect(res.status()).toBe(200); const queued = (await res.json()) as ShipState; expect(queued.turn).toBe(ship.turn); expect(queued.systems.find((s) => s.id === upgrade.id)!.level).toBe(upgrade.level); // no instant upgrade expect(totalQty(queued.inventory, 'resource')).toBe(totalQty(ship.inventory, 'resource') - upgrade.level); // cost at queue time const n = (await crewTasks(page)).find((t) => t.type === 'SHIP_UPGRADE')!.remaining; // N = the team's live duration (Task 6/8) // The ticks: the level-up lands on the Nth one. for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k); const after = (await (await page.request.get('/api/ship')).json()) as ShipState; const row = after.systems.find((s) => s.id === upgrade.id)!; expect(row.level).toBe(upgrade.level + 1); expect(after.turn).toBe(ship.turn + n); // the tick, not the click, advances the counter // The card badge flipped to the new level and the left column mirrors it — both re-fetched by // the tick, not by the click. await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible(); await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible(); } else { const target = build!; // The BUILD NEW button ARMS the action — the crew team builder mounts above the grid. const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]'); await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled(); await card.getByRole('button', { name: 'BUILD NEW' }).click(); // Dispatch — the POST only QUEUES the build: 200, no turn bump, the flat 3-parts cost already // deducted, the system NOT yet built. const res = await dispatchDefaultCrew(page, '/api/ship/build'); expect(res.status()).toBe(200); const queued = (await res.json()) as ShipState; expect(queued.turn).toBe(ship.turn); expect(queued.systems.find((s) => s.id === target.id)!.built).toBe(false); // no instant build expect(totalQty(queued.inventory, 'part')).toBe(totalQty(ship.inventory, 'part') - 3); // cost at queue time const n = (await crewTasks(page)).find((t) => t.type === 'SHIP_BUILD')!.remaining; // The ticks: the build lands (OFFLINE → LV 1) on the Nth one. for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k); const after = (await (await page.request.get('/api/ship')).json()) as ShipState; const row = after.systems.find((s) => s.id === target.id)!; expect(row.built).toBe(true); expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 }); expect(after.turn).toBe(ship.turn + n); // the tick, not the click, advances the counter // The card badge flipped OFFLINE → LV 1 and the left column's online count went up. await expect(card.getByText('LV 1')).toBeVisible(); await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).toBeVisible(); } }); test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => { // Research costs two parts, building WEAPONS three — scan until the hold has five. Every // scan/jump is queued with a default crew and ticked to completion (resolveAction). Parts drop on // ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts. let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; let iterations = 0; while (totalQty(ship.inventory, 'part') < 5 && iterations < 80) { 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) { await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } 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, 'part')).toBeGreaterThanOrEqual(5); await page.goto('/'); // SHIP SYSTEMS tab: BUILD NEW on WEAPONS is disabled — gated behind the unresearched tech. await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); const weaponsCard = page.getByRole('heading', { name: 'WEAPONS' }).locator('xpath=ancestor::div[3]'); await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible(); await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled(); // SKILL TREE tab: research WEAPONS SYSTEMS (two parts). The RESEARCH button ARMS the tech — the // crew team builder mounts — and its DISPATCH queues the RESEARCH task: 200, no turn bump, the // cost already deducted, and the card NOT yet RESEARCHED (it flips on the resolving tick). await page.getByRole('tab', { name: 'SKILL TREE' }).click(); const techCard = page.getByRole('heading', { name: 'WEAPONS SYSTEMS' }).locator('xpath=ancestor::div[3]'); await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2 const turnBefore = ship.turn; await techCard.getByRole('button', { name: 'RESEARCH' }).click(); const res = await dispatchDefaultCrew(page, '/api/research'); expect(res.status()).toBe(200); const queuedTechs = (await res.json()) as { id: number; slug: string; state: string }[]; expect(queuedTechs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.state).toBe('AVAILABLE'); // no instant flip // The research response is a bare TechDto[] — the cost deduction is checked on the ship snapshot. const queuedShip = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(queuedShip.turn).toBe(turnBefore); expect(totalQty(queuedShip.inventory, 'part')).toBe(totalQty(ship.inventory, 'part') - 2); // cost at queue time const n = (await crewTasks(page)).find((t) => t.type === 'RESEARCH')!.remaining; // The ticks: the tech flips RESEARCHED (and unlocks the gated build) on the Nth one. for (let k = 1; k <= n; k++) await tickTo(page, turnBefore + k); await expect(techCard.getByText('RESEARCHED')).toBeVisible(); // the card flips on the resolving tick const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(afterResearch.turn).toBe(turnBefore + n); // the tick, not the click, advances the counter // The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and ARMS the build. await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled(); await weaponsCard.getByRole('button', { name: 'BUILD NEW' }).click(); // Dispatch the build — again queue, not instant: 200, no turn bump, still OFFLINE, cost deducted. const buildRes = await dispatchDefaultCrew(page, '/api/ship/build'); expect(buildRes.status()).toBe(200); const queuedBuild = (await buildRes.json()) as ShipState; expect(queuedBuild.turn).toBe(afterResearch.turn); expect(queuedBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(false); // no instant build expect(totalQty(queuedBuild.inventory, 'part')).toBe(totalQty(afterResearch.inventory, 'part') - 3); // cost at queue time const n2 = (await crewTasks(page)).find((t) => t.type === 'SHIP_BUILD')!.remaining; // The ticks: WEAPONS comes online (OFFLINE → LV 1) on the Nth one. for (let k = 1; k <= n2; k++) await tickTo(page, afterResearch.turn + k); const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true); expect(afterBuild.turn).toBe(afterResearch.turn + n2); 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') { // Every scan/jump is queued with a default crew and ticked to completion (resolveAction). 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) { await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } 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 RESEARCH button ARMS the tech — // the crew team builder mounts — and its DISPATCH queues the RESEARCH task: 200, no turn bump, // the cost already deducted, and the card NOT yet 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(); const res = await dispatchDefaultCrew(page, '/api/research'); expect(res.status()).toBe(200); const queuedTechs = (await res.json()) as { id: number; slug: string; state: string }[]; expect(queuedTechs.find((t) => t.slug === 'NAV_AI')!.state).toBe('AVAILABLE'); // no instant flip const queuedShip = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(queuedShip.turn).toBe(ship.turn); expect(totalQty(queuedShip.inventory, 'resource')).toBe(totalQty(ship.inventory, 'resource') - 2); // cost at queue time const n = (await crewTasks(page)).find((t) => t.type === 'RESEARCH')!.remaining; // The ticks: the card flips RESEARCHED on the Nth one (the unlock lands with it). for (let k = 1; k <= n; k++) await tickTo(page, ship.turn + k); 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 // #27 restores whatever tab a previous step persisted (SKILL TREE after the research above), so // activate WORLD MAP explicitly before asserting on the chart. await page.getByRole('tab', { name: 'WORLD MAP' }).click(); 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) — several at once, so .first(). await expect(panel.getByText(/(DISCOVERED|UNKNOWN) \/\/ JUMP ×2/).first()).toBeVisible(); // Click the target's chart field — it ARMS the node: the crew team builder floats over the chart. await panel.getByRole('button', { name: target.name.toUpperCase() }).click(); // Dispatch with the default one-person crew — the POST only QUEUES the two-hop jump: 200, no // turn bump, no landing yet (the origin is still `current` in the queue response). const res = await dispatchDefaultCrew(page, '/api/jump'); expect(res.status()).toBe(200); const queued = (await res.json()) as WorldState; expect(queued.turn).toBe(turnBefore); expect(queued.systems.find((s) => s.current)?.id).toBe(world.systems.find((s) => s.current)!.id); // no instant landing // The hop count now feeds the duration model, not a flat turn delta: N is the team's live // duration for a two-hop jump (the route puts the hop count in the queued payload). const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // The ticks: the counter advances one per tick, and the jump lands on the Nth one. for (let k = 1; k <= n; k++) await tickTo(page, turnBefore + k); const after = (await (await page.request.get('/api/world')).json()) as WorldState; expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // the jump landed on the Nth tick expect(after.turn).toBe(turnBefore + n); // the hop count scaled the duration, not a flat +2 (#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 await page.goto('/'); await page.getByRole('tab', { name: 'MISSIONS' }).click(); const acceptButtons = page.getByRole('button', { name: 'ACCEPT' }); const panel = page.getByRole('tabpanel'); // Idempotent across retries sharing a DB: if a previous attempt already accepted a mission, it's still // ACTIVE and every ACCEPT button is disabled — complete that one instead of accepting again. const leftover = board.find((m) => m.state === 'ACTIVE'); let picked: MissionDto; if (leftover) { picked = leftover; // No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE". await expect(panel.getByText('ACTIVE')).toBeVisible(); for (const button of await acceptButtons.all()) { await expect(button).toBeDisabled(); } } else { // Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered). picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!; const idx = board.indexOf(picked); // One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active). await expect(acceptButtons.nth(idx)).toBeEnabled(); // Accepting is the one action in the suite that does NOT queue (JC-19): no crew params, and no // turn bump — the turn counter only ever moves on a NEXT TURN tick. const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState; await acceptButtons.nth(idx).click(); const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState; expect(worldAfter.turn).toBe(worldBefore.turn); // JC-19 — accept is free, it does not tick // UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled. // No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE". await expect(panel.getByText('ACTIVE')).toBeVisible(); await expect(acceptButtons.nth(idx)).toBeDisabled(); // API confirms it: exactly one ACTIVE mission, the picked one. const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]); } // Baseline of the reward kind in the hold — scans during completion may add more, so assert >=. const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; const baseline = totalQty(shipBefore.inventory, picked.rewardKind); // Complete it. Every scan/jump is queued with a default crew and ticked to completion // (resolveAction) — the mission hooks run on the resolving ticks. EXPLORE/FIND: scan until done, // jumping when a system exhausts (parts drop on ~30% of scans, so P(a FIND-part misses in 80 // scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest jump path to its target (edges // only ever get added, so the precomputed path stays valid). let deliverPath: number[] = []; if (picked.type === 'DELIVER') { const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState; deliverPath = shortestJumpPath(worldNow.systems.find((s) => s.current)!.id, picked.targetSystemId!, worldNow.edges); } let state = 'ACTIVE'; let iterations = 0; while (state !== 'DONE' && iterations < 80) { iterations++; if (deliverPath.length > 0) { await resolveAction(page, '/api/jump', { systemId: deliverPath.shift()! }); } else { const sys = (await (await page.request.get('/api/system')).json()) as SystemState; const unexplored = sys.planets.filter((p) => !p.explored); if (unexplored.length > 0) { await resolveAction(page, '/api/explore', { planetId: unexplored[0].id }); } else { await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems } } state = ((await (await page.request.get('/api/missions')).json()) as MissionDto[]).find((m) => m.id === picked.id)?.state ?? 'ACTIVE'; } expect(state).toBe('DONE'); // the bounded loop always completes: scans are plentiful, jump paths finite const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState; expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty); }); test('EVENT LOG tab shows real game events, newest first', async ({ page }) => { // Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and // message), never on absolute counts — earlier attempts leave their events behind. // The jump is queued + ticked to completion: the event it writes carries the tick it RESOLVED // on, not the turn of the queueing POST. const afterJump = await jumpToFreshSystem(page); const jumpedName = afterJump.systems.find((s) => s.current)!.name; const jumpTurn = afterJump.turn; // the resolution tick's turn // Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first. let sys = (await (await page.request.get('/api/system')).json()) as SystemState; if (!sys.planets.some((p) => !p.explored)) { await jumpToFreshSystem(page); // fresh systems always have unscanned bodies sys = (await (await page.request.get('/api/system')).json()) as SystemState; } const planet = sys.planets.find((p) => !p.explored)!; // The explore POST only queues — no turn bump, no event yet — so tick it to completion: the scan // event lands on the resolving tick, and the post-tick /api/system snapshot carries that turn. const explored = (await resolveAction(page, '/api/explore', { planetId: planet.id })) as SystemState; const exploreTurn = explored.turn; // API: this run's jump + scan are in the log, each stamped with the tick it resolved on, and the // newer one (the scan) comes first. const events = (await (await page.request.get('/api/events')).json()) as EventDto[]; const exploreEvent = events.find((e) => e.turn === exploreTurn && e.type === 'explore'); expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`)); const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`); expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!)); // UI: the terminal renders both lines (T // , uppercased) with the resolution turns. await page.goto('/'); await page.getByRole('tab', { name: 'EVENT LOG' }).click(); const panel = page.getByRole('tabpanel'); await expect(panel.getByText(new RegExp(`T${exploreTurn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible(); await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible(); }); test('GET /api/version reports the app version as JSON', async ({ page }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); expect(res.headers()['content-type']).toContain('application/json'); const body = (await res.json()) as { version: string }; expect(body.version).toMatch(/^\d+\.\d+\.\d+$/); });