import { randomUUID } from "node:crypto"; import { content } from "@deadhead/content"; import type { Dispatch } from "@deadhead/schema"; import { createDispatchRecord, createRepositionRecord, loanerModel, validateDispatch, validateReposition, type Contract, type DispatchRecord, type PilotState, type RealTime, type ShipState, } from "@deadhead/sim"; import { and, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { acceptedContracts, bays, companies, contractBoards, dispatches, pilots, players, ships, } from "../db/schema"; import { gameError } from "../http/errors"; import { acceptedContractIds } from "./boards"; import { requireCompany, requirePlayer } from "./fleet"; import { toApiDispatch } from "./mappers"; import { VALIDATION_MESSAGES } from "./messages"; import { requireOwnPilot, requireOwnShip } from "./pilots"; /** The id the tutorial loaner uses in dispatch records (it is not a ship row). */ export const LOANER_SHIP_ID = "loaner"; /** Finds a contract on any of the player's stored boards of a kind. */ export async function findBoardContract( tx: Queryable, playerId: string, kind: "bay" | "tutorial", contractId: string, ): Promise<{ contract: Contract; periodIndex: number } | undefined> { const [board] = await tx .select() .from(contractBoards) .where( and( eq(contractBoards.playerId, playerId), eq(contractBoards.kind, kind), sql`${contractBoards.contracts} @> ${JSON.stringify([{ id: contractId }])}::jsonb`, ), ); const contract = board?.contracts.find((c) => c.id === contractId); return board && contract ? { contract, periodIndex: board.periodIndex } : undefined; } /** Loads a board contract the player may still accept, or throws. */ export async function claimableContract( tx: Queryable, playerId: string, kind: "bay" | "tutorial", contractId: string, ): Promise<{ contract: Contract; periodIndex: number }> { const found = await findBoardContract(tx, playerId, kind, contractId); if (!found) throw gameError("unknown-contract", "No such contract on your boards."); if ((await acceptedContractIds(tx, playerId)).has(contractId)) { throw gameError("contract-already-accepted", "That contract has already been taken."); } return found; } export function pilotState(row: typeof pilots.$inferSelect): PilotState { return { id: row.id, isAvatar: row.isAvatar, assignedShipId: row.assignedShipId, inFlight: row.status === "in_flight", skills: row.skills, }; } /** Stores a dispatch, marks its contract accepted, and charges fuel. */ export async function storeDispatch( tx: Queryable, args: { playerId: string; record: DispatchRecord; /** Null for the tutorial loaner. */ shipId: string | null; /** The board period the contract came from; null for a reposition. */ periodIndex: number | null; now: RealTime; }, ): Promise { const { playerId, record, shipId, now } = args; const [row] = await tx .insert(dispatches) .values({ id: record.id, playerId, shipId, pilotId: record.pilotId, tutorial: record.tutorial, record, arrivalAt: record.arrivalAt, }) .returning(); if (record.contract && args.periodIndex !== null) { await tx.insert(acceptedContracts).values({ playerId, contractId: record.contract.id, periodIndex: args.periodIndex, dispatchId: record.id, acceptedAt: now, }); } if (record.fuelCost > 0) { await tx .update(companies) .set({ credits: sql`${companies.credits} - ${record.fuelCost}` }) .where(eq(companies.playerId, playerId)); } return toApiDispatch(row!); } /** * The avatar flies the company loaner on a tutorial contract (Step 6.4). The * company pays fuel, and only one tutorial run may be in flight. */ export async function dispatchTutorial( tx: Queryable, playerId: string, contractId: string, now: RealTime, ): Promise { const [player] = await tx.select().from(players).where(eq(players.id, playerId)); if (!player?.tutorialActive || !player.loanerLocationId) { throw gameError("not-in-tutorial", "The tutorial is over."); } const [running] = await tx .select({ id: dispatches.id }) .from(dispatches) .where( and( eq(dispatches.playerId, playerId), eq(dispatches.tutorial, true), eq(dispatches.status, "in_flight"), ), ); if (running) throw gameError("tutorial-run-in-flight", "Your tutorial run is still flying."); const { contract, periodIndex } = await claimableContract(tx, playerId, "tutorial", contractId); const [avatar] = await tx .select() .from(pilots) .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true))); const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); if (!avatar || !company) throw new Error(`Player ${playerId} is missing its avatar or company`); const loaner: ShipState = { id: LOANER_SHIP_ID, modelId: loanerModel(content).id, locationId: player.loanerLocationId, inFlight: false, }; const pilot = pilotState(avatar); const check = validateDispatch( { ship: loaner, pilot, contract, tutorial: true, credits: company.credits, now }, content, ); if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]); const record = createDispatchRecord( { id: randomUUID(), ship: loaner, pilot, contract, tutorial: true, now, worldSeed: player.worldSeed, }, content, ); const dispatch = await storeDispatch(tx, { playerId, record, shipId: null, periodIndex, now }); await tx .update(pilots) .set({ status: "in_flight", locationId: null }) .where(eq(pilots.id, avatar.id)); return dispatch; } export function shipState(row: typeof ships.$inferSelect): ShipState { return { id: row.id, modelId: row.modelId, // An in-flight ship has no location; validation reports it as not idle first. locationId: row.locationId ?? "", inFlight: row.status === "in_flight", }; } /** * Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10): * sim validation, fuel charged, contract accepted, ship and pilot in flight. */ export async function dispatchContract( tx: Queryable, playerId: string, input: { contractId: string; shipId: string; pilotId: string }, now: RealTime, ): Promise { const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId); const ship = await requireOwnShip(tx, playerId, input.shipId); const pilot = await requireOwnPilot(tx, playerId, input.pilotId); const player = await requirePlayer(tx, playerId); const company = await requireCompany(tx, playerId); const proposal = { ship: shipState(ship), pilot: pilotState(pilot), contract, tutorial: false, credits: company.credits, now, }; const check = validateDispatch(proposal, content); if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]); const record = createDispatchRecord( { id: randomUUID(), ...proposal, worldSeed: player.worldSeed }, content, ); const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex, now }); await setInFlight(tx, ship.id, pilot.id); return dispatch; } export async function setInFlight(tx: Queryable, shipId: string, pilotId: string) { await tx.update(ships).set({ status: "in_flight", locationId: null }).where(eq(ships.id, shipId)); await tx .update(pilots) .set({ status: "in_flight", locationId: null }) .where(eq(pilots.id, pilotId)); } /** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */ export async function dispatchReposition( tx: Queryable, playerId: string, input: { shipId: string; pilotId: string; destinationId: string }, now: RealTime, ): Promise { const ship = await requireOwnShip(tx, playerId, input.shipId); const pilot = await requireOwnPilot(tx, playerId, input.pilotId); const player = await requirePlayer(tx, playerId); const company = await requireCompany(tx, playerId); const bayRows = await tx .select({ locationId: bays.locationId }) .from(bays) .where(eq(bays.playerId, playerId)); const proposal = { ship: shipState(ship), pilot: pilotState(pilot), destinationId: input.destinationId, bayLocationIds: new Set(bayRows.map((b) => b.locationId)), credits: company.credits, now, }; const check = validateReposition(proposal, content); if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]); const record = createRepositionRecord( { id: randomUUID(), ...proposal, worldSeed: player.worldSeed }, content, ); const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex: null, now, }); await setInFlight(tx, ship.id, pilot.id); return dispatch; }