Step 10D.9: rest in the away summary and on HQ

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 22:10:48 -04:00
parent ea561b4d74
commit 9939cd3eed
5 changed files with 120 additions and 1 deletions

View File

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

View File

@ -2,6 +2,7 @@ import { content } from "@deadhead/content";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { useGameAction, useLoadedPlayer } from "../state/actions"; import { useGameAction, useLoadedPlayer } from "../state/actions";
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ /** "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)}! Discovered {locationName(s.discoveredLocationId)}!
</div> </div>
)} )}
{s.restEndsAt !== null && (
<div className="mt-1 text-sky-300">
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
until {formatLocalTime(s.restEndsAt)}
</div>
)}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -28,6 +28,10 @@ export function HQ() {
const flying = state.ships.filter((s) => s.status === "in-flight").length; const flying = state.ships.filter((s) => s.status === "in-flight").length;
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal; const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0]; 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 ( return (
<section className="space-y-4 p-4"> <section className="space-y-4 p-4">
@ -52,6 +56,19 @@ export function HQ() {
)} )}
</div> </div>
{!state.tutorial && (
<div aria-label="Pilot rest" className="rounded-lg border border-slate-800 p-3">
<h2 className="text-sm font-semibold text-slate-300">Pilots resting: {resting.length}</h2>
{resting[0] ? (
<p className="mt-1">
{resting[0].name} wakes in <Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="mt-1 text-sm text-slate-400">Everyone is ready to fly.</p>
)}
</div>
)}
{!state.tutorial && state.ships.length === 0 && ( {!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p> <p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
)} )}

View File

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

View File

@ -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.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.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`. - [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 - [ ] Step 10D.10 — Plan amendments and regression