v0.8.0 — MISSIONS: generation, accept & complete (#10) #17
@@ -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 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 ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
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);
|
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<number, number[]>();
|
||||||
|
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<number, number>();
|
||||||
|
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 }) => {
|
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
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();
|
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 }) => {
|
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/version');
|
const res = await page.request.get('/api/version');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|||||||
@@ -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 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 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 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('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
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[];
|
const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||||
expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded
|
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.
|
// Restart the app container mid-run — the named DB volume must survive it.
|
||||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
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[];
|
const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||||
expect(techAfter).toEqual(techBefore);
|
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.
|
// The page itself serves again with the landing title.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user