230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { Dispatch, Ship } from "@deadhead/schema";
|
|
import {
|
|
dispatchProgress,
|
|
realToGame,
|
|
tripFuelCost,
|
|
validateReposition,
|
|
type RepositionFailure,
|
|
} from "@deadhead/sim";
|
|
import { useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { useClock } from "../clock/clock";
|
|
import { Countdown } from "../components/Countdown";
|
|
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
|
import { location, shipModel, tripRealMs } from "../game";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
|
|
/** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */
|
|
export function Fleet() {
|
|
const state = useLoadedPlayer();
|
|
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
|
|
|
return (
|
|
<section className="space-y-3 p-4">
|
|
<h1 className="text-xl font-semibold">Fleet</h1>
|
|
{state.ships.length === 0 ? (
|
|
<p className="text-sm text-slate-400">
|
|
No ships yet —{" "}
|
|
<Link to="/market" className="text-amber-400 underline">
|
|
visit the Market
|
|
</Link>
|
|
.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{state.ships.map((ship) => (
|
|
<ShipRow
|
|
key={ship.id}
|
|
ship={ship}
|
|
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
|
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
|
onDeadhead={() => setDeadheading(ship)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ShipRow(props: {
|
|
ship: Ship;
|
|
dispatch: Dispatch | undefined;
|
|
pilotName: string | null;
|
|
onDeadhead: () => void;
|
|
}) {
|
|
const { ship, dispatch, pilotName } = props;
|
|
const now = useClock((s) => s.now);
|
|
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
|
|
|
return (
|
|
<li className="rounded-lg border border-slate-800 p-3">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="font-medium">{ship.name}</span>
|
|
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
|
|
</div>
|
|
<div className="mt-1 text-sm text-slate-300">
|
|
{dispatch
|
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
|
: `At ${locationName(ship.locationId ?? "")}`}
|
|
{" · "}
|
|
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
|
|
</div>
|
|
{dispatch && (
|
|
<div className="mt-2">
|
|
<div
|
|
role="progressbar"
|
|
aria-label={`${ship.name} progress`}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={progress}
|
|
className="h-2 overflow-hidden rounded bg-slate-800"
|
|
>
|
|
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
|
|
</div>
|
|
<div className="mt-1 text-right text-xs text-slate-400">
|
|
Arrives in <Countdown to={dispatch.arrivalAt} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{ship.status === "idle" && ship.locationId && (
|
|
<Link
|
|
to={`/contracts?location=${ship.locationId}`}
|
|
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
|
|
>
|
|
Find work here
|
|
</Link>
|
|
)}
|
|
{ship.status === "idle" && (
|
|
<button
|
|
type="button"
|
|
onClick={props.onDeadhead}
|
|
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
|
>
|
|
Deadhead (fly empty)
|
|
</button>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** Why a deadhead destination is unavailable, in plain words. */
|
|
const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
|
"cannot-land": "Can't land there",
|
|
"out-of-range": "Out of range",
|
|
"insufficient-credits": "Not enough credits for fuel",
|
|
"pilot-resting": "Pilot is resting",
|
|
"pilot-not-idle": "Pilot is flying",
|
|
"pilot-not-assigned": "No pilot assigned",
|
|
};
|
|
|
|
/**
|
|
* Fly an idle ship empty to any other discovered location, paying fuel only
|
|
* (CR-002). The sim decides which destinations are possible, as the server will.
|
|
*/
|
|
export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
|
const state = useLoadedPlayer();
|
|
const now = useClock((s) => s.now);
|
|
const reposition = useGameAction("createDispatch");
|
|
const model = shipModel(ship.modelId);
|
|
const origin = ship.locationId!;
|
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
|
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
|
const discoveredIds = new Set(discovered);
|
|
const gameNow = realToGame(now, content.config);
|
|
|
|
const options = discovered
|
|
.filter((id) => id !== origin)
|
|
.map((id) => {
|
|
const check = validateReposition(
|
|
{
|
|
ship: { id: ship.id, modelId: ship.modelId, locationId: origin, inFlight: false },
|
|
pilot: {
|
|
id: pilot?.id ?? "",
|
|
isAvatar: pilot?.isAvatar ?? false,
|
|
assignedShipId: pilot ? ship.id : null,
|
|
inFlight: pilot?.status === "in-flight",
|
|
skills: pilot?.skills ?? {},
|
|
restEndsAt: pilot?.restEndsAt ?? null,
|
|
},
|
|
destinationId: id,
|
|
discoveredIds,
|
|
credits: state.company.credits,
|
|
now,
|
|
},
|
|
content,
|
|
);
|
|
return {
|
|
id,
|
|
problem: check.ok ? null : (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
|
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow),
|
|
tripMs: tripRealMs(model, origin, id, 0, now),
|
|
};
|
|
});
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="deadhead-title"
|
|
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
|
>
|
|
<h2 id="deadhead-title" className="text-lg font-semibold">
|
|
Deadhead {ship.name}
|
|
</h2>
|
|
<p className="text-sm text-slate-400">
|
|
Fly empty from {locationName(origin)} to another place you know. You pay fuel only.
|
|
</p>
|
|
<ul className="mt-3 space-y-2">
|
|
{options.map((option) => (
|
|
<li key={option.id}>
|
|
<button
|
|
type="button"
|
|
aria-label={locationName(option.id)}
|
|
disabled={option.problem !== null || reposition.isPending}
|
|
onClick={() =>
|
|
reposition.mutate(
|
|
{
|
|
kind: "reposition",
|
|
shipId: ship.id,
|
|
pilotId: pilot!.id,
|
|
destinationId: option.id,
|
|
},
|
|
{ onSuccess: onClose },
|
|
)
|
|
}
|
|
className="w-full rounded-lg border border-slate-700 p-3 text-left disabled:opacity-50"
|
|
>
|
|
<span className="flex items-baseline justify-between gap-2">
|
|
<span className="font-medium">{locationName(option.id)}</span>
|
|
<span className="text-sm tabular-nums text-slate-300">
|
|
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
|
</span>
|
|
</span>
|
|
{option.problem && (
|
|
<span className="mt-1 block text-sm text-red-400">{option.problem}</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{reposition.isError && (
|
|
<p role="alert" className="mt-3 text-sm text-red-400">
|
|
{errorMessage(reposition.error)}
|
|
</p>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|