diff --git a/DECISIONS.md b/DECISIONS.md index e5fa39b..3a03d74 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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 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. + +## 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. diff --git a/apps/web/src/screens/Pilots.tsx b/apps/web/src/screens/Pilots.tsx index 65d3fe3..d16fd6b 100644 --- a/apps/web/src/screens/Pilots.tsx +++ b/apps/web/src/screens/Pilots.tsx @@ -1,7 +1,15 @@ import { content } from "@deadhead/content"; import type { Pilot } from "@deadhead/schema"; 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"; /** 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 }; } -/** 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() { const state = useLoadedPlayer(); return ( @@ -149,6 +157,8 @@ function PilotCard({ pilot }: { pilot: Pilot }) { )} + + {!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && ( )} @@ -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 ( +
+ Resting · wakes in +
+ ); + } + const used = Math.min(pilot.dutyHours, pilot.dutyLimit); + return ( +
+
+ Duty + + {formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)} + +
+
+
+
+ {canRest && pilot.status === "idle" && !confirming && ( + + )} + {confirming && ( +
+

+ {pilot.name} rests for {formatDuration(restRealMs())} and can't fly until then. + Rest always lasts at least one board refresh, so they wake to new contracts. +

+
+ + +
+
+ )} + {rest.isError && ( +

+ {errorMessage(rest.error)} +

+ )} +
+ ); +} + /** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */ function AssignShip({ pilot }: { pilot: Pilot }) { const state = useLoadedPlayer(); diff --git a/apps/web/test/pilots-duty.test.tsx b/apps/web/test/pilots-duty.test.tsx new file mode 100644 index 0000000..53138e4 --- /dev/null +++ b/apps/web/test/pilots-duty.test.tsx @@ -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 => ({ + 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" })); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 236a9f2..21a9547 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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`. -- [ ] 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.8 — Client: board refresh and the "nothing here" case - [ ] Step 10D.9 — Client: rest in summaries and on HQ