137 lines
7.0 KiB
TypeScript
137 lines
7.0 KiB
TypeScript
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'
|
||
|
||
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<ShipState> {
|
||
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<ShipState> {
|
||
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<void> {
|
||
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<void> {
|
||
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() })
|
||
}
|
||
|
||
// Slugs of the researched techs on this save — drives the effective caps and build gates below.
|
||
async function researchedTechSlugs(db: GameDb): Promise<Set<string>> {
|
||
return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug))
|
||
}
|
||
|
||
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). Cost: `level` units of any resource.
|
||
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||
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})`)
|
||
|
||
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 the effective max capacity, raised by techs). Cost: `capacity` units of any resource.
|
||
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||
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})`)
|
||
|
||
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. Some types are
|
||
// gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
|
||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||
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.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)
|
||
}
|