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 { generateRing, generateStartRegion, mulberry32 } 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) } async function insertSystems(db: GameDb, saveId: number, systems: { name: string; x: number; y: number }[], discovered: boolean): Promise> { const rows = await db.db .insert(schema.solarSystems) .values(systems.map((s) => ({ saveId, name: s.name, x: s.x, y: s.y, discovered }))) .returning({ id: schema.solarSystems.id, name: schema.solarSystems.name }) return new Map(rows.map((r) => [r.name, r.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 ids = await insertSystems(db, save.id, [region.start, ...region.neighbors], false) const startId = ids.get(region.start.name)! await db.db.update(schema.solarSystems).set({ discovered: true, isCurrent: true }).where(eq(schema.solarSystems.id, startId)) const resolve = (index: number) => (index === 0 ? startId : ids.get(region.neighbors[index - 1].name)!) 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 ids = await insertSystems(db, save.id, ring.systems, false) const resolve = (index: number) => (index === 0 ? target.id : ids.get(ring.systems[index - 1].name)!) 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) }