import { eq } 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 } export interface WorldState { systems: WorldSystemDto[] edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output turn: number } // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) } } export async function getWorldState(db: GameDb): Promise { 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() 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, } } // --- 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 { 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 { 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) } // --- 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 } // 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 { 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 } } export async function getSystemState(db: GameDb): Promise { 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 ', ' for members found while exploring } // The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads. export async function getCrewState(db: GameDb): Promise { await ensureSave(db) return (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 })) } 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 { 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 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 Promise> = { 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 } } export async function runTurn(db: GameDb): Promise { 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), ]) const state: QueueState = { 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 })), } 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 }