158 lines
6.1 KiB
TypeScript
158 lines
6.1 KiB
TypeScript
import type { Contract, PlayerState } from "@deadhead/schema";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useSearchParams } from "react-router";
|
|
import { useApi } from "../api/context";
|
|
import { useClock } from "../clock/clock";
|
|
import { Countdown } from "../components/Countdown";
|
|
import { cargoName, formatCredits, formatMillionKm, locationName, skillName } from "../format";
|
|
import { distanceAt } from "../game";
|
|
import { useLoadedPlayer } from "../state/actions";
|
|
|
|
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 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-3">
|
|
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
|
|
{discovered.map((id) => {
|
|
const idle = idleAt(id);
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
aria-pressed={id === locationId}
|
|
onClick={() => setParams({ location: id }, { replace: true })}
|
|
className="flex items-center gap-1.5 rounded-full border border-slate-700 px-3 py-1 text-sm aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-200"
|
|
>
|
|
{locationName(id)}
|
|
{idle > 0 && (
|
|
<span
|
|
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
|
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
|
|
>
|
|
{idle}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{idleAt(locationId) === 0 && (
|
|
<p className="text-sm text-slate-400">
|
|
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 now = useClock((s) => s.now);
|
|
const board = useQuery({
|
|
queryKey: locationBoardKey(locationId),
|
|
queryFn: () => api.call("getContractBoard", { locationId }),
|
|
});
|
|
|
|
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
|
|
if (board.isError) {
|
|
return (
|
|
<div role="alert" className="text-red-400">
|
|
Could not load contracts.{" "}
|
|
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
if (board.data.contracts.length === 0) {
|
|
return (
|
|
<p className="text-sm text-slate-400">No contracts left this period. Check back soon.</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ul className="space-y-2">
|
|
{board.data.contracts.map((contract) => {
|
|
const requirements = requirementsText(contract);
|
|
return (
|
|
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="font-medium">
|
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
|
{contract.firstContact && (
|
|
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
|
First contact
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="font-semibold tabular-nums text-emerald-400">
|
|
{formatCredits(contract.payout)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-sm text-slate-400">
|
|
{cargoName(contract.cargoTypeId)}
|
|
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
|
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
|
</div>
|
|
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
|
|
<span className="text-slate-400">
|
|
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
|
|
<Countdown to={contract.expiresAt} done="expired" />
|
|
</span>
|
|
{onSelect && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(contract)}
|
|
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
|
|
>
|
|
Dispatch
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|