Files
SpaceWanderer/lib/game/save.ts
T

249 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 })
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<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 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<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 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<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 }
}
export async function getSystemState(db: GameDb): Promise<SystemState> {
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<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`)
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 }
}