game: tech research action + ship-action gating by researched techs (build gate, raised caps)
This commit is contained in:
@@ -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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+19
-7
@@ -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<ShipState> {
|
||||
|
||||
// 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<void> {
|
||||
// Exported: research.ts spends tech costs through the same path.
|
||||
export async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise<void> {
|
||||
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<Set<string>> {
|
||||
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<ShipState> {
|
||||
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<ShipS
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource.
|
||||
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). Cost: `capacity` units of any resource.
|
||||
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||
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<ShipSt
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. Some types are
|
||||
// gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
|
||||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user