389 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|