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

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);
}