321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import type { Contract, PlayerState, Ship } from "@deadhead/schema";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
import { useSearchParams } from "react-router";
|
|
import { useApi } from "../api/context";
|
|
import { serverNow, useClock } from "../clock/clock";
|
|
import { Countdown } from "../components/Countdown";
|
|
import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format";
|
|
import { distanceAt } from "../game";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
import { pairOptions } from "./DispatchSheet";
|
|
import { DeadheadPicker } from "./Fleet";
|
|
import { content } from "@deadhead/content";
|
|
import { RouteStrip } from "../components/RouteStrip";
|
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
|
import { ClassChip, goodName, PriorityChip, tooSlowForAll } from "../components/Goods";
|
|
|
|
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
|
|
|
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
|
|
export function requirementsText(contract: Contract): string | null {
|
|
const parts = [
|
|
contract.requiredSkill
|
|
? `${skillName(contract.requiredSkill.skillId)} ${contract.requiredSkill.level}`
|
|
: null,
|
|
contract.requiredLongHaulLevel > 0 ? `Long Haul ${contract.requiredLongHaulLevel}` : null,
|
|
].filter((p): p is string => p !== null);
|
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
/**
|
|
* The most a player's ship can carry for a board at this location: the largest
|
|
* ship here, or in the whole fleet when none are here; null with no ships
|
|
* (decision 0.5.53).
|
|
*/
|
|
export function carryLimit(state: PlayerState, locationId: string): number | null {
|
|
const here = state.ships.filter((s) => s.locationId === locationId);
|
|
const ships = here.length > 0 ? here : state.ships;
|
|
if (ships.length === 0) return null;
|
|
return Math.max(...ships.map((s) => content.shipModel(s.modelId)?.cargoCapacityTonnes ?? 0));
|
|
}
|
|
|
|
/**
|
|
* The location the Contracts screen opens on: where the most recently arrived
|
|
* idle ship is, otherwise Earth (CR-002).
|
|
*/
|
|
export function defaultBoardLocation(state: PlayerState): string {
|
|
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId);
|
|
idle.sort((a, b) => b.lastArrivalAt - a.lastArrivalAt);
|
|
return idle[0]?.locationId ?? "earth";
|
|
}
|
|
|
|
/** Freight markets of every discovered location, no bay needed (Steps 9.4, 10B.7). */
|
|
export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
|
const state = useLoadedPlayer();
|
|
// `?location=moon` (from the map or Fleet) selects that location.
|
|
const [params, setParams] = useSearchParams();
|
|
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
|
const requested = params.get("location");
|
|
const locationId =
|
|
requested && discovered.includes(requested) ? requested : defaultBoardLocation(state);
|
|
const idleAt = (id: string) =>
|
|
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div
|
|
role="group"
|
|
aria-label="Location"
|
|
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1"
|
|
>
|
|
{discovered.map((id) => {
|
|
const idle = idleAt(id);
|
|
const sprite = content.location(id)?.sprite;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
aria-pressed={id === locationId}
|
|
onClick={() => setParams({ location: id }, { replace: true })}
|
|
className="flex shrink-0 items-center gap-2 rounded-full border border-space-600 bg-space-900/70 py-1 pr-3 pl-1 font-display text-sm font-semibold tracking-wide text-ink-300 backdrop-blur transition hover:border-space-500 hover:text-ink-50 aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-100 aria-pressed:shadow-glow-amber"
|
|
>
|
|
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
|
{locationName(id)}
|
|
{idle > 0 && (
|
|
<span
|
|
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
|
className="grid h-5 min-w-5 place-items-center rounded-full bg-cyan-400/20 px-1.5 text-xs text-cyan-200 tabular-nums ring-1 ring-cyan-400/40"
|
|
>
|
|
{idle}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{idleAt(locationId) === 0 && (
|
|
<p className="flex items-center gap-2 rounded-lg border border-space-700 bg-space-900/60 px-3 py-2 text-sm text-ink-300">
|
|
<Icon name="alert" className="size-4 shrink-0 text-amber-300" />
|
|
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
|
</p>
|
|
)}
|
|
<LocationBoard locationId={locationId} onSelect={onSelect} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LocationBoard({
|
|
locationId,
|
|
onSelect,
|
|
}: {
|
|
locationId: string;
|
|
onSelect?: (contract: Contract) => void;
|
|
}) {
|
|
const api = useApi();
|
|
const state = useLoadedPlayer();
|
|
const now = useClock((s) => s.now);
|
|
const limit = carryLimit(state, locationId);
|
|
const board = useQuery({
|
|
queryKey: locationBoardKey(locationId),
|
|
queryFn: () => api.call("getContractBoard", { locationId }),
|
|
// Fetch the new board as soon as the period rolls over (CR-004).
|
|
refetchInterval: (query) => {
|
|
const endsAt = query.state.data?.periodEndsAt;
|
|
return endsAt === undefined ? false : Math.max(1000, endsAt - serverNow() + 500);
|
|
},
|
|
});
|
|
|
|
if (board.isPending) return <p className="text-ink-400">Loading contracts…</p>;
|
|
if (board.isError) {
|
|
return (
|
|
<div role="alert" className="flex items-center gap-3 text-rose-300">
|
|
Could not load contracts.{" "}
|
|
<Button variant="danger" size="sm" onClick={() => void board.refetch()}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
const refresh = (
|
|
<p
|
|
aria-label="Board refresh"
|
|
className="flex items-center gap-1.5 font-display text-xs tracking-wider text-ink-400 uppercase"
|
|
>
|
|
<Icon name="clock" className="size-3.5 text-cyan-300" />
|
|
New contracts in{" "}
|
|
<span className="text-cyan-200">
|
|
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
|
</span>
|
|
</p>
|
|
);
|
|
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
|
|
if (board.data.contracts.length === 0) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{refresh}
|
|
{stuck}
|
|
<Panel tone="muted" className="text-center text-sm text-ink-400">
|
|
No contracts left this period.
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{refresh}
|
|
{stuck}
|
|
<ul className="grid gap-3 sm:grid-cols-2">
|
|
{board.data.contracts.map((contract) => {
|
|
const requirements = requirementsText(contract);
|
|
const tooHeavy = limit !== null && contract.massTonnes > limit;
|
|
return (
|
|
<Panel
|
|
as="li"
|
|
key={contract.id}
|
|
tone={contract.firstContact ? "amber" : "default"}
|
|
feature={contract.firstContact}
|
|
className="flex flex-col gap-2"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
|
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
|
</span>
|
|
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
|
|
{formatCredits(contract.payout)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-display text-lg leading-tight font-bold tracking-wide">
|
|
{goodName(contract)}
|
|
</h3>
|
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
<ClassChip contract={contract} />
|
|
{contract.priority && contract.deliveryWindowGameHours !== null && (
|
|
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
|
)}
|
|
{contract.firstContact && (
|
|
<Pill tone="amber" icon="star">
|
|
First contact
|
|
</Pill>
|
|
)}
|
|
{tooHeavy && (
|
|
<Pill tone="rose" icon="alert">
|
|
Too heavy
|
|
</Pill>
|
|
)}
|
|
{tooSlowForAll(state, contract, now) && (
|
|
<Pill tone="rose" icon="clock">
|
|
Too slow
|
|
</Pill>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<RouteStrip
|
|
originId={contract.originId}
|
|
destinationId={contract.destinationId}
|
|
size="sm"
|
|
/>
|
|
<div className="flex items-center gap-1.5 text-sm text-ink-300">
|
|
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
|
|
<span>
|
|
{contract.shipper} ·{" "}
|
|
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
|
|
{contract.massTonnes} t
|
|
</span>{" "}
|
|
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
|
</span>
|
|
</div>
|
|
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
|
<span className="text-ink-400">
|
|
<span className={requirements ? "text-amber-200" : "text-ink-300"}>
|
|
{requirements ? `Needs ${requirements}` : "No skills needed"}
|
|
</span>{" "}
|
|
· expires in <Countdown to={contract.expiresAt} done="expired" />
|
|
</span>
|
|
{onSelect && (
|
|
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
|
|
Dispatch
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Idle, crewed ships here that cannot take any contract on this board (CR-004):
|
|
* offer to rest the pilot, who wakes to a new board, or to deadhead elsewhere.
|
|
*/
|
|
function NothingHere({
|
|
locationId,
|
|
contracts,
|
|
now,
|
|
}: {
|
|
locationId: string;
|
|
contracts: Contract[];
|
|
now: number;
|
|
}) {
|
|
const state = useLoadedPlayer();
|
|
const rest = useGameAction("restPilot");
|
|
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
|
const stuck = state.ships.filter((ship) => {
|
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
|
|
if (ship.status !== "idle" || ship.locationId !== locationId || !pilot || pilot.resting) {
|
|
return false;
|
|
}
|
|
return !contracts.some((c) =>
|
|
pairOptions(state, c, now).some((o) => o.shipId === ship.id && o.problems.length === 0),
|
|
);
|
|
});
|
|
if (stuck.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
{stuck.map((ship) => {
|
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!;
|
|
return (
|
|
<Panel
|
|
key={ship.id}
|
|
role="group"
|
|
aria-label={`Nothing for ${ship.name}`}
|
|
tone="cyan"
|
|
className="text-sm"
|
|
>
|
|
<p className="flex items-center gap-2 text-cyan-100">
|
|
<Icon name="alert" className="size-4 shrink-0 text-cyan-300" />
|
|
No job here that {ship.name} and {pilot.name} can take.
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={rest.isPending || state.tutorial !== null}
|
|
onClick={() => rest.mutate({ pilotId: pilot.id })}
|
|
>
|
|
<Icon name="rest" className="size-4" />
|
|
Rest {pilot.name} — wakes to a new board
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => setDeadheading(ship)}>
|
|
<Icon name="send" className="size-4" />
|
|
Deadhead (fly empty)
|
|
</Button>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
})}
|
|
{rest.isError && (
|
|
<p role="alert" className="text-sm text-rose-300">
|
|
{errorMessage(rest.error)}
|
|
</p>
|
|
)}
|
|
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
|
</>
|
|
);
|
|
}
|