Step 10D.8: board refresh countdown and the nothing-here prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:08:43 -04:00
parent 9f699f716e
commit ea561b4d74
6 changed files with 271 additions and 50 deletions

View File

@ -806,3 +806,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The resting line is computed from the clock each render** ("Resting — 12:05"), so it counts down while the sheet is open, and it is the first problem listed. - **The resting line is computed from the clock each render** ("Resting — 12:05"), so it counts down while the sheet is open, and it is the first problem listed.
- **Duty after uses the same sim functions the server uses on arrival** (`tripGameHours` from departure and arrival, `dutyAfterTrip`, `needsRest`), so the warning matches what will happen. - **Duty after uses the same sim functions the server uses on arrival** (`tripGameHours` from departure and arrival, `dutyAfterTrip`, `needsRest`), so the warning matches what will happen.
- **The deadhead picker also respects rest** (it validates with the pilot's real `restEndsAt`), disabling every destination with "Pilot is resting". - **The deadhead picker also respects rest** (it validates with the pilot's real `restEndsAt`), disabling every destination with "Pilot is resting".
## Step 10D.8
- **The countdown uses the board's own `periodEndsAt`** from the server, and the board query refetches half a second after it, so a pilot who wakes up sees the new board without reloading.
- **"Eligible" means the dispatch sheet would allow it:** a ship is stuck when `pairOptions` finds no contract on this board with no problems for that ship and its pilot. Uncrewed ships and resting pilots get no prompt (resting pilots already show their countdown elsewhere).
- **One prompt per stuck ship**, naming the ship and pilot, so a location with several idle ships stays clear.
- **`Countdown` takes an optional `format`**; the board refresh uses `mm:ss` (`formatClock`) as the CR asks.

View File

@ -2,8 +2,16 @@ import { useClock } from "../clock/clock";
import { formatDuration } from "../format"; import { formatDuration } from "../format";
/** Live time remaining until `to` (server clock); shows `done` once it has passed. */ /** Live time remaining until `to` (server clock); shows `done` once it has passed. */
export function Countdown({ to, done = "Arriving…" }: { to: number; done?: string }) { export function Countdown({
to,
done = "Arriving…",
format = formatDuration,
}: {
to: number;
done?: string;
format?: (ms: number) => string;
}) {
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const remaining = to - now; const remaining = to - now;
return <span className="tabular-nums">{remaining > 0 ? formatDuration(remaining) : done}</span>; return <span className="tabular-nums">{remaining > 0 ? format(remaining) : done}</span>;
} }

View File

