deadhead-space-freight-tycoon/apps/web/src/screens/DispatchSheet.tsx

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>
);
}