import { eq } from 'drizzle-orm' import * as schema from '@/lib/db/schema' import { persist, type GameDb } from '@/lib/db/client' import { EFFECTS, TECHS, techStatus, type TechDto } from './tech' import { ensureSave } from './save' import { ShipError, spendItems } from './ship' // Technology research: the SKILL TREE tab's read + action. Researching a tech costs inventory // items (oldest row first, like ship actions) plus a turn, and its unlock effect then gates or // raises ship actions (enforced in lib/game/ship.ts via the same pure helpers). // Carries an HTTP status so route handlers can map failures to responses without re-parsing. export class ResearchError extends Error { constructor( public readonly status: number, message: string, ) { super(message) } } async function researchedSlugs(db: GameDb): Promise> { return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug)) } // The full tech tree with per-save state — RESEARCHED / AVAILABLE / LOCKED (with the missing // prerequisite names when locked). Auto-initializes like the other reads. export async function getTechState(db: GameDb): Promise { await ensureSave(db) const rows = await db.db.select().from(schema.tech) const researched = new Set(rows.filter((t) => t.researched).map((t) => t.slug)) return TECHS.map((def) => { const row = rows.find((r) => r.slug === def.slug)! // seeded on new game / backfill v5 const { state, missingPrereqs } = techStatus(def, researched) return { id: row.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prereqs: def.prereqs.map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug), effect: EFFECTS[def.effect]?.description ?? def.effect, state, missingPrereqs, } }) } // Research one tech: validates prerequisites (409, naming what is missing) and the cost (422), // spends it from the cargo hold, marks it researched, costs a turn and writes an event. export async function researchTech(db: GameDb, techId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const rows = await db.db.select().from(schema.tech) const target = rows.find((t) => t.id === techId) if (!target) throw new ResearchError(404, `Unknown tech ${techId}`) const def = TECHS.find((t) => t.slug === target.slug)! const researched = await researchedSlugs(db) if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`) const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug) if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`) try { await spendItems(db, save.id, def.costKind, def.costQty) } catch (err) { // spendItems throws ShipError(422) on a short hold — re-throw as this domain's error. if (err instanceof ShipError) throw new ResearchError(err.status, err.message) throw err } const turn = save.turn + 1 await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id)) await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() }) await persist(db) return getTechState(db) }