@ -124,7 +124,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
* Fly an idle ship empty to any other discovered location, paying fuel only * 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. * (CR-002). The sim decides which destinations are possible, as the server will.
*/ */
function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) { export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const reposition = useGameAction("createDispatch"); const reposition = useGameAction("createDispatch");

View File

@ -1,12 +1,22 @@
import type { Contract, PlayerState } from "@deadhead/schema"; import type { Contract, PlayerState, Ship } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { useClock } from "../clock/clock"; import { serverNow, useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { cargoName, formatCredits, formatMillionKm, locationName, skillName } from "../format"; import {
cargoName,
formatClock,
formatCredits,
formatMillionKm,
locationName,
skillName,
} from "../format";
import { distanceAt } from "../game"; import { distanceAt } from "../game";
import { useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
import { DeadheadPicker } from "./Fleet";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const; export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
@ -91,6 +101,11 @@ function LocationBoard({
const board = useQuery({ const board = useQuery({
queryKey: locationBoardKey(locationId), queryKey: locationBoardKey(locationId),
queryFn: () => api.call("getContractBoard", { 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-slate-400">Loading contracts…</p>; if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
@ -104,13 +119,27 @@ function LocationBoard({
</div> </div>
); );
} }
const refresh = (
<p aria-label="Board refresh" className="text-sm text-slate-400">
New contracts in{" "}
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</p>
);
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
if (board.data.contracts.length === 0) { if (board.data.contracts.length === 0) {
return ( return (
<p className="text-sm text-slate-400">No contracts left this period. Check back soon.</p> <div className="space-y-2">
{refresh}
{stuck}
<p className="text-sm text-slate-400">No contracts left this period.</p>
</div>
); );
} }
return ( return (
<div className="space-y-2">
{refresh}
{stuck}
<ul className="space-y-2"> <ul className="space-y-2">
{board.data.contracts.map((contract) => { {board.data.contracts.map((contract) => {
const requirements = requirementsText(contract); const requirements = requirementsText(contract);
@ -153,5 +182,77 @@ function LocationBoard({
); );
})} })}
</ul> </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 (
<div
key={ship.id}
role="group"
aria-label={`Nothing for ${ship.name}`}
className="rounded-lg border border-sky-900 bg-sky-950/40 p-3 text-sm"
>
<p>
No job here that {ship.name} and {pilot.name} can take.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<button
type="button"
disabled={rest.isPending || state.tutorial !== null}
onClick={() => rest.mutate({ pilotId: pilot.id })}
className="rounded-md border border-slate-700 px-3 py-1"
>
Rest {pilot.name} — wakes to a new board
</button>
<button
type="button"
onClick={() => setDeadheading(ship)}
className="rounded-md border border-slate-700 px-3 py-1"
>
Deadhead (fly empty)
</button>
</div>
</div>
);
})}
{rest.isError && (
<p role="alert" className="text-sm text-red-400">
{errorMessage(rest.error)}
</p>
)}
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
</>
); );
} }

View File

@ -0,0 +1,105 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const period = boardPeriodMs(content.config);
// 17.5 minutes into a board period.
const NOW = Math.ceil(Date.parse("2100-01-01T00:00:00Z") / period) * period + 17.5 * 60_000;
const periodEndsAt = (boardPeriodIndex(NOW, content.config) + 1) * period;
const courier: Ship = {
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "p1",
};
const rae: Pilot = {
id: "p1",
name: "Rae Okafor",
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: "s1",
status: "idle",
locationId: "earth",
dutyHours: 3,
dutyLimit: 12,
resting: false,
restEndsAt: null,
};
/** Too heavy for a Courier. */
const heavyJob: Contract = {
id: "c-heavy",
originId: "earth",
destinationId: "moon",
cargoTypeId: "heavy",
tier: 0,
massTonnes: 300,
payout: 9000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: periodEndsAt,
firstContact: false,
};
const api = (contracts: Contract[]) =>
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [courier],
pilots: [rae],
}),
getContractBoard: () => ({ contracts, periodEndsAt }),
restPilot: () => ({ pilot: { ...rae, resting: true, restEndsAt: NOW + period } }),
});
describe("board refresh and the nothing-here case (Step 10D.8)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("counts down to the next board period", async () => {
renderApp(api([heavyJob]), "/contracts?location=earth");
expect(await screen.findByLabelText("Board refresh")).toHaveTextContent(
"New contracts in 42:30",
);
});
it("offers rest or a deadhead when no contract fits the idle ship, and each one acts", async () => {
const client = api([heavyJob]);
renderApp(client, "/contracts?location=earth");
const prompt = await screen.findByRole("group", { name: "Nothing for Dusty Mule" });
await userEvent.click(
within(prompt).getByRole("button", { name: "Rest Rae Okafor — wakes to a new board" }),
);
await waitFor(() => expect(client.call).toHaveBeenCalledWith("restPilot", { pilotId: "p1" }));
await userEvent.click(within(prompt).getByRole("button", { name: "Deadhead (fly empty)" }));
expect(screen.getByRole("dialog", { name: "Deadhead Dusty Mule" })).toBeInTheDocument();
});
it("does not prompt when the ship has a job it can take", async () => {
renderApp(api([{ ...heavyJob, id: "c-light", massTonnes: 10 }]), "/contracts?location=earth");
await screen.findByLabelText("Board refresh");
expect(screen.queryByRole("group", { name: /Nothing for/ })).not.toBeInTheDocument();
});
});

View File

@ -171,6 +171,6 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`. - [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`.
- [x] **Step 10D.6 — Client: pilot duty on the Pilots screen.** Each pilot card shows a duty bar ("9.0 h of 14 h", `progressbar` with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call `restPilot`. Test: `pilots-duty.test.tsx`. - [x] **Step 10D.6 — Client: pilot duty on the Pilots screen.** Each pilot card shows a duty bar ("9.0 h of 14 h", `progressbar` with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call `restPilot`. Test: `pilots-duty.test.tsx`.
- [x] **Step 10D.7 — Client: duty in the dispatch flow.** The dispatch sheet disables a resting pilot's pair with "Resting — mm:ss" (new `formatClock`). Eligible pairs show "Duty after" (sim `tripGameHours` + `dutyAfterTrip`) and "Will need rest on arrival" when `needsRest`. Client validation now passes the pilot's real `restEndsAt` (dispatch sheet and deadhead picker, which shows "Pilot is resting"). Test: `dispatch-duty.test.tsx`. - [x] **Step 10D.7 — Client: duty in the dispatch flow.** The dispatch sheet disables a resting pilot's pair with "Resting — mm:ss" (new `formatClock`). Eligible pairs show "Duty after" (sim `tripGameHours` + `dutyAfterTrip`) and "Will need rest on arrival" when `needsRest`. Client validation now passes the pilot's real `restEndsAt` (dispatch sheet and deadhead picker, which shows "Pilot is resting"). Test: `dispatch-duty.test.tsx`.
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case - [x] **Step 10D.8 — Client: board refresh and the "nothing here" case.** Every location board shows "New contracts in mm:ss" to `periodEndsAt` and refetches itself just after it. For each idle, crewed, non-resting ship at the location with no contract it can take (by the sim, via `pairOptions`), a prompt offers "Rest [pilot] — wakes to a new board" (`restPilot`) and "Deadhead (fly empty)" (opens the Fleet's deadhead picker, now exported). Test: `board-refresh.test.tsx`.
- [ ] Step 10D.9 — Client: rest in summaries and on HQ - [ ] Step 10D.9 — Client: rest in summaries and on HQ
- [ ] Step 10D.10 — Plan amendments and regression - [ ] Step 10D.10 — Plan amendments and regression