Step 24.5: client: return banner, status, and setting
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-15 21:27:17 -04:00
parent 08015fe97c
commit 8fb8f1ad09
20 changed files with 335 additions and 2 deletions

View File

@ -1831,3 +1831,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Back to work" stamps the time itself**, as well as through the catalog flag: the engine stamps after the handler, and the command's reply (the player state) should already read awake.
- **Waking needs no other change**: pilots asleep were left waiting at the time of the last catch-up, which is the moment of the waking command, so the next catch-up has them decide from then.
## Step 24.5
- **The banner and the Bridge status show only with an Independent pilot.** Sleep only stops Independent pilots; with none, "Fleet asleep" would ask the player to wake nobody.
- **The Bridge status sits right under the header**, before the ship panels, so a returning player sees it on a phone without scrolling.
- **The banner shows inside the away screen when it opens**, which needs unseen summaries. A fleet that went quiet with nothing new to report still shows the Bridge status.
- **Changing the threshold is itself a manual decision**: it's a game command the player makes, so it also wakes the fleet.
- **"3 days", not "72 h"**: thresholds over 48 hours that are whole days are written in days.

View File

@ -7,6 +7,7 @@ import { formatCredits, locationName, toLocation } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Sheet } from "../ui";
import { SleepBanner } from "./FleetSleep";
import { PilotLogbook, pilotLogbook } from "./PilotLogbook";
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
@ -46,6 +47,7 @@ export function AwaySummary() {
return (
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
<SleepBanner />
<PilotLogbook logbook={logbook} />
<ul className="mt-4 space-y-3">
{yours.map((s) => (

View File

@ -0,0 +1,77 @@
import type { PlayerState } from "@deadhead/schema";
import { formatLocalTime } from "../screens/DispatchSheet";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Panel } from "../ui";
/** A sleep threshold in words: "12 h", "24 h", "48 h", "3 days" (CR-024 §2). */
export function sleepLabel(hours: number): string {
return hours > 48 && hours % 24 === 0 ? `${hours / 24} days` : `${hours} h`;
}
/** The sleep time on the device, e.g. "Mar 4, 14:05". */
export function formatSleepTime(time: number): string {
const date = new Date(time).toLocaleDateString(undefined, { month: "short", day: "numeric" });
return `${date}, ${formatLocalTime(time)}`;
}
/**
* The fleet has gone quiet (decision 0.5.137) and it matters: some pilot is
* Independent, so there is work waiting on the player's orders.
*/
export function fleetQuiet(state: PlayerState): boolean {
return state.fleetSleep.asleep && state.pilots.some((p) => p.mode === "independent");
}
/** "Back to work": a manual decision that wakes the fleet (decision 0.5.138). */
function BackToWork() {
const wake = useGameAction("backToWork");
return (
<>
<Button size="sm" className="mt-3" disabled={wake.isPending} onClick={() => wake.mutate({})}>
<Icon name="send" className="size-4" />
Back to work
</Button>
{wake.isError && (
<p role="alert" className="mt-2 text-rose-300">
{errorMessage(wake.error)}
</p>
)}
</>
);
}
/** The "while you were away" banner: when the fleet went quiet, and why. */
export function SleepBanner() {
const state = useLoadedPlayer();
if (!fleetQuiet(state)) return null;
const { sleepsAt, afterHours } = state.fleetSleep;
return (
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
<p className="flex items-start gap-2 text-amber-100">
<Icon name="rest" className="mt-0.5 size-4 shrink-0" />
Your fleet went quiet on {formatSleepTime(sleepsAt)} after {sleepLabel(afterHours)} without
orders.
</p>
<BackToWork />
</Panel>
);
}
/** The Bridge's status while the fleet sleeps. */
export function FleetAsleep() {
const state = useLoadedPlayer();
if (!fleetQuiet(state)) return null;
return (
<Panel tone="amber" className="text-sm" aria-label="Fleet asleep">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
<Icon name="rest" className="size-4 shrink-0" />
Fleet asleep — waiting for your orders
</p>
<p className="mt-1 text-ink-300">
Independent pilots start no new jobs after {sleepLabel(state.fleetSleep.afterHours)} without
orders. Any decision wakes them.
</p>
<BackToWork />
</Panel>
);
}

View File

@ -78,6 +78,8 @@ export function independentStatus(state: PlayerState, pilot: Pilot): string | nu
return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
}
if (!pilot.assignedShipId) return "Independent: needs a ship";
// The fleet has gone quiet: nothing new until the player decides (decision 0.5.137).
if (state.fleetSleep.asleep) return "Waiting for orders";
return `Waiting for new contracts at ${locationName(pilot.locationId ?? "")}`;
}

View File

@ -1,4 +1,5 @@
import { content } from "@deadhead/content";
import { FleetAsleep } from "../components/FleetSleep";
import { DispatchOfficePanel } from "../components/IndependentPilots";
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
import { dispatchProgress } from "@deadhead/sim";
@ -93,6 +94,8 @@ export function Dashboard() {
return (
<Screen className="max-w-5xl">
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
<FleetAsleep />
<div className="grid gap-4 md:grid-cols-3">
<YourShip className="md:col-span-2" />

View File

@ -7,7 +7,9 @@ import { About } from "./AboutSettings";
import { CompanyName, NewCompany } from "./CompanySettings";
import { DevTools } from "./DevTools";
import { ExportSave, ImportSave } from "./SaveSettings";
import { useLoadedPlayer } from "../state/actions";
import { content } from "@deadhead/content";
import { sleepLabel } from "../components/FleetSleep";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
import { Button, Panel, Screen, ScreenHeader } from "../ui";
@ -29,6 +31,7 @@ export function Settings() {
<Section title="Company">
<CompanyName />
<SpendingFloor />
<SleepThreshold />
<div className="flex flex-wrap gap-2">
<ExportSave />
<ImportSave hasCompany />
@ -123,6 +126,31 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
);
}
/** "Fleet goes quiet after": the sleep threshold (decision 0.5.137), a company setting. */
function SleepThreshold() {
const state = useLoadedPlayer();
const save = useGameAction("setSleepThreshold");
if (state.tutorial) return null;
return (
<div>
<Choice<string>
label="Fleet goes quiet after"
value={String(state.fleetSleep.afterHours)}
options={content.config.fleetSleep.optionsHours.map((h) => [String(h), sleepLabel(h)])}
onChange={(value) => save.mutate({ hours: Number(value) })}
/>
<p className="mt-1 text-xs text-ink-500">
Without any orders from you for this long, Independent pilots stop taking new jobs.
</p>
{save.isError && (
<p role="alert" className="mt-1 text-sm text-rose-300">
{errorMessage(save.error)}
</p>
)}
</div>
);
}
/** A labelled radio group of a few options, styled as a segmented control. */
function Choice<T extends string>(props: {
label: string;

View File

@ -0,0 +1,172 @@
import type { Pilot, PlayerState, ResolutionSummary } 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 { sleepLabel } from "../src/components/FleetSleep";
import { independentStatus } from "../src/components/IndependentPilots";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-03T12:00:00Z");
const SLEPT = Date.parse("2100-01-02T09:30:00Z");
const rosa: Pilot = {
...samplePlayerState().pilots[0]!,
id: "p1",
name: "Rosa",
isAvatar: false,
assignedShipId: "s1",
locationId: "earth",
...pilotExtras,
mode: "independent",
};
const summary: ResolutionSummary = {
dispatchId: "d1",
shipId: "s1",
pilotId: "p1",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 100,
topUp: null,
goodId: "consumer-goods",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
credits: 600,
loanRepayment: 0,
pilotPay: 200,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: SLEPT - 60_000,
};
/** An owner with Rosa flying Independent; asleep since SLEPT, or awake. */
function owner(asleep: boolean, over: Partial<PlayerState> = {}, afterHours = 24): PlayerState {
return samplePlayerState({
tutorial: null,
bays: [{ id: "b", locationId: "earth" }],
pilots: [samplePlayerState().pilots[0]!, rosa],
ships: [
{
id: "s1",
modelId: "courier",
name: "Wren",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "p1",
},
],
dispatchOffice: { owned: true, spendingFloor: 500 },
fleetSleep: { afterHours, sleepsAt: asleep ? SLEPT : NOW + 3_600_000, asleep },
...over,
});
}
/** A fake API whose state wakes up on "Back to work". */
function sleepyApi(over: Partial<PlayerState> = {}) {
let state = owner(true, over);
const api = fakeApi({
getPlayerState: () => state,
backToWork: () => {
state = owner(false, over);
return state;
},
acknowledgeSummaries: () => ({ acknowledged: 0 }),
});
return api;
}
describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows the Bridge status while asleep, and Back to work wakes the fleet", async () => {
const api = sleepyApi();
renderApp(api, "/");
const panel = await screen.findByLabelText("Fleet asleep");
expect(panel).toHaveTextContent("Fleet asleep — waiting for your orders");
await userEvent.click(within(panel).getByRole("button", { name: "Back to work" }));
expect(api.call).toHaveBeenCalledWith("backToWork", {});
await waitFor(() => expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument());
});
it("shows nothing on the Bridge while awake", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(false) }), "/");
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
});
it("shows nothing on the Bridge when no pilot is Independent: nobody is waiting", async () => {
const manual = owner(true, {
pilots: [samplePlayerState().pilots[0]!, { ...rosa, mode: "manual" }],
});
renderApp(fakeApi({ getPlayerState: () => manual }), "/");
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
expect(screen.queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
});
it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => {
const api = sleepyApi({ summaries: [summary] });
renderApp(api, "/");
const sheet = await screen.findByRole("dialog", { name: "While you were away" });
const banner = within(sheet).getByLabelText("Fleet asleep");
expect(banner).toHaveTextContent(
/^Your fleet went quiet on .+, .+ after 24 h without orders\./,
);
await userEvent.click(within(banner).getByRole("button", { name: "Back to work" }));
await waitFor(() =>
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument(),
);
});
it("leaves the banner off the away screen when the fleet didn't go quiet", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/");
const sheet = await screen.findByRole("dialog", { name: "While you were away" });
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
});
it("says Waiting for orders for a sleeping Independent pilot", () => {
const asleep = owner(true);
expect(independentStatus(asleep, rosa)).toBe("Waiting for orders");
expect(independentStatus(owner(false), rosa)).toBe("Waiting for new contracts at Earth");
});
it("saves the threshold in Settings → Company and shows the new one", async () => {
let state = owner(false);
const api = fakeApi({
getPlayerState: () => state,
setSleepThreshold: ({ hours }) => {
state = owner(false, {}, hours);
return state;
},
});
renderApp(api, "/settings");
const group = await screen.findByRole("group", { name: "Fleet goes quiet after" });
expect(
within(group)
.getAllByRole("radio")
.map((r) => r.parentElement!.textContent),
).toEqual(["12 h", "24 h", "48 h", "3 days"]);
expect(within(group).getByRole("radio", { name: "24 h" })).toBeChecked();
await userEvent.click(within(group).getByRole("radio", { name: "3 days" }));
expect(api.call).toHaveBeenCalledWith("setSleepThreshold", { hours: 72 });
await waitFor(() => expect(within(group).getByRole("radio", { name: "3 days" })).toBeChecked());
});
it("words the thresholds in hours, then days", () => {
expect([12, 24, 48, 72].map(sleepLabel)).toEqual(["12 h", "24 h", "48 h", "3 days"]);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

View File

@ -64,6 +64,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `setSleepThreshold` | `POST /api/fleet/sleep-after` | `hours` (one of `fleetSleep.optionsHours`, else `validation-error`) | `PlayerState` | yes; a manual decision (CR-024) |
| `backToWork` | `POST /api/fleet/wake` | — | `PlayerState` (awake) | yes; a manual decision that does nothing else: wakes the fleet (CR-024) |
| `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes |
| `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no |

View File

@ -525,6 +525,6 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022
- [x] **Step 24.2 — Engine: manual decision flag.** `RouteDef.manualDecision` is required on every command in the catalog. Manual: dispatch and deadhead (`createDispatch`), buying ships, upgrades, refuelling, refuel policy, emergency fuel, leasing, hiring, assigning and unassigning, resting a pilot, skill points, the Dispatch Office, pilot mode and orders, the spending floor, the deal, renaming the company, the loan and goals. Not: reads, `acknowledgeSummaries`, `markWelcomeSeen`, export and import, new company, dev tools. The engine stamps `lastManualDecisionAt` after a flagged command succeeds; Independent pilots' trips go through the commands' functions, not the catalog, so they never do. Tests: `packages/schema/test/routes.test.ts` (every command declares it; the manual list); `packages/engine/test/fleet-sleep.test.ts` (a dispatch stamps it; viewing, a board, dismissing a summary, the welcome and an export don't; a refused command doesn't; Independent pilots' trips don't).
- [x] **Step 24.3 — Engine: sleep in live play and catch-up.** `catchUp` processes Independent pilots' decisions only before the sleep time (`sleepsAt`, in `state.ts`), alongside the 7-day cap; arrivals, rest and shop ends resolve as before, and pilots due later wait at `until`. Live play is the same catch-up run by every command and by the loop. `PlayerState.fleetSleep` (`afterHours`, `sleepsAt`, `asleep`) tells the client, which shows "Waiting for orders" from it (Step 24.5). Tests (`packages/engine/test/fleet-sleep.test.ts`): two Independent pilots left 7 days start nothing at or after 24 hours, and the trips in flight then still resolve with pay; one go equals 5-hour pieces over 3 days; a trip the player sent lands after the sleep time as normal; a 48-hour threshold moves the cutoff. The 7-day cap test now uses a 30-day threshold so only the cap stops the pilots.
- [x] **Step 24.4 — Engine: waking and "Back to work".** New command `backToWork` (`POST /api/fleet/wake`, a manual decision): it sets the last decision to now and returns the player state, awake. Any manual decision does the same, and pilots waiting at that time decide from it. Tests (`packages/engine/test/fleet-sleep.test.ts`): after two days asleep, "Back to work" brings Independent trips again, none before the moment of waking; so does buying fuel. `packages/schema/test/routes.test.ts` lists it as manual.
- [ ] Step 24.5 — Client: return banner, status, and setting
- [x] **Step 24.5 — Client: return banner, status, and setting.** `apps/web/src/components/FleetSleep.tsx`: `SleepBanner` at the top of "While you were away" ("Your fleet went quiet on [date, time] after [24 h] without orders." and Back to work) and `FleetAsleep` under the Bridge's header ("Fleet asleep — waiting for your orders", the same button), both only while `fleetSleep.asleep` and some pilot is Independent. `independentStatus` reads "Waiting for orders" while asleep (after flying, the shop and rest). Settings → Company: "Fleet goes quiet after" (12 h / 24 h / 48 h / 3 days) through the new `setSleepThreshold` command (one of the offered times; a manual decision). Tests: `apps/web/test/fleet-sleep.test.tsx` (the Bridge status asleep only, not awake or with no Independent pilot, and Back to work clears it; the banner on the away screen and its button; no banner awake; the pilot status; the setting saves 3 days and shows it checked; the labels); `packages/engine/test/fleet-sleep.test.ts` (the setting takes an offered time, wakes the fleet and refuses 36 h). Screenshots: `docs/screenshots/24-5-away-banner-{phone,desktop}.png`, `24-5-bridge-asleep-{phone,desktop}.png`, `24-5-settings-{phone,desktop}.png`.
- [ ] Step 24.6 — Client: sleep notification (native)
- [ ] Step 24.7 — Plan amendments and regression

View File

@ -74,6 +74,15 @@ export function commandTable(): CommandTable {
save.company.lastManualDecisionAt = now;
return playerState(save, now);
},
// One of the offered thresholds (decision 0.5.137); a decision, so it also wakes the fleet.
setSleepThreshold: ({ save, now }, input) => {
if (!content.config.fleetSleep.optionsHours.includes(input.hours)) {
throw fail("validation-error", "Choose one of the offered times.");
}
save.company.sleepAfterHours = input.hours;
save.company.lastManualDecisionAt = now;
return playerState(save, now);
},
markWelcomeSeen: ({ save, now }) => {
save.player.welcomeSeen = true;
return playerState(save, now);

View File

@ -229,3 +229,21 @@ describe("waking the fleet (Step 24.4, decision 0.5.138)", () => {
expect((await tripsFrom(test, wake)).length).toBeGreaterThan(0);
});
});
describe("the sleep threshold setting (Step 24.5, decision 0.5.137)", () => {
it("takes one of the offered times, counts as a decision, and refuses others", async () => {
const test = newTestApp();
await signUpOwner(test.app);
test.clock.now += HOUR;
const state = await test.app.call("setSleepThreshold", { hours: 72 });
expect(state.fleetSleep).toEqual({
afterHours: 72,
sleepsAt: test.clock.now + 72 * HOUR,
asleep: false,
});
await expect(test.app.call("setSleepThreshold", { hours: 36 })).rejects.toMatchObject({
code: "validation-error",
});
expect((await readSave(test.app)).company.sleepAfterHours).toBe(72);
});
});

View File

@ -83,6 +83,8 @@ export const pilotResponseSchema = z.object({ pilot: pilotSchema });
export const pilotModeRequestSchema = z.object({ pilotId: idSchema, mode: pilotModeSchema });
export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pilotOrdersSchema });
/** CR-021: the credits Independent pilots never spend below (decision 0.5.127). */
/** Settings → Company: real hours without orders before the fleet goes quiet (CR-024). */
export const sleepThresholdRequestSchema = z.object({ hours: z.number().positive() });
export const spendingFloorRequestSchema = z.object({ credits: z.number().int().nonnegative() });
export const dispatchOfficeResponseSchema = z.object({
company: companySchema,

View File

@ -35,6 +35,7 @@ import {
restPilotRequestSchema,
shipResponseSchema,
spendSkillPointRequestSchema,
sleepThresholdRequestSchema,
spendingFloorRequestSchema,
startUpgradeRequestSchema,
unassignPilotRequestSchema,
@ -249,6 +250,14 @@ export const routes = {
// "Back to work" wakes the fleet: it is a decision (decision 0.5.138).
manualDecision: true,
},
setSleepThreshold: {
method: "POST",
path: "/api/fleet/sleep-after",
request: sleepThresholdRequestSchema,
response: playerStateSchema,
auth: true,
manualDecision: true,
},
markWelcomeSeen: {
method: "POST",
path: "/api/welcome/seen",

View File

@ -23,6 +23,7 @@ describe("route catalog", () => {
"startUpgrade",
"refuelShip",
"setRefuelPolicy",
"setSleepThreshold",
"orderEmergencyFuel",
"spendSkillPoint",
"buyDispatchOffice",
@ -112,6 +113,7 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () =>
"setPilotMode",
"setPilotOrders",
"setRefuelPolicy",
"setSleepThreshold",
"setSpendingFloor",
"spendSkillPoint",
"startUpgrade",