Step 10D.6: duty bar, rest countdown and Rest now on Pilots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:03:30 -04:00
parent 3dba7c2019
commit da9e053598
4 changed files with 200 additions and 3 deletions

View File

@ -794,3 +794,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot. - **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot.
- **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes. - **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes.
- **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away. - **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away.
## Step 10D.6
- **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.

View File

@ -1,7 +1,15 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema"; import type { Pilot } from "@deadhead/schema";
import { useState } from "react"; import { useState } from "react";
import { formatCredits, locationName, shipModelName } from "../format"; import { MS_PER_HOUR } from "@deadhead/sim";
import { Countdown } from "../components/Countdown";
import {
formatCredits,
formatDuration,
formatGameHours,
locationName,
shipModelName,
} from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** XP needed for the pilot's current and next level, or null at max level. */ /** XP needed for the pilot's current and next level, or null at max level. */
@ -13,7 +21,7 @@ export function levelProgress(xp: number, level: number) {
return { xp: xp - previous, needed: next - previous }; return { xp: xp - previous, needed: next - previous };
} }
/** Pilots: levels, skills, assignment; hire, assign and spend skill points (Step 9.7). */ /** Pilots: levels, skills, assignment, duty and rest; hire, assign, rest and spend skill points (Steps 9.7, 10D.6). */
export function Pilots() { export function Pilots() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
return ( return (
@ -149,6 +157,8 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
)} )}
</div> </div>
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && ( {!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
<AssignShip pilot={pilot} /> <AssignShip pilot={pilot} />
)} )}
@ -156,6 +166,95 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
); );
} }
/** Real time a rest lasts: at least one contract board period (decision 0.5.46). */
export function restRealMs(): number {
const { restGameHours, timeCompression } = content.config;
return (restGameHours * MS_PER_HOUR) / timeCompression;
}
/** Duty used of the limit, or the rest countdown; and "Rest now" with a confirmation (CR-004). */
function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
const rest = useGameAction("restPilot");
const [confirming, setConfirming] = useState(false);
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}>
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</div>
);
}
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
<div className="mt-2">
<div className="flex items-baseline justify-between text-xs text-slate-400">
<span>Duty</span>
<span>
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</span>
</div>
<div
role="progressbar"
aria-label={`${pilot.name} duty`}
aria-valuemin={0}
aria-valuemax={pilot.dutyLimit}
aria-valuenow={used}
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800"
>
<div
className="h-full bg-amber-400"
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
/>
</div>
{canRest && pilot.status === "idle" && !confirming && (
<button
type="button"
onClick={() => setConfirming(true)}
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
>
Rest now
</button>
)}
{confirming && (
<div
role="group"
aria-label={`Rest ${pilot.name}?`}
className="mt-2 rounded-md bg-slate-900 p-2"
>
<p className="text-slate-300">
{pilot.name} rests for {formatDuration(restRealMs())} and can&apos;t fly until then.
Rest always lasts at least one board refresh, so they wake to new contracts.
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
disabled={rest.isPending}
onClick={() =>
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
>
Rest {pilot.name}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-md border border-slate-700 px-3 py-1"
>
Cancel
</button>
</div>
</div>
)}
{rest.isError && (
<p role="alert" className="mt-1 text-red-400">
{errorMessage(rest.error)}
</p>
)}
</div>
);
}
/** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */ /** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */
function AssignShip({ pilot }: { pilot: Pilot }) { function AssignShip({ pilot }: { pilot: Pilot }) {
const state = useLoadedPlayer(); const state = useLoadedPlayer();

View File

@ -0,0 +1,92 @@
import type { Pilot, PlayerState } from "@deadhead/schema";
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 NOW = Date.parse("2100-01-01T00:00:00Z");
const pilot = (overrides: Partial<Pilot>): Pilot => ({
id: "p",
name: "Pilot",
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
...overrides,
});
const state = (): PlayerState =>
samplePlayerState({
tutorial: null,
bays: [{ id: "b1", locationId: "earth" }],
pilots: [
pilot({ id: "p-rae", name: "Rae Okafor", dutyHours: 9, dutyLimit: 14 }),
pilot({
id: "p-jun",
name: "Jun Sato",
resting: true,
restEndsAt: NOW + 25 * 60_000,
}),
],
});
const card = async (name: string) => (await screen.findByText(name)).closest("li")!;
describe("pilot duty on the Pilots screen (Step 10D.6)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows a duty bar matching the pilot's hours and limit", async () => {
renderApp(fakeApi({ getPlayerState: state }), "/pilots");
const rae = await card("Rae Okafor");
const bar = within(rae).getByRole("progressbar", { name: "Rae Okafor duty" });
expect(bar).toHaveAttribute("aria-valuenow", "9");
expect(bar).toHaveAttribute("aria-valuemax", "14");
expect(rae).toHaveTextContent("9.0 h of 14 h");
});
it("shows a resting pilot's countdown and no Rest now action", async () => {
renderApp(fakeApi({ getPlayerState: state }), "/pilots");
const jun = await card("Jun Sato");
expect(jun).toHaveTextContent("Resting · wakes in 25m 00s");
expect(within(jun).queryByRole("button", { name: "Rest now" })).not.toBeInTheDocument();
expect(within(jun).queryByRole("progressbar")).not.toBeInTheDocument();
});
it("rests a pilot after confirming that rest lasts at least one board refresh", async () => {
const api = fakeApi({
getPlayerState: state,
restPilot: ({ pilotId }) => ({
pilot: pilot({
id: pilotId,
name: "Rae Okafor",
resting: true,
restEndsAt: NOW + 3_600_000,
}),
}),
});
renderApp(api, "/pilots");
const rae = await card("Rae Okafor");
await userEvent.click(within(rae).getByRole("button", { name: "Rest now" }));
const confirm = within(rae).getByRole("group", { name: "Rest Rae Okafor?" });
expect(confirm).toHaveTextContent("at least one board refresh");
expect(api.call).not.toHaveBeenCalledWith("restPilot", expect.anything());
await userEvent.click(within(confirm).getByRole("button", { name: "Rest Rae Okafor" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("restPilot", { pilotId: "p-rae" }));
});
});

View File

@ -169,7 +169,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
- [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`. - [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`.
- [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`.
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen - [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 - [ ] Step 10D.7 — Client: duty in the dispatch flow
- [ ] 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