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' import type { Task, TaskType } from './tasks' // 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, } }) } function syntheticTask(type: TaskType, payload: object): Task { return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } } // RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted // at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the // current action has none). export async function resolveResearchTask(db: GameDb, task: Task): Promise { await ensureSave(db) const { techId } = JSON.parse(task.payload) as { techId: number } 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)! if (target.researched) throw new ResearchError(409, `${def.name} is already researched`) await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() }) await persist(db) } // The research pre-checks, extracted for the queueing route (5a(ii)): the same codes and // messages as researchTech — unknown tech (404), already researched (409), missing prereqs // (409, naming what is missing). The cost check stays out: the queueing path deducts at // queue time (queueTask) and the research lands when the task completes on the tick. export async function checkResearchPreconditions(db: GameDb, techId: number): Promise { await ensureSave(db) 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`) } // Research one tech: validates prerequisites (409, naming what is missing) and the cost (422), // spends it from the cargo hold, then delegates the unlock to the resolver at the new turn. 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.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveResearchTask(db, syntheticTask('RESEARCH', { techId })) return getTechState(db) }