Step 24.5: client: return banner, status, and setting
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
08015fe97c
commit
8fb8f1ad09
|
|
@ -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.
|
- **"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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { formatCredits, locationName, toLocation } from "../format";
|
||||||
import { formatLocalTime } from "../screens/DispatchSheet";
|
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { Button, Icon, Sheet } from "../ui";
|
import { Button, Icon, Sheet } from "../ui";
|
||||||
|
import { SleepBanner } from "./FleetSleep";
|
||||||
import { PilotLogbook, pilotLogbook } from "./PilotLogbook";
|
import { PilotLogbook, pilotLogbook } from "./PilotLogbook";
|
||||||
|
|
||||||
/** "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). */
|
||||||
|
|
@ -46,6 +47,7 @@ export function AwaySummary() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
|
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
|
||||||
|
<SleepBanner />
|
||||||
<PilotLogbook logbook={logbook} />
|
<PilotLogbook logbook={logbook} />
|
||||||
<ul className="mt-4 space-y-3">
|
<ul className="mt-4 space-y-3">
|
||||||
{yours.map((s) => (
|
{yours.map((s) => (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,8 @@ export function independentStatus(state: PlayerState, pilot: Pilot): string | nu
|
||||||
return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
|
return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
|
||||||
}
|
}
|
||||||
if (!pilot.assignedShipId) return "Independent: needs a ship";
|
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 ?? "")}`;
|
return `Waiting for new contracts at ${locationName(pilot.locationId ?? "")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
|
import { FleetAsleep } from "../components/FleetSleep";
|
||||||
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
import { DispatchOfficePanel } from "../components/IndependentPilots";
|
||||||
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
|
||||||
import { dispatchProgress } from "@deadhead/sim";
|
import { dispatchProgress } from "@deadhead/sim";
|
||||||
|
|
@ -93,6 +94,8 @@ export function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<Screen className="max-w-5xl">
|
<Screen className="max-w-5xl">
|
||||||
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
|
<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">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<YourShip className="md:col-span-2" />
|
<YourShip className="md:col-span-2" />
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import { About } from "./AboutSettings";
|
||||||
import { CompanyName, NewCompany } from "./CompanySettings";
|
import { CompanyName, NewCompany } from "./CompanySettings";
|
||||||
import { DevTools } from "./DevTools";
|
import { DevTools } from "./DevTools";
|
||||||
import { ExportSave, ImportSave } from "./SaveSettings";
|
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 { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
||||||
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
||||||
|
|
||||||
|
|
@ -29,6 +31,7 @@ export function Settings() {
|
||||||
<Section title="Company">
|
<Section title="Company">
|
||||||
<CompanyName />
|
<CompanyName />
|
||||||
<SpendingFloor />
|
<SpendingFloor />
|
||||||
|
<SleepThreshold />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ExportSave />
|
<ExportSave />
|
||||||
<ImportSave hasCompany />
|
<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. */
|
/** A labelled radio group of a few options, styled as a segmented control. */
|
||||||
function Choice<T extends string>(props: {
|
function Choice<T extends string>(props: {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no |
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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.6 — Client: sleep notification (native)
|
||||||
- [ ] Step 24.7 — Plan amendments and regression
|
- [ ] Step 24.7 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,15 @@ export function commandTable(): CommandTable {
|
||||||
save.company.lastManualDecisionAt = now;
|
save.company.lastManualDecisionAt = now;
|
||||||
return playerState(save, 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 }) => {
|
markWelcomeSeen: ({ save, now }) => {
|
||||||
save.player.welcomeSeen = true;
|
save.player.welcomeSeen = true;
|
||||||
return playerState(save, now);
|
return playerState(save, now);
|
||||||
|
|
|
||||||
|
|
@ -229,3 +229,21 @@ describe("waking the fleet (Step 24.4, decision 0.5.138)", () => {
|
||||||
expect((await tripsFrom(test, wake)).length).toBeGreaterThan(0);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ export const pilotResponseSchema = z.object({ pilot: pilotSchema });
|
||||||
export const pilotModeRequestSchema = z.object({ pilotId: idSchema, mode: pilotModeSchema });
|
export const pilotModeRequestSchema = z.object({ pilotId: idSchema, mode: pilotModeSchema });
|
||||||
export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pilotOrdersSchema });
|
export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pilotOrdersSchema });
|
||||||
/** CR-021: the credits Independent pilots never spend below (decision 0.5.127). */
|
/** 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 spendingFloorRequestSchema = z.object({ credits: z.number().int().nonnegative() });
|
||||||
export const dispatchOfficeResponseSchema = z.object({
|
export const dispatchOfficeResponseSchema = z.object({
|
||||||
company: companySchema,
|
company: companySchema,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
restPilotRequestSchema,
|
restPilotRequestSchema,
|
||||||
shipResponseSchema,
|
shipResponseSchema,
|
||||||
spendSkillPointRequestSchema,
|
spendSkillPointRequestSchema,
|
||||||
|
sleepThresholdRequestSchema,
|
||||||
spendingFloorRequestSchema,
|
spendingFloorRequestSchema,
|
||||||
startUpgradeRequestSchema,
|
startUpgradeRequestSchema,
|
||||||
unassignPilotRequestSchema,
|
unassignPilotRequestSchema,
|
||||||
|
|
@ -249,6 +250,14 @@ export const routes = {
|
||||||
// "Back to work" wakes the fleet: it is a decision (decision 0.5.138).
|
// "Back to work" wakes the fleet: it is a decision (decision 0.5.138).
|
||||||
manualDecision: true,
|
manualDecision: true,
|
||||||
},
|
},
|
||||||
|
setSleepThreshold: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/fleet/sleep-after",
|
||||||
|
request: sleepThresholdRequestSchema,
|
||||||
|
response: playerStateSchema,
|
||||||
|
auth: true,
|
||||||
|
manualDecision: true,
|
||||||
|
},
|
||||||
markWelcomeSeen: {
|
markWelcomeSeen: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/welcome/seen",
|
path: "/api/welcome/seen",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ describe("route catalog", () => {
|
||||||
"startUpgrade",
|
"startUpgrade",
|
||||||
"refuelShip",
|
"refuelShip",
|
||||||
"setRefuelPolicy",
|
"setRefuelPolicy",
|
||||||
|
"setSleepThreshold",
|
||||||
"orderEmergencyFuel",
|
"orderEmergencyFuel",
|
||||||
"spendSkillPoint",
|
"spendSkillPoint",
|
||||||
"buyDispatchOffice",
|
"buyDispatchOffice",
|
||||||
|
|
@ -112,6 +113,7 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () =>
|
||||||
"setPilotMode",
|
"setPilotMode",
|
||||||
"setPilotOrders",
|
"setPilotOrders",
|
||||||
"setRefuelPolicy",
|
"setRefuelPolicy",
|
||||||
|
"setSleepThreshold",
|
||||||
"setSpendingFloor",
|
"setSpendingFloor",
|
||||||
"spendSkillPoint",
|
"spendSkillPoint",
|
||||||
"startUpgrade",
|
"startUpgrade",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue