205 lines
10 KiB
TypeScript
205 lines
10 KiB
TypeScript
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.
|
||
// Turn-advancing actions re-anchor the stream, so top-ups on different turns generate
|
||
// different contracts. Mission accept is free and tops up at the current turn, so it may
|
||
// share an anchor with a top-up that already ran at that turn (e.g. the new-game seed at
|
||
// turn 0) — the generated contract can then mirror an existing one: cosmetic, never a
|
||
// double reward (rewards are per row).
|
||
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). Instant and
|
||
// free (JC-19) — no turn, no cost; the event is stamped at the current turn. 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`)
|
||
|
||
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
|
||
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.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))
|
||
}
|
||
}
|