Step 23.3: client: status line
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 18:11:54 -04:00
parent d5811bfa0e
commit bc5234ac69
9 changed files with 122 additions and 2 deletions

View File

@ -1796,3 +1796,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Existing saves:** the migration gives every ship `shopPilotId: null`. Recording the current pilot instead would reset duty for long-finished upgrades too, since the migration can't tell the time.
- **"Shorter than a rest doesn't count"** is checked at the start of the upgrade (shop time against the rest length), so no pilot is recorded.
- **No sim change:** the reset is bookkeeping on the save (who was aboard, when the shop ends), so it lives in the engine beside the other catch-up events; the sim's duty and rest rules are unchanged.
## Step 23.3
- **The shop line replaces the duty bar** while it shows, like an ordinary rest: duty is reset when the shop time ends, so the old hours would only mislead. It wins over an ordinary rest too, since that rest now ends with the shop at the latest.
- **The test unassigns through the state, not a button**: the app has no unassign control (pilots are unassigned by the engine command), so the component test swaps in the unassigned pilot's state and refetches; the engine test checks that unassigning clears `restingInShopUntil`.

View File

@ -71,6 +71,9 @@ export function independentStatus(state: PlayerState, pilot: Pilot): string | nu
? `Independent: flying ${goodName(flight.contract)} to ${where}`
: `Independent: flying empty to ${where}`;
}
if (pilot.restingInShopUntil !== null) {
return `Resting while in the shop until ${formatLocalTime(pilot.restingInShopUntil)}`;
}
if (pilot.resting && pilot.restEndsAt !== null) {
return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
}

View File

@ -0,0 +1,11 @@
import { formatClock } from "../format";
import { Countdown } from "./Countdown";
/** "Resting while in the shop — mm:ss", counting down to the upgrade's end (decision 0.5.135). */
export function ShopRest({ until }: { until: number }) {
return (
<span>
Resting while in the shop — <Countdown to={until} format={formatClock} done="any moment" />
</span>
);
}

View File

