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 {
cargoName,
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";
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 (
{discovered.map((id) => {
const idle = idleAt(id);
const sprite = content.location(id)?.sprite;
return (
);
})}
{idleAt(locationId) === 0 && (
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
)}
);
}
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 }),
// 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 Loading contracts…
;
if (board.isError) {
return (
Could not load contracts.{" "}
);
}
const refresh = (
New contracts in{" "}
);
const stuck = ;
if (board.data.contracts.length === 0) {
return (
{refresh}
{stuck}
No contracts left this period.
);
}
return (
{refresh}
{stuck}
{board.data.contracts.map((contract) => {
const requirements = requirementsText(contract);
return (
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{contract.firstContact && (
First contact
)}
{formatCredits(contract.payout)}
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
{requirements ? `Needs ${requirements}` : "No skills needed"}
{" "}
· expires in
{onSelect && (
)}
);
})}
);
}
/**
* 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(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 (
No job here that {ship.name} and {pilot.name} can take.
);
})}
{rest.isError && (
{errorMessage(rest.error)}
)}
{deadheading && setDeadheading(null)} />}
>
);
}