Files
SpaceWanderer/lib/game/research.ts
T

119 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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,
}
})
}
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<void> {
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<void> {
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<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.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await resolveResearchTask(db, syntheticTask('RESEARCH', { techId }))
return getTechState(db)
}