From 4ef924edd695b66f03ba29fc1688ddeb787af578 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 00:20:02 +0000 Subject: [PATCH] db+game: tech table + starting tech list seeded on new game and v5 backfill step --- lib/db/client.ts | 13 ++++++ lib/db/schema.ts | 20 +++++++++ lib/game/save.test.ts | 27 ++++++++++- lib/game/save.ts | 17 ++++++- lib/game/tech.ts | 102 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 lib/game/tech.ts diff --git a/lib/db/client.ts b/lib/db/client.ts index e831be9..1b4149a 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -81,6 +81,19 @@ CREATE TABLE IF NOT EXISTS ship_systems ( UNIQUE (save_id, type) ); +CREATE TABLE IF NOT EXISTS tech ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + slug TEXT NOT NULL, -- stable key, e.g. WEAPONS_SYSTEMS + name TEXT NOT NULL, -- display name, e.g. 'WEAPONS SYSTEMS' + cost_kind TEXT NOT NULL, -- 'resource' | 'part' + cost_qty INTEGER NOT NULL DEFAULT 1, + prerequisites TEXT NOT NULL DEFAULT '', -- comma-separated tech slugs, '' when none + effect TEXT NOT NULL, -- effect slug — semantics in EFFECTS (lib/game/tech.ts) + researched INTEGER NOT NULL DEFAULT 0, + UNIQUE (save_id, slug) +); + 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 c551eab..b657a79 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -88,6 +88,26 @@ export const shipSystems = sqliteTable( (t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)], ) +// The technology tree, one row per known tech (see TECHS in lib/game/tech.ts). Definitions are +// seeded from the pure module on new game / backfill v5 — same pattern as ship_systems. +// `researched` is the per-save progress; prerequisites/effect are stored for completeness but +// the canonical semantics live in lib/game/tech.ts (EFFECTS). +export const tech = sqliteTable( + 'tech', + { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + slug: text('slug').notNull(), // stable key, e.g. WEAPONS_SYSTEMS + name: text('name').notNull(), // display name, e.g. 'WEAPONS SYSTEMS' + costKind: text('cost_kind').notNull(), // 'resource' | 'part' + costQty: integer('cost_qty').notNull().default(1), + prerequisites: text('prerequisites').notNull().default(''), // comma-separated tech slugs, '' when none + effect: text('effect').notNull(), // effect slug — semantics in EFFECTS (lib/game/tech.ts) + researched: integer('researched', { mode: 'boolean' }).notNull().default(false), + }, + (t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)], +) + // 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 3a78532..fee3b3f 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -7,6 +7,7 @@ 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 { TECHS } from './tech' import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' @@ -329,11 +330,15 @@ describe('backfill (old saves)', () => { 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)) + const techShape = (rows: { id: number; slug: string; name: string; costKind: string; costQty: number; prerequisites: string; effect: string; researched: boolean }[]) => + rows.map((t) => ({ slug: t.slug, name: t.name, costKind: t.costKind, costQty: t.costQty, prerequisites: t.prerequisites, effect: t.effect, researched: t.researched })).sort((a, b) => a.slug.localeCompare(b.slug)) + const beforeTech = techShape(await db.db.select().from(schema.tech)) - // Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL). + // Simulate a save created by pre-v0.5 code: no bodies, no crew, no ship, no tech, 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.delete(schema.tech) await db.db.update(schema.saves).set({ schemaVersion: null }) const world = await getWorldState(db) // ensureSave → backfill runs @@ -345,6 +350,7 @@ describe('backfill (old saves)', () => { } expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew) expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip) + expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech) const [save] = await db.db.select().from(schema.saves) expect(save.schemaVersion).toBe(SCHEMA_VERSION) @@ -355,6 +361,7 @@ describe('backfill (old saves)', () => { 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) + expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech) 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]) @@ -387,4 +394,22 @@ describe('backfill (old saves)', () => { } } }) + + it('seeds the tech tree on a new game: one row per known tech, none researched', async () => { + const { db } = await freshFile() + await getWorldState(db) + const rows = (await db.db.select().from(schema.tech)).sort((a, b) => a.slug.localeCompare(b.slug)) + expect(rows).toHaveLength(TECHS.length) + for (const def of TECHS) { + const row = rows.find((r) => r.slug === def.slug)! + expect({ name: row.name, costKind: row.costKind, costQty: row.costQty, prerequisites: row.prerequisites, effect: row.effect }).toEqual({ + name: def.name, + costKind: def.costKind, + costQty: def.costQty, + prerequisites: def.prereqs.join(','), + effect: def.effect, + }) + expect(row.researched).toBe(false) // nothing is researched at the start + } + }) }) diff --git a/lib/game/save.ts b/lib/game/save.ts index 6916314..c3f4ac0 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -4,6 +4,7 @@ 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 { TECHS } from './tech' 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. @@ -94,9 +95,9 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi // 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 +// current — each step is idempotent (a no-op when its data already exists). When #10 adds // seeding, bump SCHEMA_VERSION and append a step here. -export const SCHEMA_VERSION = 4 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship +export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree // 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. @@ -106,6 +107,13 @@ async function seedStartingShip(db: GameDb, saveId: number): Promise { } } +// Seeds the tech tree: one row per known tech (definitions from lib/game/tech.ts), none researched. +async function seedStartingTech(db: GameDb, saveId: number): Promise { + for (const def of TECHS) { + await db.db.insert(schema.tech).values({ saveId, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect }) + } +} + // 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 { @@ -133,6 +141,7 @@ async function createNewGame(db: GameDb): Promise { await insertRoutes(db, region.edges, resolve) await seedStartingCrew(db, save.id, seed) await seedStartingShip(db, save.id) + await seedStartingTech(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) @@ -155,6 +164,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id) version = 4 } + if (version < 5) { + if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id) + version = 5 + } 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/tech.ts b/lib/game/tech.ts new file mode 100644 index 0000000..13cc578 --- /dev/null +++ b/lib/game/tech.ts @@ -0,0 +1,102 @@ +// Pure technology model: the known techs, their costs/prerequisites and what each unlock does. +// No DB imports — both lib/game/research.ts + lib/game/ship.ts (server) and the frontend import +// from here so research/cap/gate logic can never drift from what the API enforces. + +import { MAX_CAPACITY, MAX_LEVEL } from './ship-costs' + +export interface TechDef { + slug: string // stable key used in DB + prerequisites + name: string // display name + costKind: 'resource' | 'part' + costQty: number + prereqs: string[] // slugs that must be researched first (short chains only) + effect: string // effect slug — see EFFECTS below +} + +// The technology tree, in display order. Roots have no prerequisites; the two chains are +// WEAPONS SYSTEMS → PLASMA CANNON and REACTOR OVERCLOCK → FUSION CORE. Costs stay small so a +// handful of scans makes something affordable (the e2e scan loop relies on that). +export const TECHS: TechDef[] = [ + { slug: 'WEAPONS_SYSTEMS', name: 'WEAPONS SYSTEMS', costKind: 'part', costQty: 2, prereqs: [], effect: 'BUILD_WEAPONS' }, + { slug: 'REACTOR_OVERCLOCK', name: 'REACTOR OVERCLOCK', costKind: 'resource', costQty: 3, prereqs: [], effect: 'REACTOR_LEVEL_CAP' }, + { slug: 'HULL_PLATING', name: 'HULL PLATING', costKind: 'resource', costQty: 3, prereqs: [], effect: 'HULL_CAPACITY_CAP' }, + { slug: 'NAV_AI', name: 'NAVIGATION AI', costKind: 'resource', costQty: 2, prereqs: [], effect: 'NAV_LEVEL_CAP' }, + { slug: 'PLASMA_CANNON', name: 'PLASMA CANNON', costKind: 'part', costQty: 3, prereqs: ['WEAPONS_SYSTEMS'], effect: 'WEAPONS_LEVEL_CAP' }, + { slug: 'FUSION_CORE', name: 'FUSION CORE', costKind: 'resource', costQty: 4, prereqs: ['REACTOR_OVERCLOCK'], effect: 'REACTOR_CAPACITY_CAP' }, +] + +// What each researched tech changes on the ship. `unlock_build` gates a BUILD action; the cap +// effects raise the UPGRADE/EXPAND ceiling for one system type (stacked over the base caps). +export interface TechEffect { + kind: 'unlock_build' | 'raise_level_cap' | 'raise_capacity_cap' + shipType: string + bonus?: number // cap effects only +} + +export const EFFECTS: Record = { + BUILD_WEAPONS: { kind: 'unlock_build', shipType: 'WEAPONS', description: 'Unlocks building the WEAPONS system' }, + REACTOR_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'REACTOR', bonus: 2, description: `Raises REACTOR max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` }, + HULL_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'HULL', bonus: 3, description: `Raises HULL max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` }, + NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` }, + WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` }, + REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` }, +} + +export type TechStateName = 'RESEARCHED' | 'AVAILABLE' | 'LOCKED' + +// One tech as served to the UI (GET /api/tech) and consumed by both tabs. +export interface TechDto { + id: number + slug: string + name: string + costKind: 'resource' | 'part' + costQty: number + prereqs: string[] // prerequisite NAMES (for display), in TECHS order + effect: string // human-readable unlock description + state: TechStateName + missingPrereqs: string[] // unmet prerequisite names — non-empty only when LOCKED +} + +export function techBySlug(slug: string): TechDef | undefined { + return TECHS.find((t) => t.slug === slug) +} + +// RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the +// missing prerequisite names so the UI can say which one is blocking it). +export function techStatus(def: TechDef, researchedSlugs: Set): { state: TechStateName; missingPrereqs: string[] } { + if (researchedSlugs.has(def.slug)) return { state: 'RESEARCHED', missingPrereqs: [] } + const missing = def.prereqs.filter((p) => !researchedSlugs.has(p)).map((slug) => techBySlug(slug)?.name ?? slug) + return missing.length === 0 ? { state: 'AVAILABLE', missingPrereqs: [] } : { state: 'LOCKED', missingPrereqs: missing } +} + +// The UPGRADE ceiling for a system type given what is researched (base cap + bonuses). +export function effectiveLevelCap(shipType: string, researchedSlugs: Set): number { + let cap = MAX_LEVEL + for (const def of TECHS) { + if (!researchedSlugs.has(def.slug)) continue + const effect = EFFECTS[def.effect] + if (effect?.kind === 'raise_level_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0 + } + return cap +} + +// The EXPAND ceiling for a system type given what is researched. +export function effectiveCapacityCap(shipType: string, researchedSlugs: Set): number { + let cap = MAX_CAPACITY + for (const def of TECHS) { + if (!researchedSlugs.has(def.slug)) continue + const effect = EFFECTS[def.effect] + if (effect?.kind === 'raise_capacity_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0 + } + return cap +} + +// The name of the tech that must be researched before a system type can be BUILT, or null when +// building it is not gated (or the gate is already researched). +export function buildGate(shipType: string, researchedSlugs: Set): string | null { + for (const def of TECHS) { + const effect = EFFECTS[def.effect] + if (effect?.kind === 'unlock_build' && effect.shipType === shipType && !researchedSlugs.has(def.slug)) return def.name + } + return null +}