535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
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<DispatchFailure, string> = {
|
|
"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<string> {
|
|
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 (
|
|
<div className="mt-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={props.pending || !quote.affordable}
|
|
onClick={(e) => {
|
|
// Inside the pair's label: don't let the click pick the radio.
|
|
e.preventDefault();
|
|
props.onRefuel(quote.request);
|
|
}}
|
|
>
|
|
Refuel here first · {formatFu(quote.boughtFu)} for {formatCredits(quote.cost)}
|
|
</Button>
|
|
{!quote.affordable && (
|
|
<p className="mt-1 text-rose-300">Not enough credits for the fuel this trip needs.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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<string | null>(
|
|
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 (
|
|
<Sheet
|
|
titleId="dispatch-title"
|
|
eyebrow="Dispatch"
|
|
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
|
|
>
|
|
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
|
|
{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"}
|
|
</p>
|
|
{contract.priority && contract.deliveryWindowGameHours !== null && (
|
|
<div className="mt-2">
|
|
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
|
</div>
|
|
)}
|
|
<div className="mt-3">
|
|
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
|
|
</div>
|
|
|
|
{options.length === 0 ? (
|
|
<p className="mt-3 text-sm text-ink-400">
|
|
No idle ships at {locationName(contract.originId)}.
|
|
</p>
|
|
) : (
|
|
<ul className="mt-3 space-y-2">
|
|
{options.map((option) => {
|
|
const blocked = option.problems.length > 0;
|
|
const picked = chosen === option.shipId;
|
|
return (
|
|
<li key={option.shipId}>
|
|
<label
|
|
aria-disabled={blocked}
|
|
className={`block rounded-xl border p-3 text-sm transition ${
|
|
blocked
|
|
? "border-space-700 bg-space-950/40 opacity-70"
|
|
: picked
|
|
? "cursor-pointer border-amber-400 bg-amber-500/10 shadow-glow-amber"
|
|
: "cursor-pointer border-space-600 bg-space-900/60 hover:border-space-500"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="radio"
|
|
name="pair"
|
|
value={option.shipId}
|
|
disabled={blocked}
|
|
checked={picked}
|
|
onChange={() => setChosen(option.shipId)}
|
|
className="size-4 shrink-0 accent-amber-400"
|
|
/>
|
|
<Art sprite={option.modelSprite} className="size-10 shrink-0" />
|
|
<span className="min-w-0">
|
|
<span className="block font-display font-bold">{option.shipName}</span>
|
|
<span className="block text-xs text-ink-400">
|
|
{option.modelName} · {option.pilotName ?? "no pilot"}
|
|
</span>
|
|
{option.service && (
|
|
<span aria-label="Service" className="block text-xs text-cyan-200">
|
|
{option.service}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
{blocked ? (
|
|
<ul aria-label="Problems" className="mt-2 space-y-0.5 pl-1 text-rose-300">
|
|
{option.problems.map((p) => (
|
|
<li
|
|
key={p}
|
|
className="flex gap-1.5 before:text-rose-400 before:content-['✕']"
|
|
>
|
|
{p}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
<Cell label="Trip" value={formatDuration(option.tripMs)} />
|
|
<Cell
|
|
label="Fuel used (at your average price)"
|
|
value={formatCredits(option.fuelCost)}
|
|
/>
|
|
{option.pilotId !== null &&
|
|
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
|
|
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
|
|
)}
|
|
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
|
|
<Cell
|
|
label="ETA (game)"
|
|
value={formatGameTime(
|
|
realToGame(option.arrivalAt, content.config),
|
|
displayPreferences().clock,
|
|
)}
|
|
/>
|
|
<Cell label="ETA (real)" value={formatLocalTime(option.arrivalAt)} />
|
|
{option.duty && (
|
|
<Cell
|
|
label="Duty after"
|
|
value={`${formatGameHours(option.duty.after)} of ${formatGameHours(option.duty.limit)}`}
|
|
/>
|
|
)}
|
|
</dl>
|
|
)}
|
|
<p aria-label="Fuel" className="mt-2 text-xs text-ink-400 tabular-nums">
|
|
Fuel needed {formatFu(option.fuel.neededFu)} · on board{" "}
|
|
{formatFu(option.fuel.onBoardFu)} · left after{" "}
|
|
{formatFu(Math.max(0, option.fuel.leftFu))}
|
|
</p>
|
|
{option.shortOfFuel && (
|
|
<RefuelFirst
|
|
shipId={option.shipId}
|
|
neededFu={option.fuel.neededFu}
|
|
pending={refuel.isPending}
|
|
onRefuel={(request) =>
|
|
refuel.mutate(request, { onSuccess: () => setChosen(option.shipId) })
|
|
}
|
|
/>
|
|
)}
|
|
{!blocked && option.priority && (
|
|
<p aria-label="Priority margin" className="mt-2 text-amber-200">
|
|
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
|
|
trip,{" "}
|
|
{formatGameHoursSpan(
|
|
option.priority.windowHours - option.priority.tripHours,
|
|
prefsCountdown,
|
|
)}{" "}
|
|
to spare in the{" "}
|
|
{formatGameHoursSpan(option.priority.windowHours, prefsCountdown)} window
|
|
</p>
|
|
)}
|
|
{!blocked && option.duty?.willRest && (
|
|
<p className="mt-2 text-amber-300">Will need rest on arrival</p>
|
|
)}
|
|
</label>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{(dispatch.isError || refuel.isError) && (
|
|
<p role="alert" className="mt-3 text-sm text-rose-300">
|
|
{errorMessage(dispatch.error ?? refuel.error)}
|
|
</p>
|
|
)}
|
|
<div className="mt-4 flex gap-2">
|
|
<Button variant="secondary" onClick={onClose} className="flex-1">
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={confirm} disabled={!selected || dispatch.isPending} className="flex-1">
|
|
Confirm
|
|
</Button>
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="rounded-lg bg-space-950/60 px-2 py-1.5 ring-1 ring-space-700">
|
|
<dt className="font-display text-xs text-ink-400">{label}</dt>
|
|
<dd
|
|
className={`font-display text-sm font-semibold tabular-nums ${good ? "text-emerald-300" : "text-ink-50"}`}
|
|
>
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|