import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import type { Pilot } from "@deadhead/schema"; import { isResting, pilotLevel, restEndsAt, 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"; export async function hasBayAt( tx: Queryable, playerId: string, locationId: string, ): Promise { 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 { 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, now); } /** * 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, now: RealTime, ): Promise { 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, now); } /** 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, now: RealTime, ): Promise { 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, now); } /** * Rests an idle pilot now, at any duty level (CR-004): duty resets and they * cannot fly until the rest ends. No bay is needed; crew swaps stay allowed. */ export async function restPilot( tx: Queryable, playerId: string, pilotId: string, now: RealTime, ): Promise { await requireTutorialOver(tx, playerId, "resting a pilot"); const pilot = await requireOwnPilot(tx, playerId, pilotId); if (pilot.status === "in_flight") { throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]); } if (isResting(pilot.restEndsAt, now)) { throw gameError("pilot-resting", "That pilot is already resting."); } const [updated] = await tx .update(pilots) .set({ dutyHours: 0, restEndsAt: restEndsAt(now, content.config) }) .where(eq(pilots.id, pilotId)) .returning(); const ship = updated!.assignedShipId ? await requireOwnShip(tx, playerId, updated!.assignedShipId) : null; return toApiPilot(updated!, ship?.locationId ?? null, now); } /** 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, now: RealTime, ): Promise { 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, now); }