import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { explorationRng, mulberry32, rollExploration } from './world' import { SCHEMA_VERSION, explorePlanet, getWorldState, jumpToSystem } from './save' import { acceptMission, MIN_AVAILABLE, REWARD_ITEM_NAME, generateMission, missionRng, pickMissionType, topUpMissionBoard, type MissionDef, type MissionSystem } from './missions' const dirs: string[] = [] async function freshFile(): Promise<{ db: GameDb; file: string }> { const dir = await mkdtemp(path.join(tmpdir(), 'sw-missions-test-')) dirs.push(dir) const file = path.join(dir, 'test.db') return { db: await openGameDb(file), file } } afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }) }) // A snapshot with one discovered non-current system to deliver to. const SYSTEMS: MissionSystem[] = [ { id: 1, name: 'Start', discovered: true, isCurrent: true }, { id: 2, name: 'Alpha', discovered: true, isCurrent: false }, { id: 3, name: 'Beta', discovered: false, isCurrent: false }, ] describe('generateMission (pure)', () => { it('DELIVER targets a discovered non-current system and returns null when none exists', () => { const def = generateMission('DELIVER', mulberry32(7), SYSTEMS)! expect(def.type).toBe('DELIVER') expect(def.targetSystemId).toBe(2) // the only candidate expect(def.description).toContain('Alpha') expect(generateMission('DELIVER', mulberry32(7), [SYSTEMS[0]])).toBeNull() // only the current system is discovered }) it('EXPLORE targets 2–4 scans, FIND one item kind; rewards are always 1–3', () => { for (let i = 0; i < 200; i++) { const e = generateMission('EXPLORE', mulberry32(i), SYSTEMS)! expect(e.progressTarget).toBeGreaterThanOrEqual(2) expect(e.progressTarget).toBeLessThanOrEqual(4) expect(e.description).toMatch(/^Scan [2-4] bodies$/) const f = generateMission('FIND', mulberry32(i), SYSTEMS)! expect(['resource', 'part']).toContain(f.conditionKind) expect(f.description).toContain(f.conditionKind!) for (const d of [e, f]) { expect(d.rewardQty).toBeGreaterThanOrEqual(1) expect(d.rewardQty).toBeLessThanOrEqual(3) } } }) it('is deterministic — the same (seed, turn) stream generates identical missions', () => { const a = generateMission('DELIVER', missionRng('42', 5), SYSTEMS) const b = generateMission('DELIVER', missionRng('42', 5), SYSTEMS) expect(a).toEqual(b) }) }) describe('pickMissionType (pure)', () => { it('never picks DELIVER without a target, and does pick it when one exists', () => { for (let i = 0; i < 200; i++) expect(pickMissionType(mulberry32(i), false)).not.toBe('DELIVER') const types = new Set(Array.from({ length: 200 }, (_, i) => pickMissionType(mulberry32(i), true))) expect(types.has('DELIVER')).toBe(true) for (const t of types) expect(['DELIVER', 'EXPLORE', 'FIND']).toContain(t) }) }) describe('topUpMissionBoard', () => { it('seeds a fresh game with MIN_AVAILABLE AVAILABLE non-DELIVER missions and is idempotent', async () => { const { db } = await freshFile() await getWorldState(db) // new game — createNewGame already topped the board up const rows = await db.db.select().from(schema.missions) expect(rows).toHaveLength(MIN_AVAILABLE) for (const r of rows) { expect(r.state).toBe('AVAILABLE') expect(r.type).not.toBe('DELIVER') // only the start system is discovered yet — no targets exist } await topUpMissionBoard(db) // a second pass inserts nothing expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE) }) it('generates deterministically from (seed, turn) on a hand-crafted save', async () => { const { db } = await freshFile() // A v6 save with start (current), one discovered neighbour and one undiscovered. await db.db.insert(schema.saves).values({ seed: '42', turn: 5, createdAt: new Date().toISOString(), schemaVersion: SCHEMA_VERSION }) const [start] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Start', x: 50, y: 50, discovered: true, isCurrent: true }).returning() const [alpha] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Alpha', x: 60, y: 50, discovered: true, isCurrent: false }).returning() const [beta] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Beta', x: 40, y: 50 }).returning() await topUpMissionBoard(db) // Replay the same pure pipeline with the same (seed, turn) stream — must match row for row. const snapshot: MissionSystem[] = [ { id: start.id, name: 'Start', discovered: true, isCurrent: true }, { id: alpha.id, name: 'Alpha', discovered: true, isCurrent: false }, { id: beta.id, name: 'Beta', discovered: false, isCurrent: false }, ] const rng = missionRng('42', 5) const expected: MissionDef[] = [] while (expected.length < MIN_AVAILABLE) { const type = pickMissionType(rng, snapshot.some((s) => s.discovered && !s.isCurrent)) expected.push(generateMission(type, rng, snapshot)!) } const rows = await db.db.select().from(schema.missions) expect(rows.map((r) => ({ type: r.type, description: r.description, targetSystemId: r.targetSystemId, conditionKind: r.conditionKind, progressTarget: r.progressTarget, rewardKind: r.rewardKind, rewardQty: r.rewardQty }))).toEqual(expected) }) }) // Inserts a mission row directly so tests can control exactly what the board holds. async function insertMission(db: GameDb, fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number }): Promise { const [save] = await db.db.select().from(schema.saves).limit(1) const [row] = await db.db.insert(schema.missions).values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1 }).returning() return row.id } describe('acceptMission', () => { it('rejects unknown ids with 404 and a second accept while one is active with 409 naming the active mission', async () => { const { db } = await freshFile() await getWorldState(db) // new game — board seeded await expect(acceptMission(db, 999)).rejects.toMatchObject({ status: 404 }) const [a, b] = (await db.db.select().from(schema.missions)) await acceptMission(db, a.id) await expect(acceptMission(db, a.id)).rejects.toMatchObject({ status: 409, message: /already active/i }) // re-accepting the active one await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ }) }) it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => { const { db } = await freshFile() await getWorldState(db) const [save] = await db.db.select().from(schema.saves).limit(1) const [a] = await db.db.select().from(schema.missions) const board = await acceptMission(db, a.id) expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE') expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up const [after] = await db.db.select().from(schema.saves).limit(1) expect(after.turn).toBe(save.turn + 1) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')! expect(event.message).toContain('Accepted mission') expect(event.turn).toBe(save.turn + 1) }) }) describe('completion', () => { it('EXPLORE counts scans, completes at the target and grants the reward into the hold', async () => { const { db } = await freshFile() await getWorldState(db) // new game — empty inventory, all bodies unexplored const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 3 bodies', progressTarget: 3, rewardKind: 'part', rewardQty: 2 }) await acceptMission(db, id) const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null) await explorePlanet(db, planets[0].id) let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! expect(mission.state).toBe('ACTIVE') expect(mission.progress).toBe(1) await explorePlanet(db, planets[1].id) mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! expect(mission.state).toBe('ACTIVE') expect(mission.progress).toBe(2) await explorePlanet(db, planets[2].id) // third scan — completes mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! expect(mission.state).toBe('DONE') expect(mission.progress).toBe(3) const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! expect(reward.kind).toBe('part') expect(reward.qty).toBe(2) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))! expect(event.message).toContain('Scan 3 bodies') }) it('FIND completes when a haul contains its kind and ignores scans that do not', async () => { const { db } = await freshFile() await getWorldState(db) const [save] = await db.db.select().from(schema.saves).limit(1) const id = await insertMission(db, { type: 'FIND', description: 'Find at least one part while scanning', conditionKind: 'part', rewardKind: 'resource', rewardQty: 3 }) // Pick bodies by their deterministic per-planet rolls — one without parts first (no progress), // then one with (completes). const planets = await db.db.select().from(schema.planets) const haulOf = (p: { id: number }) => rollExploration(explorationRng(save.seed, p.id)) const noPart = planets.find((p) => !haulOf(p).some((i) => i.kind === 'part'))! expect(noPart).toBeDefined() const withPart = planets.find((p) => haulOf(p).some((i) => i.kind === 'part'))! expect(withPart).toBeDefined() await acceptMission(db, id) await explorePlanet(db, noPart.id) // haul has no parts — the mission does not advance let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! expect(mission.state).toBe('ACTIVE') expect(mission.progress).toBe(0) await explorePlanet(db, withPart.id) // haul has parts — completes mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! expect(mission.state).toBe('DONE') const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! expect(reward.kind).toBe('resource') expect(reward.qty).toBe(3) }) it('DELIVER completes on arrival at its target system', async () => { const { db } = await freshFile() const world = await getWorldState(db) // new game — start is current, six undiscovered neighbours const [start] = await db.db.select().from(schema.solarSystems).where(eq(schema.solarSystems.isCurrent, true)) const neighbor = world.systems.find((s) => !s.discovered)! await jumpToSystem(db, neighbor.id) // start is now discovered + non-current — a valid target const id = await insertMission(db, { type: 'DELIVER', description: `Jump to ${start.name}`, targetSystemId: start.id, rewardKind: 'resource', rewardQty: 1 }) await acceptMission(db, id) // Wrong destination: one of the ring neighbours generated around `neighbor` on the first jump. const world2 = await getWorldState(db) const wrongEdge = world2.edges.find(([a, b]) => (a === neighbor.id || b === neighbor.id) && a !== start.id && b !== start.id)! const wrongId = wrongEdge[0] === neighbor.id ? wrongEdge[1] : wrongEdge[0] await jumpToSystem(db, wrongId) // not the target — no completion expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE') await jumpToSystem(db, neighbor.id) // back through `neighbor`… const result = await jumpToSystem(db, start.id) // …to the target — completes const mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! expect(mission.state).toBe('DONE') expect(result.systems.find((s) => s.current)?.id).toBe(start.id) const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! expect(reward.kind).toBe('resource') expect(reward.qty).toBe(1) }) it('rewards of the same kind accumulate into one hold row across missions', async () => { const { db } = await freshFile() await getWorldState(db) const a = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 2 }) const b = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 more bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 3 }) const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null) await acceptMission(db, a) await explorePlanet(db, planets[0].id) await explorePlanet(db, planets[1].id) // completes A — 2× resource reward expect((await db.db.select().from(schema.missions)).find((m) => m.id === a)!.state).toBe('DONE') await acceptMission(db, b) await explorePlanet(db, planets[2].id) await explorePlanet(db, planets[3].id) // completes B — 3× more of the same kind+name expect((await db.db.select().from(schema.missions)).find((m) => m.id === b)!.state).toBe('DONE') const rewards = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.name === REWARD_ITEM_NAME) expect(rewards).toHaveLength(1) // accumulated, not a second row expect(rewards[0].qty).toBe(5) }) }) describe('backfill v6', () => { it('seeds the board on a pre-#10 save when it opens — deterministic and idempotent', async () => { const { db } = await freshFile() await getWorldState(db) // new game at the current version // Simulate a save created by pre-v6 code: no mission rows, stamped v5. await db.db.delete(schema.missions) await db.db.update(schema.saves).set({ schemaVersion: 5 }) await getWorldState(db) // ensureSave → backfill runs the v6 step const rows = await db.db.select().from(schema.missions) expect(rows).toHaveLength(MIN_AVAILABLE) for (const r of rows) expect(r.state).toBe('AVAILABLE') const [save] = await db.db.select().from(schema.saves) expect(save.schemaVersion).toBe(SCHEMA_VERSION) // Idempotent — a second pass changes nothing. await getWorldState(db) expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE) }) })