148 lines
6.1 KiB
TypeScript
148 lines
6.1 KiB
TypeScript
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<Map<string, number>> {
|
|
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<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
|
|
}
|
|
}
|
|
|
|
// 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<void> {
|
|
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<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,
|
|
}
|
|
}
|
|
|
|
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)
|
|
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)
|
|
}
|