Step 10D.7: rest and duty outlook in the dispatch sheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:06:09 -04:00
parent da9e053598
commit 9f699f716e
6 changed files with 189 additions and 4 deletions

View File

@ -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. - **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. - **"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. - **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".

View File

@ -16,6 +16,15 @@ export function formatDuration(ms: number): string {
return `${seconds}s`; 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". */ /** A game-hours duration, e.g. "5.4 h" or "250 h". */
export function formatGameHours(hours: number): string { export function formatGameHours(hours: number): string {
return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`; return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`;

View File

@ -2,16 +2,27 @@ import { content, type ShipModel } from "@deadhead/content";
import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
import { import {
checkRoute, checkRoute,
dutyAfterTrip,
LONG_HAUL_SKILL_ID, LONG_HAUL_SKILL_ID,
needsRest,
realToGame, realToGame,
tripFuelCost, tripFuelCost,
tripGameHours,
validateDispatch, validateDispatch,
type DispatchFailure, type DispatchFailure,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { useState } from "react"; import { useState } from "react";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; 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 { requirementsText } from "./LocationBoards";
import { location, shipModel, tripRealMs } from "../game"; import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
@ -47,6 +58,10 @@ export function explainProblems(
now: number, now: number,
): string[] { ): string[] {
const problems: 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 (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
if (contract.massTonnes > model.cargoCapacityTonnes) { if (contract.massTonnes > model.cargoCapacityTonnes) {
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
@ -100,6 +115,15 @@ export interface PairOption {
fuelCost: number; fuelCost: number;
netProfit: number; netProfit: number;
arrivalAt: 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. */ /** 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, assignedShipId: pilot.assignedShipId,
inFlight: pilot.status === "in-flight", inFlight: pilot.status === "in-flight",
skills: pilot.skills, skills: pilot.skills,
restEndsAt: null, restEndsAt: pilot.restEndsAt,
}, },
contract, contract,
tutorial: false, tutorial: false,
@ -169,6 +193,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
fuelCost, fuelCost,
netProfit: contract.payout - fuelCost - pilotPay, netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs, arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
}; };
}); });
} }
@ -268,8 +293,20 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
</dd> </dd>
<dt>ETA (real)</dt> <dt>ETA (real)</dt>
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd> <dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
{option.duty && (
<>
<dt>Duty after</dt>
<dd className="tabular-nums">
{formatGameHours(option.duty.after)} of{" "}
{formatGameHours(option.duty.limit)}
</dd>
</>
)}
</dl> </dl>
)} )}
{option.problems.length === 0 && option.duty?.willRest && (
<p className="mt-1 text-amber-300">Will need rest on arrival</p>
)}
</label> </label>
</li> </li>
))} ))}

View File

@ -147,7 +147,7 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void })
assignedShipId: pilot ? ship.id : null, assignedShipId: pilot ? ship.id : null,
inFlight: pilot?.status === "in-flight", inFlight: pilot?.status === "in-flight",
skills: pilot?.skills ?? {}, skills: pilot?.skills ?? {},
restEndsAt: null, restEndsAt: pilot?.restEndsAt ?? null,
}, },
destinationId: id, destinationId: id,
discoveredIds, discoveredIds,

View File

@ -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>): 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");
});
});

View File

@ -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.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.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`.
- [ ] 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.8 — Client: board refresh and the "nothing here" case
- [ ] 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