game: tech research action + ship-action gating by researched techs (build gate, raised caps)

This commit is contained in:
2026-09-29 00:24:25 +00:00
parent 4ef924edd6
commit 8818f0928d
4 changed files with 249 additions and 7 deletions
+79
View File
@@ -0,0 +1,79 @@
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<Set<string>> {
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<TechDto[]> {
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<TechDto[]> {
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)
}