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
+201
View File
@@ -0,0 +1,201 @@
import { eq } from 'drizzle-orm'
import * as schema from '@/lib/db/schema'
import { persist, type GameDb } from '@/lib/db/client'
import { mulberry32, type FoundItem } from './world'
// Mission board (#10): contracts generated deterministically from the save seed. Three types —
// DELIVER (jump to a target system), EXPLORE (scan N bodies), FIND (haul in an item of one kind).
// One mission is ACTIVE at a time; the board always holds MIN_AVAILABLE AVAILABLE contracts.
// The pure helpers below are DB-free so generation/completion rules stay unit-testable alone.
export type MissionType = 'DELIVER' | 'EXPLORE' | 'FIND'
export type ItemKind = 'resource' | 'part'
export type MissionStateName = 'AVAILABLE' | 'ACTIVE' | 'DONE'
// The board never drops below this many AVAILABLE contracts (topped up after every action).
export const MIN_AVAILABLE = 3
// What generation produces and the DB stores — one row per mission.
export interface MissionDef {
type: MissionType
description: string
targetSystemId: number | null // DELIVER only
conditionKind: ItemKind | null // FIND only
progressTarget: number // EXPLORE: scans required; 1 for the others
rewardKind: ItemKind
rewardQty: number // 1–3, consistent with exploration hauls
}
// A system as far as generation cares about.
export interface MissionSystem {
id: number
name: string
discovered: boolean
isCurrent: boolean
}
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every
// action costs exactly one turn, so the stream never repeats for two different top-ups.
export function missionRng(seed: string, turn: number): () => number {
return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0)
}
// Which type to generate next: DELIVER when a target exists and the coin says so, else EXPLORE/FIND.
export function pickMissionType(rng: () => number, canDeliver: boolean): MissionType {
if (canDeliver && rng() < 0.5) return 'DELIVER'
return rng() < 0.5 ? 'EXPLORE' : 'FIND'
}
// One mission of the given type from the current world snapshot. DELIVER returns null when no
// discovered non-current system exists to target (e.g. a brand-new game).
export function generateMission(type: MissionType, rng: () => number, systems: MissionSystem[]): MissionDef | null {
const rewardKind: ItemKind = rng() < 0.5 ? 'resource' : 'part'
const rewardQty = 1 + Math.floor(rng() * 3) // 1–3
if (type === 'DELIVER') {
const candidates = systems.filter((s) => s.discovered && !s.isCurrent)
if (candidates.length === 0) return null
const target = candidates[Math.floor(rng() * candidates.length)]
return { type, description: `Jump to ${target.name}`, targetSystemId: target.id, conditionKind: null, progressTarget: 1, rewardKind, rewardQty }
}
if (type === 'EXPLORE') {
const scans = 2 + Math.floor(rng() * 3) // 2–4
return { type, description: `Scan ${scans} bodies`, targetSystemId: null, conditionKind: null, progressTarget: scans, rewardKind, rewardQty }
}
const kind: ItemKind = rng() < 0.5 ? 'resource' : 'part'
return { type, description: `Find at least one ${kind} while scanning`, targetSystemId: null, conditionKind: kind, progressTarget: 1, rewardKind, rewardQty }
}
// What an action did to the world — what completion checks run against.
export interface MissionAction {
jumpedTo?: number // system id reached by a jump
outcome?: FoundItem[] // haul from a scan
}
// How far an action advances a mission: new progress, or null when the action doesn't touch it.
// DELIVER completes on arrival at its target; EXPLORE counts every scan (capped); FIND completes
// when a haul contains its kind.
export function advanceMission(
mission: { type: MissionType; targetSystemId: number | null; conditionKind: ItemKind | null; progressTarget: number; progress: number },
action: MissionAction,
): number | null {
if (action.jumpedTo !== undefined) {
return mission.type === 'DELIVER' && mission.targetSystemId === action.jumpedTo ? 1 : null
}
if (mission.type === 'EXPLORE') return Math.min(mission.progressTarget, mission.progress + 1)
if (mission.type === 'FIND' && mission.conditionKind !== null && (action.outcome ?? []).some((i) => i.kind === mission.conditionKind)) return 1
return null
}
// --- DB layer -----------------------------------------------------------------
export interface MissionDto extends MissionDef {
id: number
progress: number
state: MissionStateName
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class MissionError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
async function readMissions(db: GameDb): Promise<MissionDto[]> {
return (await db.db.select().from(schema.missions)).map((m) => ({
id: m.id,
type: m.type as MissionType,
description: m.description,
targetSystemId: m.targetSystemId,
conditionKind: m.conditionKind as ItemKind | null,
progressTarget: m.progressTarget,
progress: m.progress,
rewardKind: m.rewardKind as ItemKind,
rewardQty: m.rewardQty,
state: m.state as MissionStateName,
}))
}
// The board for the MISSIONS tab. Assumes a save exists (routes call ensureSave first) and tops
// the AVAILABLE list up before reading so the tab always has contracts to offer.
export async function getMissions(db: GameDb): Promise<MissionDto[]> {
await topUpMissionBoard(db)
return readMissions(db)
}
// Keeps at least MIN_AVAILABLE AVAILABLE missions on the board, generating deterministically from
// (seed, turn). Idempotent — a second call with a full list inserts nothing.
export async function topUpMissionBoard(db: GameDb): Promise<void> {
const [save] = await db.db.select().from(schema.saves).limit(1)
if (!save) return
const systems = (await db.db.select().from(schema.solarSystems)).map((s) => ({ id: s.id, name: s.name, discovered: s.discovered, isCurrent: s.isCurrent }))
let available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length
if (available >= MIN_AVAILABLE) return
const rng = missionRng(save.seed, save.turn)
while (available < MIN_AVAILABLE) {
const type = pickMissionType(rng, systems.some((s) => s.discovered && !s.isCurrent))
const def = generateMission(type, rng, systems)
if (!def) break // no DELIVER target — the other types always generate
await db.db.insert(schema.missions).values({ saveId: save.id, type: def.type, description: def.description, targetSystemId: def.targetSystemId, conditionKind: def.conditionKind, progressTarget: def.progressTarget, rewardKind: def.rewardKind, rewardQty: def.rewardQty })
available += 1
}
}
// Mission rewards are granted under one neutral name — spenders (ship actions, research) consume by kind only.
export const REWARD_ITEM_NAME = 'MISSION REWARD'
const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS')
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like
// every other action and writes an event. Returns the fresh board.
export async function acceptMission(db: GameDb, missionId: number): Promise<MissionDto[]> {
const [save] = await db.db.select().from(schema.saves).limit(1)
if (!save) throw new MissionError(404, 'No save yet')
const rows = await readMissions(db)
const target = rows.find((m) => m.id === missionId)
if (!target) throw new MissionError(404, `Unknown mission ${missionId}`)
if (target.state !== 'AVAILABLE') throw new MissionError(409, `${target.description} is already ${target.state.toLowerCase()}`)
const active = rows.find((m) => m.state === 'ACTIVE')
if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`)
const turn = save.turn + 1
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
await persist(db)
return readMissions(db)
}
// Runs after a jump or scan: advances the ACTIVE mission (if any) and, on completion, marks it
// DONE, grants its reward into the cargo hold and writes an event. Called from save.ts inside the
// action that triggered it, before persist — so everything lands in one atomic write.
export async function checkMissionCompletion(db: GameDb, saveId: number, turn: number, action: MissionAction): Promise<void> {
const row = (await db.db.select().from(schema.missions)).find((m) => m.state === 'ACTIVE')
if (!row) return
const active = { type: row.type as MissionType, targetSystemId: row.targetSystemId, conditionKind: row.conditionKind as ItemKind | null, progressTarget: row.progressTarget, progress: row.progress }
const newProgress = advanceMission(active, action)
if (newProgress === null) return
if (newProgress >= active.progressTarget) {
await db.db.update(schema.missions).set({ state: 'DONE', progress: newProgress }).where(eq(schema.missions.id, row.id))
// The reward lands in the hold like any haul — same kind+name accumulates into one row.
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === row.rewardKind && i.name === REWARD_ITEM_NAME)
if (existing) {
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + row.rewardQty }).where(eq(schema.inventoryItems.id, existing.id))
} else {
await db.db.insert(schema.inventoryItems).values({ saveId, kind: row.rewardKind, name: REWARD_ITEM_NAME, qty: row.rewardQty })
}
await db.db.insert(schema.events).values({ saveId, turn, type: 'mission', message: `Mission complete — ${row.description} (+${row.rewardQty}× ${kindLabel(row.rewardKind as ItemKind)})`, createdAt: new Date().toISOString() })
} else {
await db.db.update(schema.missions).set({ progress: newProgress }).where(eq(schema.missions.id, row.id))
}
}