deadhead-space-freight-tycoon/apps/server/src/game/fleet.ts

129 lines
4.4 KiB
TypeScript

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";
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;
}
/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */
export async function requireTutorialOver(tx: Queryable, playerId: string, what: string) {
const player = await requirePlayer(tx, playerId);
if (player.tutorialActive) {
throw gameError("tutorial-active", `Finish the company runs before ${what}.`);
}
}
/** 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<Bay> {
await requireTutorialOver(tx, playerId, "leasing a bay");
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, delivered idle and uncrewed to one of the player's bays
* (Steps 6.5, 10B.6; decision 0.5.35).
*/
export async function buyShip(
tx: Queryable,
playerId: string,
input: { modelId: string; bayId: string },
now: RealTime,
): Promise<Ship> {
const { modelId } = input;
await requireTutorialOver(tx, playerId, "buying a ship");
const model = content.shipModel(modelId);
if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`);
const [bay] = await tx
.select()
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.id, input.bayId)));
if (!bay) throw gameError("not-found", "You have no such bay.");
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: bay.locationId,
createdAt: now,
})
.returning();
return {
id: ship!.id,
modelId: ship!.modelId,
name: ship!.name,
status: "idle",
locationId: ship!.locationId,
dispatchId: null,
pilotId: null,
lastArrivalAt: ship!.createdAt,
};
}