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

389 lines
14 KiB
TypeScript

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<DispatchFailure, string> = {
"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<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 tracking-wide">
{option.shipName}
</span>
<span className="block text-xs text-ink-400">
{option.modelName} · {option.pilotName ?? "no pilot"}
</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" value={formatCredits(option.fuelCost)} />
<Cell label="Net profit" value={formatCredits(option.netProfit)} good />
<Cell
label="ETA (game)"
value={formatGameTime(realToGame(option.arrivalAt, content.config))}
/>
<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>
)}
{!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200">
Priority: {formatWindow(option.priority.tripHours)} trip,{" "}
{formatWindow(option.priority.windowHours - option.priority.tripHours)} to
spare in the {formatWindow(option.priority.windowHours)} window
</p>
)}
{!blocked && option.duty?.willRest && (
<p className="mt-2 text-amber-300">Will need rest on arrival</p>
)}
</label>
</li>
);
})}
</ul>
)}
{dispatch.isError && (
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(dispatch.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-[10px] tracking-[0.14em] text-ink-400 uppercase">{label}</dt>
<dd
className={`font-display text-sm font-semibold tabular-nums ${good ? "text-emerald-300" : "text-ink-50"}`}
>
{value}
</dd>
</div>
);
}