diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5ab2610..518f699 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -12,9 +12,40 @@ interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: u 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 } const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); +// BFS over the undirected jump routes — [next, ..., target], empty when already there. +const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][]): number[] => { + const adj = new Map(); + for (const [a, b] of edges) { + if (!adj.has(a)) adj.set(a, []); + if (!adj.has(b)) adj.set(b, []); + adj.get(a)!.push(b); + adj.get(b)!.push(a); + } + const prev = new Map(); + const seen = new Set([fromId]); + const queue = [fromId]; + while (queue.length > 0 && !seen.has(toId)) { + const cur = queue.shift()!; + for (const next of adj.get(cur) ?? []) { + if (!seen.has(next)) { + seen.add(next); + prev.set(next, cur); + queue.push(next); + } + } + } + expect(seen.has(toId)).toBe(true); // the world graph is connected by construction + const path: number[] = []; + for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) { + path.unshift(cur); + } + return path; +}; + test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); @@ -255,6 +286,66 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu await expect(weaponsCard.getByText('LV 1')).toBeVisible(); }); +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 + + // Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered). + const picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!; + const idx = board.indexOf(picked); + + // 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); + + await page.goto('/'); + await page.getByRole('tab', { name: 'MISSIONS' }).click(); + // One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active). + const acceptButtons = page.getByRole('button', { name: 'ACCEPT' }); + 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. + const panel = page.getByRole('tabpanel'); + await expect(panel.getByText('ACTIVE', { exact: true })).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)!]); + + // 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('GET /api/version reports the app version as JSON', async ({ page }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index af817cf..6ba6d17 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -16,6 +16,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] } +interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -53,6 +54,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[]; expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded + // Mission board baseline: contracts, states and progress must survive too. The GET tops the board + // up first — a no-op here because every action leaves at least MIN_AVAILABLE AVAILABLE contracts. + const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; + expect(missionsBefore.length).toBeGreaterThan(0); // the board is always seeded on new game + // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -95,6 +101,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[]; expect(techAfter).toEqual(techBefore); + // Mission board survived too: same contracts, states and progress (no re-seed or re-top-up on restart). + const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; + expect(missionsAfter).toEqual(missionsBefore); + // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();