import type { GameConfig, Location, ShipModel } from "@deadhead/content"; import type { SimContent } from "./board"; import { MS_PER_HOUR, type RealTime } from "./clock"; import type { Contract } from "./contract"; import { routeDistance } from "./distance"; import { isResting } from "./duty"; import { checkRoute } from "./eligibility"; import { fuelCost } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { deriveSeed, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades"; export interface ShipState { id: string; modelId: string; locationId: string; inFlight: boolean; /** Installed upgrades (CR-009); absent means none. */ upgrades?: UpgradeLevels; /** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */ inShopUntil?: RealTime | null; } /** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */ export function shipStats(ship: ShipState, content: SimContent): EffectiveStats { return effectiveStats(findShipModel(ship.modelId, content), ship.upgrades ?? {}, content); } 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; /** Resting until this real time; null when not resting (CR-004). */ restEndsAt: RealTime | null; } 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; /** * Locations the player has discovered, for the no-strand rule (decision 0.5.79). * Absent: every location counts. */ discoveredIds?: ReadonlySet; } /** Every reason `validateDispatch` can fail, in check order. */ export const DISPATCH_FAILURES = [ "ship-not-idle", "ship-in-shop", "ship-not-at-origin", "pilot-resting", "pilot-not-idle", "pilot-not-assigned", "over-capacity", "cannot-land", "out-of-range", "would-strand", "too-slow", "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. */ export function tripFuelCost( model: ShipModel, origin: Location, destination: Location, config: GameConfig, ): number { return Math.round(fuelCost(model, origin, routeDistance(origin, destination), config)); } const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); /** A ship's trip time for a contract: cruise, overhead and handling (game hours). */ export function contractTripGameHours( model: ShipModel, contract: Contract, content: SimContent, ): number { const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); const distance = routeDistance(origin, destination); return travelTimeGameHours(model, distance, contract.massTonnes, content.config); } /** * The no-strand rule (decision 0.5.79): true when the ship could not fly from * the destination to any other discovered location. `discoveredIds` absent * means every location counts. The map is fixed (0.5.93), so arrival time * doesn't matter. */ export function wouldStrand( model: ShipModel, destination: Location, discoveredIds: ReadonlySet | undefined, content: SimContent, ): boolean { return !content.locations.some( (next) => next.id !== destination.id && (discoveredIds?.has(next.id) ?? true) && checkRoute(model, destination, next).ok, ); } /** 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 = shipStats(ship, content); const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); if (ship.inFlight) return fail("ship-not-idle"); if (isInShop(ship, now)) return fail("ship-in-shop"); if (ship.locationId !== contract.originId) return fail("ship-not-at-origin"); // Rest blocks flying only (decision 0.5.44); checked before the other pilot checks. if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting"); 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); if (!route.ok) return fail(route.reason); if (wouldStrand(model, destination, proposal.discoveredIds, content)) { return fail("would-strand"); } // Priority (decision 0.5.56): the trip must fit the delivery window. if ( contract.priority && contract.deliveryWindowGameHours !== null && contractTripGameHours(model, contract, content) > contract.deliveryWindowGameHours ) { return fail("too-slow"); } 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, content.config)) 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 = shipStats(ship, content); const origin = findLocation(args.originId, content); const destination = findLocation(args.destinationId, content); const distance = routeDistance(origin, destination); 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, content.config), 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", "ship-in-shop", "pilot-resting", "pilot-not-idle", "pilot-not-assigned", "already-there", "location-undiscovered", "cannot-land", "out-of-range", "would-strand", "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 (isInShop(ship, now)) return failWith("ship-in-shop"); if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting"); 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 = shipStats(ship, content); const origin = findLocation(ship.locationId, content); const destination = findLocation(destinationId, content); const route = checkRoute(model, origin, destination); if (!route.ok) return failWith(route.reason); if (wouldStrand(model, destination, discoveredIds, content)) { return failWith("would-strand"); } if (credits < tripFuelCost(model, origin, destination, content.config)) { 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, ); }