From f356ffbe939bc95078ac6d89ecf3513417340a92 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 01:55:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(missions):=20mission=20board=20=E2=80=94?= =?UTF-8?q?=20schema,=20deterministic=20generation,=20top-up=20+=20v6=20ba?= =?UTF-8?q?ckfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/db/client.ts | 14 ++ lib/db/schema.ts | 18 +++ lib/game/missions.test.ts | 294 ++++++++++++++++++++++++++++++++++++++ lib/game/missions.ts | 201 ++++++++++++++++++++++++++ lib/game/save.ts | 15 +- 5 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 lib/game/missions.test.ts create mode 100644 lib/game/missions.ts diff --git a/lib/db/client.ts b/lib/db/client.ts index 1b4149a..5158809 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -94,6 +94,20 @@ CREATE TABLE IF NOT EXISTS tech ( UNIQUE (save_id, slug) ); +CREATE TABLE IF NOT EXISTS missions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + type TEXT NOT NULL, -- 'DELIVER' | 'EXPLORE' | 'FIND' + description TEXT NOT NULL, -- human-readable objective, e.g. "Jump to Kepler Drift" + target_system_id INTEGER, -- DELIVER only — destination system (NULL otherwise) + condition_kind TEXT, -- FIND only — 'resource' | 'part' (NULL otherwise) + progress_target INTEGER NOT NULL DEFAULT 1, -- EXPLORE: scans required; 1 for the others + progress INTEGER NOT NULL DEFAULT 0, -- scans counted since acceptance; 0/1 for DELIVER/FIND + reward_kind TEXT NOT NULL, -- 'resource' | 'part' + reward_qty INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL DEFAULT 'AVAILABLE' -- 'AVAILABLE' | 'ACTIVE' | 'DONE' +); + CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index b657a79..fbc8018 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -108,6 +108,24 @@ export const tech = sqliteTable( (t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)], ) +// The mission board (#10): contracts generated deterministically from the save seed, one ACTIVE +// at a time. `type` drives completion semantics (see advanceMission in lib/game/missions.ts): +// DELIVER jumps to target_system_id, EXPLORE counts scans up to progress_target, FIND completes +// when a scan's haul contains condition_kind. The reward lands in inventory_items on completion. +export const missions = sqliteTable('missions', { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + type: text('type').notNull(), // 'DELIVER' | 'EXPLORE' | 'FIND' + description: text('description').notNull(), // human-readable objective, e.g. "Jump to Kepler Drift" + targetSystemId: integer('target_system_id'), // DELIVER only — destination system (NULL otherwise) + conditionKind: text('condition_kind'), // FIND only — 'resource' | 'part' (NULL otherwise) + progressTarget: integer('progress_target').notNull().default(1), // EXPLORE: scans required; 1 for the others + progress: integer('progress').notNull().default(0), // scans counted since acceptance; 0/1 for DELIVER/FIND + rewardKind: text('reward_kind').notNull(), // 'resource' | 'part' + rewardQty: integer('reward_qty').notNull().default(1), + state: text('state').notNull().default('AVAILABLE'), // 'AVAILABLE' | 'ACTIVE' | 'DONE' +}) + // Append-only game log. Writers so far: new-game + jump events; later issues add more // writers and a read endpoint. export const events = sqliteTable('events', { diff --git a/lib/game/missions.test.ts b/lib/game/missions.test.ts new file mode 100644 index 0000000..4ac7d6b --- /dev/null +++ b/lib/game/missions.test.ts @@ -0,0 +1,294 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { openGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { explorationRng, mulberry32, rollExploration } from './world' +import { SCHEMA_VERSION, explorePlanet, getWorldState, jumpToSystem } from './save' +import { acceptMission, MIN_AVAILABLE, REWARD_ITEM_NAME, generateMission, missionRng, pickMissionType, topUpMissionBoard, type MissionDef, type MissionSystem } from './missions' + +const dirs: string[] = [] + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-missions-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + return { db: await openGameDb(file), file } +} + +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +// A snapshot with one discovered non-current system to deliver to. +const SYSTEMS: MissionSystem[] = [ + { id: 1, name: 'Start', discovered: true, isCurrent: true }, + { id: 2, name: 'Alpha', discovered: true, isCurrent: false }, + { id: 3, name: 'Beta', discovered: false, isCurrent: false }, +] + +describe('generateMission (pure)', () => { + it('DELIVER targets a discovered non-current system and returns null when none exists', () => { + const def = generateMission('DELIVER', mulberry32(7), SYSTEMS)! + expect(def.type).toBe('DELIVER') + expect(def.targetSystemId).toBe(2) // the only candidate + expect(def.description).toContain('Alpha') + + expect(generateMission('DELIVER', mulberry32(7), [SYSTEMS[0]])).toBeNull() // only the current system is discovered + }) + + it('EXPLORE targets 2–4 scans, FIND one item kind; rewards are always 1–3', () => { + for (let i = 0; i < 200; i++) { + const e = generateMission('EXPLORE', mulberry32(i), SYSTEMS)! + expect(e.progressTarget).toBeGreaterThanOrEqual(2) + expect(e.progressTarget).toBeLessThanOrEqual(4) + expect(e.description).toMatch(/^Scan [2-4] bodies$/) + + const f = generateMission('FIND', mulberry32(i), SYSTEMS)! + expect(['resource', 'part']).toContain(f.conditionKind) + expect(f.description).toContain(f.conditionKind!) + + for (const d of [e, f]) { + expect(d.rewardQty).toBeGreaterThanOrEqual(1) + expect(d.rewardQty).toBeLessThanOrEqual(3) + } + } + }) + + it('is deterministic — the same (seed, turn) stream generates identical missions', () => { + const a = generateMission('DELIVER', missionRng('42', 5), SYSTEMS) + const b = generateMission('DELIVER', missionRng('42', 5), SYSTEMS) + expect(a).toEqual(b) + }) +}) + +describe('pickMissionType (pure)', () => { + it('never picks DELIVER without a target, and does pick it when one exists', () => { + for (let i = 0; i < 200; i++) expect(pickMissionType(mulberry32(i), false)).not.toBe('DELIVER') + + const types = new Set(Array.from({ length: 200 }, (_, i) => pickMissionType(mulberry32(i), true))) + expect(types.has('DELIVER')).toBe(true) + for (const t of types) expect(['DELIVER', 'EXPLORE', 'FIND']).toContain(t) + }) +}) + +describe('topUpMissionBoard', () => { + it('seeds a fresh game with MIN_AVAILABLE AVAILABLE non-DELIVER missions and is idempotent', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game — createNewGame already topped the board up + + const rows = await db.db.select().from(schema.missions) + expect(rows).toHaveLength(MIN_AVAILABLE) + for (const r of rows) { + expect(r.state).toBe('AVAILABLE') + expect(r.type).not.toBe('DELIVER') // only the start system is discovered yet — no targets exist + } + + await topUpMissionBoard(db) // a second pass inserts nothing + expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE) + }) + + it('generates deterministically from (seed, turn) on a hand-crafted save', async () => { + const { db } = await freshFile() + // A v6 save with start (current), one discovered neighbour and one undiscovered. + await db.db.insert(schema.saves).values({ seed: '42', turn: 5, createdAt: new Date().toISOString(), schemaVersion: SCHEMA_VERSION }) + const [start] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Start', x: 50, y: 50, discovered: true, isCurrent: true }).returning() + const [alpha] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Alpha', x: 60, y: 50, discovered: true, isCurrent: false }).returning() + const [beta] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Beta', x: 40, y: 50 }).returning() + + await topUpMissionBoard(db) + + // Replay the same pure pipeline with the same (seed, turn) stream — must match row for row. + const snapshot: MissionSystem[] = [ + { id: start.id, name: 'Start', discovered: true, isCurrent: true }, + { id: alpha.id, name: 'Alpha', discovered: true, isCurrent: false }, + { id: beta.id, name: 'Beta', discovered: false, isCurrent: false }, + ] + const rng = missionRng('42', 5) + const expected: MissionDef[] = [] + while (expected.length < MIN_AVAILABLE) { + const type = pickMissionType(rng, snapshot.some((s) => s.discovered && !s.isCurrent)) + expected.push(generateMission(type, rng, snapshot)!) + } + + const rows = await db.db.select().from(schema.missions) + expect(rows.map((r) => ({ type: r.type, description: r.description, targetSystemId: r.targetSystemId, conditionKind: r.conditionKind, progressTarget: r.progressTarget, rewardKind: r.rewardKind, rewardQty: r.rewardQty }))).toEqual(expected) + }) +}) + +// Inserts a mission row directly so tests can control exactly what the board holds. +async function insertMission(db: GameDb, fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number }): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const [row] = await db.db.insert(schema.missions).values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1 }).returning() + return row.id +} + +describe('acceptMission', () => { + it('rejects unknown ids with 404 and a second accept while one is active with 409 naming the active mission', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game — board seeded + + await expect(acceptMission(db, 999)).rejects.toMatchObject({ status: 404 }) + + const [a, b] = (await db.db.select().from(schema.missions)) + await acceptMission(db, a.id) + await expect(acceptMission(db, a.id)).rejects.toMatchObject({ status: 409, message: /already active/i }) // re-accepting the active one + await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ }) + }) + + it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => { + const { db } = await freshFile() + await getWorldState(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [a] = await db.db.select().from(schema.missions) + + const board = await acceptMission(db, a.id) + + expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE') + expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up + const [after] = await db.db.select().from(schema.saves).limit(1) + expect(after.turn).toBe(save.turn + 1) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')! + expect(event.message).toContain('Accepted mission') + expect(event.turn).toBe(save.turn + 1) + }) +}) + +describe('completion', () => { + it('EXPLORE counts scans, completes at the target and grants the reward into the hold', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game — empty inventory, all bodies unexplored + const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 3 bodies', progressTarget: 3, rewardKind: 'part', rewardQty: 2 }) + + await acceptMission(db, id) + const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null) + await explorePlanet(db, planets[0].id) + let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('ACTIVE') + expect(mission.progress).toBe(1) + + await explorePlanet(db, planets[1].id) + mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('ACTIVE') + expect(mission.progress).toBe(2) + + await explorePlanet(db, planets[2].id) // third scan — completes + mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('DONE') + expect(mission.progress).toBe(3) + + const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! + expect(reward.kind).toBe('part') + expect(reward.qty).toBe(2) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))! + expect(event.message).toContain('Scan 3 bodies') + }) + + it('FIND completes when a haul contains its kind and ignores scans that do not', async () => { + const { db } = await freshFile() + await getWorldState(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const id = await insertMission(db, { type: 'FIND', description: 'Find at least one part while scanning', conditionKind: 'part', rewardKind: 'resource', rewardQty: 3 }) + + // Pick bodies by their deterministic per-planet rolls — one without parts first (no progress), + // then one with (completes). + const planets = await db.db.select().from(schema.planets) + const haulOf = (p: { id: number }) => rollExploration(explorationRng(save.seed, p.id)) + const noPart = planets.find((p) => !haulOf(p).some((i) => i.kind === 'part'))! + expect(noPart).toBeDefined() + const withPart = planets.find((p) => haulOf(p).some((i) => i.kind === 'part'))! + expect(withPart).toBeDefined() + + await acceptMission(db, id) + await explorePlanet(db, noPart.id) // haul has no parts — the mission does not advance + let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('ACTIVE') + expect(mission.progress).toBe(0) + + await explorePlanet(db, withPart.id) // haul has parts — completes + mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('DONE') + + const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! + expect(reward.kind).toBe('resource') + expect(reward.qty).toBe(3) + }) + + it('DELIVER completes on arrival at its target system', async () => { + const { db } = await freshFile() + const world = await getWorldState(db) // new game — start is current, six undiscovered neighbours + const [start] = await db.db.select().from(schema.solarSystems).where(eq(schema.solarSystems.isCurrent, true)) + const neighbor = world.systems.find((s) => !s.discovered)! + + await jumpToSystem(db, neighbor.id) // start is now discovered + non-current — a valid target + const id = await insertMission(db, { type: 'DELIVER', description: `Jump to ${start.name}`, targetSystemId: start.id, rewardKind: 'resource', rewardQty: 1 }) + + await acceptMission(db, id) + + // Wrong destination: one of the ring neighbours generated around `neighbor` on the first jump. + const world2 = await getWorldState(db) + const wrongEdge = world2.edges.find(([a, b]) => (a === neighbor.id || b === neighbor.id) && a !== start.id && b !== start.id)! + const wrongId = wrongEdge[0] === neighbor.id ? wrongEdge[1] : wrongEdge[0] + await jumpToSystem(db, wrongId) // not the target — no completion + expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE') + + await jumpToSystem(db, neighbor.id) // back through `neighbor`… + const result = await jumpToSystem(db, start.id) // …to the target — completes + const mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)! + expect(mission.state).toBe('DONE') + expect(result.systems.find((s) => s.current)?.id).toBe(start.id) + + const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)! + expect(reward.kind).toBe('resource') + expect(reward.qty).toBe(1) + }) + + it('rewards of the same kind accumulate into one hold row across missions', async () => { + const { db } = await freshFile() + await getWorldState(db) + const a = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 2 }) + const b = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 more bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 3 }) + const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null) + + await acceptMission(db, a) + await explorePlanet(db, planets[0].id) + await explorePlanet(db, planets[1].id) // completes A — 2× resource reward + expect((await db.db.select().from(schema.missions)).find((m) => m.id === a)!.state).toBe('DONE') + + await acceptMission(db, b) + await explorePlanet(db, planets[2].id) + await explorePlanet(db, planets[3].id) // completes B — 3× more of the same kind+name + expect((await db.db.select().from(schema.missions)).find((m) => m.id === b)!.state).toBe('DONE') + + const rewards = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.name === REWARD_ITEM_NAME) + expect(rewards).toHaveLength(1) // accumulated, not a second row + expect(rewards[0].qty).toBe(5) + }) +}) + +describe('backfill v6', () => { + it('seeds the board on a pre-#10 save when it opens — deterministic and idempotent', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game at the current version + + // Simulate a save created by pre-v6 code: no mission rows, stamped v5. + await db.db.delete(schema.missions) + await db.db.update(schema.saves).set({ schemaVersion: 5 }) + + await getWorldState(db) // ensureSave → backfill runs the v6 step + + const rows = await db.db.select().from(schema.missions) + expect(rows).toHaveLength(MIN_AVAILABLE) + for (const r of rows) expect(r.state).toBe('AVAILABLE') + const [save] = await db.db.select().from(schema.saves) + expect(save.schemaVersion).toBe(SCHEMA_VERSION) + + // Idempotent — a second pass changes nothing. + await getWorldState(db) + expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE) + }) +}) diff --git a/lib/game/missions.ts b/lib/game/missions.ts new file mode 100644 index 0000000..5fbdca0 --- /dev/null +++ b/lib/game/missions.ts @@ -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 { + 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)) + } +} diff --git a/lib/game/save.ts b/lib/game/save.ts index c3f4ac0..f901601 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -4,6 +4,7 @@ import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' +import { checkMissionCompletion, topUpMissionBoard } from './missions' import { TECHS } from './tech' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world' @@ -95,9 +96,8 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi // What each schema version added to a save. Saves created by older code lack the data newer // versions seed at new-game time, so ensureSave runs these steps in order for anything below -// current — each step is idempotent (a no-op when its data already exists). When #10 adds -// seeding, bump SCHEMA_VERSION and append a step here. -export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree +// current — each step is idempotent (a no-op when its data already exists). +export const SCHEMA_VERSION = 6 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board // Seeds the starting ship: one row per known type — core types built at base values, the rest // unbuilt (level/capacity 0) until a BUILD action. @@ -142,6 +142,7 @@ async function createNewGame(db: GameDb): Promise { await seedStartingCrew(db, save.id, seed) await seedStartingShip(db, save.id) await seedStartingTech(db, save.id) + await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso }) await persist(db) @@ -168,6 +169,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id) version = 5 } + if (version < 6) { + await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist + version = 6 + } if (from !== SCHEMA_VERSION) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) @@ -233,6 +238,8 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise