db+game: tech table + starting tech list seeded on new game and v5 backfill step
This commit is contained in:
+26
-1
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+15
-2
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
@@ -133,6 +141,7 @@ async function createNewGame(db: GameDb): Promise<void> {
|
||||
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))
|
||||
|
||||
@@ -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<string, TechEffect & { description: string }> = {
|
||||
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<string>): { 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<string>): 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<string>): 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>): 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
|
||||
}
|
||||
Reference in New Issue
Block a user