Files
SpaceWanderer/lib/game/research.test.ts

221 lines
11 KiB
TypeScript
Raw Permalink 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 { 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, spendItems, upgradeSystem, type ShipState } from './ship'
import { TECHS } from './tech'
import { SCHEMA_VERSION } from './save'
import { getTechState, researchTech, resolveResearchTask } from './research'
import type { Task } from './tasks'
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)
})
})
// --- A/B: resolver vs the legacy immediate action (Task 4a) ---------------------------
const T0 = '2026-10-01T00:00:00.000Z'
// Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build
// per world → ids line up.
async function buildResearchFixture(db: GameDb): Promise<void> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
for (const def of TECHS) {
await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
}
}
const task = (payload: object): Task =>
({ id: 0, saveId: 0, type: 'RESEARCH', payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
async function snapshot(db: GameDb): Promise<string> {
const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
db.db.select().from(schema.solarSystems),
db.db.select().from(schema.jumpRoutes),
db.db.select().from(schema.planets),
db.db.select().from(schema.inventoryItems),
db.db.select().from(schema.crew),
db.db.select().from(schema.shipSystems),
db.db.select().from(schema.tech),
db.db.select().from(schema.missions),
db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
])
return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
}
describe('resolveResearchTask (A/B against researchTech)', () => {
// A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched,
// event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick
// advanced the turn (Task 4b), then the resolver marks the tech. Same starting world →
// byte-identical end states.
it('lands exactly what researchTech lands', async () => {
const { db: a } = await freshFile()
const { db: b } = await freshFile()
await buildResearchFixture(a)
await buildResearchFixture(b)
await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources
await addItem(b, 'resource', 'IRON ORE', 2)
const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
await researchTech(a, navA.id)
await spendItems(b, 1, 'resource', 2) // the queue-time deduction
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await resolveResearchTask(b, task({ techId: navB.id }))
expect(await snapshot(b)).toEqual(await snapshot(a))
expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true)
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'research')!
expect(ev.turn).toBe(1)
expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)')
})
it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => {
const { db } = await freshFile()
await buildResearchFixture(db)
const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' })
await resolveResearchTask(db, task({ techId: nav.id }))
await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' })
})
})