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, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs' import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech' import { ensureSave, type InventoryItemDto } from './save' import type { Task, TaskType } from './tasks' 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. // Exported: research.ts spends tech costs through the same path. export 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)) } } // Slugs of the researched techs on this save — drives the effective caps and build gates below. async function researchedTechSlugs(db: GameDb): Promise> { return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug)) } function syntheticTask(type: TaskType, payload: object): Task { return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 } } // SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution // time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before // delegating); no mission hooks (the current action has none). export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise { await ensureSave(db) const { systemId } = JSON.parse(task.payload) as { systemId: number } 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`) const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) const cost = upgradeCost(target.level) await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) await persist(db) } // SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted). export async function resolveShipExpandTask(db: GameDb, task: Task): Promise { await ensureSave(db) const { systemId } = JSON.parse(task.payload) as { systemId: number } 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`) const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) const cost = expandCost(target.capacity) await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() }) await persist(db) } // SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already // deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts). export async function resolveShipBuildTask(db: GameDb, task: Task): Promise { await ensureSave(db) const { type } = JSON.parse(task.payload) as { type: string } 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`) const gate = buildGate(type, await researchedTechSlugs(db)) if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id)) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() }) await persist(db) } // The upgrade pre-checks, extracted for the queueing route (5a(ii)): the same codes and // messages as upgradeSystem — unknown system (404), not built (409), at max level (409). // Cost, turn and the level-up effect stay out: the queueing path deducts at queue time // (queueTask) and the effect lands when the task completes on the tick. export async function checkUpgradePreconditions(db: GameDb, systemId: number): Promise { await ensureSave(db) 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`) const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) } // UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). 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`) const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db)) if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`) await spendItems(db, save.id, 'resource', upgradeCost(target.level)) const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveShipUpgradeTask(db, syntheticTask('SHIP_UPGRADE', { systemId })) return readShipState(db) } // The expand pre-checks, extracted for the queueing route (5a(ii)): the same codes and // messages as expandSystem — unknown system (404), not built (409), at max capacity (409). export async function checkExpandPreconditions(db: GameDb, systemId: number): Promise { await ensureSave(db) 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`) const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) } // EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). 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`) const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db)) if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`) await spendItems(db, save.id, 'resource', expandCost(target.capacity)) const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveShipExpandTask(db, syntheticTask('SHIP_EXPAND', { systemId })) return readShipState(db) } // The build pre-checks, extracted for the queueing route (5a(ii)): the same codes and // messages as buildSystem — unknown type (404), already built (409), build-gate tech // missing (409). The parts cost stays out: the queueing path deducts it at queue time. export async function checkBuildPreconditions(db: GameDb, type: string): Promise { await ensureSave(db) 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`) const gate = buildGate(type, await researchedTechSlugs(db)) if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) } // 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`) const gate = buildGate(type, await researchedTechSlugs(db)) if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`) await spendItems(db, save.id, 'part', BUILD_PART_COST) const turn = save.turn + 1 await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) await resolveShipBuildTask(db, syntheticTask('SHIP_BUILD', { type })) return readShipState(db) }