game: save lifecycle — auto-init, world state, jump with adjacency validation + chart growth

This commit is contained in:
2026-09-28 21:28:14 +00:00
parent 1634c0602e
commit 58ac64af4f
2 changed files with 279 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { openGameDb, type GameDb } from '@/lib/db/client'
import { getWorldState, jumpToSystem, type WorldState } from './save'
const dirs: string[] = []
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-save-test-'))
dirs.push(dir)
const file = path.join(dir, 'test.db')
return { db: await openGameDb(file), file }
}
afterAll(async () => {
for (const d of dirs) await rm(d, { recursive: true, force: true })
})
function currentOf(world: WorldState): WorldState['systems'][number] {
const current = world.systems.filter((s) => s.current)
expect(current).toHaveLength(1)
return current[0]
}
describe('ensureSave / getWorldState', () => {
it('auto-initializes a save on first read: start + 6 neighbours, turn 0, new_game event', async () => {
const { db } = await freshFile()
const world = await getWorldState(db)
expect(world.turn).toBe(0)
expect(world.systems).toHaveLength(7)
expect(currentOf(world).discovered).toBe(true)
expect(world.systems.filter((s) => !s.discovered)).toHaveLength(6)
expect(world.edges).toHaveLength(12) // start↔each neighbour + ring cycle
const ids = new Set(world.systems.map((s) => s.id))
for (const [a, b] of world.edges) {
expect(ids.has(a)).toBe(true)
expect(ids.has(b)).toBe(true)
}
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
expect(rows[0].values).toEqual([['new_game', 0]])
})
it('is idempotent — a second read does not regenerate the world', async () => {
const { db } = await freshFile()
const first = await getWorldState(db)
const second = await getWorldState(db)
expect(second).toEqual(first)
})
})
describe('jumpToSystem validation', () => {
it('rejects unknown systems with 404 and jumping to the current system with 409', async () => {
const { db } = await freshFile()
const world = await getWorldState(db)
const start = currentOf(world)
await expect(jumpToSystem(db, 999_999)).rejects.toMatchObject({ status: 404 })
await expect(jumpToSystem(db, start.id)).rejects.toMatchObject({ status: 409 })
const after = await getWorldState(db)
expect(after.turn).toBe(0) // rejected jumps mutate nothing
expect(currentOf(after).id).toBe(start.id)
})
it('rejects non-adjacent systems with 409 and leaves state untouched', async () => {
const { db } = await freshFile()
const w0 = await getWorldState(db)
const start = currentOf(w0)
// Every initial neighbour is adjacent to the start, so jump once first.
const n0 = w0.systems.find((s) => !s.current)!
await jumpToSystem(db, n0.id)
const w1 = await getWorldState(db)
expect(currentOf(w1).id).toBe(n0.id)
// A system with no route to the current one (ring members two steps away qualify).
const adjacent = new Set(
w1.edges.filter(([a, b]) => a === n0.id || b === n0.id).flatMap(([a, b]) => [a, b]),
)
const far = w1.systems.find((s) => !adjacent.has(s.id))!
await expect(jumpToSystem(db, far.id)).rejects.toMatchObject({ status: 409 })
const after = await getWorldState(db)
expect(after.turn).toBe(1)
expect(currentOf(after).id).toBe(n0.id)
})
})
describe('jumpToSystem success path', () => {
it('sets current, increments turn, discovers the target and grows the chart', async () => {
const { db } = await freshFile()
const w0 = await getWorldState(db)
const start = currentOf(w0)
const target = w0.systems.find((s) => !s.current)! // all initial neighbours are adjacent to the start
const after = await jumpToSystem(db, target.id)
expect(after.turn).toBe(1)
expect(currentOf(after).id).toBe(target.id)
expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true)
// Expansion: 4 new undiscovered neighbours around the target, each routed to it.
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
expect(fresh).toHaveLength(4)
expect(fresh.every((s) => !s.discovered)).toBe(true)
for (const n of fresh) {
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true)
}
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
expect(rows[0].values).toEqual([['new_game', 0], ['jump', 1]])
})
it('persists the current system and turn across a DB reopen (container restart)', async () => {
const { db, file } = await freshFile()
const w0 = await getWorldState(db)
const target = w0.systems.find((s) => !s.current)!
const after = await jumpToSystem(db, target.id)
const reopened = await openGameDb(file) // a new process would see the same file
const again = await getWorldState(reopened)
expect(again.turn).toBe(after.turn)
expect(currentOf(again).id).toBe(target.id)
expect(again.systems).toHaveLength(after.systems.length)
})
})
+147
View File
@@ -0,0 +1,147 @@
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)
}