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, type UpgradeLevels, } from "@deadhead/sim"; import { and, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { acceptedContracts, companies, contractBoards, discoveredLocations, 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"; import { shipUpgradeLevels } from "./upgrades"; /** 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: "location" | "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: "location" | "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, restEndsAt: row.restEndsAt, }; } /** 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, upgrades: UpgradeLevels = {}): 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", // Effective stats and the shop block (CR-009). upgrades, inShopUntil: row.inShopUntil, }; } /** * Dispatches an owned ship and its pilot on a location-board contract, from * wherever the ship is (Steps 6.10, 10B.5; no bay involved): * 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, "location", 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 discovered = await tx .select({ locationId: discoveredLocations.locationId }) .from(discoveredLocations) .where(eq(discoveredLocations.playerId, playerId)); const proposal = { ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)), pilot: pilotState(pilot), contract, // The no-strand rule counts only discovered locations (decision 0.5.79). discoveredIds: new Set(discovered.map((d) => d.locationId)), 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)); } /** Deadhead: flies a ship and its pilot empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */ 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 discovered = await tx .select({ locationId: discoveredLocations.locationId }) .from(discoveredLocations) .where(eq(discoveredLocations.playerId, playerId)); const proposal = { ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)), pilot: pilotState(pilot), destinationId: input.destinationId, discoveredIds: new Set(discovered.map((d) => d.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; }