Files
SpaceWanderer/lib/game/missions.test.ts

296 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number> {
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, spends no 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) // free — no turn cost
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) // stamped at the current turn — accept spends none
})
})
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.turn).toBe(3) // free accept (turn 0) + three scans (1–3); completion is written by the third scan
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)
})
})