Files
SpaceWanderer/e2e/smoke.spec.ts
T
2026-09-29 15:37:45 +00:00

553 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from '@playwright/test';
import { jumpToFreshSystem } from './helpers';
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
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 }
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<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 prev = new Map<number, number>();
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;
};
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<turn> // <MESSAGE> 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 — clickable nodes are role=button with their name as accessible label.
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(after.turn).toBe(before.turn + 1);
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).
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
// Wait until the jump response is applied to the chart — server state is committed by then, so
// the /api/world read below can't race the in-flight POST.
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
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
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
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();
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(after.turn).toBe(before.turn + 1);
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
// UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone.
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<string, number>(); // 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) {
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
expect(res.status()).toBe(200);
for (const item of ((await res.json()) as ExploreResult).outcome) {
found.set(`${item.kind}|${item.name}`, item.qty);
}
} else {
// Current system fully scanned — jump somewhere 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];
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
}
}
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 }) => {
const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
await page.goto('/');
await page.getByRole('tab', { name: 'CREW' }).click();
for (const member of roster) {
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. 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) {
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
} 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];
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
}
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) {
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();
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 + 1);
// The card badge flipped to the new level and the left column mirrors it.
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
} else {
const target = build!;
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();
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 });
// 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. 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) {
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, '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 card flips to RESEARCHED.
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();
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState;
expect(afterResearch.turn).toBe(turnBefore + 1); // research costs a turn
// The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and builds.
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();
const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState;
expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true);
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
// #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();
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
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();
await acceptButtons.nth(idx).click();
// 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 (accepting cost a turn).
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. 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) {
expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200);
} 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) {
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
}
}
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.
const afterJump = await jumpToFreshSystem(page);
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
const jumpTurn = afterJump.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)!;
const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } });
expect(exploreRes.status()).toBe(200);
const explored = (await exploreRes.json()) as ExploreResult;
// API: this run's jump + scan are in the log, 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 === explored.turn && 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<turn> // <MESSAGE>, uppercased).
await page.goto('/');
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
const panel = page.getByRole('tabpanel');
await expect(panel.getByText(new RegExp(`T${explored.turn} // 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+$/);
});