112 lines
3.6 KiB
TypeScript
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,
|
|
};
|
|
}
|