Files
SpaceWanderer/lib/game/save.ts
T

795 lines
42 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 { and, eq, inArray } from 'drizzle-orm'
import type { GameDb } from '@/lib/db/client'
import { persist } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { ACTION_SKILL, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { resolveResearchTask, ResearchError } from './research'
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
import { grantXp, makePowerOf, titleFor, type CrewIdentity, type SkillRow } from './skills'
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
// Single-player by design — exactly one save row ever exists, so every query below is
// unscoped (there is nothing to scope against).
export interface WorldSystemDto {
id: number
name: string
x: number
y: number
discovered: boolean
current: boolean
}
// One row of the snapshot task queue — what the GET builders expose of the tasks table
// (distinct from the tick's TickResult, which reports every row including DONE): the display
// fields the UI needs, names resolved, no raw payload — plus the raw row state (JC-11):
// `status` (the array's ACTIVE-block-first order isn't readable back from it) and the crew
// ids, which the team builder needs for exact member↔row matching (name matching breaks on
// the `unknown` fallback).
export interface QueueDto {
id: number
type: TaskType
targetLabel: string
leadName: string
helperNames: string[]
remaining: number
status: TaskStatus // ACTIVE block first in the array, then PENDING
leadCrewId: number // crew.id of the team lead
helperCrewIds: number[] // crew.id of the supporting crew (degrades to [] on a corrupted blob, like helperNames)
}
export interface WorldState {
systems: WorldSystemDto[]
edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output
turn: number
tasks: QueueDto[] // the active + pending queue — DONE rows never appear
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class JumpError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
function newSeed(): string {
const buf = new Uint32Array(1)
crypto.getRandomValues(buf)
return String(buf[0])
}
// Deterministic per save+turn: the whole world of a seed is reproducible from (seed, turn).
function expansionRng(seed: string, turn: number): () => number {
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
}
// The bodies of one system (planets + moons), derived from (seed, system id) via bodyRng —
// independent of generation order and every other system. Shared by new-game creation and the
// v2 backfill so both produce identical rows for a given save+system.
async function insertBodiesForSystem(db: GameDb, systemId: number, seed: string): Promise<void> {
for (const body of generatePlanets(bodyRng(seed, systemId))) {
const [planet] = await db.db.insert(schema.planets).values({ systemId, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
for (const moon of body.moons) {
await db.db.insert(schema.planets).values({ systemId, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
}
}
}
// Inserts one system together with its generated bodies, so a system is never left without
// worlds to explore.
async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise<number> {
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
await insertBodiesForSystem(db, row.id, seed)
return row.id
}
// v2 backfill: every system without planets gets its generated bodies. Idempotent — systems
// that already have rows are skipped, so a second run changes nothing.
async function ensureBodiesForAllSystems(db: GameDb, saveId: number, seed: string): Promise<void> {
const systems = await db.db.select().from(schema.solarSystems)
for (const system of systems) {
const rows = await db.db.select({ id: schema.planets.id }).from(schema.planets).where(eq(schema.planets.systemId, system.id))
if (rows.length === 0) await insertBodiesForSystem(db, system.id, seed)
}
}
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
for (const [a, b] of edges) {
const from = resolve(a)
const to = resolve(b)
await db.db.insert(schema.jumpRoutes).values({ fromSystem: from, toSystem: to })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: to, toSystem: from }) // both directions → adjacency is a single-row lookup
}
}
// Seeds the starting crew for a fresh save: 2–3 members derived from the seed alone.
async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promise<void> {
for (const member of generateStartingCrew(startingCrewRng(seed))) {
await db.db.insert(schema.crew).values({ saveId, name: member.name, role: member.role, traitGood: member.traitGood, traitBad: member.traitBad, origin: 'STARTING CREW' })
}
}
// 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).
export const SCHEMA_VERSION = 8 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table · v8: crew_skills (role-based starting levels)
// 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.
async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
for (const def of SHIP_TYPES) {
await db.db.insert(schema.shipSystems).values({ saveId, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
}
}
// Seeds the tech tree: one row per known tech (definitions from lib/game/tech.ts), none researched.
async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
for (const def of TECHS) {
await db.db.insert(schema.tech).values({ saveId, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
}
}
// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill
// (level = STARTING_LEVELS[role][skill], xp = the threshold that level implies — levelFromXp
// must round-trip: a seeded L2 starts at xp 10, not 0, or Task 14's grantXp (which recomputes
// level from cumulative xp) would regress the member L2 → L0). Called by createNewGame and the
// v8 backfill. A role missing from STARTING_LEVELS seeds four all-L0 rows instead of throwing
// — the seeder must never brick a save.
async function seedCrewSkills(db: GameDb, saveId: number) {
const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId))
const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId))
for (const m of crew) {
if (have.has(m.id)) continue // already seeded — idempotent
const levels = STARTING_LEVELS[m.role] ?? { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }
for (const skill of Object.keys(levels) as SkillName[]) {
await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: levels[skill], xp: levels[skill] > 0 ? XP_THRESHOLDS[levels[skill] - 1] : 0 })
}
}
}
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
// Safe to call on every request (single player, one process: check-then-insert cannot race).
export async function ensureSave(db: GameDb): Promise<void> {
const existing = await db.db.select().from(schema.saves).limit(1)
if (existing.length === 0) {
await createNewGame(db)
return
}
await backfillSave(db, existing[0])
}
async function createNewGame(db: GameDb): Promise<void> {
const seed = newSeed()
const region = generateStartRegion(mulberry32(Number(seed)))
const nowIso = new Date().toISOString()
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso, schemaVersion: SCHEMA_VERSION }).returning()
// All inserted as undiscovered first; the start is then marked discovered + current.
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
const neighborIds: number[] = []
for (const n of region.neighbors) neighborIds.push(await insertSystemWithBodies(db, save.id, n, seed))
await db.db.update(schema.solarSystems).set({ discovered: true, isCurrent: true }).where(eq(schema.solarSystems.id, startId))
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
await insertRoutes(db, region.edges, resolve)
await seedStartingCrew(db, save.id, seed)
await seedCrewSkills(db, save.id)
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)
}
// Ordered idempotent backfill for a save created by older code. A NULL version means v1.
async function backfillSave(db: GameDb, save: { id: number; seed: string; schemaVersion: number | null }): Promise<void> {
const from = save.schemaVersion ?? 1
let version = from
if (version < 2) {
await ensureBodiesForAllSystems(db, save.id, save.seed) // bodies derive from (seed, system id) — identical to fresh generation
version = 2
}
if (version < 3) {
if ((await db.db.select().from(schema.crew)).length === 0) await seedStartingCrew(db, save.id, save.seed)
version = 3
}
if (version < 4) {
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
version = 4
}
if (version < 5) {
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 (version < 7) {
// The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
version = 7
}
if (version < 8) {
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
version = 8
}
if (from !== SCHEMA_VERSION) {
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
await persist(db)
}
}
// The snapshot task queue — the GET builders' read of the tasks table (distinct from the
// tick's TickResult, which reports every row): one select of the tasks table filtered to
// ACTIVE + PENDING (DONE rows never appear) plus one select each of the name-resolution
// tables. The ACTIVE block comes first, then the PENDING block, each ordered by
// (queuedAtTurn, id). Pure read: `remaining` is the stored row value, never recomputed;
// crew names resolve from the roster by id, `unknown` tolerating a deleted member (the same
// spirit as 4b's <= 0 check). Private — every caller is a public read that already ran
// ensureSave.
async function taskQueue(db: GameDb): Promise<QueueDto[]> {
const [taskRows, crew, systems, planets, tech] = await Promise.all([
db.db.select().from(schema.tasks).where(inArray(schema.tasks.status, ['ACTIVE', 'PENDING'])),
db.db.select().from(schema.crew),
db.db.select().from(schema.solarSystems),
db.db.select().from(schema.planets),
db.db.select().from(schema.tech),
])
const systemName = new Map(systems.map((s): [number, string] => [s.id, s.name]))
const planetName = new Map(planets.map((p): [number, string] => [p.id, p.name]))
const techName = new Map(tech.map((t): [number, string] => [t.id, t.name]))
const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown'
// Corrupted-row tolerance: every JSON read goes through a guarded parse — a malformed
// payload / helpers / type degrades to `unknown` / [] instead of throwing out of this
// pure read (one bad row must not 500 the /api/world, /api/system and /api/crew routes).
const parse = <T>(raw: string): T | null => { try { return JSON.parse(raw) as T } catch { return null } }
const parseHelpers = (raw: string): number[] => { const v = parse<unknown>(raw); return Array.isArray(v) ? (v as number[]) : [] }
// targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the
// target's display name (the jump destination, the scanned body, the researched tech), the
// ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string
// as-is for a build. A deleted lookup target, a malformed payload, or a missing field
// falls back to `unknown`.
const labelFor: Record<TaskType, (payload: string) => string> = {
JUMP: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? (systemName.get(p.systemId) ?? 'unknown') : 'unknown' },
EXPLORE: (payload) => { const p = parse<{ planetId: number }>(payload); return p ? (planetName.get(p.planetId) ?? 'unknown') : 'unknown' },
SHIP_UPGRADE: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — upgrade` : 'unknown' },
SHIP_EXPAND: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — expand` : 'unknown' },
SHIP_BUILD: (payload) => parse<{ type: string }>(payload)?.type ?? 'unknown',
RESEARCH: (payload) => { const p = parse<{ techId: number }>(payload); return p ? (techName.get(p.techId) ?? 'unknown') : 'unknown' },
}
const queue = taskRows.map((t) => ({
id: t.id,
type: t.type as TaskType,
targetLabel: labelFor[t.type as TaskType]?.(t.payload) ?? 'unknown',
leadName: crewName(t.leadCrewId),
helperNames: parseHelpers(t.helpers).map(crewName),
remaining: t.remaining,
status: t.status as TaskStatus,
leadCrewId: t.leadCrewId,
helperCrewIds: parseHelpers(t.helpers),
queuedAtTurn: t.queuedAtTurn,
}))
const byQueueOrder = (rows: typeof queue) => rows.sort((a, b) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id)
const ordered = [...byQueueOrder(queue.filter((t) => t.status === 'ACTIVE')), ...byQueueOrder(queue.filter((t) => t.status === 'PENDING'))]
// queuedAtTurn is only for ordering — the exposed shape is exactly QueueDto (status stays: JC-11).
return ordered.map(({ queuedAtTurn, ...dto }) => dto)
}
export async function getWorldState(db: GameDb): Promise<WorldState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const routes = await db.db.select().from(schema.jumpRoutes)
// Dedupe the stored directed pairs into sorted undirected edges.
const seen = new Set<string>()
const edges: [number, number][] = []
for (const r of routes) {
const a = Math.min(r.fromSystem, r.toSystem)
const b = Math.max(r.fromSystem, r.toSystem)
const key = `${a}-${b}`
if (!seen.has(key)) {
seen.add(key)
edges.push([a, b])
}
}
return {
systems: systems.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, discovered: s.discovered, current: s.isCurrent })),
edges,
turn: save.turn,
tasks: await taskQueue(db),
}
}
// --- Per-type resolvers (PR A, spec §3) -------------------------------------------
//
// The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can
// resolve a completed task row against whatever the world looks like at resolution time.
// Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces
// block of this plan): effects land on the PAYLOAD target, never on the current location;
// no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at
// queue time — Task 3; the immediate public functions deduct before delegating); a minimal
// guard against a stale or double-resolved target (existing error class + exact message —
// a throw is the signal for the 4b tick's re-validation); the existing event text formats
// at the current turn; persist at the end.
// A stand-in task row for the immediate public functions delegating to a resolver: the
// resolvers only read `type` + `payload`, so the identity fields are zeroed.
function syntheticTask(type: TaskType, payload: object): Task {
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
}
// JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and
// when it was undiscovered grow the chart ring off the same (seed, turn) stream as before.
export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> {
await ensureSave(db)
const { systemId } = JSON.parse(task.payload) as { systemId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const target = systems.find((s) => s.id === systemId)
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
if (target.isCurrent) throw new JumpError(409, 'Already at this system')
const current = systems.find((s) => s.isCurrent)!
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
if (!target.discovered) {
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
// system, so there is always somewhere to jump next and a route back.
const rng = expansionRng(save.seed, save.turn)
const takenNames = new Set(systems.map((s) => s.name))
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
const ringIds: number[] = []
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
await insertRoutes(db, ring.edges, resolve)
}
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
await topUpMissionBoard(db)
await persist(db)
}
export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const target = systems.find((s) => s.id === systemId)
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
const current = systems.find((s) => s.isCurrent)!
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes)
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop
// jump costs one turn per hop, so single-hop jumps keep their existing cost.
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
// The immediate jump keeps its own turn cost (the tick owns turn advances for queued
// tasks): advance it before delegating so the resolver's event lands at exactly the
// turn the old code wrote.
const turn = save.turn + hopCount
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount }))
return getWorldState(db)
}
// The jump pre-checks, extracted for the queueing route (5a): the same codes and messages
// as jumpToSystem — unknown system (404), already there (409), outside range (409) — and
// the hop count (reachableWithin + NAV AI, the existing reachability logic) the route puts
// in the queued JUMP payload.
export async function checkJumpTarget(db: GameDb, systemId: number): Promise<number> {
await ensureSave(db)
const systems = await db.db.select().from(schema.solarSystems)
const target = systems.find((s) => s.id === systemId)
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
const current = systems.find((s) => s.isCurrent)!
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes)
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops.
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
return hopCount
}
// --- Local system + exploration ---------------------------------------------
export interface PlanetDto {
id: number
name: string
type: string
hazard: boolean
explored: boolean
parent: number | null // NULL for a planet, the parent planet's id for a moon
}
export interface InventoryItemDto {
kind: 'resource' | 'part'
name: string
qty: number
}
export interface SystemState {
systemId: number
systemName: string
planets: PlanetDto[] // insertion order = display order (a moon follows its parent row)
inventory: InventoryItemDto[]
turn: number
tasks: QueueDto[] // the active + pending queue — DONE rows never appear
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class ExploreError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
async function readSystemState(db: GameDb): Promise<SystemState> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const current = systems.find((s) => s.isCurrent)!
const planets = (await db.db.select().from(schema.planets).where(eq(schema.planets.systemId, current.id))).map((p) => ({
id: p.id,
name: p.name,
type: p.type,
hazard: p.hazard,
explored: p.explored,
parent: p.parentId,
}))
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn, tasks: await taskQueue(db) }
}
export async function getSystemState(db: GameDb): Promise<SystemState> {
await ensureSave(db)
return readSystemState(db)
}
// --- Crew roster --------------------------------------------------------------
export interface CrewDto {
id: number
name: string
role: string
traitGood: string
traitBad: string
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
skills: { skill: SkillName; level: number }[] // the member's four crew_skills rows, fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (JC-27); display-oriented — no xp (JC-26)
title: string | null // 'SENIOR <ROLE>' from the member's highest level ≥ 3, 'MASTER <ROLE>' from ≥ 5, null below (titleFor)
}
// The fixed display order of the four skills (JC-27, spec §6) — the CREW-tab line and the
// /api/crew payload never depend on DB row order.
const SKILL_ORDER: SkillName[] = ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH']
// One CrewDto from a crew row and that member's crew_skills rows: the four fixed-order
// { skill, level } entries (JC-26/27) plus the title from the member's highest level
// (titleFor — null below L3). A member with no rows (a hand-built save whose crew was never
// seeded) reads all-L0 with a null title — the same fallback as makePowerOf's missing-row →
// L0 → power 1, so a preview built from the DTO matches the queueing route's rows.
function buildCrewDto(
c: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string },
skillRows: { crewId: number; skill: string; level: number }[],
): CrewDto {
const levelBySkill = new Map<SkillName, number>()
for (const r of skillRows) levelBySkill.set(r.skill as SkillName, r.level)
const skills = SKILL_ORDER.map((skill) => ({ skill, level: levelBySkill.get(skill) ?? 0 }))
const title = titleFor({ name: c.name, role: c.role }, skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
return { id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin, skills, title }
}
// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
// carry a sibling tasks field, so the endpoint returns this object.
export interface CrewState {
crew: CrewDto[]
tasks: QueueDto[]
}
// The full roster in join order (starting crew first, then finds), wrapped with the queue.
// Auto-initializes like the other reads.
export async function getCrewState(db: GameDb): Promise<CrewState> {
await ensureSave(db)
const [crew, skillRows] = await Promise.all([
db.db.select().from(schema.crew),
db.db.select().from(schema.crewSkills),
])
const rowsByCrew = new Map<number, typeof skillRows>()
for (const r of skillRows) {
const list = rowsByCrew.get(r.crewId) ?? []
list.push(r)
rowsByCrew.set(r.crewId, list)
}
return { crew: crew.map((c) => buildCrewDto(c, rowsByCrew.get(c.id) ?? [])), tasks: await taskQueue(db) }
}
export interface ExploreResult extends SystemState {
outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing)
crewFound: CrewDto | null // a member who joined the ship on this scan (null when none)
}
// EXPLORE — scan the payload's planet (any system — the UI only offered the current
// system's rows; a queued scan may resolve while the ship is elsewhere): deterministic
// haul into the inventory, planet marked explored, crew-join roll on the same
// (seed, planet) stream.
export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> {
await ensureSave(db)
const { planetId } = JSON.parse(task.payload) as { planetId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
const rng = explorationRng(save.seed, target.id)
let outcome = rollExploration(rng) // resource/part haul first…
// Trait outcome effects (#43): the assigned team (lead + helpers) can change how this
// per-planet stream is drawn. Read their traits from the roster (a plain select — no rng draw).
const assigned = new Set([task.leadCrewId, ...task.helpers].filter((id) => id > 0))
const crewRows = await db.db.select().from(schema.crew)
let hasFHT = false
let hasTalker = false
for (const c of crewRows) {
if (!assigned.has(c.id)) continue
if (c.traitGood === 'FINDS HIDDEN THINGS' || c.traitBad === 'FINDS HIDDEN THINGS') hasFHT = true
if (c.traitGood === 'TALKS TO ANYONE' || c.traitBad === 'TALKS TO ANYONE') hasTalker = true
}
// FINDS HIDDEN THINGS: one extra haul roll on the same seeded stream, appended (spec §5).
if (hasFHT) outcome = [...outcome, ...rollExploration(rng)]
const takenNames = new Set(crewRows.map((c) => c.name))
// TALKS TO ANYONE: recruit chance 30% instead of the base 18% (base === crew.ts CREW_CHANCE).
const foundMember = rollCrewMember(rng, takenNames, hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE)
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
for (const item of outcome) {
// Accumulate: the same kind+name on this save bumps qty instead of adding a row.
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name)
if (existing) {
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id))
} else {
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
}
}
let crewFound: CrewDto | null = null
if (foundMember) {
const systems = await db.db.select().from(schema.solarSystems)
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
await seedCrewSkills(db, save.id) // the recruit gets role-based starting rows like the starting crew — the seeder is idempotent, so only the new member is seeded (R-P6)
crewFound = buildCrewDto(row, await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, row.id)))
}
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, save.turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
await topUpMissionBoard(db)
await persist(db)
return { outcome, crewFound }
}
// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored,
// costs a turn, accumulates finds into inventory and writes an event. The same per-planet
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing
// planet may be scanned (the UI only offers the current system's rows).
export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
// The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn.
const turn = save.turn + 1
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId }))
return { ...(await readSystemState(db)), outcome, crewFound }
}
// The explore pre-checks, extracted for the queueing route (5a): the same codes and messages
// as explorePlanet — unknown body (404), already scanned (409).
export async function checkExploreTarget(db: GameDb, planetId: number): Promise<void> {
await ensureSave(db)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
}
// --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) ---------------------
//
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
// The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
// before the final persist); this function owns the table writes.
// The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type —
// a TaskType without a resolver is a compile error.
const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
JUMP: resolveJumpTask,
EXPLORE: resolveExploreTask,
SHIP_UPGRADE: resolveShipUpgradeTask,
SHIP_EXPAND: resolveShipExpandTask,
SHIP_BUILD: resolveShipBuildTask,
RESEARCH: resolveResearchTask,
}
// Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the
// routes' queueing path).
function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task {
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
}
// The plain queue snapshot Task 3's engine runs over, mapped from the schema's rows: crew,
// tasks, inventory, shipSystems and tech, all in id order (single save, unscoped). The route
// never touches lib/db/schema, so the single save row is resolved here, not passed in.
export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const [crew, taskRows, inventory, shipSystems, tech, skillRows] = await Promise.all([
db.db.select().from(schema.crew),
db.db.select().from(schema.tasks),
db.db.select().from(schema.inventoryItems),
db.db.select().from(schema.shipSystems),
db.db.select().from(schema.tech),
db.db.select().from(schema.crewSkills),
])
return {
turn: save.turn,
crew: crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id))),
tasks: taskRows.map(rowToTask),
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
}
}
// The route's DB-boundary insert (the route→save pattern): queueTask's row minus the two
// values only the DB boundary knows (id autoincrement, saveId = the single save's id).
// payload is already a JSON string from queueTask (passed through); the crew ids serialize
// to the JSON column. Persists so the queue survives a crash; returns the new row id.
export async function saveTaskRow(db: GameDb, task: Omit<Task, 'id' | 'saveId'>): Promise<number> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const [row] = await db.db
.insert(schema.tasks)
.values({ saveId: save.id, type: task.type, payload: task.payload, leadCrewId: task.leadCrewId, helpers: JSON.stringify(task.helpers), duration: task.duration, remaining: task.remaining, status: task.status, queuedAtTurn: task.queuedAtTurn })
.returning({ id: schema.tasks.id })
await persist(db)
return row.id
}
// The route's DB-boundary hold write (the route→save pattern): queueTask's deducted inventory
// replaces the save's rows — array order = oldest first = id order, so spendItems' oldest-row-
// first consumption and the id-ordered reads stay consistent after the rewrite. Persists so
// the deduction survives a crash; the queueing routes call it with queueTask's result.
export async function saveInventory(db: GameDb, items: InventoryItemDto[]): Promise<void> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.saveId, save.id))
for (const item of items) {
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
}
await persist(db)
}
export async function runTurn(db: GameDb): Promise<TickResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const state = await queueStateFromSave(db)
// PR B (§6): the skill rows this tick's XP grant runs over — string ids end to end (JC-24):
// crew_skills' numeric id columns stringify into SkillRow's, and `members` keys off
// String(crew.id) so grantXp's crewIds.includes(r.crewId) matches. The snapshot is taken
// before any resolution: a recruit who joins mid-tick has no rows in it — and a recruit is
// never on a task that completes in the same tick.
const skillRows0: SkillRow[] = (await db.db.select().from(schema.crewSkills)).map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
let skillRows = skillRows0
const members: Record<string, CrewIdentity> = Object.fromEntries(state.crew.map((c): [string, CrewIdentity] => [String(c.id), { name: c.name, role: c.role }]))
const levelUpEvents: string[] = []
const result = tickTurn(state)
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
// and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new
// turn, exactly where today's actions place them.
await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id))
// (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard
// (stale or double-resolved target): the task fails — it is already DONE (the tick freed
// the team; the turn is spent), the effect simply does not land, and no event is written
// for it. Only the four domain error classes fail a task; anything else rethrows.
for (const task of result.completed) {
try {
await TASK_RESOLVERS[task.type](db, task)
// §6: every assigned member — lead and helpers equally — gains +1 XP in the action's
// skill. The rows reassign, so a member on several completions this tick accumulates,
// and the level-up events collect for the batch write below. A task whose resolver
// failed its 4a guard grants nothing: its effect did not land.
const { rows, events } = grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)
skillRows = rows
levelUpEvents.push(...events)
} catch (err) {
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
throw err
}
}
// The tick's level-up events as one batch at the new turn, and the crew_skills rows that
// changed — both land in the final whole-DB write (JC-25): the resolution, the events and
// the skill progression commit together. Only rows whose level or xp moved are written.
for (const message of levelUpEvents) {
await db.db.insert(schema.events).values({ saveId: save.id, turn: result.turn, type: 'level_up', message, createdAt: new Date().toISOString() })
}
const beforeSkill = new Map(skillRows0.map((r): [string, SkillRow] => [`${r.crewId}:${r.skill}`, r]))
for (const row of skillRows) {
const old = beforeSkill.get(`${row.crewId}:${row.skill}`)!
if (old.level !== row.level || old.xp !== row.xp) {
await db.db.update(schema.crewSkills).set({ level: row.level, xp: row.xp }).where(and(eq(schema.crewSkills.crewId, Number(row.crewId)), eq(schema.crewSkills.skill, row.skill)))
}
}
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
// one final whole-DB write makes the turn atomic on disk.
for (const before of state.tasks) {
const after = result.tasks.find((t) => t.id === before.id)
if (after && (after.status !== before.status || after.remaining !== before.remaining)) {
await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id))
}
}
await persist(db)
return result
}
// The queueing routes' power-band builder (PR B, §6): the save's roster and crew_skills rows
// folded into makePowerOf's closure for the action's skill — the same closure the live
// duration preview uses, so a queued task's stored duration matches what the crew picker
// shows. The single save row is passed in by the route rather than re-selected here.
export async function makePowerOfFor(db: GameDb, save: { id: number }, type: TaskType): Promise<(m: CrewMember) => number> {
const [crew, skillRows] = await Promise.all([
db.db.select().from(schema.crew).where(eq(schema.crew.saveId, save.id)),
db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.saveId, save.id)),
])
const roster: CrewDto[] = crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id)))
const rows: SkillRow[] = skillRows.map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
return makePowerOf(roster, rows, type)
}