diff --git a/DECISIONS.md b/DECISIONS.md index a6e1c6d..221bc33 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/src/components/Countdown.tsx b/apps/web/src/components/Countdown.tsx index 5e4f5d1..9f99d83 100644 --- a/apps/web/src/components/Countdown.tsx +++ b/apps/web/src/components/Countdown.tsx @@ -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 {remaining > 0 ? formatDuration(remaining) : done}; + return {remaining > 0 ? format(remaining) : done}; } diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 3cd5808..bfd03f4 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -124,7 +124,7 @@ const DEADHEAD_REASONS: Partial> = { * 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"); diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index 4ea010e..2814c65 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -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

Loading contracts…

; @@ -104,54 +119,140 @@ function LocationBoard({ ); } + const refresh = ( +

+ New contracts in{" "} + +

+ ); + const stuck = ; if (board.data.contracts.length === 0) { return ( -

No contracts left this period. Check back soon.

+
+ {refresh} + {stuck} +

No contracts left this period.

+
); } return ( - + + ); +} + +/** + * 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)} />} + ); } diff --git a/apps/web/test/board-refresh.test.tsx b/apps/web/test/board-refresh.test.tsx new file mode 100644 index 0000000..eceec1f --- /dev/null +++ b/apps/web/test/board-refresh.test.tsx @@ -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(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e4ac27b..9b16715 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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