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 { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, 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) } // Inserts one system together with its generated bodies (planets + moons), so a system is // never left without worlds to explore. Bodies derive from (seed, system id) via bodyRng — // independent of generation order and every other system. 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 }) for (const body of generatePlanets(bodyRng(seed, row.id))) { const [planet] = await db.db.insert(schema.planets).values({ systemId: row.id, 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: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard }) } } return row.id } 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 } } // Creates the save + starting region when the DB has none. No-op otherwise — safe to call // on every request (single player, one process: check-then-insert cannot race in practice). export async function ensureSave(db: GameDb): Promise { const existing = await db.db.select().from(schema.saves).limit(1) if (existing.length > 0) return 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 }).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 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) } 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, } } 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) const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id) if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`) const turn = save.turn + 1 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, 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.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() }) await persist(db) 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) } export interface ExploreResult extends SystemState { outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing) } // 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. 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`) const outcome = rollExploration(explorationRng(save.seed, target.id)) const turn = save.turn + 1 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 }) } } const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}`, createdAt: new Date().toISOString() }) await persist(db) return { ...(await readSystemState(db)), outcome } }