From 58ac64af4f3ba6509ea7d45fae84b2507744532f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 21:28:14 +0000 Subject: [PATCH] =?UTF-8?q?game:=20save=20lifecycle=20=E2=80=94=20auto-ini?= =?UTF-8?q?t,=20world=20state,=20jump=20with=20adjacency=20validation=20+?= =?UTF-8?q?=20chart=20growth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/save.test.ts | 132 +++++++++++++++++++++++++++++++++++++ lib/game/save.ts | 147 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 lib/game/save.test.ts create mode 100644 lib/game/save.ts diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts new file mode 100644 index 0000000..011ef9a --- /dev/null +++ b/lib/game/save.test.ts @@ -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) + }) +}) diff --git a/lib/game/save.ts b/lib/game/save.ts new file mode 100644 index 0000000..d0d57cc --- /dev/null +++ b/lib/game/save.ts @@ -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> { + 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 { + 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 { + 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 { + 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() + 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 { + 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) +}