v0.6.0 — SHIP SYSTEMS: expand/upgrade/build + save backfill (#8) #15
@@ -72,6 +72,15 @@ CREATE TABLE IF NOT EXISTS crew (
|
||||
origin TEXT NOT NULL -- 'STARTING CREW' or '<planet>, <system>' 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),
|
||||
|
||||
+15
-1
@@ -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', {
|
||||
|
||||
+24
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+15
-1
@@ -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<void> {
|
||||
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<void> {
|
||||
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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user