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:
parent
9f699f716e
commit
ea561b4d74
|
|
@ -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.
|
||||
- **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".
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@ import { useClock } from "../clock/clock";
|
|||
import { formatDuration } from "../format";
|
||||
|
||||
/** 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 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
|||
* 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.
|
||||
*/
|
||||
function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const reposition = useGameAction("createDispatch");
|
||||
|
|
|
|||
|
|
@ -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 { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { serverNow, useClock } from "../clock/clock";
|
||||
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 { 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;
|
||||
|
||||
|
|
@ -91,6 +101,11 @@ function LocationBoard({
|
|||
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-slate-400">Loading contracts…</p>;
|
||||
|
|
@ -104,54 +119,140 @@ function LocationBoard({
|
|||
</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) {
|
||||
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 (
|
||||
<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>
|
||||
<div className="space-y-2">
|
||||
{refresh}
|
||||
{stuck}
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.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`.
|
||||
- [ ] 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.10 — Plan amendments and regression
|
||||
|
|
|
|||
Loading…
Reference in New Issue