@ -12,6 +12,7 @@ import { LoanCard } from "../components/LoanCard";
import { RouteStrip } from "../components/RouteStrip";
import { formatGameHours, locationName, shipModelName } from "../format";
import { lowFuel } from "../game";
import { ShopRest } from "../components/ShopRest";
import { useLoadedPlayer } from "../state/actions";
import {
Art,
@ -381,7 +382,12 @@ function PilotDuty({ pilot }: { pilot: Pilot }) {
<Eyebrow>
{pilot.name} · Level {pilot.level}
</Eyebrow>
{pilot.resting && pilot.restEndsAt !== null ? (
{pilot.restingInShopUntil !== null ? (
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
<Icon name="rest" className="size-4 shrink-0" />
<ShopRest until={pilot.restingInShopUntil} />
</p>
) : pilot.resting && pilot.restEndsAt !== null ? (
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
<Icon name="rest" className="size-4 shrink-0" />
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />

View File

@ -11,6 +11,7 @@ import {
locationName,
shipModelName,
} from "../format";
import { ShopRest } from "../components/ShopRest";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
@ -239,6 +240,18 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
const rest = useGameAction("restPilot");
const [confirming, setConfirming] = useState(false);
// Waiting with the ship in the shop counts as rest (decision 0.5.135).
if (pilot.restingInShopUntil !== null) {
return (
<div
className="mt-3 flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-950/40 px-3 py-2 text-sky-200"
aria-label={`${pilot.name} rest`}
>
<Icon name="rest" className="size-4 shrink-0" />
<ShopRest until={pilot.restingInShopUntil} />
</div>
);
}
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<div

View File

@ -0,0 +1,82 @@
import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
import { act, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { independentStatus } from "../src/components/IndependentPilots";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const SHOP_ENDS = NOW + 12 * 60_000 + 34_000;
const ship: Ship = {
id: "s1",
modelId: "courier",
name: "Wren",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "p1",
lastArrivalAt: NOW - 60_000,
...shipExtras("courier"),
inShopUntil: SHOP_ENDS,
};
const base = samplePlayerState();
const ada = (overrides: Partial<Pilot>): Pilot => ({
...base.pilots[0]!,
id: "p1",
name: "Ada",
isAvatar: false,
dutyHours: 11,
...overrides,
});
/** Ada waiting with the ship in the shop, or unassigned and left at the HQ. */
const inShop = ada({ assignedShipId: "s1", locationId: "earth", restingInShopUntil: SHOP_ENDS });
const unassigned = ada({ assignedShipId: null, locationId: "earth", restingInShopUntil: null });
const owner = (pilot: Pilot): PlayerState =>
samplePlayerState({
tutorial: null,
bays: [{ id: "b1", locationId: "earth" }],
ships: [{ ...ship, pilotId: pilot.assignedShipId ? "p1" : null }],
pilots: [base.pilots[0]!, pilot],
});
describe("resting while in the shop (Step 23.3, decision 0.5.135)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows the shop countdown as rest, and drops it once the pilot is unassigned", async () => {
let state = owner(inShop);
const { queryClient } = renderApp(fakeApi({ getPlayerState: () => state }), "/pilots");
const card = (await screen.findByText("Ada")).closest("li")!;
expect(within(card).getByLabelText("Ada rest")).toHaveTextContent(
"Resting while in the shop — 12:34",
);
// No duty bar while the shop counts as rest.
expect(within(card).queryByRole("progressbar", { name: "Ada duty" })).not.toBeInTheDocument();
state = owner(unassigned);
await act(() => queryClient.invalidateQueries());
const after = (await screen.findByText("Ada")).closest("li")!;
expect(after).not.toHaveTextContent(/Resting/);
expect(within(after).getByRole("progressbar", { name: "Ada duty" })).toBeInTheDocument();
});
it("counts down with the clock", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(inShop) }), "/pilots");
const card = (await screen.findByText("Ada")).closest("li")!;
act(() => useClock.setState({ now: NOW + 4 * 60_000 }));
expect(within(card).getByLabelText("Ada rest")).toHaveTextContent("08:34");
});
it("says so in an Independent pilot's status line", () => {
const state = owner({ ...inShop, mode: "independent" });
expect(independentStatus(state, state.pilots[1]!)).toMatch(/^Resting while in the shop until /);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

View File

@ -512,5 +512,5 @@ Spec: `memory-bank/change-requests/CR-023-shop-time-rest.md` (received as "CR-02
- [x] **Step 23.1 — Content test.** Every upgrade level's shop time, in game hours (real minutes × time compression), is at least `restGameHours`: today 10, 20 and 30 game hours against a 10-hour rest. Test: `packages/content/test/upgrades.test.ts`.
- [x] **Step 23.2 — Engine: duty reset on upgrade completion.** `startUpgrade` records the ship's pilot as `shopPilotId` (save version 8; the 7 → 8 migration sets it null, so an upgrade already under way finishes without the rest), but only when the shop time covers a full rest. `unassignPilot` clears it: a pilot can only fly another ship after leaving this one, so "assigned the whole time" and "didn't fly" are the same check. The shop's end is a new event in the catch-up queue (before arrivals and decisions at the same moment): `finishShop` sets the pilot's duty to zero, ends a rest that would run later, and brings an Independent pilot's next decision forward to the turnaround after it. The dev "finish shop" does the same at once. `Pilot.restingInShopUntil` tells the client. Tests (`packages/engine/test/shop-rest.test.ts`): a pilot near the limit is at zero duty and flies the moment the upgrade ends; one who left and flew another ship keeps their duty; one who left and came back doesn't get the reset; a longer rest ends with the shop and a shorter one runs its course; an Independent pilot comes out rested and decides after the turnaround; catch-up at once equals 30-second steps. `packages/schema/test/save.test.ts`: the 7 → 8 migration.
- [ ] Step 23.3 — Client: status line
- [x] **Step 23.3 — Client: status line.** While `Pilot.restingInShopUntil` is set, the pilot's card on Pilots and the Bridge's own-pilot panel read "Resting while in the shop — mm:ss" (`ShopRest`, counting down to the upgrade's end) in place of the duty bar, and an Independent pilot's status line reads "Resting while in the shop until …". The Fleet card keeps its "In the shop" countdown. Tests (`apps/web/test/shop-rest.test.tsx`): the line with the right countdown and no duty bar; after the pilot is unassigned the line is gone and the duty bar is back; it counts down with the clock; the Independent status. Screenshots: `docs/screenshots/23-3-shop-rest-phone.png`, `23-3-shop-rest-desktop.png`.
- [ ] Step 23.4 — Regression