Files
SpaceWanderer/lib/game/missions.ts
T
kamilkrzywda f356ffbe93 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.
2026-09-29 01:55:34 +00:00

202 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}