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 { 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 { 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 { 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' }) }) })