feat(missions): mission board — schema, deterministic generation, top-up + v6 backfill

New missions table (schema + DDL). Pure model in lib/game/missions.ts:
DELIVER / EXPLORE / FIND contracts generated from missionRng(seed, turn),
board always topped up to >=3 AVAILABLE after every action. save.ts wires
SCHEMA_VERSION 5 -> 6 with a v6 backfill step and completion hooks inside
jumpToSystem/explorePlanet (one atomic persist). Unit tests cover generation
determinism, top-up idempotency, backfill, accept validation and per-type
completion incl. reward accumulation.
This commit is contained in:
2026-09-29 01:55:34 +00:00
parent 5657e8938a
commit f356ffbe93
5 changed files with 539 additions and 3 deletions
+294
View File
@@ -0,0 +1,294 @@
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, 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)
})
})