diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 4cd0750..890afe5 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -728,3 +728,276 @@ test('GET /api/version reports the app version as JSON', async ({ page }) => { 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); +}; + +// 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; + 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 (jump first if it is exhausted). + let sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + if (!sys.planets.some((p) => !p.explored)) { + await jumpToFreshSystem(page); + sys = (await (await page.request.get('/api/system')).json()) as SystemStateQ; + } + const planet = sys.planets.find((p) => !p.explored)!; + + 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); + } + } + + // 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); +});