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

112 lines
3.6 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";
/** 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<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: it appears idle at Earth with no pilot (Step 6.5). */
export async function buyShip(
tx: Queryable,
playerId: string,
modelId: string,
now: RealTime,
): Promise<Ship> {
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,
};
}