From c7580d92bdaf72d6204cf1eebe741e1f8882a2c0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:25:06 +0000 Subject: [PATCH 1/7] =?UTF-8?q?db+game:=20schema=5Fversion=20on=20saves=20?= =?UTF-8?q?with=20ordered=20idempotent=20backfill=20(v2=20bodies,=20v3=20c?= =?UTF-8?q?rew)=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) From 09108919953a3ea152d688c0730950209d592205 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:32:04 +0000 Subject: [PATCH 2/7] 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) +} From 5fe306d64db58428f2c9ca603d6cd7b0488cf40c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:35:28 +0000 Subject: [PATCH 3/7] =?UTF-8?q?game:=20ship=20actions=20=E2=80=94=20upgrad?= =?UTF-8?q?e/expand/build=20with=20cost=20model,=20validation=20(404/409/4?= =?UTF-8?q?22),=20turn=20+=20event=20per=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/ship.test.ts | 148 ++++++++++++++++++++++++++++++++++++++++++ lib/game/ship.ts | 124 +++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 lib/game/ship.test.ts create mode 100644 lib/game/ship.ts diff --git a/lib/game/ship.test.ts b/lib/game/ship.test.ts new file mode 100644 index 0000000..b6dc322 --- /dev/null +++ b/lib/game/ship.test.ts @@ -0,0 +1,148 @@ +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 { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs' +import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship' + +const dirs: string[] = [] + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-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 }) +}) + +async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty }) +} + +const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)! + +describe('getShipState', () => { + it('new game: starting ship in display order — core types built at base values, WEAPONS unbuilt', async () => { + const { db } = await freshFile() + const state = await getShipState(db) + + expect(state.systems.map((s) => s.type)).toEqual(SHIP_TYPES.map((t) => t.type)) // display order follows SHIP_TYPES + for (const def of SHIP_TYPES) { + const row = systemOf(state, def.type) + if (def.builtAtStart) { + expect(row.built).toBe(true) + expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY }) + } else { + expect(row.built).toBe(false) // unbuilt until a BUILD action + expect(row.level).toBe(0) + expect(row.capacity).toBe(0) + } + } + expect(state.inventory).toEqual([]) + expect(state.turn).toBe(0) + }) +}) + +describe('upgradeSystem', () => { + it('rejects with 422 and mutates nothing when the hold is empty', async () => { + const { db } = await freshFile() + const before = await getShipState(db) + const reactorId = systemOf(before, 'REACTOR').id + + await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 422 }) + + const after = await getShipState(db) + expect(systemOf(after, 'REACTOR').level).toBe(BASE_LEVEL) // no level change… + expect(after.turn).toBe(before.turn) // …and no turn spent on a failed action + }) + + it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => { + const { db } = await freshFile() + await getShipState(db) + await expect(upgradeSystem(db, 999)).rejects.toMatchObject({ status: 404 }) + const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id + await expect(upgradeSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 }) + }) + + it('consumes the cost from the oldest resource row first (deleting rows that hit zero), costs a turn and writes an event', async () => { + const { db } = await freshFile() + await getShipState(db) + await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 1 + await addItem(db, 'resource', 'CRYSTAL DUST', 5) + + const reactorId = systemOf(await getShipState(db), 'REACTOR').id + const state = await upgradeSystem(db, reactorId) // level 1 → cost 1 resource + + expect(systemOf(state, 'REACTOR').level).toBe(BASE_LEVEL + 1) + expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 5]]) // IRON ORE row deleted + expect(state.turn).toBe(1) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'upgrade')! + expect(event.turn).toBe(1) + expect(event.message).toContain('REACTOR') + }) + + it('caps at MAX_LEVEL with 409', async () => { + const { db } = await freshFile() + await getShipState(db) + const reactorId = systemOf(await getShipState(db), 'REACTOR').id + await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // cost path covered above + + await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 }) + expect((await getShipState(db)).turn).toBe(0) + }) +}) + +describe('expandSystem', () => { + it('raises capacity by one for `capacity` resources; caps at MAX_CAPACITY with 409', async () => { + const { db } = await freshFile() + await getShipState(db) + await addItem(db, 'resource', 'IRON ORE', BASE_CAPACITY + 1) // cost is the current capacity (2), one left over + + const hullId = systemOf(await getShipState(db), 'HULL').id + const state = await expandSystem(db, hullId) + expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1) + expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]]) + + await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) + await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 }) + }) + + it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => { + const { db } = await freshFile() + await getShipState(db) + await expect(expandSystem(db, 999)).rejects.toMatchObject({ status: 404 }) + const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id + await expect(expandSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 }) + }) +}) + +describe('buildSystem', () => { + it('builds an unbuilt type at base values for a flat parts cost; a second build is 409 and unknown types are 404', async () => { + const { db } = await freshFile() + await getShipState(db) + + await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet + + await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST) + const state = await buildSystem(db, 'WEAPONS') + const weapons = systemOf(state, 'WEAPONS') + expect(weapons.built).toBe(true) + expect({ level: weapons.level, capacity: weapons.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY }) + expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // parts row deleted + expect(state.turn).toBe(1) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')! + expect(event.message).toContain('WEAPONS') + + await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built + await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type + }) +}) diff --git a/lib/game/ship.ts b/lib/game/ship.ts new file mode 100644 index 0000000..e628991 --- /dev/null +++ b/lib/game/ship.ts @@ -0,0 +1,124 @@ +import { eq } from 'drizzle-orm' +import * as schema from '@/lib/db/schema' +import { persist, type GameDb } from '@/lib/db/client' +import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs' +import { ensureSave, type InventoryItemDto } from './save' + +export interface ShipSystemDto { + id: number + type: string + level: number + capacity: number + built: boolean // derived — level > 0 (unbuilt rows sit at level/capacity 0) +} + +// The ship's full state for the SHIP SYSTEMS tab and left column. +export interface ShipState { + systems: ShipSystemDto[] // display order follows SHIP_TYPES + inventory: InventoryItemDto[] + turn: number +} + +// Carries an HTTP status so route handlers can map failures to responses without re-parsing. +export class ShipError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +async function readShipState(db: GameDb): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const rows = await db.db.select().from(schema.shipSystems) + const orderOf = (type: string) => { + const i = SHIP_TYPES.findIndex((t) => t.type === type) + return i === -1 ? 99 : i // unknown types (shouldn't exist) sort last + } + const systems = [...rows] + .sort((a, b) => orderOf(a.type) - orderOf(b.type)) + .map((r) => ({ id: r.id, type: r.type, level: r.level, capacity: r.capacity, built: r.level > 0 })) + const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })) + return { systems, inventory, turn: save.turn } +} + +// The ship's systems + cargo hold + turn. Auto-initializes like the other reads. +export async function getShipState(db: GameDb): Promise { + await ensureSave(db) + return readShipState(db) +} + +// Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit +// zero are deleted. The total is checked up front, so an insufficient hold mutates nothing. +async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise { + const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id) + if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) { + throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`) + } + let remaining = amount + for (const row of rows) { + if (remaining <= 0) break + const take = Math.min(row.qty, remaining) + remaining -= take + if (take >= row.qty) await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, row.id)) + else await db.db.update(schema.inventoryItems).set({ qty: row.qty - take }).where(eq(schema.inventoryItems.id, row.id)) + } +} + +async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise { + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId)) + await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() }) +} + +// UPGRADE — level up a built system by one (capped at MAX_LEVEL). Cost: `level` units of any resource. +export async function upgradeSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + if (target.level >= MAX_LEVEL) throw new ShipError(409, `${target.type} is at max level (${MAX_LEVEL})`) + + const cost = upgradeCost(target.level) + await spendItems(db, save.id, 'resource', cost) + const turn = save.turn + 1 + await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) + await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`) + await persist(db) + return readShipState(db) +} + +// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource. +export async function expandSystem(db: GameDb, systemId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) + if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) + if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) + if (target.capacity >= MAX_CAPACITY) throw new ShipError(409, `${target.type} is at max capacity (${MAX_CAPACITY})`) + + const cost = expandCost(target.capacity) + await spendItems(db, save.id, 'resource', cost) + const turn = save.turn + 1 + await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) + await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`) + await persist(db) + return readShipState(db) +} + +// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. +export async function buildSystem(db: GameDb, type: string): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) + if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) + if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) + + await spendItems(db, save.id, 'part', BUILD_PART_COST) + const turn = save.turn + 1 + await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) + await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`) + await persist(db) + return readShipState(db) +} From fce27ff6ca67ecd951f211d761038cfda337454f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:38:11 +0000 Subject: [PATCH 4/7] api+openapi: GET /api/ship + POST upgrade/expand/build with 400/404/409/422 mapping --- app/api/ship/build/route.ts | 28 ++++ app/api/ship/expand/route.ts | 28 ++++ app/api/ship/route.ts | 8 ++ app/api/ship/upgrade/route.ts | 28 ++++ lib/openapi.test.ts | 25 ++++ lib/openapi.ts | 237 ++++++++++++++++++++++++++++++++++ 6 files changed, 354 insertions(+) create mode 100644 app/api/ship/build/route.ts create mode 100644 app/api/ship/expand/route.ts create mode 100644 app/api/ship/route.ts create mode 100644 app/api/ship/upgrade/route.ts diff --git a/app/api/ship/build/route.ts b/app/api/ship/build/route.ts new file mode 100644 index 0000000..56775ae --- /dev/null +++ b/app/api/ship/build/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ShipError, buildSystem } from '@/lib/game/ship' + +// Build an unbuilt ship system type at base values. Body: { "type": string }. +// Costs inventory items + a turn; returns the fresh ship state. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const type = (body as { type?: unknown }).type + if (typeof type !== 'string' || type.length === 0) { + return Response.json({ error: 'type must be a non-empty string' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await buildSystem(db, type)) + } catch (err) { + if (err instanceof ShipError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/ship/expand/route.ts b/app/api/ship/expand/route.ts new file mode 100644 index 0000000..34a0e82 --- /dev/null +++ b/app/api/ship/expand/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ShipError, expandSystem } from '@/lib/game/ship' + +// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }. +// Costs inventory items + a turn; returns the fresh ship state. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const systemId = (body as { systemId?: unknown }).systemId + if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { + return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await expandSystem(db, systemId)) + } catch (err) { + if (err instanceof ShipError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/ship/route.ts b/app/api/ship/route.ts new file mode 100644 index 0000000..dd70995 --- /dev/null +++ b/app/api/ship/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { getShipState } from '@/lib/game/ship' + +// The ship's systems + cargo hold + turn; auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + return Response.json(await getShipState(db)) +} diff --git a/app/api/ship/upgrade/route.ts b/app/api/ship/upgrade/route.ts new file mode 100644 index 0000000..3e44660 --- /dev/null +++ b/app/api/ship/upgrade/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ShipError, upgradeSystem } from '@/lib/game/ship' + +// Level up a built ship system by one (capped at max level). Body: { "systemId": number }. +// Costs inventory items + a turn; returns the fresh ship state. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const systemId = (body as { systemId?: unknown }).systemId + if (typeof systemId !== 'number' || !Number.isInteger(systemId)) { + return Response.json({ error: 'systemId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await upgradeSystem(db, systemId)) + } catch (err) { + if (err instanceof ShipError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 1538aca..13aa9b3 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -8,12 +8,37 @@ describe('OpenAPI spec', () => { '/api/crew', '/api/explore', '/api/jump', + '/api/ship', + '/api/ship/build', + '/api/ship/expand', + '/api/ship/upgrade', '/api/system', '/api/version', '/api/world', ]); }); + it('describes the ship endpoints as ShipState in and out, with error responses on the actions', () => { + const ship = openApiSpec.paths['/api/ship'].get.responses[200].content['application/json'].schema; + expect(ship.$ref).toBe('#/components/schemas/ShipState'); + + const system = openApiSpec.components.schemas.ShipSystem.properties; + expect(Object.keys(system)).toEqual(['id', 'type', 'level', 'capacity', 'built']); + + for (const [path, request] of [ + ['/api/ship/upgrade', '#/components/schemas/UpgradeRequest'], + ['/api/ship/expand', '#/components/schemas/ExpandRequest'], + ['/api/ship/build', '#/components/schemas/BuildRequest'], + ] as const) { + const post = openApiSpec.paths[path].post; + expect(post.requestBody.content['application/json'].schema.$ref).toBe(request); + expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ShipState'); + for (const status of ['400', '404', '409', '422'] as const) { + expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + } + } + }); + it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => { const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema; expect(crew.type).toBe('array'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 3102655..a3fb3d6 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -186,6 +186,198 @@ export const openApiSpec = { }, }, }, + '/api/ship': { + get: { + summary: 'Ship state', + description: + "The ship's systems (built flag derived from level), the cargo hold and the turn counter. The starting ship is seeded on new game — core types built at base values, the rest unbuilt until a build action. Auto-initializes a new game when no save exists yet.", + operationId: 'getShip', + responses: { + 200: { + description: 'Current ship state', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + }, + }, + }, + '/api/ship/upgrade': { + post: { + summary: 'Upgrade a ship system', + description: + "Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.", + operationId: 'upgradeShipSystem', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UpgradeRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh ship state after the upgrade', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer systemId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown ship system id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'System is not built or already at max level', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough resources in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + '/api/ship/expand': { + post: { + summary: 'Expand a ship system', + description: + "Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.", + operationId: 'expandShipSystem', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ExpandRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh ship state after the expansion', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer systemId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown ship system id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'System is not built or already at max capacity', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough resources in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + '/api/ship/build': { + post: { + summary: 'Build a ship system', + description: + 'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.', + operationId: 'buildShipSystem', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/BuildRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh ship state after the build', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ShipState' }, + }, + }, + }, + 400: { + description: 'Malformed body or empty type', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown ship system type', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'System is already built', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 422: { + description: 'Not enough parts in the cargo hold for the cost', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -275,6 +467,51 @@ export const openApiSpec = { }, required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'], }, + ShipSystem: { + type: 'object', + properties: { + id: { type: 'integer' }, + type: { type: 'string', description: "System type, e.g. REACTOR or WEAPONS" }, + level: { type: 'integer', description: '0 when not built yet' }, + capacity: { type: 'integer', description: '0 when not built yet' }, + built: { type: 'boolean', description: 'Derived — true when level > 0' }, + }, + required: ['id', 'type', 'level', 'capacity', 'built'], + }, + ShipState: { + type: 'object', + properties: { + systems: { + type: 'array', + description: "The ship's systems in display order", + items: { $ref: '#/components/schemas/ShipSystem' }, + }, + inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, + turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' }, + }, + required: ['systems', 'inventory', 'turn'], + }, + UpgradeRequest: { + type: 'object', + properties: { + systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' }, + }, + required: ['systemId'], + }, + ExpandRequest: { + type: 'object', + properties: { + systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' }, + }, + required: ['systemId'], + }, + BuildRequest: { + type: 'object', + properties: { + type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" }, + }, + required: ['type'], + }, SystemState: { type: 'object', properties: { From de0cb45b0965ead1c8d621f72ac31dafe0f11732 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:43:03 +0000 Subject: [PATCH 5/7] ui: real SHIP SYSTEMS tab (upgrade/expand/build buttons) + left column mirrors live ship state --- components/mission-control.tsx | 124 ++++++++++++++++++--------------- components/ship-systems.tsx | 122 ++++++++++++++++++++++++++++++++ lib/game/ship-costs.ts | 3 + 3 files changed, 191 insertions(+), 58 deletions(-) create mode 100644 components/ship-systems.tsx diff --git a/components/mission-control.tsx b/components/mission-control.tsx index f67cb28..d3e67ef 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -13,12 +13,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Terminal, type TerminalLine } from '@/components/ui/terminal' import { CrewPanel } from '@/components/crew-panel' import { LocalSystemPanel } from '@/components/local-system' +import { ShipSystemsPanel } from '@/components/ship-systems' import { WorldMapPanel } from '@/components/world-map' +import { CREW_CAPACITY } from '@/lib/game/ship-costs' +import type { CrewDto } from '@/lib/game/save' +import type { ShipState } from '@/lib/game/ship' // Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control // showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The -// WORLD MAP, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew); -// the other tabs keep placeholder content until their own issues land. Single player, no auth, by design. +// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system, +// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land. +// Single player, no auth, by design. function useNarrow(threshold = 900): boolean { const [narrow, setNarrow] = useState( @@ -38,22 +43,6 @@ function now(): string { // ── Placeholder content (no game logic — real values land with the turn engine) ── -const shipSystems: SystemEntry[] = [ - { name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' }, - { name: 'LIFE SUPPORT', status: 'ACTIVE' }, - { name: 'NAV COMPUTER', status: 'SCANNING' }, - { name: 'WEAPONS', status: 'OFFLINE' }, - { name: 'COMMS', status: 'ACTIVE' }, -] - -const shipSystemCards = [ - { name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] }, - { name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] }, - { name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] }, - { name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] }, - { name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] }, -] - const skillCards = [ { name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const }, { name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const }, @@ -89,6 +78,36 @@ const logLines: TerminalLine[] = [ export function MissionControl() { const narrow = useNarrow() const [tick, setTick] = useState(0) + // Ship state is owned here (not in the tab panel) so the left column mirrors ship actions + // immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count + // feeds the roster bar — refetched on page load, like the other panels. + const [ship, setShip] = useState(null) + const [crewCount, setCrewCount] = useState(null) + + useEffect(() => { + let cancelled = false + fetch('/api/ship') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as ShipState + }) + .then((s) => { + if (!cancelled) setShip(s) + }) + .catch(() => {}) // left column + tab keep their loading state on failure + fetch('/api/crew') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as CrewDto[] + }) + .then((c) => { + if (!cancelled) setCrewCount(c.length) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 1000) @@ -257,11 +276,25 @@ export function MissionControl() { SHIP SYSTEMS
- 4 ONLINE + s.built) ? 'ACTIVE' : 'OFFLINE'}> + {ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'} +
- + {ship ? ( + ({ + name: s.type, + status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const), + detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined, + }))} + /> + ) : ( +
+ LOADING… +
+ )} @@ -269,11 +302,17 @@ export function MissionControl() { VESSEL METRICS -
- - - -
+ {ship ? ( + // Bars driven by real values: hull/reactor from system levels (level × 20, capped), + // crew roster vs the display cap. +
+ s.type === 'HULL')?.level ?? 0) * 20)} /> + s.type === 'REACTOR')?.level ?? 0) * 20)} /> + +
+ ) : ( + LOADING… + )}
@@ -330,40 +369,9 @@ export function MissionControl() { - {/* SHIP SYSTEMS TAB */} + {/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */} -
- {shipSystemCards.map((system) => ( - - -
- {system.name} - - {system.level} - -
-
- -

- MODULE BAY // PLACEHOLDER READOUT -

-
- - {system.actions.map((action) => ( - - ))} - -
- ))} -
+
{/* SKILL TREE TAB */} diff --git a/components/ship-systems.tsx b/components/ship-systems.tsx new file mode 100644 index 0000000..37177bc --- /dev/null +++ b/components/ship-systems.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs' +import type { ShipState, ShipSystemDto } from '@/lib/game/ship' + +// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left +// column mirrors actions immediately; this panel applies each action's response directly via +// onChanged (no polling). Buttons are disabled when unaffordable or capped, using the same +// cost constants the API enforces (lib/game/ship-costs.ts). + +interface ShipSystemsPanelProps { + ship: ShipState | null + narrow?: boolean + onChanged: (next: ShipState) => void +} + +export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) { + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) // one action at a time — turn-based game + + if (!ship) { + return ( +
+ LOADING SHIP SYSTEMS… +
+ ) + } + + const resources = totalQty(ship.inventory, 'resource') + const parts = totalQty(ship.inventory, 'part') + + async function act(url: string, body: unknown) { + setBusy(true) + setError(null) + try { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + const data = (await res.json()) as ShipState & { error?: string } + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`) + onChanged(data) // response is the fresh ship state — apply directly + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( +
+ {error && ( +
+ {error.toUpperCase()} +
+ )} +
+ {ship.systems.map((system) => ( + + ))} +
+
+ ) +} + +function SystemCard({ + system, + resources, + parts, + busy, + onAct, +}: { + system: ShipSystemDto + resources: number + parts: number + busy: boolean + onAct: (url: string, body: unknown) => void +}) { + const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level) + const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity) + const buildDisabled = busy || system.built || parts < BUILD_PART_COST + + return ( + + +
+ {system.type} + + {system.built ? `LV ${system.level}` : 'OFFLINE'} + +
+
+ +

+ {system.built ? `LEVEL ${system.level} · CAPACITY ${system.capacity}` : 'NOT INSTALLED'} +

+

+ {system.built + ? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES` + : `BUILD ${BUILD_PART_COST}× PARTS`} +

+
+ + {system.built ? ( + <> + + + + ) : ( + + )} + +
+ ) +} diff --git a/lib/game/ship-costs.ts b/lib/game/ship-costs.ts index cdb213b..5067772 100644 --- a/lib/game/ship-costs.ts +++ b/lib/game/ship-costs.ts @@ -23,6 +23,9 @@ export const BASE_CAPACITY = 2 export const MAX_LEVEL = 5 // UPGRADE cap export const MAX_CAPACITY = 10 // EXPAND cap +// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet). +export const CREW_CAPACITY = 6 + // 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) From 7e975566abb6291b254f0937bb2dd9c442a36a14 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:46:21 +0000 Subject: [PATCH 6/7] e2e: ship action via UI (bounded scan until affordable) + ship state survives container restart --- e2e/smoke.spec.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++ e2e/state.spec.ts | 10 ++++++++ 2 files changed, 75 insertions(+) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index a103a25..eec460c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -9,6 +9,10 @@ interface SystemState { systemId: number; systemName: string; planets: PlanetDto interface FoundItem { kind: string; name: string; qty: number } interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } +interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } +interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number } + +const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); @@ -139,6 +143,67 @@ test('CREW tab renders real roster cards, each showing both traits and an origin } }); +test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => { + // Scan bodies until something is affordable: an upgrade costs `level` resources (at least one), + // a build costs three parts. Jumping keeps it going if a whole system is exhausted. + let ship = (await (await page.request.get('/api/ship')).json()) as ShipState; + const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level); + const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built) : undefined); + + let iterations = 0; + while (!upgradeTarget() && !buildTarget() && iterations < 60) { + iterations++; + const sys = (await (await page.request.get('/api/system')).json()) as SystemState; + const unexplored = sys.planets.filter((p) => !p.explored); + if (unexplored.length > 0) { + expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200); + } else { + const world = (await (await page.request.get('/api/world')).json()) as WorldState; + const currentId = world.systems.find((s) => s.current)!.id; + const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!; + const targetId = edge[0] === currentId ? edge[1] : edge[0]; + expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200); + } + ship = (await (await page.request.get('/api/ship')).json()) as ShipState; + } + + const upgrade = upgradeTarget(); + const build = !upgrade ? buildTarget() : undefined; + expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on + + await page.goto('/'); + await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click(); + + if (upgrade) { + const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]'); + await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled(); + await card.getByRole('button', { name: 'UPGRADE' }).click(); + + const after = (await (await page.request.get('/api/ship')).json()) as ShipState; + const row = after.systems.find((s) => s.id === upgrade.id)!; + expect(row.level).toBe(upgrade.level + 1); + expect(after.turn).toBe(ship.turn + 1); + + // The card badge flipped to the new level and the left column mirrors it. + await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible(); + await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible(); + } else { + const target = build!; + const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]'); + await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled(); + await card.getByRole('button', { name: 'BUILD NEW' }).click(); + + const after = (await (await page.request.get('/api/ship')).json()) as ShipState; + const row = after.systems.find((s) => s.id === target.id)!; + expect(row.built).toBe(true); + expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 }); + + // The card badge flipped OFFLINE → LV 1 and the left column's online count went up. + await expect(card.getByText('LV 1')).toBeVisible(); + await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).toBeVisible(); + } +}); + test('GET /api/version reports the app version as JSON', async ({ page }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index f0d2a75..bdb7702 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -14,6 +14,8 @@ interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; tur interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } +interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } +interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -52,6 +54,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded + // Ship baseline: the systems (levels/capacities) + cargo hold must survive too. + const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded + // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -86,6 +92,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; expect(crewAfter).toEqual(crewBefore); + // Ship state survived too: same systems, levels/capacities and cargo hold. + const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState; + expect(shipAfter).toEqual(shipBefore); + // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); From dcaeb4ddedeb57ed59e63df2e64aa294ea1d0518 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 23:47:37 +0000 Subject: [PATCH 7/7] release v0.6.0 --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3939df..8ed4c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.6.0 — 2026-09-28 23:46 +- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design. +- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions. +- New table (`lib/db/schema.ts` + DDL): `ship_systems` (id/save_id/type/level/capacity, UNIQUE(save_id,type)). `built` is derived, not stored — level > 0 means built; unbuilt rows sit at level/capacity 0. Starting ship seeded on new game AND as backfill step v4: core types (HULL, REACTOR, LIFE SUPPORT, NAV COMPUTER, COMMS ARRAY) built at base values, WEAPONS unbuilt until a build action. +- Cost model (`lib/game/ship-costs.ts`, pure — imported by both the API and the UI so affordability can't drift): UPGRADE costs `level` units of any resource (capped at MAX_LEVEL 5), EXPAND costs `capacity` units of any resource (capped at MAX_CAPACITY 10), BUILD costs a flat 3 parts. Consumption takes from the oldest cargo row of the required kind first, deleting rows that hit zero; an unaffordable action mutates nothing. +- Ship actions (`lib/game/ship.ts`): `upgradeSystem` / `expandSystem` / `buildSystem` — validate (404 unknown id/type, 409 not built / already built / at cap, 422 insufficient inventory), spend the cost, bump level/capacity or build at base values, cost one turn and write an event each. +- New endpoints: `GET /api/ship` → `{systems:[{id,type,level,capacity,built}],inventory,turn}`; `POST /api/ship/upgrade {systemId}`, `POST /api/ship/expand {systemId}`, `POST /api/ship/build {type}` — each returns the fresh ship state. OpenAPI spec in code updated (10 paths). +- SHIP SYSTEMS tab now renders REAL data: cards show type + level/capacity with UPGRADE / EXPAND buttons per built card and BUILD NEW for unbuilt types, disabled when unaffordable or capped; each action's response is applied directly (no polling). Left column mirrors live state too — the SHIP SYSTEMS panel lists every system with its status/level/capacity + an N ONLINE badge, and VESSEL METRICS bars are driven by real values (hull/reactor from system levels, crew roster vs a cap of 6). Placeholder constants for both removed; other tabs keep theirs until their issues land. +- Tests: unit — backfill upgrades a hand-built v1 save in place (deterministic vs fresh generation, idempotent), starting ship seeding, cost validation with no mutation on failure, exact oldest-first inventory deduction, level/capacity caps, build-once, turn + event per action; e2e — SHIP SYSTEMS tab performs one affordable UPGRADE/BUILD via the UI after a bounded scan loop until something is affordable (asserting card + left column reflect it), and ship state survives the mid-run container restart. + ## v0.5.0 — 2026-09-28 22:51 - CREW tab — crew roster with good/bad traits (issue #7): the ship's people on top of issue #6's exploration loop. Single-player, no auth, by design. Display + arrival only — no dismiss/assign actions yet. - New table (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `crew` (id/save_id/name/role/trait_good/trait_bad/origin). Origin is 'STARTING CREW' for the seeded crew or ', ' for members found while exploring. diff --git a/package.json b/package.json index 2763c13..9e67326 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "next dev -p 3000",