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

302 lines
11 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 { ShipUpgrades } from "./ShipUpgrades";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
import { location, 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 flying = state.ships.filter((s) => s.status === "in-flight").length;
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}
onDeadhead={() => setDeadheading(ship)}
onUpgrades={() => setUpgrading(ship.id)}
/>
))}
</ul>
)}
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
{upgrading && <ShipUpgrades shipId={upgrading} onClose={() => setUpgrading(null)} />}
</Screen>
);
}
function ShipRow(props: {
ship: Ship;
dispatch: Dispatch | undefined;
pilotName: string | null;
onDeadhead: () => void;
onUpgrades: () => 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;
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 text-sm text-ink-200">
{dispatch
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
: `At ${locationName(ship.locationId ?? "")}`}
{" · "}
<span className={pilotName ? "text-ink-300" : "text-amber-300"}>
{pilotName ?? "No pilot"}
</span>
</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>
)}
{inShop && (
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
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>
)}
<Button
variant="ghost"
size="sm"
onClick={props.onUpgrades}
aria-label={`Upgrades for ${ship.name}`}
>
Upgrades
{Object.keys(ship.upgrades).length > 0 &&
` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`}
</Button>
</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",
"insufficient-credits": "Not enough credits for 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");
// 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 gameNow = realToGame(now, content.config);
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,
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 (
<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.`}
>
<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 · {formatDuration(option.tripMs)}
</span>
</span>
{option.problem && (
<span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span>
)}
</span>
</button>
</li>
);
})}
</ul>
{reposition.isError && (
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(reposition.error)}
</p>
)}
<Button variant="secondary" block onClick={onClose} className="mt-4">
Cancel
</Button>
</Sheet>
);
}