import { content, type ShipModel } from "@deadhead/content"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import { checkRoute, contractTripGameHours, dutyAfterTrip, LONG_HAUL_SKILL_ID, needsRest, realToGame, tripFuelCost, tripGameHours, validateDispatch, type DispatchFailure, } from "@deadhead/sim"; import { useState } from "react"; 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 { location, shipModel, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { RouteStrip } from "../components/RouteStrip"; import { Art, Button, Sheet } from "../ui"; import { formatWindow, goodName, PriorityChip } from "../components/Goods"; /** 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", "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", "too-slow": "Too slow for the Priority window", "cargo-skill-too-low": "Cargo skill too low", "long-haul-too-low": "Long Haul too low", "contract-expired": "Contract expired", "insufficient-credits": "Not enough credits for fuel", }; /** * 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, fuelCost: number, gameNow: number, now: number, ): string[] { const problems: string[] = []; // Resting blocks flying (CR-004): first, with the time left. if (pilot.resting && pilot.restEndsAt !== null) { problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`); } 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), gameNow, ); if (!route.ok) { problems.push( route.reason === "out-of-range" ? `Out of range right now (${model.name} reaches ${model.fuelRangeMillionKm}M km)` : INELIGIBLE_REASONS["cannot-land"], ); } // 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, now, content); if (trip > window) { problems.push(`Too slow — ${formatWindow(trip)} vs ${formatWindow(window)} 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})`); } if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]); if (state.company.credits < fuelCost) { problems.push(`Not enough credits for fuel (${formatCredits(fuelCost)})`); } return problems; } /** Real-world arrival time on the device, e.g. "14:05". */ export function formatLocalTime(time: number): string { return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } 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; /** Why this pair cannot take the contract; empty when it can. */ problems: string[]; tripMs: number; fuelCost: 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) => { const model = shipModel(ship.modelId); const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const tripMs = tripRealMs( model, contract.originId, contract.destinationId, contract.massTonnes, now, ); const fuelCost = tripFuelCost( model, location(contract.originId), location(contract.destinationId), gameNow, ); const pilotPay = pilot && !pilot.isAvatar ? content.config.pilotPayPerDelivery : 0; let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]]; if (pilot) { const check = validateDispatch( { ship: { id: ship.id, modelId: ship.modelId, locationId: ship.locationId!, inFlight: false, }, pilot: { id: pilot.id, isAvatar: pilot.isAvatar, assignedShipId: pilot.assignedShipId, inFlight: pilot.status === "in-flight", skills: pilot.skills, restEndsAt: pilot.restEndsAt, }, contract, tutorial: false, credits: state.company.credits, now, }, content, ); // The sim decides eligibility (as the server will); the list explains every problem. if (check.ok) { problems = []; } else { const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now); 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, problems, tripMs, fuelCost, netProfit: contract.payout - fuelCost - pilotPay, arrivalAt: now + tripMs, duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, priority: contract.priority && contract.deliveryWindowGameHours !== null ? { tripHours: contractTripGameHours(model, contract, now, content), windowHours: contract.deliveryWindowGameHours, } : null, }; }); } /** 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 state = useLoadedPlayer(); const now = useClock((s) => s.now); const dispatch = useGameAction("createDispatch"); 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 && (

{errorMessage(dispatch.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}
); }