import { expect, test } from '@playwright/test'; // 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 } const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); 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(); await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible(); }); test('world map shows the generated starting system and its neighbours', async ({ page }) => { await page.goto('/'); // Live chart title carries the turn counter; a fresh save sits at the start with six jump routes. await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible(); const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ }); await expect(jumpButtons.first()).toBeVisible(); await expect(jumpButtons).toHaveCount(6); }); 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('/'); await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).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 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(); // 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); const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built) : 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('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+$/); });