176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { content } from "@deadhead/content";
|
|
import type { Pilot } from "@deadhead/schema";
|
|
import { pilotLevel, skillPointsAvailable, type RealTime } from "@deadhead/sim";
|
|
import { and, count, eq } from "drizzle-orm";
|
|
import type { Queryable } from "../db/client";
|
|
import { bays, pilots, ships } from "../db/schema";
|
|
import { gameError } from "../http/errors";
|
|
import { requirePlayer, requireTutorialOver, spend } from "./fleet";
|
|
import { toApiPilot } from "./mappers";
|
|
import { VALIDATION_MESSAGES } from "./messages";
|
|
import { pilotName } from "./names";
|
|
|
|
async function hasBayAt(tx: Queryable, playerId: string, locationId: string): Promise<boolean> {
|
|
const [bay] = await tx
|
|
.select({ id: bays.id })
|
|
.from(bays)
|
|
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
|
|
return bay !== undefined;
|
|
}
|
|
|
|
export async function requireOwnPilot(tx: Queryable, playerId: string, pilotId: string) {
|
|
const [pilot] = await tx
|
|
.select()
|
|
.from(pilots)
|
|
.where(and(eq(pilots.id, pilotId), eq(pilots.playerId, playerId)));
|
|
if (!pilot) throw gameError("not-found", "No such pilot.");
|
|
return pilot;
|
|
}
|
|
|
|
export async function requireOwnShip(tx: Queryable, playerId: string, shipId: string) {
|
|
const [ship] = await tx
|
|
.select()
|
|
.from(ships)
|
|
.where(and(eq(ships.id, shipId), eq(ships.playerId, playerId)));
|
|
if (!ship) throw gameError("not-found", "No such ship.");
|
|
return ship;
|
|
}
|
|
|
|
/** Hires a level-0 pilot at one of the player's bays (Step 6.8, decision 0.5.22). */
|
|
export async function hirePilot(
|
|
tx: Queryable,
|
|
playerId: string,
|
|
locationId: string,
|
|
now: RealTime,
|
|
): Promise<Pilot> {
|
|
await requireTutorialOver(tx, playerId, "hiring pilots");
|
|
if (!(await hasBayAt(tx, playerId, locationId))) {
|
|
throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays.");
|
|
}
|
|
await spend(tx, playerId, content.config.pilotHireCost, "a new pilot");
|
|
|
|
const player = await requirePlayer(tx, playerId);
|
|
const [hired] = await tx
|
|
.select({ n: count() })
|
|
.from(pilots)
|
|
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false)));
|
|
const [pilot] = await tx
|
|
.insert(pilots)
|
|
.values({
|
|
id: randomUUID(),
|
|
playerId,
|
|
name: pilotName(player.worldSeed, hired!.n),
|
|
locationId,
|
|
createdAt: now,
|
|
})
|
|
.returning();
|
|
return toApiPilot(pilot!, null);
|
|
}
|
|
|
|
/**
|
|
* Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same
|
|
* location. The avatar is locked to the loaner during the tutorial (decision 0.5.23).
|
|
*/
|
|
export async function assignPilot(
|
|
tx: Queryable,
|
|
playerId: string,
|
|
pilotId: string,
|
|
shipId: string,
|
|
): Promise<Pilot> {
|
|
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
|
const ship = await requireOwnShip(tx, playerId, shipId);
|
|
const player = await requirePlayer(tx, playerId);
|
|
|
|
if (pilot.isAvatar && player.tutorialActive) {
|
|
throw gameError("avatar-in-tutorial", "You fly the company loaner until the tutorial ends.");
|
|
}
|
|
if (pilot.status === "in_flight") {
|
|
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
|
|
}
|
|
if (pilot.assignedShipId !== null) {
|
|
throw gameError(
|
|
"pilot-already-assigned",
|
|
"That pilot already has a ship. Unassign them first.",
|
|
);
|
|
}
|
|
if (ship.status === "in_flight") {
|
|
throw gameError("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]);
|
|
}
|
|
if (ship.locationId !== pilot.locationId) {
|
|
throw gameError("ship-not-idle-here", "The pilot and the ship must be at the same location.");
|
|
}
|
|
const [crew] = await tx
|
|
.select({ id: pilots.id })
|
|
.from(pilots)
|
|
.where(eq(pilots.assignedShipId, shipId));
|
|
if (crew) throw gameError("ship-crewed", "That ship already has a pilot.");
|
|
|
|
const [updated] = await tx
|
|
.update(pilots)
|
|
.set({ assignedShipId: shipId, locationId: null })
|
|
.where(eq(pilots.id, pilotId))
|
|
.returning();
|
|
return toApiPilot(updated!, ship.locationId);
|
|
}
|
|
|
|
/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */
|
|
export async function unassignPilot(
|
|
tx: Queryable,
|
|
playerId: string,
|
|
pilotId: string,
|
|
): Promise<Pilot> {
|
|
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
|
if (pilot.assignedShipId === null) {
|
|
throw gameError("pilot-not-assigned-to-ship", "That pilot has no ship.");
|
|
}
|
|
if (pilot.status === "in_flight") {
|
|
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
|
|
}
|
|
const ship = await requireOwnShip(tx, playerId, pilot.assignedShipId);
|
|
if (ship.locationId === null || !(await hasBayAt(tx, playerId, ship.locationId))) {
|
|
throw gameError("no-bay-at-location", "Pilots can only leave their ship at one of your bays.");
|
|
}
|
|
|
|
const [updated] = await tx
|
|
.update(pilots)
|
|
.set({ assignedShipId: null, locationId: ship.locationId })
|
|
.where(eq(pilots.id, pilotId))
|
|
.returning();
|
|
return toApiPilot(updated!, null);
|
|
}
|
|
|
|
/** Spends one available skill point on a skill below its max level (Step 6.12). */
|
|
export async function spendSkillPoint(
|
|
tx: Queryable,
|
|
playerId: string,
|
|
pilotId: string,
|
|
skillId: string,
|
|
): Promise<Pilot> {
|
|
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
|
const skill = content.skill(skillId);
|
|
if (!skill) throw gameError("unknown-skill", `No skill "${skillId}".`);
|
|
|
|
const level = pilotLevel(pilot.xp, content.config);
|
|
if (skillPointsAvailable(level, pilot.skillPointsSpent, content.config) <= 0) {
|
|
throw gameError("no-skill-points", "That pilot has no skill points to spend.");
|
|
}
|
|
const current = pilot.skills[skillId] ?? 0;
|
|
if (current >= skill.maxLevel) {
|
|
throw gameError("skill-maxed", `${skill.name} is already at its maximum level.`);
|
|
}
|
|
|
|
const [updated] = await tx
|
|
.update(pilots)
|
|
.set({
|
|
skills: { ...pilot.skills, [skillId]: current + 1 },
|
|
skillPointsSpent: pilot.skillPointsSpent + 1,
|
|
})
|
|
.where(eq(pilots.id, pilotId))
|
|
.returning();
|
|
const [ship] = updated!.assignedShipId
|
|
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
|
|
: [];
|
|
return toApiPilot(updated!, ship?.locationId ?? null);
|
|
}
|