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 { 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 { 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 { 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 { 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 { 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)) } }