import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import type { Bay, Ship } from "@deadhead/schema"; import type { RealTime } from "@deadhead/sim"; import { and, count, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { bays, companies, discoveredLocations, players, ships } from "../db/schema"; import { gameError } from "../http/errors"; import { shipName } from "./names"; /** Where newly bought ships appear. */ export const SHIPYARD_LOCATION_ID = "earth"; export async function requireCompany(tx: Queryable, playerId: string) { const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); if (!company) throw new Error(`Player ${playerId} has no company`); return company; } export async function requirePlayer(tx: Queryable, playerId: string) { const [player] = await tx.select().from(players).where(eq(players.id, playerId)); if (!player) throw new Error(`Player ${playerId} not found`); return player; } /** Deducts credits, or throws `insufficient-funds` without changing anything. */ export async function spend(tx: Queryable, playerId: string, amount: number, what: string) { const company = await requireCompany(tx, playerId); if (company.credits < amount) { throw gameError("insufficient-funds", `Not enough credits for ${what} (${amount} needed).`); } await tx .update(companies) .set({ credits: sql`${companies.credits} - ${amount}` }) .where(eq(companies.playerId, playerId)); } /** Leases a bay at a discovered location for its one-time price (Step 6.6). */ export async function leaseBay( tx: Queryable, playerId: string, locationId: string, now: RealTime, ): Promise { const location = content.location(locationId); if (!location) throw gameError("not-found", `No location "${locationId}".`); const [discovered] = await tx .select() .from(discoveredLocations) .where( and( eq(discoveredLocations.playerId, playerId), eq(discoveredLocations.locationId, locationId), ), ); if (!discovered) { throw gameError( "location-undiscovered", `Deliver to ${location.name} before leasing a bay there.`, ); } const [existing] = await tx .select() .from(bays) .where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId))); if (existing) throw gameError("bay-already-leased", `You already have a bay at ${location.name}.`); await spend(tx, playerId, location.bayLeasePrice, `a bay at ${location.name}`); const [bay] = await tx .insert(bays) .values({ id: randomUUID(), playerId, locationId, createdAt: now }) .returning(); return { id: bay!.id, locationId: bay!.locationId }; } /** Buys a ship model: it appears idle at Earth with no pilot (Step 6.5). */ export async function buyShip( tx: Queryable, playerId: string, modelId: string, now: RealTime, ): Promise { const model = content.shipModel(modelId); if (!model) throw gameError("not-found", `No ship model "${modelId}".`); await spend(tx, playerId, model.price, `a ${model.name}`); const player = await requirePlayer(tx, playerId); const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId)); const [ship] = await tx .insert(ships) .values({ id: randomUUID(), playerId, modelId, name: shipName(player.worldSeed, owned!.n), locationId: SHIPYARD_LOCATION_ID, createdAt: now, }) .returning(); return { id: ship!.id, modelId: ship!.modelId, name: ship!.name, status: "idle", locationId: ship!.locationId, dispatchId: null, pilotId: null, }; }