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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user