diff --git a/lib/game/ship.test.ts b/lib/game/ship.test.ts new file mode 100644 index 0000000..b6dc322 --- /dev/null +++ b/lib/game/ship.test.ts @@ -0,0 +1,148 @@ +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, upgradeSystem, type ShipState } from './ship' + +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]]) + + 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) + + 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.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 + }) +}) diff --git a/lib/game/ship.ts b/lib/game/ship.ts new file mode 100644 index 0000000..e628991 --- /dev/null +++ b/lib/game/ship.ts @@ -0,0 +1,124 @@ +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 { ensureSave, type InventoryItemDto } from './save' + +export interface ShipSystemDto { + id: number + type: string + level: number + capacity: number + built: boolean // derived — level > 0 (unbuilt rows sit at level/capacity 0) +} + +// The ship's full state for the SHIP SYSTEMS tab and left column. +export interface ShipState { + systems: ShipSystemDto[] // display order follows SHIP_TYPES + inventory: InventoryItemDto[] + turn: number +} + +// Carries an HTTP status so route handlers can map failures to responses without re-parsing. +export class ShipError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +async function readShipState(db: GameDb): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const rows = await db.db.select().from(schema.shipSystems) + const orderOf = (type: string) => { + const i = SHIP_TYPES.findIndex((t) => t.type === type) + return i === -1 ? 99 : i // unknown types (shouldn't exist) sort last + } + const systems = [...rows] + .sort((a, b) => orderOf(a.type) - orderOf(b.type)) + .map((r) => ({ id: r.id, type: r.type, level: r.level, capacity: r.capacity, built: r.level > 0 })) + const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })) + return { systems, inventory, turn: save.turn } +} + +// The ship's systems + cargo hold + turn. Auto-initializes like the other reads. +export async function getShipState(db: GameDb): Promise { + await ensureSave(db) + return readShipState(db) +} + +// 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 { + 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}`) + } + let remaining = amount + for (const row of rows) { + if (remaining <= 0) break + const take = Math.min(row.qty, remaining) + remaining -= take + if (take >= row.qty) await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, row.id)) + else await db.db.update(schema.inventoryItems).set({ qty: row.qty - take }).where(eq(schema.inventoryItems.id, row.id)) + } +} + +async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise { + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId)) + 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. +export async function upgradeSystem(db: GameDb, systemId: number): Promise { + 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 cost = upgradeCost(target.level) + await spendItems(db, save.id, 'resource', cost) + const turn = save.turn + 1 + await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) + await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`) + await persist(db) + return readShipState(db) +} + +// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource. +export async function expandSystem(db: GameDb, systemId: number): Promise { + 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 cost = expandCost(target.capacity) + await spendItems(db, save.id, 'resource', cost) + const turn = save.turn + 1 + await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) + await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`) + await persist(db) + return readShipState(db) +} + +// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. +export async function buildSystem(db: GameDb, type: string): Promise { + 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`) + + await spendItems(db, save.id, 'part', BUILD_PART_COST) + const turn = save.turn + 1 + await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) + await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`) + await persist(db) + return readShipState(db) +}