Files
SpaceWanderer/lib/game/save.ts
T

381 lines
18 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 { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions'
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<void> {
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<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 })
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<void> {
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<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
}
}
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<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)
// #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}`)
const turn = save.turn + hopCount
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 checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
await topUpMissionBoard(db)
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)
}
// --- Crew roster --------------------------------------------------------------
export interface CrewDto {
id: number
name: string
role: string
traitGood: string
traitBad: string
origin: string // 'STARTING CREW' or '<planet>, <system>' 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<CrewDto[]> {
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)
}
// 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<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 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
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 })
}
}
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.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}${crewText}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
await topUpMissionBoard(db)
await persist(db)
return { ...(await readSystemState(db)), outcome, crewFound }
}