Files
2026-10-06 13:03:21 +00:00

1093 lines
65 KiB
TypeScript
Raw Permalink 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, type Page } from '@playwright/test';
import { drainQueue, 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<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;
};
// --- 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<QueueTaskDto[]> {
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<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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
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('#51: jumping into undiscovered territory mints 1–4 new systems, each routed to the target, and the graph stays connected', async ({ page }) => {
await drainQueue(page);
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
const after = await jumpToFreshSystem(page);
const target = after.systems.find((s) => s.current)!;
const beforeIds = new Set(before.systems.map((s) => s.id));
const fresh = after.systems.filter((s) => !beforeIds.has(s.id));
// #51: seeded-random 1–4 new systems (the frontier guarantee ensures at least one)
expect(fresh.length).toBeGreaterThanOrEqual(1);
expect(fresh.length).toBeLessThanOrEqual(4);
// all new systems are undiscovered
expect(fresh.every((s) => !s.discovered)).toBe(true);
// each new system is directly routed to the jump target
for (const n of fresh) {
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true);
}
// the whole graph stays connected: BFS from the target reaches every system
const adj = new Map<number, number[]>();
for (const [a, b] of after.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 seen = new Set([target.id]);
const queue = [target.id];
while (queue.length > 0) {
const cur = queue.shift()!;
for (const nb of adj.get(cur) ?? []) {
if (!seen.has(nb)) {
seen.add(nb);
queue.push(nb);
}
}
}
expect(seen.size).toBe(after.systems.length); // no orphaned systems
});
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// 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) {
// 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]');
// exact: the new skill line renders 'ENGINEERING', which a loose 'ENGINEER' role search
// would also match (substring) — the role line is the only element with the role's exact text.
await expect(card.getByText(member.role, { exact: true })).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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// 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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// 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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// 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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
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 }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
// 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<turn> // <MESSAGE>, 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+$/);
});
// --- The new queue + tick model scenarios (Task 10 / #44) -------------------------------
// All five start from a clean queue (drainQueue): the CI DB persists across retries, and a
// failed earlier attempt can leave an ACTIVE / PENDING row — or an in-transit JUMP — behind.
// Every duration below is read live from the queue responses (the team builder's value, Task 8),
// never hard-coded, and every tick goes through the raw POST /api/turn — the only turn bump.
// The full queue row keyed by id — the same array the Task 9 strip renders (ACTIVE block first,
// then PENDING; DONE rows are filtered out of the snapshots, so only ACTIVE/PENDING appear).
interface QueueTaskRow { id: number; type: string; status: string; remaining: number }
// World / system snapshot views carrying that full row shape (the top-level DTOs keep the minimal
// {type, remaining} subset the reworked tests read).
interface WorldStateQ { systems: WorldSystemDto[]; edges: [number, number][]; turn: number; tasks: QueueTaskRow[] }
interface SystemStateQ { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number; tasks: QueueTaskRow[] }
// One POST /api/turn (no body) — the only thing that advances the turn counter.
const tickOnce = async (page: Page) => {
expect((await page.request.post('/api/turn')).status()).toBe(200);
};
test('queueing a one-person JUMP leaves the ship in the origin until its Nth tick (#44)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
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];
const T = world.turn;
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2);
// The queue POST: 200, no turn bump, no landing — the free member starts the JUMP ACTIVE at once.
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.turn).toBe(T);
expect(queued.systems.find((s) => s.current)?.id).toBe(currentId); // unchanged immediately after the queue POST
const n = queued.tasks.find((t) => t.type === 'JUMP')!.remaining; // N = the team's live duration (Task 6/8)
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
// N-1 ticks: still in transit — remaining 1, the origin still current.
for (let k = 0; k < n - 1; k++) await tickOnce(page);
const mid = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(mid.turn).toBe(T + n - 1);
expect(mid.systems.find((s) => s.current)?.id).toBe(currentId); // unchanged after N-1 ticks
expect(mid.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(1);
// The Nth tick lands the jump.
await tickOnce(page);
const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(after.turn).toBe(T + n);
expect(after.systems.find((s) => s.current)?.id).toBe(targetId); // changed exactly on the Nth tick
expect(after.tasks.some((t) => t.type === 'JUMP')).toBe(false); // resolved rows are filtered out
});
test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their own durations (#44)', async ({ page }) => {
await drainQueue(page);
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2); // disjoint one-person teams
// The explore's body: an unexplored planet in the current system. A jump's fallback target can
// be a fully-explored system (jumpToFreshSystem prefers undiscovered, not unexplored), so loop
// like the other preambles instead of a single if.
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
for (let guard = 0; !sys.planets.some((p) => !p.explored) && guard < 10; guard++) {
await jumpToFreshSystem(page);
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
}
const planet = sys.planets.find((p) => !p.explored)!;
expect(planet).toBeDefined(); // the world keeps producing fresh unscanned bodies
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
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];
const targetName = world.systems.find((s) => s.id === targetId)!.name;
const T = world.turn;
// Mandated order (JC-17): the EXPLORE is queued FIRST — while a JUMP is ACTIVE no new task of any
// type may be queued, so a jump-first order would 409 the explore. Both crews are free, so both
// tasks start ACTIVE immediately and run in parallel, with no interleaving clicks.
const expRes = await page.request.post('/api/explore', { data: { planetId: planet.id, leadCrewId: crew[1].id, helperCrewIds: [] } });
expect(expRes.status()).toBe(200);
const expQ = (await expRes.json()) as SystemStateQ;
expect(expQ.turn).toBe(T);
const nExp = expQ.tasks.find((t) => t.type === 'EXPLORE')!.remaining;
expect(expQ.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE');
const jmpRes = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(jmpRes.status()).toBe(200); // legal even while the non-jump explore is ACTIVE
const jmpQ = (await jmpRes.json()) as WorldStateQ;
expect(jmpQ.turn).toBe(T);
const nJmp = jmpQ.tasks.find((t) => t.type === 'JUMP')!.remaining;
expect(jmpQ.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE'); // the explore kept running
expect(jmpQ.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
const getEvents = async () => (await (await page.request.get('/api/events')).json()) as EventDto[];
// A planet flip and a jump are both recorded by the event log (the same records the EVENT LOG test
// reads), each stamped with the tick it resolved on — turn numbers are unique within a run, so the
// (turn, message) pair pins this run's own tasks.
const exploreEventAt = async (turn: number) =>
(await getEvents()).find((e) => e.type === 'explore' && e.turn === turn && e.message.startsWith(`Explored ${planet.name} — found`));
const jumpEventAt = async (turn: number) =>
(await getEvents()).find((e) => e.type === 'jump' && e.turn === turn && e.message === `Jumped to ${targetName}`);
if (nExp < nJmp) {
// The scan resolves first (tick nExp), while the ship is still in the origin — so the flip is
// readable directly off the current system.
for (let k = 0; k < nExp; k++) await tickOnce(page);
const mid = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(mid.turn).toBe(T + nExp);
expect(mid.planets.find((p) => p.id === planet.id)?.explored).toBe(true); // the scan's effect, on its own tick
expect(mid.tasks.some((t) => t.type === 'EXPLORE')).toBe(false);
expect(await exploreEventAt(T + nExp)).toBeDefined();
const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(midWorld.systems.find((s) => s.current)?.id).toBe(currentId); // not landed yet
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nJmp - nExp); // nJmp - nExp ticks left
for (let k = 0; k < nJmp - nExp; k++) await tickOnce(page);
} else if (nJmp < nExp) {
// The jump lands first (tick nJmp); the origin is no longer `current` (GET /api/system is
// current-only), so the scan's flip is proven by the event it writes on its own resolution tick.
for (let k = 0; k < nJmp; k++) await tickOnce(page);
const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(midWorld.turn).toBe(T + nJmp);
expect(midWorld.systems.find((s) => s.current)?.id).toBe(targetId);
expect(await jumpEventAt(T + nJmp)).toBeDefined();
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.status).toBe('ACTIVE');
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - nJmp); // nExp - nJmp ticks left
for (let k = 0; k < nExp - nJmp; k++) await tickOnce(page);
} else {
// Equal durations: both decrement in lockstep and stay ACTIVE through every tick before the last.
for (let k = 1; k < nExp; k++) {
await tickOnce(page);
const midWorld = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(midWorld.tasks.find((t) => t.type === 'EXPLORE')!.remaining).toBe(nExp - k);
expect(midWorld.tasks.find((t) => t.type === 'JUMP')!.remaining).toBe(nExp - k);
}
// The Nth tick resolves both — the loop above covered ticks 1..N-1 only (mirrors how the other
// scenarios end on their resolving tick).
await tickOnce(page);
}
// Both resolved at their own durations: the jump landed, each event is stamped on its task's
// resolution tick, and the queue is clean again.
const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(after.turn).toBe(T + Math.max(nExp, nJmp));
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
expect(after.tasks.some((t) => t.type === 'JUMP' || t.type === 'EXPLORE')).toBe(false);
expect(await exploreEventAt(T + nExp)).toBeDefined();
expect(await jumpEventAt(T + nJmp)).toBeDefined();
});
test('a second task for a member on an ACTIVE one is pre-queued PENDING and auto-starts on its tick (#44)', async ({ page }) => {
await drainQueue(page);
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2);
// Two unexplored bodies in the current system; a fresh system always has them, so jump until the
// current one does (the bounded guard fails the test if the world ever stops producing them).
let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
for (let guard = 0; sys.planets.filter((p) => !p.explored).length < 2 && guard < 10; guard++) {
await jumpToFreshSystem(page);
sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
}
const unexplored = sys.planets.filter((p) => !p.explored);
expect(unexplored.length).toBeGreaterThanOrEqual(2);
const planetA = unexplored[0]!;
const planetB = unexplored[1]!;
const T = sys.turn;
// Task 1: crew[0] is free, so the scan starts ACTIVE at once.
const res1 = await page.request.post('/api/explore', { data: { planetId: planetA.id, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res1.status()).toBe(200);
const q1 = (await res1.json()) as SystemStateQ;
expect(q1.turn).toBe(T); // no turn bump on queue
const t1 = q1.tasks.find((t) => t.type === 'EXPLORE')!; // the only row — the queue was clean
expect(t1.status).toBe('ACTIVE');
const na = t1.remaining;
// Task 2, same member, a different target: a lead on an ACTIVE may chain (JC-16) — it is accepted
// (the member is on ACTIVE, not PENDING, and no jump is in transit) and starts PENDING.
const res2 = await page.request.post('/api/explore', { data: { planetId: planetB.id, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res2.status()).toBe(200);
const q2 = (await res2.json()) as SystemStateQ;
expect(q2.turn).toBe(T);
expect(q2.tasks.find((t) => t.id === t1.id)!.status).toBe('ACTIVE');
const t2 = q2.tasks.find((t) => t.id !== t1.id && t.type === 'EXPLORE')!;
expect(t2.status).toBe('PENDING'); // the second is dimmed in the strip (Task 9) until it starts
const nb = t2.remaining; // its full duration — untouched until it starts
// The snapshot orders the ACTIVE block before the PENDING block (Task 6).
expect(q2.tasks.findIndex((t) => t.id === t1.id)).toBeLessThan(q2.tasks.findIndex((t) => t.id === t2.id));
// Tick until task 1 resolves — a loop keyed on its specific id (resolveAction's find-by-type would
// grab the earliest same-type row, which is the ambiguity two same-type rows create).
for (let k = 1; k < na; k++) {
await tickOnce(page);
const snap = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(snap.tasks.find((t) => t.id === t1.id)!.remaining).toBe(na - k); // the ACTIVE row decrements
expect(snap.tasks.find((t) => t.id === t2.id)!.status).toBe('PENDING'); // the second keeps waiting
}
// The na-th tick: task 1 resolves (filtered out of the snapshot), and task 2 auto-starts on that
// same tick — ACTIVE with its FULL remaining (the first decrement lands on the next tick).
await tickOnce(page);
const done = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(done.tasks.some((t) => t.id === t1.id)).toBe(false);
expect(done.tasks.find((t) => t.id === t2.id)!.status).toBe('ACTIVE');
expect(done.tasks.find((t) => t.id === t2.id)!.remaining).toBe(nb);
// Leave the queue clean for the next scenario: tick task 2 to completion.
for (let k = 0; k < nb; k++) await tickOnce(page);
const clean = (await (await page.request.get('/api/system')).json()) as SystemStateQ;
expect(clean.tasks.length).toBe(0);
});
test('queueing a second JUMP while one is in transit is refused 409 (#44)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
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];
const T = world.turn;
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(2);
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.turn).toBe(T);
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE'); // in transit — and deliberately not ticked
// A second JUMP while the first is in transit — a different, free member, so the 409 is the
// global in-transit rule ("a jump is in transit — no new tasks can be queued" / "a jump is
// already queued"), not crew availability. (The UI variant and the full 409 matrix are
// unit-covered by Task 3 — this single API case is what e2e carries.)
const second = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[1].id, helperCrewIds: [] } });
expect(second.status()).toBe(409);
expect(await second.text()).toMatch(/jump/i);
// The first is still in transit, untouched by the refused queue.
const still = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(still.turn).toBe(T);
expect(still.systems.find((s) => s.current)?.id).toBe(currentId);
expect(still.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
// The jump is left in transit on purpose (the scenario is about the blocked queue); the next
// scenario's drainQueue ticks it out before its empty-queue precondition.
});
test('NEXT TURN with an empty queue is a free skip — +1 turn, the strip stays hidden (#44)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(world.tasks.length).toBe(0); // the precondition: nothing queued
const T = world.turn;
await page.goto('/');
// The button's live counter shows the current turn; with an empty queue the strip renders nothing.
await expect(page.getByText(`TURN ${T}`, { exact: true })).toBeVisible();
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'NEXT TURN' }).click();
await expect(page.getByText(`TURN ${T + 1}`, { exact: true })).toBeVisible(); // the click is a real tick
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toHaveCount(0); // a free skip queues no task
// API confirms the model: the turn advanced by exactly one and the queue is still empty.
const after = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
expect(after.turn).toBe(T + 1);
expect(after.tasks.length).toBe(0);
});
test('NEXT TURN button is full-width in the right pane and not covered by the task strip (#52)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
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];
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(1);
// Queue a one-person JUMP and deliberately do not tick it (the #44 jump scenarios' pattern,
// the in-transit 409 one leaves it that way on purpose): the strip renders from the shell's
// system snapshot — the same queue — so a non-empty queue keeps ACTIVE TASKS visible all test.
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
await page.goto('/');
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toBeVisible(); // the strip is up
// Two rects intersect when their x AND y ranges overlap (touching edges do not count).
type Box = { x: number; y: number; width: number; height: number };
const intersects = (a: Box, b: Box) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
const buttonBox = await page.getByRole('button', { name: 'NEXT TURN' }).boundingBox();
expect(buttonBox).not.toBeNull();
// The strip's root div is the parent of its 'ACTIVE TASKS' label (components/task-strip.tsx).
const stripBox = await page.getByText('ACTIVE TASKS', { exact: true }).locator('xpath=..').boundingBox();
expect(stripBox).not.toBeNull();
// The CARGO HOLD panel: the Panel root is the h3 heading's grandparent div (components/ui/panel.tsx).
const panelBox = await page.getByRole('heading', { name: 'CARGO HOLD // INVENTORY' }).locator('xpath=ancestor::div[2]').boundingBox();
expect(panelBox).not.toBeNull();
// (a) The button spans the right pane's content width — >= 280 on its own, and within ±8px of
// the CARGO HOLD panel, a sibling full-width element in the same pane column.
expect(buttonBox!.width).toBeGreaterThanOrEqual(280);
expect(Math.abs(buttonBox!.width - panelBox!.width)).toBeLessThanOrEqual(8);
// (b) The button's box does not intersect the active-tasks strip's box.
expect(intersects(buttonBox!, stripBox!)).toBe(false);
await drainQueue(page); // leave the queue clean for the next scenario
});