import { content, type ShipModel } from "@deadhead/content"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import { checkRoute, contractTripGameHours, isInShop, rangeFrom, dutyAfterTrip, LONG_HAUL_SKILL_ID, needsRest, realToGame, tripGameHours, validateDispatch, wouldStrand, type DispatchFailure, } from "@deadhead/sim"; import { useEffect, useState } from "react"; import { useApi } from "../api/context"; import { formatFu, refuelQuote } from "../components/Fuel"; import { useClock } from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; import { cargoName, formatClock, formatCredits, formatDuration, formatGameHours, locationName, skillName, } from "../format"; import { requirementsText } from "./LocationBoards"; import { jobMoney, location, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { RouteStrip } from "../components/RouteStrip"; import { Art, Button, Sheet } from "../ui"; import { goodName, PriorityChip } from "../components/Goods"; import { displayPreferences, formatGameHoursSpan, formatSpan, formatTimeOfDay, } from "../clock/timeFormat"; import { usePreferences } from "../state/preferences"; import { serviceTag } from "../components/Service"; /** Short player-facing reasons a ship+pilot cannot take a contract. */ export const INELIGIBLE_REASONS: Record = { "ship-not-idle": "Ship is flying", "ship-not-at-origin": "Not at pickup", "ship-in-shop": "Ship is in the shop", "pilot-resting": "Pilot is resting", "pilot-not-idle": "Pilot is flying", "pilot-not-assigned": "No pilot assigned", "over-capacity": "Too heavy", "cannot-land": "Can't land there", "out-of-range": "Out of range", "would-strand": "Would be stranded", "too-slow": "Too slow for the Priority window", "cargo-skill-too-low": "Cargo skill too low", "long-haul-too-low": "Long Haul too low", "hull-too-damaged": "Hull too damaged for hazardous cargo: repair at an HQ", "contract-expired": "Contract expired", "not-enough-fuel": "Not enough fuel", }; /** The no-strand rule in words (CR-012), for the dispatch sheet and deadhead picker. */ export function strandedText(destinationId: string): string { const name = locationName(destinationId); return `Would be stranded — can't fly out of ${name} from there`; } /** Locations the player has discovered, for the no-strand rule. */ export function discoveredIdsOf(state: PlayerState): Set { return new Set(state.locations.filter((l) => l.discovered).map((l) => l.id)); } /** * Every reason a ship and pilot cannot take a contract, in plain words with the * numbers involved, e.g. "Too heavy: 228 t (holds 20 t)". Display only: the * sim's validateDispatch decides eligibility. */ export function explainProblems( state: PlayerState, contract: Contract, model: ShipModel, pilot: Pilot, /** The leg's fuel and what the ship has on board, in fu (CR-015). */ fuel: { neededFu: number; onBoardFu: number }, gameNow: number, now: number, /** The ship's hull, in percent (CR-026); absent: whole. */ hullConditionPct = 100, ): string[] { const problems: string[] = []; // Resting blocks flying (CR-004): first, with the time left. if (pilot.resting && pilot.restEndsAt !== null) { problems.push( `Resting — ${formatSpan(pilot.restEndsAt - now, displayPreferences().countdown, formatClock)}`, ); } if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]); if (contract.massTonnes > model.cargoCapacityTonnes) { problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); } const route = checkRoute( model, location(contract.originId), location(contract.destinationId), content.config, ); if (!route.ok) { problems.push( route.reason === "out-of-range" ? `Out of range (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId), content.config))}M km from ${locationName(contract.originId)})` : INELIGIBLE_REASONS["cannot-land"], ); } else if ( wouldStrand( model, location(contract.destinationId), fuel.onBoardFu - fuel.neededFu, discoveredIdsOf(state), content, ) ) { problems.push(strandedText(contract.destinationId)); } // Priority (CR-007): the trip must fit the delivery window. const window = contract.priority ? contract.deliveryWindowGameHours : null; if (window !== null) { const trip = contractTripGameHours(model, contract, content); if (trip > window) { problems.push( `Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`, ); } } if (contract.requiredSkill) { const have = pilot.skills[contract.requiredSkill.skillId] ?? 0; if (have < contract.requiredSkill.level) { problems.push( `Needs ${skillName(contract.requiredSkill.skillId)} ${contract.requiredSkill.level} (pilot has ${have})`, ); } } const longHaul = pilot.skills[LONG_HAUL_SKILL_ID] ?? 0; if (longHaul < contract.requiredLongHaulLevel) { problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`); } // A damaged hull can't carry Dangerous cargo until repaired (decision 0.5.148). if ( contract.cargoTypeId === "dangerous" && hullConditionPct < content.config.hull.noDangerousBelowPct ) { problems.push(INELIGIBLE_REASONS["hull-too-damaged"]); } if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]); if (fuel.onBoardFu < fuel.neededFu) { problems.push( `Not enough fuel (needs ${Math.ceil(fuel.neededFu)} fu, has ${Math.floor(fuel.onBoardFu)} fu)`, ); } return problems; } /** Real-world arrival time on the device, e.g. "14:05". */ export function formatLocalTime(time: number): string { return formatTimeOfDay(time, displayPreferences().clock); } export interface PairOption { shipId: string; shipName: string; modelName: string; /** The model's art (decision 0.5.50). */ modelSprite: string; pilotId: string | null; pilotName: string | null; /** "Service booked: Repair at Mars" or "Repair recommended" (CR-027); null otherwise. */ service: string | null; /** Why this pair cannot take the contract; empty when it can. */ problems: string[]; tripMs: number; /** The fuel used, at the tank's average price (decision 0.5.98). */ fuelCost: number; /** The leg's fuel, what is on board, and what would be left (fu, CR-015). */ fuel: { neededFu: number; onBoardFu: number; leftFu: number }; /** Blocked only for want of fuel: "Refuel here first" makes it flyable (Step 15.7). */ shortOfFuel: boolean; /** A hired pilot's profit share; 0 for the avatar (CR-010). */ pilotPay: number; netProfit: number; arrivalAt: number; /** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */ duty: { after: number; limit: number; willRest: boolean } | null; /** Priority only (CR-007): this ship's trip against the window, in game hours. */ priority: { tripHours: number; windowHours: number } | null; } /** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */ export function dutyOutlook(pilot: Pilot, departureAt: number, arrivalAt: number) { const hours = tripGameHours(departureAt, arrivalAt, content.config); const after = dutyAfterTrip(pilot.dutyHours, hours, false); return { after, limit: pilot.dutyLimit, willRest: needsRest(after, pilot.dutyLimit) }; } /** Every ship at the contract's origin, with its pilot, numbers and eligibility. */ export function pairOptions(state: PlayerState, contract: Contract, now: number): PairOption[] { const gameNow = realToGame(now, content.config); return state.ships .filter((ship) => ship.status === "idle" && ship.locationId === contract.originId) .map((ship) => { // Effective stats: upgrades change capacity, range and trip time (CR-009). const model = shipStatsOf(ship); const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const tripMs = tripRealMs( model, contract.originId, contract.destinationId, contract.massTonnes, ); const { fuelFu, fuelCost, pilotPay, netProfit } = jobMoney(ship, pilot, contract); let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]]; let shortOfFuel = false; if (pilot) { const check = validateDispatch( { ship: simShip(ship), pilot: { id: pilot.id, isAvatar: pilot.isAvatar, assignedShipId: pilot.assignedShipId, inFlight: pilot.status === "in-flight", skills: pilot.skills, restEndsAt: pilot.restEndsAt, }, contract, tutorial: false, now, discoveredIds: discoveredIdsOf(state), }, content, ); // The sim decides eligibility (as the server will); the list explains every problem. if (check.ok) { problems = []; } else { // Fuel is checked last, so this reason means nothing else is wrong. shortOfFuel = check.reason === "not-enough-fuel"; const all = explainProblems( state, contract, model, pilot, { neededFu: fuelFu, onBoardFu: ship.fuelFu }, gameNow, now, ship.hullConditionPct, ); // In the shop (CR-009): first, with the time left. if (isInShop(ship, now)) all.unshift( `In the shop — ${formatSpan(ship.inShopUntil! - now, displayPreferences().countdown, formatClock)}`, ); problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]]; } } return { shipId: ship.id, shipName: ship.name, modelName: model.name, modelSprite: model.sprite, pilotId: pilot?.id ?? null, pilotName: pilot?.name ?? null, service: serviceTag(ship), problems, tripMs, fuelCost, fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu }, shortOfFuel, pilotPay, netProfit, arrivalAt: now + tripMs, duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, priority: contract.priority && contract.deliveryWindowGameHours !== null ? { tripHours: contractTripGameHours(model, contract, content), windowHours: contract.deliveryWindowGameHours, } : null, }; }); } /** * "Refuel here first" with its price (Step 15.7): the fuel it buys and what * it costs, disabled when the credits don't cover the leg's fuel. */ function RefuelFirst(props: { shipId: string; neededFu: number; pending: boolean; onRefuel: (request: { shipId: string; amountFu?: number }) => void; }) { const state = useLoadedPlayer(); const ship = state.ships.find((s) => s.id === props.shipId)!; const quote = refuelQuote(ship, props.neededFu, state.company.credits); return (
{!quote.affordable && (

Not enough credits for the fuel this trip needs.

)}
); } /** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) { const api = useApi(); // While it's open, Independent pilots leave this contract to the player (decision 0.5.126). useEffect(() => { api.setViewedContract?.(contract.id); return () => api.setViewedContract?.(null); }, [api, contract.id]); const state = useLoadedPlayer(); const prefsCountdown = usePreferences((p) => p.countdown); const now = useClock((s) => s.now); const dispatch = useGameAction("createDispatch"); const refuel = useGameAction("refuelShip"); const options = pairOptions(state, contract, now); const [chosen, setChosen] = useState( options.find((o) => o.problems.length === 0)?.shipId ?? null, ); const selected = options.find((o) => o.shipId === chosen && o.problems.length === 0); const confirm = () => { if (!selected?.pilotId) return; dispatch.mutate( { kind: "contract", contractId: contract.id, shipId: selected.shipId, pilotId: selected.pilotId, }, { onSuccess: onClose }, ); }; return (

{goodName(contract)} for {contract.shipper} · {cargoName(contract.cargoTypeId)} {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "} {formatCredits(contract.payout)} {" · "} {requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}

{contract.priority && contract.deliveryWindowGameHours !== null && (
)}
{options.length === 0 ? (

No idle ships at {locationName(contract.originId)}.

) : (
    {options.map((option) => { const blocked = option.problems.length > 0; const picked = chosen === option.shipId; return (
  • ); })}
)} {(dispatch.isError || refuel.isError) && (

{errorMessage(dispatch.error ?? refuel.error)}

)}
); } /** One number in a pair's outlook; `dt` directly before `dd` ("Trip2h 05m"). */ function Cell({ label, value, good }: { label: string; value: string; good?: boolean }) { return (
{label}
{value}
); }