675 lines
34 KiB
TypeScript
675 lines
34 KiB
TypeScript
import { 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 { 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 { 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 = 7 // 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
|
||
|
||
// 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 })
|
||
}
|
||
}
|
||
|
||
// 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 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 (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
|
||
}
|
||
|
||
// 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 = (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
||
return { crew, 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)
|
||
const outcome = rollExploration(rng) // resource/part haul first…
|
||
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
|
||
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
|
||
|
||
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()
|
||
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
|
||
}
|
||
|
||
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] = 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),
|
||
])
|
||
return {
|
||
turn: save.turn,
|
||
crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
|
||
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)
|
||
|
||
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)
|
||
} catch (err) {
|
||
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
|
||
throw err
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|