- resolveJumpTask: build existingEdgeKeys from all jump routes (both
endpoints in the save's system set), pass all systems except the
target as attachCandidates, insert closing routes for each attach.
- save.test.ts: jump test now expects 1–4 fresh systems (was pinned
to exactly 4); A/B snapshot test stays green (both paths share the
same resolveJumpTask code).
- e2e/helpers.ts: update stale comment ('4 fresh neighbours' →
'1–4 new systems').
- e2e/smoke.spec.ts: new test verifying 1–4 mints, all undiscovered,
each routed to the target, and BFS connectivity of the full graph.
91 lines
5.2 KiB
TypeScript
91 lines
5.2 KiB
TypeScript
import { expect, type Page } from '@playwright/test';
|
||
|
||
// Shared e2e helpers — kept out of the specs so both files can reuse them.
|
||
|
||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||
|
||
// Minimal crew member — GET /api/crew is the {crew, tasks} object (Task 6 / JC-7); the full
|
||
// member shape lives in e2e/state.spec.ts.
|
||
interface CrewDto { id: number; role: string }
|
||
|
||
// The real TaskType strings (lib/game/tasks.ts) keyed by the action POST path — there is no
|
||
// bare SHIP type, ship actions split into UPGRADE / EXPAND / BUILD.
|
||
const TASK_TYPE: Record<string, string> = {
|
||
'/api/jump': 'JUMP',
|
||
'/api/explore': 'EXPLORE',
|
||
'/api/research': 'RESEARCH',
|
||
'/api/ship/upgrade': 'SHIP_UPGRADE',
|
||
'/api/ship/expand': 'SHIP_EXPAND',
|
||
'/api/ship/build': 'SHIP_BUILD',
|
||
};
|
||
|
||
// The GET endpoint that serves each action's final snapshot (the action paths are POST-only).
|
||
const FINAL_SNAPSHOT: Record<string, string> = {
|
||
'/api/jump': '/api/world',
|
||
'/api/explore': '/api/system',
|
||
'/api/research': '/api/tech',
|
||
'/api/ship/upgrade': '/api/ship',
|
||
'/api/ship/expand': '/api/ship',
|
||
'/api/ship/build': '/api/ship',
|
||
};
|
||
|
||
// Queue an action POST with a default one-person crew, then tick until that task resolves.
|
||
// The POST only queues (200, no turn bump, effect not yet visible); the effect appears once the
|
||
// task hits 0 on a tick — a task that starts ACTIVE with remaining N resolves on the Nth tick
|
||
// (lib/game/tasks.ts tickTurn) — so N (the full duration) is read from the queue response's
|
||
// tasks array and POST /api/turn is sent exactly N times. Returns the final snapshot for the
|
||
// path. A 409 on the queue POST is a busy / in-transit rejection (Task 3), not a tick-count bug.
|
||
export async function resolveAction(page: Page, path: string, data: Record<string, unknown>): Promise<unknown> {
|
||
const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[];
|
||
const members: CrewDto[] = Array.isArray(roster) ? roster : (roster.crew ?? []); // {crew, tasks} object (JC-7); the array guard keeps the helper shape-tolerant
|
||
expect(members.length).toBeGreaterThan(0); // starting crew is always seeded
|
||
const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } });
|
||
expect(res.status()).toBe(200);
|
||
const snap = (await res.json()) as { tasks?: { type: string; remaining: number }[] };
|
||
const n = snap.tasks?.find((t) => t.type === TASK_TYPE[path])?.remaining ?? 1;
|
||
for (let i = 0; i < n; i++) expect((await page.request.post('/api/turn')).status()).toBe(200);
|
||
return (await page.request.get(FINAL_SNAPSHOT[path])).json();
|
||
}
|
||
|
||
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
||
// undiscovered system mints a seeded-random 1–4 new systems (at least one always minted —
|
||
// lib/game/world.ts #51), so fresh territory is always available — picking the FIRST edge
|
||
// instead ping-pongs between exhausted systems, because edges come back in insertion order and
|
||
// the oldest route out of a system is usually the one that led here. Falls back to any
|
||
// non-current adjacent system if none is undiscovered. The jump is queued with a default
|
||
// one-person crew and ticked to completion (resolveAction) before this returns, so the caller
|
||
// is already standing in the target system, not merely en route to it.
|
||
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||
const currentId = world.systems.find((s) => s.current)!.id;
|
||
|
||
const adjacentIds = new Set<number>();
|
||
for (const [a, b] of world.edges) {
|
||
if (a === currentId) adjacentIds.add(b);
|
||
else if (b === currentId) adjacentIds.add(a);
|
||
}
|
||
const candidates = [...adjacentIds].map((id) => world.systems.find((s) => s.id === id)!);
|
||
const target = candidates.find((s) => !s.discovered) ?? candidates[0];
|
||
expect(target).toBeDefined(); // every system has at least one route out (ring generation)
|
||
|
||
const after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState;
|
||
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<void> {
|
||
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);
|
||
}
|
||
}
|