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,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<number> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -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<void> {
|
||||
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<WorldS
|
||||
|
||||
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: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
||||
await checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
|
||||
await topUpMissionBoard(db)
|
||||
await persist(db)
|
||||
|
||||
return getWorldState(db)
|
||||
@@ -357,6 +364,8 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
|
||||
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
||||
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: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
|
||||
await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
|
||||
await topUpMissionBoard(db)
|
||||
await persist(db)
|
||||
|
||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||
|
||||
Reference in New Issue
Block a user