game: save lifecycle — auto-init, world state, jump with adjacency validation + chart growth
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user