import type { Location, ShipModel } from "@deadhead/content"; import type { SimContent } from "./board"; import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock"; import type { Contract } from "./contract"; import { routeDistance } from "./distance"; import { checkRoute } from "./eligibility"; import { fuelCost } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { deriveSeed, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; export interface ShipState { id: string; modelId: string; locationId: string; inFlight: boolean; } export interface PilotState { id: string; /** The player's own pilot: exempt from pilot pay (decision 0.5.12). */ isAvatar: boolean; assignedShipId: string | null; inFlight: boolean; skills: SkillLevels; } export interface DispatchProposal { /** For a tutorial run, the company loaner (decision 0.5.26: it is not in the ships list). */ ship: ShipState; pilot: PilotState; contract: Contract; /** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */ tutorial: boolean; credits: number; now: RealTime; } /** Every reason `validateDispatch` can fail, in check order. */ export const DISPATCH_FAILURES = [ "ship-not-idle", "ship-not-at-origin", "pilot-not-idle", "pilot-not-assigned", "over-capacity", "cannot-land", "out-of-range", "cargo-skill-too-low", "long-haul-too-low", "contract-expired", "insufficient-credits", ] as const; export type DispatchFailure = (typeof DISPATCH_FAILURES)[number]; export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure }; export function findShipModel(id: string, content: SimContent): ShipModel { const model = content.shipModels.find((s) => s.id === id); if (!model) throw new Error(`Unknown ship model ${id}`); return model; } export function findLocation(id: string, content: SimContent): Location { const location = content.locations.find((l) => l.id === id); if (!location) throw new Error(`Unknown location ${id}`); return location; } /** Fuel charged for a trip, in whole credits, using the distance at departure. */ export function tripFuelCost( model: ShipModel, origin: Location, destination: Location, departure: GameTime, ): number { return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure))); } const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); /** Validates a proposed contract dispatch, returning the first failing reason. */ export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck { const { ship, pilot, contract, tutorial, credits, now } = proposal; const model = findShipModel(ship.modelId, content); const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); const departure = realToGame(now, content.config); if (ship.inFlight) return fail("ship-not-idle"); if (ship.locationId !== contract.originId) return fail("ship-not-at-origin"); if (pilot.inFlight) return fail("pilot-not-idle"); // Decision 0.5.23: during the tutorial the avatar flies only the loaner. if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) { return fail("pilot-not-assigned"); } if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity"); const route = checkRoute(model, origin, destination, departure); if (!route.ok) return fail(route.reason); const pilotCheck = checkPilotEligibility(pilot.skills, contract); if (!pilotCheck.ok) return fail(pilotCheck.reason); if (now >= contract.expiresAt) return fail("contract-expired"); if (!tutorial) { if (credits < tripFuelCost(model, origin, destination, departure)) return fail("insufficient-credits"); } return { ok: true }; } export interface DispatchRecord { id: string; shipId: string; pilotId: string; /** Null for a reposition. */ contract: Contract | null; originId: string; destinationId: string; tutorial: boolean; departureAt: RealTime; arrivalAt: RealTime; /** Route distance at departure, in million km. */ distanceMillionKm: number; /** Credits charged to the player; 0 for tutorial runs (the company pays). */ fuelCost: number; expectedPayout: number; /** Seed for anything rolled about this trip. */ seed: Seed; } export interface RecordInput { id: string; ship: ShipState; pilot: PilotState; contract: Contract; tutorial: boolean; now: RealTime; worldSeed: Seed; } function buildRecord( args: { id: string; ship: ShipState; pilot: PilotState; contract: Contract | null; originId: string; destinationId: string; tutorial: boolean; now: RealTime; worldSeed: Seed; }, content: SimContent, ): DispatchRecord { const { id, ship, pilot, contract, tutorial, now, worldSeed } = args; const { config } = content; const model = findShipModel(ship.modelId, content); const origin = findLocation(args.originId, content); const destination = findLocation(args.destinationId, content); const departure = realToGame(now, config); const distance = routeDistance(origin, destination, departure); const mass = contract?.massTonnes ?? 0; const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR; return { id, shipId: ship.id, pilotId: pilot.id, contract, originId: origin.id, destinationId: destination.id, tutorial, departureAt: now, arrivalAt: now + Math.round(travelGameMs / config.timeCompression), distanceMillionKm: distance, fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure), expectedPayout: contract?.payout ?? 0, seed: deriveSeed(worldSeed, `dispatch:${id}`), }; } /** Turns an already-validated dispatch into its record. Same inputs give the same record. */ export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord { const { contract } = input; return buildRecord( { ...input, originId: contract.originId, destinationId: contract.destinationId }, content, ); } export interface RepositionProposal { ship: ShipState; pilot: PilotState; destinationId: string; /** Locations the player has discovered; only these can be deadheaded to (CR-002). */ discoveredIds: ReadonlySet; credits: number; now: RealTime; } /** Every reason `validateReposition` can fail, in check order. */ export const REPOSITION_FAILURES = [ "ship-not-idle", "pilot-not-idle", "pilot-not-assigned", "already-there", "location-undiscovered", "cannot-land", "out-of-range", "insufficient-credits", ] as const; export type RepositionFailure = (typeof REPOSITION_FAILURES)[number]; export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure }; /** * Validates a deadhead (CR-002): an empty flight from wherever the ship is to * any other discovered location. Only range and landing apply; no skills are * needed (decision 0.5.24) and bays play no part. */ export function validateReposition( proposal: RepositionProposal, content: SimContent, ): RepositionCheck { const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal; const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason }); if (ship.inFlight) return failWith("ship-not-idle"); if (pilot.inFlight) return failWith("pilot-not-idle"); if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned"); if (destinationId === ship.locationId) return failWith("already-there"); if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered"); const model = findShipModel(ship.modelId, content); const origin = findLocation(ship.locationId, content); const destination = findLocation(destinationId, content); const departure = realToGame(now, content.config); const route = checkRoute(model, origin, destination, departure); if (!route.ok) return failWith(route.reason); if (credits < tripFuelCost(model, origin, destination, departure)) { return failWith("insufficient-credits"); } return { ok: true }; } export interface RepositionRecordInput { id: string; ship: ShipState; pilot: PilotState; destinationId: string; now: RealTime; worldSeed: Seed; } /** Record for a validated reposition: no contract, fuel only, zero payout. */ export function createRepositionRecord( input: RepositionRecordInput, content: SimContent, ): DispatchRecord { return buildRecord( { ...input, contract: null, originId: input.ship.locationId, tutorial: false }, content, ); }