405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import { IndependentBadge } from "../components/IndependentPilots";
|
|
import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
|
|
import {
|
|
dispatchProgress,
|
|
tripFuel,
|
|
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 { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel";
|
|
import { ShipUpgrades } from "./ShipUpgrades";
|
|
import { ServiceTag } from "../components/Service";
|
|
import { strandedText } from "./DispatchSheet";
|
|
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
|
import { location, lowFuel, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
import {
|
|
Art,
|
|
Button,
|
|
buttonClass,
|
|
Icon,
|
|
Meter,
|
|
Panel,
|
|
Pill,
|
|
Screen,
|
|
ScreenHeader,
|
|
Sheet,
|
|
} from "../ui";
|
|
|
|
/** 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);
|
|
const [upgrading, setUpgrading] = useState<string | null>(null);
|
|
const [fueling, setFueling] = useState<string | null>(null);
|
|
|
|
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
|
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
|
|
|
|
return (
|
|
<Screen className="max-w-4xl">
|
|
<ScreenHeader
|
|
title="Fleet"
|
|
eyebrow={
|
|
state.ships.length > 0
|
|
? `${state.ships.length} ${state.ships.length === 1 ? "ship" : "ships"} · ${flying} flying`
|
|
: "Your ships"
|
|
}
|
|
icon="fleet"
|
|
/>
|
|
{state.ships.length === 0 ? (
|
|
<Panel tone="muted" className="flex flex-col items-center gap-3 py-8 text-center">
|
|
<Art sprite="ship-courier" className="size-20 opacity-60" />
|
|
<p className="text-sm text-ink-300">
|
|
No ships yet —{" "}
|
|
<Link to="/market" className="font-semibold text-amber-300 underline">
|
|
visit the Market
|
|
</Link>
|
|
.
|
|
</p>
|
|
</Panel>
|
|
) : (
|
|
<ul className="grid gap-3 md:grid-cols-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}
|
|
pilot={state.pilots.find((p) => p.id === ship.pilotId)}
|
|
discoveredIds={discoveredIds}
|
|
onDeadhead={() => setDeadheading(ship)}
|
|
onUpgrades={() => setUpgrading(ship.id)}
|
|
onFuel={() => setFueling(ship.id)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
|
{upgrading && <ShipUpgrades shipId={upgrading} onClose={() => setUpgrading(null)} />}
|
|
{fueling && <ShipFuel shipId={fueling} onClose={() => setFueling(null)} />}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
function ShipRow(props: {
|
|
ship: Ship;
|
|
dispatch: Dispatch | undefined;
|
|
pilotName: string | null;
|
|
pilot: Pilot | undefined;
|
|
discoveredIds: ReadonlySet<string>;
|
|
onDeadhead: () => void;
|
|
onUpgrades: () => void;
|
|
onFuel: () => void;
|
|
}) {
|
|
const { ship, dispatch, pilotName } = props;
|
|
const now = useClock((s) => s.now);
|
|
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
|
const model = shipModel(ship.modelId);
|
|
// In the upgrade shop until then (CR-009); the clock decides, so it ends on time.
|
|
const inShop = ship.inShopUntil !== null && now < ship.inShopUntil;
|
|
// Ships no longer refuel on their own (CR-018): say when the tank is short.
|
|
const low = dispatch ? null : lowFuel(ship, props.discoveredIds);
|
|
|
|
return (
|
|
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
|
|
<Art
|
|
sprite={model.sprite}
|
|
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="truncate font-display text-lg font-bold tracking-wide">
|
|
{ship.name}
|
|
</span>
|
|
{dispatch ? (
|
|
<Pill tone="cyan" icon="send">
|
|
In flight
|
|
</Pill>
|
|
) : inShop ? (
|
|
<Pill tone="amber">In the shop</Pill>
|
|
) : (
|
|
<Pill tone="emerald">Idle</Pill>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-ink-200">
|
|
<span>
|
|
{dispatch
|
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
|
: `At ${locationName(ship.locationId ?? "")}`}
|
|
{" · "}
|
|
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
|
|
{pilotName ?? "No pilot"}
|
|
</span>
|
|
</span>
|
|
{/* A pilot who flies on their own (CR-021). */}
|
|
<IndependentBadge pilot={props.pilot} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{dispatch && (
|
|
<div>
|
|
<Meter label={`${ship.name} progress`} value={progress} tone="cyan" />
|
|
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
|
|
<span>{progress}%</span>
|
|
<span>
|
|
Arrives in{" "}
|
|
<span className="text-cyan-200">
|
|
<Countdown to={dispatch.arrivalAt} />
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<FuelGauge ship={ship} />
|
|
<HullGauge ship={ship} />
|
|
<ServiceTag ship={ship} />
|
|
{low && (
|
|
<p
|
|
role="status"
|
|
aria-label={`${ship.name} low fuel`}
|
|
className="flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
|
>
|
|
<span className="font-display font-semibold tracking-wide uppercase">Low fuel</span>
|
|
<span className="text-rose-200/80">
|
|
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
|
|
</span>
|
|
</p>
|
|
)}
|
|
{inShop && (
|
|
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
|
|
{ship.repairing ? "Under repair" : "In the shop"} —{" "}
|
|
<Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
|
|
</p>
|
|
)}
|
|
{ship.status === "idle" && !inShop && (
|
|
<div className="mt-auto flex flex-wrap gap-2">
|
|
{ship.locationId && (
|
|
<Link
|
|
to={`/contracts?location=${ship.locationId}`}
|
|
className={`${buttonClass("primary", "sm")} flex-1 basis-40`}
|
|
>
|
|
<Icon name="jobs" className="size-4" />
|
|
Find work here
|
|
</Link>
|
|
)}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={props.onDeadhead}
|
|
className="flex-1 basis-40"
|
|
>
|
|
<Icon name="send" className="size-4" />
|
|
Deadhead (fly empty)
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={props.onFuel}
|
|
aria-label={`Fuel for ${ship.name}`}
|
|
className="flex-1"
|
|
>
|
|
Fuel
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={props.onUpgrades}
|
|
aria-label={`Upgrades for ${ship.name}`}
|
|
className="flex-1"
|
|
>
|
|
Upgrades
|
|
{Object.keys(ship.upgrades).length > 0 &&
|
|
` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`}
|
|
</Button>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** 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",
|
|
"would-strand": "Would be stranded",
|
|
"not-enough-fuel": "Not enough fuel",
|
|
"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",
|
|
};
|
|
|
|
/**
|
|
* 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 refuel = useGameAction("refuelShip");
|
|
// Effective stats: upgraded fuel tanks reach farther (CR-009).
|
|
const model = shipStatsOf(ship);
|
|
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 options = discovered
|
|
.filter((id) => id !== origin)
|
|
.map((id) => {
|
|
const check = validateReposition(
|
|
{
|
|
ship: simShip(ship),
|
|
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,
|
|
now,
|
|
},
|
|
content,
|
|
);
|
|
const neededFu = tripFuel(model, location(origin), location(id), content.config);
|
|
return {
|
|
id,
|
|
neededFu,
|
|
shortOfFuel: !check.ok && check.reason === "not-enough-fuel",
|
|
problem: check.ok
|
|
? null
|
|
: check.reason === "would-strand"
|
|
? strandedText(id)
|
|
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
|
// The fuel used, at the tank's average price (decision 0.5.98).
|
|
fuelCost: Math.round(neededFu * ship.fuelAvgPrice),
|
|
tripMs: tripRealMs(model, origin, id, 0),
|
|
};
|
|
});
|
|
|
|
// Enough for the hungriest leg that is only short of fuel (Step 15.7).
|
|
const shortLegFu = Math.max(0, ...options.filter((o) => o.shortOfFuel).map((o) => o.neededFu));
|
|
|
|
return (
|
|
<Sheet
|
|
titleId="deadhead-title"
|
|
eyebrow="Fly empty"
|
|
title={`Deadhead ${ship.name}`}
|
|
subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`}
|
|
>
|
|
<p aria-label="Fuel on board" className="mt-3 text-sm text-ink-300">
|
|
On board: {formatFu(ship.fuelFu)} of {formatFu(ship.stats.tankCapacityFu)}
|
|
</p>
|
|
{shortLegFu > 0 && (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="mt-2"
|
|
disabled={refuel.isPending}
|
|
onClick={() => refuel.mutate(refuelForLeg(ship, shortLegFu, state.company.credits))}
|
|
>
|
|
Refuel here first
|
|
</Button>
|
|
)}
|
|
<ul className="mt-4 space-y-2">
|
|
{options.map((option) => {
|
|
const sprite = content.location(option.id)?.sprite;
|
|
return (
|
|
<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="flex w-full items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-left transition hover:border-amber-400/70 hover:bg-amber-500/10 active:scale-[0.99] disabled:pointer-events-none disabled:opacity-55"
|
|
>
|
|
{sprite && <Art sprite={sprite} className="size-10 shrink-0" />}
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-baseline justify-between gap-2">
|
|
<span className="font-display font-bold tracking-wide">
|
|
{locationName(option.id)}
|
|
</span>
|
|
<span className="font-display text-sm text-ink-300 tabular-nums">
|
|
{formatCredits(option.fuelCost)} fuel used · {formatDuration(option.tripMs)}
|
|
</span>
|
|
</span>
|
|
<span className="block text-xs text-ink-400 tabular-nums">
|
|
Needs {formatFu(option.neededFu)} · left after{" "}
|
|
{formatFu(Math.max(0, ship.fuelFu - option.neededFu))}
|
|
</span>
|
|
{option.problem && (
|
|
<span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{(reposition.isError || refuel.isError) && (
|
|
<p role="alert" className="mt-3 text-sm text-rose-300">
|
|
{errorMessage(reposition.error ?? refuel.error)}
|
|
</p>
|
|
)}
|
|
<Button variant="secondary" block onClick={onClose} className="mt-4">
|
|
Cancel
|
|
</Button>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A ship's hull condition (decision 0.5.148), with a red "Needs repair" below
|
|
* 25%: repairs are done at an HQ from the ship's Upgrades sheet.
|
|
*/
|
|
export function HullGauge({ ship }: { ship: Ship }) {
|
|
const pct = ship.hullConditionPct;
|
|
const { needsRepairBelowPct, noDangerousBelowPct } = content.config.hull;
|
|
const tone = pct < needsRepairBelowPct ? "rose" : pct < noDangerousBelowPct ? "amber" : "emerald";
|
|
return (
|
|
<div>
|
|
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
|
<span>Hull</span>
|
|
<span className="tracking-normal normal-case">{Math.round(pct)}%</span>
|
|
</div>
|
|
<Meter label={`${ship.name} hull`} value={pct} tone={tone} size="sm" className="mt-1" />
|
|
{pct < needsRepairBelowPct && (
|
|
<p
|
|
role="status"
|
|
aria-label={`${ship.name} needs repair`}
|
|
className="mt-2 flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
|
|
>
|
|
<span className="font-display font-semibold tracking-wide uppercase">Needs repair</span>
|
|
<span className="text-rose-200/80">Repair it at an HQ</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|