From 9f699f716ea5c621a5a0c1eeaadac7235751481c Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 22:06:09 -0400 Subject: [PATCH] Step 10D.7: rest and duty outlook in the dispatch sheet Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/format.ts | 9 ++ apps/web/src/screens/DispatchSheet.tsx | 41 +++++++- apps/web/src/screens/Fleet.tsx | 2 +- apps/web/test/dispatch-duty.test.tsx | 133 +++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 6 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 apps/web/test/dispatch-duty.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 3a03d74..a6e1c6d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -800,3 +800,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The confirmation is inline in the pilot card**, not a browser `confirm()`, so it is styled and testable. It names the real rest length (one hour with current settings, computed from config) and says the pilot wakes to new contracts. - **"Rest now" is hidden during the tutorial and for in-flight pilots**, matching what the server allows. - **The duty bar caps at full** when a pilot finished a trip over the limit; the text shows the actual hours. + +## Step 10D.7 + +- **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". diff --git a/apps/web/src/format.ts b/apps/web/src/format.ts index 782fb6a..2a72084 100644 --- a/apps/web/src/format.ts +++ b/apps/web/src/format.ts @@ -16,6 +16,15 @@ export function formatDuration(ms: number): string { return `${seconds}s`; } +/** A countdown as "mm:ss", or "h:mm:ss" from an hour up. */ +export function formatClock(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const pad = (n: number) => String(n).padStart(2, "0"); + const mmss = `${pad(Math.floor((totalSeconds % 3600) / 60))}:${pad(totalSeconds % 60)}`; + return hours > 0 ? `${hours}:${mmss}` : mmss; +} + /** A game-hours duration, e.g. "5.4 h" or "250 h". */ export function formatGameHours(hours: number): string { return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`; diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 5cfbc68..989f4a7 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -2,16 +2,27 @@ import { content, type ShipModel } from "@deadhead/content"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import { checkRoute, + dutyAfterTrip, LONG_HAUL_SKILL_ID, + needsRest, realToGame, tripFuelCost, + tripGameHours, validateDispatch, type DispatchFailure, } from "@deadhead/sim"; import { useState } from "react"; import { useClock } from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; -import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format"; +import { + cargoName, + formatClock, + formatCredits, + formatDuration, + formatGameHours, + locationName, + skillName, +} from "../format"; import { requirementsText } from "./LocationBoards"; import { location, shipModel, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; @@ -47,6 +58,10 @@ export function explainProblems( now: number, ): string[] { const problems: string[] = []; + // Resting blocks flying (CR-004): first, with the time left. + if (pilot.resting && pilot.restEndsAt !== null) { + problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`); + } if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]); if (contract.massTonnes > model.cargoCapacityTonnes) { problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); @@ -100,6 +115,15 @@ export interface PairOption { fuelCost: number; netProfit: number; arrivalAt: number; + /** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */ + duty: { after: number; limit: number; willRest: boolean } | null; +} + +/** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */ +export function dutyOutlook(pilot: Pilot, departureAt: number, arrivalAt: number) { + const hours = tripGameHours(departureAt, arrivalAt, content.config); + const after = dutyAfterTrip(pilot.dutyHours, hours, false); + return { after, limit: pilot.dutyLimit, willRest: needsRest(after, pilot.dutyLimit) }; } /** Every ship at the contract's origin, with its pilot, numbers and eligibility. */ @@ -141,7 +165,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) assignedShipId: pilot.assignedShipId, inFlight: pilot.status === "in-flight", skills: pilot.skills, - restEndsAt: null, + restEndsAt: pilot.restEndsAt, }, contract, tutorial: false, @@ -169,6 +193,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) fuelCost, netProfit: contract.payout - fuelCost - pilotPay, arrivalAt: now + tripMs, + duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, }; }); } @@ -268,8 +293,20 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
ETA (real)
{formatLocalTime(option.arrivalAt)}
+ {option.duty && ( + <> +
Duty after
+
+ {formatGameHours(option.duty.after)} of{" "} + {formatGameHours(option.duty.limit)} +
+ + )} )} + {option.problems.length === 0 && option.duty?.willRest && ( +

Will need rest on arrival

+ )} ))} diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index f0190f1..3cd5808 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -147,7 +147,7 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) assignedShipId: pilot ? ship.id : null, inFlight: pilot?.status === "in-flight", skills: pilot?.skills ?? {}, - restEndsAt: null, + restEndsAt: pilot?.restEndsAt ?? null, }, destinationId: id, discoveredIds, diff --git a/apps/web/test/dispatch-duty.test.tsx b/apps/web/test/dispatch-duty.test.tsx new file mode 100644 index 0000000..a35323d --- /dev/null +++ b/apps/web/test/dispatch-duty.test.tsx @@ -0,0 +1,133 @@ +import { content } from "@deadhead/content"; +import type { Contract, Pilot, Ship } from "@deadhead/schema"; +import { realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; +import { screen, 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 { formatGameHours } from "../src/format"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const ship = (id: string, name: string, pilotId: string): Ship => ({ + id, + name, + modelId: "courier", + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: 0, + pilotId, +}); + +const pilot = (id: string, name: string, shipId: string, extra: Partial): Pilot => ({ + id, + name, + isAvatar: false, + xp: 0, + level: 0, + skills: {}, + skillPointsAvailable: 0, + assignedShipId: shipId, + status: "idle", + locationId: "earth", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, + ...extra, +}); + +const moonJob: Contract = { + id: "c-moon", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: 10, + payout: 900, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 3_600_000, + firstContact: false, +}; + +const state = samplePlayerState({ + tutorial: null, + company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 }, + bays: [{ id: "b1", locationId: "earth" }], + ships: [ + ship("s-rest", "Sleepy Gull", "p-rest"), + ship("s-tired", "Tired Heron", "p-tired"), + ship("s-fresh", "Fresh Lark", "p-fresh"), + ], + pilots: [ + pilot("p-rest", "Ana Resting", "s-rest", { + resting: true, + restEndsAt: NOW + 12 * 60_000 + 5_000, + }), + pilot("p-tired", "Bo Tired", "s-tired", { dutyHours: 11 }), + pilot("p-fresh", "Cy Fresh", "s-fresh", { dutyHours: 1 }), + ], +}); + +/** The Courier's game hours for the Moon job, straight from the sim. */ +const tripHours = () => + travelTimeGameHours( + content.shipModel("courier")!, + routeDistance( + content.location("earth")!, + content.location("moon")!, + realToGame(NOW, content.config), + ), + moonJob.massTonnes, + content.config, + ); + +async function openSheet() { + renderApp( + fakeApi({ + getPlayerState: () => state, + getContractBoard: () => ({ contracts: [moonJob], periodEndsAt: NOW + 3_600_000 }), + }), + "/contracts?location=earth", + ); + await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); + return screen.getByRole("dialog"); +} +const option = (dialog: HTMLElement, shipName: string) => + within(dialog).getByText(shipName).closest("li")!; + +describe("duty in the dispatch sheet (Step 10D.7)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("disables a resting pilot's pair with the time left", async () => { + const resting = option(await openSheet(), "Sleepy Gull"); + expect(within(resting).getByRole("radio")).toBeDisabled(); + expect(resting).toHaveTextContent("Resting — 12:05"); + }); + + it("warns when the trip takes the pilot to the limit, with the sim's duty numbers", async () => { + const dialog = await openSheet(); + const tired = option(dialog, "Tired Heron"); + expect(tired).toHaveTextContent( + `Duty after${formatGameHours(11 + tripHours())} of ${formatGameHours(12)}`, + ); + expect(tired).toHaveTextContent("Will need rest on arrival"); + }); + + it("shows no warning when the pilot stays under the limit", async () => { + const fresh = option(await openSheet(), "Fresh Lark"); + expect(fresh).toHaveTextContent( + `Duty after${formatGameHours(1 + tripHours())} of ${formatGameHours(12)}`, + ); + expect(fresh).not.toHaveTextContent("Will need rest"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 21a9547..e4ac27b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -170,7 +170,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec - [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated. - [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`. -- [ ] Step 10D.7 — Client: duty in the dispatch flow +- [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 - [ ] Step 10D.9 — Client: rest in summaries and on HQ - [ ] Step 10D.10 — Plan amendments and regression