148 lines
6.7 KiB
TypeScript
148 lines
6.7 KiB
TypeScript
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)
|
||
})
|
||
})
|