From 9939cd3eeddf87522f86137c6e9d06d7c1d6139e Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 22:10:48 -0400 Subject: [PATCH] Step 10D.9: rest in the away summary and on HQ Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 ++ apps/web/src/components/AwaySummary.tsx | 7 ++ apps/web/src/screens/HQ.tsx | 17 +++++ apps/web/test/rest-summary-hq.test.tsx | 90 +++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 apps/web/test/rest-summary-hq.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 221bc33..69bfb4b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -813,3 +813,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"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. + +## Step 10D.9 + +- **The summary gives the wake time as the device's local clock time** (like the dispatch sheet's real ETA), since the player reads it on returning; HQ gives a live countdown instead. +- **The HQ rest card is always shown after the tutorial**, with "Everyone is ready to fly." when nobody rests, so the player learns the mechanic exists before it first bites. diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index e10eb4e..108c044 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -2,6 +2,7 @@ import { content } from "@deadhead/content"; import { useState } from "react"; import { useNavigate } from "react-router"; import { formatCredits, locationName } from "../format"; +import { formatLocalTime } from "../screens/DispatchSheet"; import { useGameAction, useLoadedPlayer } from "../state/actions"; /** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ @@ -72,6 +73,12 @@ export function AwaySummary() { Discovered {locationName(s.discoveredLocationId)}! )} + {s.restEndsAt !== null && ( +
+ {state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting + until {formatLocalTime(s.restEndsAt)} +
+ )} ))} diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx index 9bbaaab..8510337 100644 --- a/apps/web/src/screens/HQ.tsx +++ b/apps/web/src/screens/HQ.tsx @@ -28,6 +28,10 @@ export function HQ() { const flying = state.ships.filter((s) => s.status === "in-flight").length; const runsBeforeDeal = content.config.tutorialRunsBeforeDeal; const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0]; + // Resting pilots and who wakes first (CR-004). + const resting = state.pilots + .filter((p) => p.resting && p.restEndsAt !== null) + .sort((a, b) => a.restEndsAt! - b.restEndsAt!); return (
@@ -52,6 +56,19 @@ export function HQ() { )} + {!state.tutorial && ( +
+

Pilots resting: {resting.length}

+ {resting[0] ? ( +

+ {resting[0].name} wakes in +

+ ) : ( +

Everyone is ready to fly.

+ )} +
+ )} + {!state.tutorial && state.ships.length === 0 && (

No ships yet — buy one in the Market.

)} diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx new file mode 100644 index 0000000..c6c1f70 --- /dev/null +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -0,0 +1,90 @@ +import type { Pilot, ResolutionSummary } from "@deadhead/schema"; +import { screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { formatLocalTime } from "../src/screens/DispatchSheet"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const pilot = (id: string, name: string, extra: Partial = {}): Pilot => ({ + id, + name, + isAvatar: false, + xp: 0, + level: 0, + skills: {}, + skillPointsAvailable: 0, + assignedShipId: null, + status: "idle", + locationId: "earth", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, + ...extra, +}); + +const summary = (extra: Partial): ResolutionSummary => ({ + dispatchId: "d1", + shipId: "s1", + pilotId: "p-rae", + originId: "earth", + destinationId: "moon", + tutorial: false, + credits: 500, + loanRepayment: 0, + pilotXp: 10, + discoveredLocationId: null, + scriptedId: null, + restEndsAt: null, + resolvedAt: NOW, + ...extra, +}); + +const pilots = [ + pilot("p-rae", "Rae Okafor", { resting: true, restEndsAt: NOW + 40 * 60_000 }), + pilot("p-jun", "Jun Sato", { resting: true, restEndsAt: NOW + 9 * 60_000 }), + pilot("p-cy", "Cy Fresh"), +]; + +describe("rest in summaries and on HQ (Step 10D.9)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("says in the away summary that a pilot is resting, only for trips that ended in rest", async () => { + const restEndsAt = NOW + 40 * 60_000; + renderApp( + fakeApi({ + getPlayerState: () => + samplePlayerState({ + tutorial: null, + pilots, + summaries: [ + summary({ dispatchId: "d1", restEndsAt }), + summary({ dispatchId: "d2", pilotId: "p-cy" }), + ], + }), + }), + "/", + ); + const dialog = await screen.findByRole("dialog", { name: "While you were away" }); + const [rested, plain] = within(dialog).getAllByRole("listitem"); + expect(rested).toHaveTextContent(`Rae Okafor is resting until ${formatLocalTime(restEndsAt)}`); + expect(plain).not.toHaveTextContent("resting"); + }); + + it("shows how many pilots are resting and who wakes first", async () => { + renderApp( + fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null, pilots }) }), + "/", + ); + const card = await screen.findByLabelText("Pilot rest"); + expect(card).toHaveTextContent("Pilots resting: 2"); + expect(card).toHaveTextContent("Jun Sato wakes in 9m 00s"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9b16715..36d5caa 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -172,5 +172,5 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec - [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`. - [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 +- [x] **Step 10D.9 — Client: rest in summaries and on HQ.** The "While you were away" entry for a trip that ended in mandatory rest says "[Pilot] is resting until [local time]". HQ (after the tutorial) has a "Pilot rest" card: "Pilots resting: N" and the soonest to wake with a countdown, or "Everyone is ready to fly." Test: `rest-summary-hq.test.tsx`. - [ ] Step 10D.10 — Plan amendments and regression