From 09108919953a3ea152d688c0730950209d592205 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:32:04 +0000 Subject: [PATCH] db+game: ship_systems table + starting ship seed (new game and v4 backfill step) --- lib/db/client.ts | 9 +++++++++ lib/db/schema.ts | 16 ++++++++++++++- lib/game/save.test.ts | 25 ++++++++++++++++++++++- lib/game/save.ts | 16 ++++++++++++++- lib/game/ship-costs.ts | 45 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 lib/game/ship-costs.ts diff --git a/lib/db/client.ts b/lib/db/client.ts index c786749..e831be9 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -72,6 +72,15 @@ CREATE TABLE IF NOT EXISTS crew ( origin TEXT NOT NULL -- 'STARTING CREW' or ', ' for members found while exploring ); +CREATE TABLE IF NOT EXISTS ship_systems ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + type TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 0, -- 0 = not built yet ('built' is derived: level > 0) + capacity INTEGER NOT NULL DEFAULT 0, + UNIQUE (save_id, type) +); + CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ddf6b6b..c551eab 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' // Drizzle schema for game state. Single-player by design: exactly ONE save row ever exists // (no auth/accounts), so every table hangs off saves.id but only one save is in play. @@ -74,6 +74,20 @@ export const crew = sqliteTable('crew', { origin: text('origin').notNull(), }) +// The ship's systems, one row per known type (see SHIP_TYPES in lib/game/ship-costs.ts). +// `built` is derived, not stored: level > 0 means built — unbuilt rows sit at level/capacity 0. +export const shipSystems = sqliteTable( + 'ship_systems', + { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + type: text('type').notNull(), + level: integer('level').notNull().default(0), // 0 = not built yet + capacity: integer('capacity').notNull().default(0), + }, + (t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)], +) + // Append-only game log. Writers so far: new-game + jump events; later issues add more // writers and a read endpoint. export const events = sqliteTable('events', { diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index c453d7a..3a78532 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -6,6 +6,7 @@ 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 { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' @@ -325,10 +326,14 @@ describe('backfill (old saves)', () => { 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)) + const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) => + rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on + const beforeShip = shipOf(await db.db.select().from(schema.shipSystems)) - // Simulate a save created by pre-v0.4 code: no bodies, no crew, no version stamp (NULL). + // Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL). await db.db.delete(schema.planets) await db.db.delete(schema.crew) + await db.db.delete(schema.shipSystems) await db.db.update(schema.saves).set({ schemaVersion: null }) const world = await getWorldState(db) // ensureSave → backfill runs @@ -339,6 +344,7 @@ describe('backfill (old saves)', () => { 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) + expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip) const [save] = await db.db.select().from(schema.saves) expect(save.schemaVersion).toBe(SCHEMA_VERSION) @@ -348,6 +354,7 @@ describe('backfill (old saves)', () => { 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) + expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip) 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]) @@ -364,4 +371,20 @@ describe('backfill (old saves)', () => { await getWorldState(db) // second read: no backfill work to do expect((await db.db.select().from(schema.planets)).length).toBe(planetCount) }) + + it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => { + const { db } = await freshFile() + await getWorldState(db) + const rows = (await db.db.select().from(schema.shipSystems)).sort((a, b) => a.type.localeCompare(b.type)) + expect(rows).toHaveLength(SHIP_TYPES.length) + for (const def of SHIP_TYPES) { + const row = rows.find((r) => r.type === def.type)! + if (def.builtAtStart) { + expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY }) + } else { + expect(row.level).toBe(0) // unbuilt until a BUILD action + expect(row.capacity).toBe(0) + } + } + }) }) diff --git a/lib/game/save.ts b/lib/game/save.ts index d4701fd..6916314 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -3,6 +3,7 @@ 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 { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world' // Save lifecycle: auto-init on first hit, world state reads, and the jump action. @@ -95,7 +96,15 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi // 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 +export const SCHEMA_VERSION = 4 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship + +// 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 { + 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 }) + } +} // 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). @@ -123,6 +132,7 @@ async function createNewGame(db: GameDb): Promise { 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 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) @@ -141,6 +151,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema 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 (from !== SCHEMA_VERSION) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) diff --git a/lib/game/ship-costs.ts b/lib/game/ship-costs.ts new file mode 100644 index 0000000..cdb213b --- /dev/null +++ b/lib/game/ship-costs.ts @@ -0,0 +1,45 @@ +// Pure ship-system model: the known system types, base values and the action cost model. +// No DB imports — both lib/game/ship.ts (server) and the frontend import from here so the +// UI's affordability/cap logic can never drift from what the API enforces. + +export interface ShipTypeDef { + type: string + builtAtStart: boolean // part of the starting ship? (otherwise unbuilt until BUILD) +} + +// The known system types — display order. Reuses the placeholder SHIP SYSTEMS tab card names +// for continuity, plus HULL so the vessel-metrics hull bar can be driven by a real level. +export const SHIP_TYPES: ShipTypeDef[] = [ + { type: 'HULL', builtAtStart: true }, + { type: 'REACTOR', builtAtStart: true }, + { type: 'LIFE SUPPORT', builtAtStart: true }, + { type: 'NAV COMPUTER', builtAtStart: true }, + { type: 'COMMS ARRAY', builtAtStart: true }, + { type: 'WEAPONS', builtAtStart: false }, +] + +export const BASE_LEVEL = 1 // what a system starts at when built (starting ship or BUILD) +export const BASE_CAPACITY = 2 +export const MAX_LEVEL = 5 // UPGRADE cap +export const MAX_CAPACITY = 10 // EXPAND cap + +// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the +// required kind first (see spendItems in lib/game/ship.ts): +// UPGRADE {systemId} — `level` units of any resource (scales with current level) +// EXPAND {systemId} — `capacity` units of any resource (scales with current capacity) +// BUILD {type} — a flat amount of parts (parts are rarer than resources, so builds +// are the bigger investment) +export function upgradeCost(level: number): number { + return level +} + +export function expandCost(capacity: number): number { + return capacity +} + +export const BUILD_PART_COST = 3 + +// Total qty of one kind across the cargo hold — used for affordability checks. +export function totalQty(items: { kind: string; qty: number }[], kind: 'resource' | 'part'): number { + return items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0) +}