diff --git a/lib/game/research.test.ts b/lib/game/research.test.ts new file mode 100644 index 0000000..ef91764 --- /dev/null +++ b/lib/game/research.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { openGameDb, type GameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' +import { BUILD_PART_COST, MAX_LEVEL, upgradeCost } from './ship-costs' +import { buildSystem, getShipState, upgradeSystem, type ShipState } from './ship' +import { TECHS } from './tech' +import { getTechState, researchTech } from './research' + +const dirs: string[] = [] + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-research-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + return { db: await openGameDb(file), file } +} + +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty }) +} + +const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)! + +describe('getTechState', () => { + it('new game: every tech seeded — roots AVAILABLE, chained ones LOCKED naming the missing prereq', async () => { + const { db } = await freshFile() + const techs = await getTechState(db) + + expect(techs).toHaveLength(TECHS.length) + for (const def of TECHS) { + const t = techs.find((x) => x.slug === def.slug)! + if (def.prereqs.length === 0) { + expect(t.state).toBe('AVAILABLE') + expect(t.missingPrereqs).toEqual([]) + } else { + expect(t.state).toBe('LOCKED') + expect(t.missingPrereqs).toEqual(def.prereqs.map((slug) => TECHS.find((x) => x.slug === slug)!.name)) + } + } + }) +}) + +describe('researchTech', () => { + it('rejects unknown id with 404, locked techs with 409 naming the missing prereq, and re-research with 409', async () => { + const { db } = await freshFile() + const techs = await getTechState(db) + + await expect(researchTech(db, 999)).rejects.toMatchObject({ status: 404 }) + + const plasma = techs.find((t) => t.slug === 'PLASMA_CANNON')! // locked behind WEAPONS SYSTEMS + await expect(researchTech(db, plasma.id)).rejects.toMatchObject({ status: 409, message: /WEAPONS SYSTEMS/ }) + + const weapons = techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')! + await addItem(db, 'part', 'SERVO ACTUATOR', weapons.costQty) + await researchTech(db, weapons.id) + await expect(researchTech(db, weapons.id)).rejects.toMatchObject({ status: 409 }) // already researched + }) + + it('validates the cost against the hold — 422 with no mutation when short', async () => { + const { db } = await freshFile() + const techs = await getTechState(db) + const nav = techs.find((t) => t.slug === 'NAV_AI')! // resource×2, root + const before = await getShipState(db) + + await expect(researchTech(db, nav.id)).rejects.toMatchObject({ status: 422 }) + + expect((await getShipState(db)).turn).toBe(before.turn) // no turn spent on a failed research + expect((await db.db.select().from(schema.tech)).find((t) => t.slug === 'NAV_AI')!.researched).toBe(false) + }) + + it('spends the cost from the oldest row first, persists researched state, costs a turn and writes an event', async () => { + const { db } = await freshFile() + await getShipState(db) + await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 2 + await addItem(db, 'resource', 'CRYSTAL DUST', 5) + + const techs = await getTechState(db) + const nav = techs.find((t) => t.slug === 'NAV_AI')! + const result = await researchTech(db, nav.id) + + expect(result.find((t) => t.slug === 'NAV_AI')!.state).toBe('RESEARCHED') + expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 4]]) // IRON ORE row deleted + expect((await getShipState(db)).turn).toBe(1) + + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'research')! + expect(event.turn).toBe(1) + expect(event.message).toContain('NAVIGATION AI') + + // Persisted: a fresh read still shows it researched. + expect((await getTechState(db)).find((t) => t.slug === 'NAV_AI')!.state).toBe('RESEARCHED') + }) + + it('unlocks the next chain step once its prerequisite is researched', async () => { + const { db } = await freshFile() + await getTechState(db) // ensureSave — the save must exist before items can be added + await addItem(db, 'part', 'SERVO ACTUATOR', 5) // WEAPONS SYSTEMS (2) + PLASMA CANNON (3) + let techs = await getTechState(db) + + expect(techs.find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('LOCKED') + await researchTech(db, techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.id) + + techs = await getTechState(db) + expect(techs.find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('AVAILABLE') // prereq met → researchable now + await researchTech(db, techs.find((t) => t.slug === 'PLASMA_CANNON')!.id) + expect((await getTechState(db)).find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('RESEARCHED') + }) +}) + +describe('gating of ship actions', () => { + it('WEAPONS build is 409 until the WEAPONS SYSTEMS tech is researched, then succeeds', async () => { + const { db } = await freshFile() + await getShipState(db) + await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST + 2) // research (2) + build (3) + + await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409, message: /requires/ }) // gated — even with parts in the hold + const techs = await getTechState(db) + await researchTech(db, techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.id) + + const state = await buildSystem(db, 'WEAPONS') + expect(systemOf(state, 'WEAPONS').built).toBe(true) + }) + + it('raises the REACTOR upgrade cap once REACTOR OVERCLOCK is researched', async () => { + const { db } = await freshFile() + await getShipState(db) + const reactorId = systemOf(await getShipState(db), 'REACTOR').id + await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // at the base cap + + await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 }) + + await addItem(db, 'resource', 'IRON ORE', 3 + upgradeCost(MAX_LEVEL)) // research cost (3) + upgrade cost at level 5 + const techs = await getTechState(db) + await researchTech(db, techs.find((t) => t.slug === 'REACTOR_OVERCLOCK')!.id) + + const state = await upgradeSystem(db, reactorId) // the raised cap admits one more level + expect(systemOf(state, 'REACTOR').level).toBe(MAX_LEVEL + 1) + }) +}) diff --git a/lib/game/research.ts b/lib/game/research.ts new file mode 100644 index 0000000..13cae51 --- /dev/null +++ b/lib/game/research.ts @@ -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> { + 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) +} diff --git a/lib/game/ship.test.ts b/lib/game/ship.test.ts index b6dc322..40bad31 100644 --- a/lib/game/ship.test.ts +++ b/lib/game/ship.test.ts @@ -129,6 +129,10 @@ describe('buildSystem', () => { const { db } = await freshFile() await getShipState(db) + // WEAPONS is gated behind the WEAPONS SYSTEMS tech (issue #9) — research it directly so this + // test keeps covering the cost path; the gate itself is covered in research.test.ts. + await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS')) + await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST) diff --git a/lib/game/ship.ts b/lib/game/ship.ts index e628991..969bdb3 100644 --- a/lib/game/ship.ts +++ b/lib/game/ship.ts @@ -1,7 +1,8 @@ import { eq } from 'drizzle-orm' import * as schema from '@/lib/db/schema' import { persist, type GameDb } from '@/lib/db/client' -import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs' +import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs' +import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech' import { ensureSave, type InventoryItemDto } from './save' export interface ShipSystemDto { @@ -51,7 +52,8 @@ export async function getShipState(db: GameDb): Promise { // Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit // zero are deleted. The total is checked up front, so an insufficient hold mutates nothing. -async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise { +// Exported: research.ts spends tech costs through the same path. +export async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise { const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id) if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) { throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`) @@ -71,14 +73,20 @@ async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() }) } -// UPGRADE — level up a built system by one (capped at MAX_LEVEL). Cost: `level` units of any resource. +// Slugs of the researched techs on this save — drives the effective caps and build gates below. +async function researchedTechSlugs(db: GameDb): Promise> { + return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug)) +} + +// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). Cost: `level` units of any resource. export async function upgradeSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) - if (target.level >= MAX_LEVEL) throw new ShipError(409, `${target.type} is at max level (${MAX_LEVEL})`) + const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) + if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) const cost = upgradeCost(target.level) await spendItems(db, save.id, 'resource', cost) @@ -89,14 +97,15 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId) if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`) if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`) - if (target.capacity >= MAX_CAPACITY) throw new ShipError(409, `${target.type} is at max capacity (${MAX_CAPACITY})`) + const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) + if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) const cost = expandCost(target.capacity) await spendItems(db, save.id, 'resource', cost) @@ -107,13 +116,16 @@ export async function expandSystem(db: GameDb, systemId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type) if (!target) throw new ShipError(404, `Unknown ship system type ${type}`) if (target.level > 0) throw new ShipError(409, `${target.type} is already built`) + const gate = buildGate(type, await researchedTechSlugs(db)) + if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) await spendItems(db, save.id, 'part', BUILD_PART_COST) const turn = save.turn + 1