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 { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs' import { buildSystem, expandSystem, getShipState, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, spendItems, upgradeSystem, type ShipState } from './ship' import { SCHEMA_VERSION } from './save' import { TECHS } from './tech' import type { Task } from './tasks' const dirs: string[] = [] async function freshFile(): Promise<{ db: GameDb; file: string }> { const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-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('getShipState', () => { it('new game: starting ship in display order — core types built at base values, WEAPONS unbuilt', async () => { const { db } = await freshFile() const state = await getShipState(db) expect(state.systems.map((s) => s.type)).toEqual(SHIP_TYPES.map((t) => t.type)) // display order follows SHIP_TYPES for (const def of SHIP_TYPES) { const row = systemOf(state, def.type) if (def.builtAtStart) { expect(row.built).toBe(true) expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY }) } else { expect(row.built).toBe(false) // unbuilt until a BUILD action expect(row.level).toBe(0) expect(row.capacity).toBe(0) } } expect(state.inventory).toEqual([]) expect(state.turn).toBe(0) }) }) describe('upgradeSystem', () => { it('rejects with 422 and mutates nothing when the hold is empty', async () => { const { db } = await freshFile() const before = await getShipState(db) const reactorId = systemOf(before, 'REACTOR').id await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 422 }) const after = await getShipState(db) expect(systemOf(after, 'REACTOR').level).toBe(BASE_LEVEL) // no level change… expect(after.turn).toBe(before.turn) // …and no turn spent on a failed action }) it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => { const { db } = await freshFile() await getShipState(db) await expect(upgradeSystem(db, 999)).rejects.toMatchObject({ status: 404 }) const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id await expect(upgradeSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 }) }) it('consumes the cost from the oldest resource row first (deleting rows that hit zero), 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 1 await addItem(db, 'resource', 'CRYSTAL DUST', 5) const reactorId = systemOf(await getShipState(db), 'REACTOR').id const state = await upgradeSystem(db, reactorId) // level 1 → cost 1 resource expect(systemOf(state, 'REACTOR').level).toBe(BASE_LEVEL + 1) expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 5]]) // IRON ORE row deleted expect(state.turn).toBe(1) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'upgrade')! expect(event.turn).toBe(1) expect(event.message).toContain('REACTOR') }) it('caps at MAX_LEVEL with 409', 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)) // cost path covered above await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 }) expect((await getShipState(db)).turn).toBe(0) }) }) describe('expandSystem', () => { it('raises capacity by one for `capacity` resources; caps at MAX_CAPACITY with 409', async () => { const { db } = await freshFile() await getShipState(db) await addItem(db, 'resource', 'IRON ORE', BASE_CAPACITY + 1) // cost is the current capacity (2), one left over const hullId = systemOf(await getShipState(db), 'HULL').id const state = await expandSystem(db, hullId) expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1) expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]]) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'expand')! expect(event.turn).toBe(1) expect(event.message).toContain('HULL') await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 }) }) it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => { const { db } = await freshFile() await getShipState(db) await expect(expandSystem(db, 999)).rejects.toMatchObject({ status: 404 }) const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id await expect(expandSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 }) }) }) describe('buildSystem', () => { it('builds an unbuilt type at base values for a flat parts cost; a second build is 409 and unknown types are 404', async () => { 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) const state = await buildSystem(db, 'WEAPONS') const weapons = systemOf(state, 'WEAPONS') expect(weapons.built).toBe(true) expect({ level: weapons.level, capacity: weapons.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY }) expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // parts row deleted expect(state.turn).toBe(1) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')! expect(event.turn).toBe(1) expect(event.message).toContain('WEAPONS') await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type }) }) // --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) --------------------- const T0 = '2026-10-01T00:00:00.000Z' // Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with // WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up. async function buildShipFixture(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 SHIP_TYPES) { await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 }) } 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, researched: def.slug === 'WEAPONS_SYSTEMS' }) } } const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', payload: object): Task => ({ id: 0, saveId: 0, type, 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('resolveShip* (A/B against the immediate actions)', () => { // A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event). // B = the queued shape: the cost is deducted up front (queueTask did it at queue time — // Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect. // Same starting world → byte-identical end states. it('UPGRADE: lands exactly what upgradeSystem lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildShipFixture(a) await buildShipFixture(b) await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1 await addItem(b, 'resource', 'IRON ORE', 1) const reactorId = systemOf(await getShipState(a), 'REACTOR').id await upgradeSystem(a, reactorId) await spendItems(b, 1, 'resource', 1) // the queue-time deduction await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId })) expect(await snapshot(b)).toEqual(await snapshot(a)) 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 === 'upgrade')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)') }) it('EXPAND: lands exactly what expandSystem lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildShipFixture(a) await buildShipFixture(b) await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2 await addItem(b, 'resource', 'CRYSTAL DUST', 2) const hullId = systemOf(await getShipState(a), 'HULL').id await expandSystem(a, hullId) await spendItems(b, 1, 'resource', 2) await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId })) expect(await snapshot(b)).toEqual(await snapshot(a)) 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 === 'expand')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)') }) it('BUILD: lands exactly what buildSystem lands', async () => { const { db: a } = await freshFile() const { db: b } = await freshFile() await buildShipFixture(a) await buildShipFixture(b) await addItem(a, 'part', 'SERVO ACTUATOR', 3) await addItem(b, 'part', 'SERVO ACTUATOR', 3) await buildSystem(a, 'WEAPONS') await spendItems(b, 1, 'part', 3) await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' })) expect(await snapshot(b)).toEqual(await snapshot(a)) 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 === 'build')! expect(ev.turn).toBe(1) expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)') }) it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => { const { db } = await freshFile() await buildShipFixture(db) const state = await getShipState(db) const reactorId = systemOf(state, 'REACTOR').id const hullId = systemOf(state, 'HULL').id const weaponsId = systemOf(state, 'WEAPONS').id await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' }) await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` }) await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' }) await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` }) await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' }) await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS')) await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' }) await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' }) }) })