From c7580d92bdaf72d6204cf1eebe741e1f8882a2c0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:25:06 +0000 Subject: [PATCH] =?UTF-8?q?db+game:=20schema=5Fversion=20on=20saves=20with?= =?UTF-8?q?=20ordered=20idempotent=20backfill=20(v2=20bodies,=20v3=20crew)?= =?UTF-8?q?=20=E2=80=94=20no=20more=20DB=20wipes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/db/client.ts | 11 ++++++- lib/db/schema.ts | 3 ++ lib/game/save.test.ts | 67 +++++++++++++++++++++++++++++++++++++++- lib/game/save.ts | 72 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/lib/db/client.ts b/lib/db/client.ts index a11e4d6..c786749 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS saves ( id INTEGER PRIMARY KEY AUTOINCREMENT, seed TEXT NOT NULL, turn INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + schema_version INTEGER -- which save-schema version this row was backfilled up to (NULL = v1) ); CREATE TABLE IF NOT EXISTS solar_systems ( @@ -98,6 +99,14 @@ async function open(file: string): Promise { const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database(); raw.exec(DDL); // no-op for tables that already exist in an older file + // Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot + // add columns, so check and add any that are missing. This is what makes DB wipes unnecessary — + // old saves open fine and get backfilled by ensureSave (see lib/game/save.ts). + const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? []; + if (!saveCols.includes('schema_version')) { + raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER'); + } + const instance: GameDb = { db: drizzle(raw, { schema }), raw, file }; // First run: stamp when this DB was created and land the file on disk so the ./db volume diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 4dccd33..ddf6b6b 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -11,6 +11,9 @@ export const saves = sqliteTable('saves', { seed: text('seed').notNull(), turn: integer('turn').notNull().default(0), createdAt: text('created_at').notNull(), + // Which schema version this save was last brought up to (NULL on saves from before the + // column existed = v1). ensureSave runs ordered idempotent backfill steps for anything below. + schemaVersion: integer('schema_version'), }) export const solarSystems = sqliteTable('solar_systems', { diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 3b0129e..c453d7a 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -2,10 +2,11 @@ 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 { eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' -import { getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' +import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' const dirs: string[] = [] @@ -300,3 +301,67 @@ describe('explorePlanet crew finds', () => { expect((await getCrewState(db)).length).toBe(before.length + 1) }) }) + +describe('backfill (old saves)', () => { + // Planets/moons of one system in insertion order, nested by parent — ids are excluded so a + // regenerated set compares equal to the original even though autoincrement moved on. + function bodyShape(rows: { id: number; name: string; type: string; hazard: boolean; parentId: number | null }[]) { + return rows + .filter((r) => r.parentId === null) + .map((p) => ({ name: p.name, type: p.type, hazard: p.hazard, moons: rows.filter((m) => m.parentId === p.id).map((m) => [m.name, m.type, m.hazard]) })) + } + + it('upgrades a v1 save (no bodies, no crew) in place — deterministic and idempotent', async () => { + const { db } = await freshFile() + await getWorldState(db) // new game: everything seeded at the current version + + const systems = await db.db.select().from(schema.solarSystems) + const rowsFor = (systemId: number) => db.db.select().from(schema.planets).where(eq(schema.planets.systemId, systemId)) + const beforeBodies: Record = {} + for (const s of systems) { + const rows = await rowsFor(s.id) + beforeBodies[s.id] = bodyShape(rows.sort((a, b) => a.id - b.id)) + } + const crewOf = (rows: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }[]) => + rows.map((c) => ({ name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) // ids excluded — re-seed moves autoincrement on + const beforeCrew = crewOf(await db.db.select().from(schema.crew)) + + // Simulate a save created by pre-v0.4 code: no bodies, no crew, no version stamp (NULL). + await db.db.delete(schema.planets) + await db.db.delete(schema.crew) + await db.db.update(schema.saves).set({ schemaVersion: null }) + + const world = await getWorldState(db) // ensureSave → backfill runs + expect(world.systems).toHaveLength(7) + + for (const s of systems) { + const rows = await rowsFor(s.id) + expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) // identical to fresh generation + } + expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew) + + const [save] = await db.db.select().from(schema.saves) + expect(save.schemaVersion).toBe(SCHEMA_VERSION) + + // Idempotent: a second pass changes nothing. + const planetCount = (await db.db.select().from(schema.planets)).length + await getWorldState(db) + expect((await db.db.select().from(schema.planets)).length).toBe(planetCount) + expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length) + for (const s of systems) { + const rows = await rowsFor(s.id) + expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) + } + }) + + it('leaves an up-to-date save untouched', async () => { + const { db } = await freshFile() + await getWorldState(db) + const planetCount = (await db.db.select().from(schema.planets)).length + const [save] = await db.db.select().from(schema.saves) + expect(save.schemaVersion).toBe(SCHEMA_VERSION) + + await getWorldState(db) // second read: no backfill work to do + expect((await db.db.select().from(schema.planets)).length).toBe(planetCount) + }) +}) diff --git a/lib/game/save.ts b/lib/game/save.ts index 4922e3e..d4701fd 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -45,20 +45,36 @@ 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 { - 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 }) +// 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 { + 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: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard }) + 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 { + 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 { + 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 { for (const [a, b] of edges) { const from = resolve(a) @@ -75,17 +91,29 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi } } -// 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). +// 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). When #9/#10 add +// seeding, bump SCHEMA_VERSION and append a step here. +export const SCHEMA_VERSION = 3 // v1: save + start region only · v2: bodies for every system · v3: starting crew + +// 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 { const existing = await db.db.select().from(schema.saves).limit(1) - if (existing.length > 0) return + if (existing.length === 0) { + await createNewGame(db) + return + } + await backfillSave(db, existing[0]) +} +async function createNewGame(db: GameDb): Promise { 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() + 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[] = [] @@ -100,6 +128,26 @@ export async function ensureSave(db: GameDb): Promise { 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 { + 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 (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 { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1)