diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 31c4e19..331f3db 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -72,3 +72,18 @@ export async function jumpToFreshSystem(page: Page): Promise { expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued return after; } + +// Tick the save's task queue until it is empty (free skips are legal 200s; every tick decrements +// every ACTIVE row and PENDING rows auto-start, so a non-empty queue always shrinks — the bound +// only fails a pathological state). Every spec that queues actions starts from this on retry: +// the CI DB persists across attempts, and a failed one may have left a row behind — an in-transit +// JUMP would 409 every new queue (lib/game/tasks.ts's in-transit rule). +export async function drainQueue(page: Page): Promise { + for (let ticks = 0; ; ticks++) { + const body = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[]; tasks?: { id: number }[] } | CrewDto[]; + const tasks = Array.isArray(body) ? [] : (body.tasks ?? []); + if (tasks.length === 0) return; + expect(ticks).toBeLessThan(60); + expect((await page.request.post('/api/turn')).status()).toBe(200); + } +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 890afe5..3fc8068 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { jumpToFreshSystem, resolveAction } from './helpers'; +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). @@ -172,6 +172,7 @@ test('world map shows the generated starting system and its neighbours', async ( }); 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)!; @@ -199,6 +200,7 @@ test('jumping to an adjacent system updates the world state and turn counter', a }); 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)!; @@ -247,6 +249,7 @@ test('local system shows the current system bodies with staggered moons and expl }); 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)!; @@ -280,6 +283,7 @@ test('exploring a body flips its row state, bumps the turn and shows the scan re }); 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(); // kind|name -> qty @@ -338,6 +342,7 @@ test('CREW tab renders real roster cards, each showing both traits and an origin }); 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 @@ -433,6 +438,7 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card }); 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. @@ -508,6 +514,7 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu }); 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 }[]; @@ -601,6 +608,7 @@ test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', asyn }); 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 @@ -684,6 +692,7 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn }); 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 @@ -748,18 +757,6 @@ const tickOnce = async (page: Page) => { expect((await page.request.post('/api/turn')).status()).toBe(200); }; -// Tick the save's task queue until it is empty. Free skips are legal 200s and every tick -// decrements every ACTIVE row (PENDING rows auto-start), so a non-empty queue always shrinks — -// the bound only fails a pathological state, not a slow one. -async function drainQueue(page: Page): Promise { - for (let ticks = 0; ; ticks++) { - const { tasks } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] }; - if (tasks.length === 0) return; - expect(ticks).toBeLessThan(60); - await tickOnce(page); - } -} - 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; @@ -799,13 +796,16 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow 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 (jump first if it is exhausted). + // 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; - if (!sys.planets.some((p) => !p.explored)) { + 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; @@ -874,6 +874,9 @@ test('a JUMP and a disjoint-crew EXPLORE run in parallel and resolve at their ow 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 diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 2cae7ca..29aa700 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import path from 'node:path'; import { expect, test } from '@playwright/test'; -import { jumpToFreshSystem, resolveAction } from './helpers'; +import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers'; // Repo root — docker-compose.yml lives here; `docker compose restart` runs on // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the @@ -13,9 +13,10 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } -// Mirrors the task row of lib/game/tasks.ts minus saveId — GET /api/crew is the {crew, tasks} -// object (Task 6 / JC-7): ACTIVE block first, then PENDING, DONE filtered. -interface TaskDto { id: number; type: string; payload: string; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number } +// Mirrors the QueueDto of lib/game/save.ts — GET /api/crew is the {crew, tasks} object (Task 6 / +// JC-7): the queue's display shape (resolved names, no raw payload), ACTIVE block first, then +// PENDING, DONE filtered. +interface TaskDto { id: number; type: string; targetLabel: string; leadName: string; helperNames: string[]; remaining: number; status: string; leadCrewId: number; helperCrewIds: number[] } interface CrewState { crew: CrewDto[]; tasks: TaskDto[] } 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 } @@ -26,6 +27,10 @@ interface EventDto { id: number; turn: number; type: string; message: string; cr test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); + // Retry-safety: a failed earlier attempt may have left a row queued — an in-transit JUMP would + // 409 every new queue (lib/game/tasks.ts's in-transit rule), so drain before the baselines. + await drainQueue(page); + // Baseline: the app serves and reports its version. const resBefore = await page.request.get('/api/version'); expect(resBefore.status()).toBe(200);