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

314 lines
12 KiB
TypeScript

import type { Contract, PlayerState, Ship } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query";
import { useEffect, 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, locationName, skillName } from "../format";
import { notReady, readyShips } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
import { JobsTable } from "./JobsTable";
import { DeadheadPicker } from "./Fleet";
import { content } from "@deadhead/content";
import { Art, Button, Icon, Panel } from "../ui";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
const seenKey = (locationId: string) => `deadhead:seen-board:${locationId}`;
/** Contract ids on the last board the player saw here, or null if never (decision 0.5.62). */
function readSeen(locationId: string): string[] | null {
try {
const raw = localStorage.getItem(seenKey(locationId));
return raw ? (JSON.parse(raw) as string[]) : null;
} catch {
return null;
}
}
function writeSeen(locationId: string, ids: string[]): void {
try {
localStorage.setItem(seenKey(locationId), JSON.stringify(ids));
} catch {
// Storage blocked: the board works, it just can't tag new contracts.
}
}
/**
* Contracts that weren't on the last board the player saw at this location
* (decision 0.5.62): none on a first visit. Tags stay until the board changes
* again, minus contracts taken in the meantime.
*/
export function useNewContracts(locationId: string, contracts: Contract[] | undefined) {
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
const ids = contracts?.map((c) => c.id) ?? null;
const idsKey = ids?.join(",") ?? null;
useEffect(() => {
if (ids === null) return;
const seen = readSeen(locationId);
const added = seen === null ? [] : ids.filter((id) => !seen.includes(id));
setFresh((previous) =>
added.length > 0 ? new Set(added) : new Set([...previous].filter((id) => ids.includes(id))),
);
writeSeen(locationId, ids);
// `idsKey` stands for `ids` (a new array every render).
}, [locationId, idsKey]);
return fresh;
}
/** 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 now = useClock((s) => s.now);
// Only ships a job could go out on now count (decision 0.5.134).
const readyAt = (id: string) => readyShips(state, id, now).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 ready = readyAt(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 min-h-11 shrink-0 items-center gap-1.5 rounded-full border border-glass-line py-1 pr-3 pl-1 text-[13px] font-medium text-ink-300 transition hover:text-ink-50 aria-pressed:border-white/50 aria-pressed:bg-white/14 aria-pressed:text-ink-50"
>
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
{locationName(id)}
{ready > 0 && (
<span
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
className="grid h-5 min-w-5 place-items-center rounded-full bg-action/20 px-1.5 text-xs font-semibold text-action tabular-nums"
>
{ready}
</span>
)}
</button>
);
})}
</div>
{readyAt(locationId) === 0 && <NoneReady locationId={locationId} now={now} />}
<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);
// Jobs are the player's alone for the first minutes of a board (decision 0.5.126).
const pilotsOnTheirOwn = state.pilots.some((p) => p.mode === "independent");
const firstLookMs = content.config.independentPilots.firstLookRealMinutes * 60_000;
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);
},
});
const fresh = useNewContracts(locationId, board.data?.contracts);
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 text-xs text-ink-300">
<Icon name="clock" className="size-3.5" />
New contracts in{" "}
<span className="font-semibold text-ink-100 tabular-nums">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
{fresh.size > 0 && (
<span
role="status"
className="ml-2 animate-rise rounded-full border border-glass-line bg-white/5 px-2 py-0.5 text-ink-100 normal-case"
>
New contracts posted
</span>
)}
</p>
);
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
// Boards show only jobs the fleet can complete (CR-019), so an empty one says so.
if (board.data.contracts.length === 0) {
return (
<div className="space-y-3">
<Panel tone="muted" role="status" className="text-center text-sm text-ink-300">
No jobs here your fleet can take right now. New contracts in{" "}
<span className="font-display text-ink-100 tabular-nums">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
.
</Panel>
{stuck}
</div>
);
}
return (
<div className="space-y-3">
{refresh}
{stuck}
<JobsTable
contracts={board.data.contracts}
fresh={fresh}
firstLookMs={firstLookMs}
pilotsOnTheirOwn={pilotsOnTheirOwn}
onSelect={onSelect}
/>
</div>
);
}
/**
* Why no ship here can take a job now (decision 0.5.134): none docked, or the
* pilots here resting (with who wakes first), or none crewed for the player.
*/
function NoneReady({ locationId, now }: { locationId: string; now: number }) {
const state = useLoadedPlayer();
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === locationId);
const waking = idle
.map((s) => notReady(state, s, now))
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
return (
<p className="flex items-center gap-2 rounded-xl border border-glass-line px-3 py-2 text-sm text-ink-300">
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0" />
{idle.length === 0 ? (
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
) : waking ? (
<span>
No pilot here is ready to fly. {waking.name} is resting, ready in{" "}
<Countdown to={waking.restEndsAt!} format={formatClock} done="a moment" />.
</span>
) : (
"No ship here is ready for a job right now."
)}
</p>
);
}
/**
* 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}`}
className="text-sm"
>
<p className="flex items-center gap-2 text-ink-100">
<Icon name="alert" className="size-4 shrink-0 text-ink-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)} />}
</>
);
}