296 lines
15 KiB
TypeScript
296 lines
15 KiB
TypeScript
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)
|
||
})
|
||
})
|