diff --git a/DECISIONS.md b/DECISIONS.md
index 3a454e6..7308224 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1713,3 +1713,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Two sessions recorded a CR-020 the same evening**: the Bridge dashboard and HQs tab (recorded 22:19, built as Phase 20, decisions 0.5.118–0.5.121), and Independent Pilots (recorded 22:59 in another session working in the same checkout). The first one keeps CR-020. Independent Pilots becomes **CR-021 / Phase 21**, decisions **0.5.122–0.5.130**. Its first five steps had already been committed and pushed as "Step 20.1"–"Step 20.5"; they are Steps 21.1–21.5 in the plan, progress and here, and the code and test comments now say CR-021. History isn't rewritten.
- **"The HQ screen" in CR-021 is the Bridge** (the old HQ tab, now the Dashboard). The new HQs tab is information only (decision 0.5.121), so the Dispatch Office is bought on the Bridge.
+
+## Step 21.6
+
+- **The Dispatch Office lives on the Bridge** (the old HQ screen, see "CR-021 renumbered"), in the right-hand column above Goals. It's hidden during the tutorial.
+- **The mode toggle reads "When you send them / Independent"** rather than "Manual / Independent", so it doesn't sound like the ships' Manual refuel setting.
+- **Orders are edited as a draft and saved together** with "Save orders": several checkboxes changing one at a time would otherwise each fire a command.
+- **"Anywhere you know" is `destinationIds: null`**, so places discovered later are included. Unticking it starts from every place known now.
+- **The Fleet badge sits on the pilot line**: beside the status pill on a phone it truncated the ship's name.
+- **The spending floor's button is "Save floor"**, distinct from the company name's "Save".
diff --git a/apps/web/src/components/IndependentPilots.tsx b/apps/web/src/components/IndependentPilots.tsx
new file mode 100644
index 0000000..a0611f5
--- /dev/null
+++ b/apps/web/src/components/IndependentPilots.tsx
@@ -0,0 +1,319 @@
+import { content } from "@deadhead/content";
+import type { Pilot, PilotOrders, PlayerState } from "@deadhead/schema";
+import { useState } from "react";
+import { Link } from "react-router";
+import { formatCredits, locationName } from "../format";
+import { formatLocalTime } from "../screens/DispatchSheet";
+import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
+import { Button, Icon, Panel, Pill } from "../ui";
+import { goodName } from "./Goods";
+
+const inputClass =
+ "rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none";
+
+/** The one-time Dispatch Office on the Bridge (decision 0.5.123). */
+export function DispatchOfficePanel() {
+ const state = useLoadedPlayer();
+ const buy = useGameAction("buyDispatchOffice");
+ if (state.tutorial) return null;
+ const price = content.config.independentPilots.dispatchOfficePrice;
+
+ if (state.dispatchOffice.owned) {
+ return (
+
+
+
+ Dispatch Office
+
+
+ Your hired pilots can run jobs on their own. Set them to Independent on{" "}
+
+ Pilots
+
+ .
+
+
+ );
+ }
+ return (
+
+
Dispatch Office
+
+ Lets your hired pilots run jobs on their own, so the company keeps earning while you're
+ away.
+
+
+ {buy.isError && (
+
+ {errorMessage(buy.error)}
+
+ )}
+
+ );
+}
+
+/**
+ * What an Independent pilot is doing (CR-021 §2): flying a job, resting, or
+ * waiting for new contracts; null for a Manual pilot.
+ */
+export function independentStatus(state: PlayerState, pilot: Pilot): string | null {
+ if (pilot.mode !== "independent") return null;
+ const flight = state.activeDispatches.find((d) => d.pilotId === pilot.id);
+ if (flight) {
+ const where = locationName(flight.destinationId);
+ return flight.contract
+ ? `Independent: flying ${goodName(flight.contract)} to ${where}`
+ : `Independent: flying empty to ${where}`;
+ }
+ if (pilot.resting && pilot.restEndsAt !== null) {
+ return `Resting until ${formatLocalTime(pilot.restEndsAt)}`;
+ }
+ if (!pilot.assignedShipId) return "Independent: needs a ship";
+ return `Waiting for new contracts at ${locationName(pilot.locationId ?? "")}`;
+}
+
+/** Manual or Independent, the status line, and the orders (decisions 0.5.122–0.5.124). */
+export function PilotIndependence({ pilot }: { pilot: Pilot }) {
+ const state = useLoadedPlayer();
+ const setMode = useGameAction("setPilotMode");
+ // The player is an owner-operator: never Independent (decision 0.5.122).
+ if (pilot.isAvatar || state.tutorial) return null;
+ const owned = state.dispatchOffice.owned;
+ const status = independentStatus(state, pilot);
+
+ return (
+
+
+ {status && (
+
+ {status}
+
+ )}
+ {setMode.isError && (
+
+ {errorMessage(setMode.error)}
+
+ )}
+ {owned && }
+
+ );
+}
+
+/** A pilot's orders (decision 0.5.124), edited as a draft and saved together. */
+function OrdersEditor({ pilot }: { pilot: Pilot }) {
+ const state = useLoadedPlayer();
+ const save = useGameAction("setPilotOrders");
+ const [draft, setDraft] = useState(pilot.orders);
+ const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
+ const allPlaces = draft.destinationIds === null;
+ const toggle = (list: readonly string[], id: string, on: boolean) =>
+ on ? [...new Set([...list, id])] : list.filter((x) => x !== id);
+
+ return (
+
+
+ Orders
+
+
+
+ );
+}
+
+/** Settings → Company: the credits pilots never spend the company below (decision 0.5.127). */
+export function SpendingFloor() {
+ const state = useLoadedPlayer();
+ const save = useGameAction("setSpendingFloor");
+ const [draft, setDraft] = useState(String(state.dispatchOffice.spendingFloor));
+ if (state.tutorial) return null;
+ const value = Math.floor(Number(draft));
+ const valid = draft.trim() !== "" && Number.isFinite(value) && value >= 0;
+ return (
+
+ );
+}
+
+/** The Fleet card's badge for a ship whose pilot flies on their own. */
+export function IndependentBadge({ pilot }: { pilot: Pilot | undefined }) {
+ if (pilot?.mode !== "independent") return null;
+ return (
+
+ Independent
+
+ );
+}
diff --git a/apps/web/src/screens/Dashboard.tsx b/apps/web/src/screens/Dashboard.tsx
index a3751f2..a66107b 100644
--- a/apps/web/src/screens/Dashboard.tsx
+++ b/apps/web/src/screens/Dashboard.tsx
@@ -1,4 +1,5 @@
import { content } from "@deadhead/content";
+import { DispatchOfficePanel } from "../components/IndependentPilots";
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
import { dispatchProgress } from "@deadhead/sim";
import { Link } from "react-router";
@@ -216,6 +217,7 @@ export function Dashboard() {
+
+ {dispatch
+ ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
+ : `At ${locationName(ship.locationId ?? "")}`}
+ {" · "}
+
+ {pilotName ?? "No pilot"}
+
+ {/* A pilot who flies on their own (CR-021). */}
+
diff --git a/apps/web/src/screens/Pilots.tsx b/apps/web/src/screens/Pilots.tsx
index c391b77..9f81ed8 100644
--- a/apps/web/src/screens/Pilots.tsx
+++ b/apps/web/src/screens/Pilots.tsx
@@ -1,4 +1,5 @@
import { content } from "@deadhead/content";
+import { PilotIndependence } from "../components/IndependentPilots";
import type { Pilot } from "@deadhead/schema";
import { useState } from "react";
import { MS_PER_HOUR } from "@deadhead/sim";
@@ -216,6 +217,8 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
)}
+
+
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx
index d28614d..f105bfb 100644
--- a/apps/web/src/screens/Settings.tsx
+++ b/apps/web/src/screens/Settings.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { SpendingFloor } from "../components/IndependentPilots";
import { resetMapView } from "../map/camera";
import { About } from "./AboutSettings";
import { CompanyName, NewCompany } from "./CompanySettings";
@@ -24,6 +25,7 @@ export function Settings() {
+
diff --git a/apps/web/test/independent-controls.test.tsx b/apps/web/test/independent-controls.test.tsx
new file mode 100644
index 0000000..9ef8d1b
--- /dev/null
+++ b/apps/web/test/independent-controls.test.tsx
@@ -0,0 +1,200 @@
+import { content } from "@deadhead/content";
+import type { Dispatch, Pilot, PlayerState, Ship } from "@deadhead/schema";
+import { screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { independentStatus } from "../src/components/IndependentPilots";
+import { formatLocalTime } from "../src/screens/DispatchSheet";
+import { pilotExtras } from "./fixtures";
+import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
+
+const NOW = Date.now();
+
+const ship = (id: string, pilotId: string | null): Ship => ({
+ id,
+ modelId: "courier",
+ name: `Ship ${id}`,
+ status: "idle",
+ locationId: "earth",
+ dispatchId: null,
+ lastArrivalAt: 0,
+ ...shipExtras("courier"),
+ pilotId,
+});
+
+const hired = (over: Partial = {}): Pilot => ({
+ id: "p1",
+ name: "Ada Vance",
+ isAvatar: false,
+ isVeteran: false,
+ xp: 0,
+ level: 0,
+ skills: {},
+ skillPointsAvailable: 0,
+ assignedShipId: "s1",
+ status: "idle",
+ locationId: "earth",
+ dutyHours: 0,
+ dutyLimit: 12,
+ resting: false,
+ restEndsAt: null,
+ ...pilotExtras,
+ ...over,
+});
+
+function owner(over: Partial = {}): PlayerState {
+ const base = samplePlayerState({ tutorial: null });
+ return {
+ ...base,
+ company: { ...base.company, credits: 100_000 },
+ bays: [{ id: "b1", locationId: "earth" }],
+ ships: [ship("s0", "avatar"), ship("s1", "p1")],
+ pilots: [{ ...base.pilots[0]!, assignedShipId: "s0", locationId: "earth" }, hired()],
+ ...over,
+ };
+}
+
+const ownedOffice = { dispatchOffice: { owned: true, spendingFloor: 500 } };
+
+describe("Dispatch Office and pilot controls (Step 21.6, decisions 0.5.122–0.5.124)", () => {
+ it("disables Independent with a reason before the Dispatch Office, and hides it for you", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner() }), "/pilots");
+ const card = (await screen.findByText("Ada Vance")).closest("li")!;
+ expect(within(card).getByRole("radio", { name: "Independent" })).toBeDisabled();
+ expect(card).toHaveTextContent("Buy a Dispatch Office on the Bridge");
+ const yours = screen.getByText("Captain").closest("li")!;
+ expect(within(yours).queryByRole("radio", { name: "Independent" })).not.toBeInTheDocument();
+ });
+
+ it("sets a pilot Independent once the office is bought", async () => {
+ const api = fakeApi({
+ getPlayerState: () => owner(ownedOffice),
+ setPilotMode: ({ pilotId, mode }) => ({ pilot: hired({ id: pilotId, mode }) }),
+ });
+ renderApp(api, "/pilots");
+ const card = (await screen.findByText("Ada Vance")).closest("li")!;
+ await userEvent.click(within(card).getByRole("radio", { name: "Independent" }));
+ expect(api.call).toHaveBeenCalledWith("setPilotMode", { pilotId: "p1", mode: "independent" });
+ });
+
+ it("saves edited orders through the engine", async () => {
+ const api = fakeApi({
+ getPlayerState: () => owner(ownedOffice),
+ setPilotOrders: ({ pilotId, orders }) => ({ pilot: hired({ id: pilotId, orders }) }),
+ });
+ renderApp(api, "/pilots");
+ const card = (await screen.findByText("Ada Vance")).closest("li")!;
+ await userEvent.click(within(card).getByText("Orders"));
+ const form = within(card).getByRole("form", { name: "Orders for Ada Vance" });
+ await userEvent.click(within(form).getByRole("checkbox", { name: "Take Priority jobs" }));
+ await userEvent.click(within(form).getByRole("checkbox", { name: "Anywhere you know" }));
+ await userEvent.click(within(form).getByRole("checkbox", { name: "Earth" }));
+ await userEvent.clear(within(form).getByRole("spinbutton"));
+ await userEvent.type(within(form).getByRole("spinbutton"), "300");
+ await userEvent.click(within(form).getByRole("button", { name: "Save orders" }));
+ expect(api.call).toHaveBeenCalledWith("setPilotOrders", {
+ pilotId: "p1",
+ orders: {
+ ...pilotExtras.orders,
+ destinationIds: ["moon"],
+ takePriority: false,
+ minProfit: 300,
+ },
+ });
+ });
+
+ it("describes what an Independent pilot is doing", () => {
+ const flight: Dispatch = {
+ id: "d1",
+ shipId: "s1",
+ pilotId: "p1",
+ contract: {
+ id: "c1",
+ originId: "earth",
+ destinationId: "moon",
+ cargoTypeId: "high-value",
+ goodId: "medical-supplies",
+ shipper: "Meridian Health",
+ priority: false,
+ deliveryWindowGameHours: null,
+ tier: 1,
+ massTonnes: 6,
+ payout: 3_000,
+ requiredSkill: { skillId: "expensive-cargo", level: 1 },
+ requiredLongHaulLevel: 0,
+ offeredAt: NOW,
+ expiresAt: NOW + 600_000,
+ firstContact: false,
+ },
+ originId: "earth",
+ destinationId: "moon",
+ tutorial: false,
+ departureAt: NOW,
+ arrivalAt: NOW + 120_000,
+ distanceMillionKm: 0.38,
+ fuelCost: 50,
+ fuelBurnedFu: 5,
+ expectedPayout: 3_000,
+ seed: 1,
+ status: "in-flight",
+ resolvedAt: null,
+ };
+ const independent = hired({ mode: "independent" });
+ expect(
+ independentStatus(owner({ activeDispatches: [flight] }), {
+ ...independent,
+ status: "in-flight",
+ locationId: null,
+ }),
+ ).toBe("Independent: flying Medical Supplies to Moon");
+ expect(independentStatus(owner(), { ...independent, locationId: "mars" })).toBe(
+ "Waiting for new contracts at Mars",
+ );
+ const wakes = NOW + 30 * 60_000;
+ expect(independentStatus(owner(), { ...independent, resting: true, restEndsAt: wakes })).toBe(
+ `Resting until ${formatLocalTime(wakes)}`,
+ );
+ expect(independentStatus(owner(), hired())).toBeNull();
+ });
+
+ it("shows the Independent badge only on ships crewed by Independent pilots", async () => {
+ const state = owner({
+ pilots: [{ ...owner().pilots[0]! }, hired({ mode: "independent" })],
+ });
+ renderApp(fakeApi({ getPlayerState: () => state }), "/fleet");
+ const theirs = (await screen.findByText("Ship s1")).closest("li")!;
+ const yours = screen.getByText("Ship s0").closest("li")!;
+ expect(within(theirs).getByText("Independent")).toBeInTheDocument();
+ expect(within(yours).queryByText("Independent")).not.toBeInTheDocument();
+ });
+
+ it("sells the Dispatch Office on the Bridge and sets the spending floor in Settings", async () => {
+ const api = fakeApi({
+ getPlayerState: () => owner(),
+ buyDispatchOffice: () => ({
+ company: owner().company,
+ dispatchOffice: { owned: true, spendingFloor: 500 },
+ }),
+ setSpendingFloor: ({ credits }) => ({
+ company: owner().company,
+ dispatchOffice: { owned: false, spendingFloor: credits },
+ }),
+ });
+ const { unmount } = renderApp(api, "/");
+ const office = await screen.findByLabelText("Dispatch Office");
+ expect(office).toHaveTextContent("Lets your hired pilots run jobs on their own");
+ const price = content.config.independentPilots.dispatchOfficePrice.toLocaleString("en-US");
+ await userEvent.click(
+ within(office).getByRole("button", { name: `Buy Dispatch Office (${price} cr)` }),
+ );
+ expect(api.call).toHaveBeenCalledWith("buyDispatchOffice", {});
+ unmount();
+
+ renderApp(api, "/settings");
+ const floor = await screen.findByRole("spinbutton");
+ await userEvent.clear(floor);
+ await userEvent.type(floor, "2000");
+ await userEvent.click(screen.getByRole("button", { name: "Save floor" }));
+ expect(api.call).toHaveBeenCalledWith("setSpendingFloor", { credits: 2000 });
+ });
+});
diff --git a/docs/screenshots/21-6-dispatch-office-desktop.png b/docs/screenshots/21-6-dispatch-office-desktop.png
new file mode 100644
index 0000000..4de443c
Binary files /dev/null and b/docs/screenshots/21-6-dispatch-office-desktop.png differ
diff --git a/docs/screenshots/21-6-dispatch-office-owned-phone.png b/docs/screenshots/21-6-dispatch-office-owned-phone.png
new file mode 100644
index 0000000..21c98ce
Binary files /dev/null and b/docs/screenshots/21-6-dispatch-office-owned-phone.png differ
diff --git a/docs/screenshots/21-6-fleet-badge-phone.png b/docs/screenshots/21-6-fleet-badge-phone.png
new file mode 100644
index 0000000..acbd69f
Binary files /dev/null and b/docs/screenshots/21-6-fleet-badge-phone.png differ
diff --git a/docs/screenshots/21-6-pilot-orders-phone.png b/docs/screenshots/21-6-pilot-orders-phone.png
new file mode 100644
index 0000000..9ac52cf
Binary files /dev/null and b/docs/screenshots/21-6-pilot-orders-phone.png differ
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index ad49e65..57466d8 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -467,7 +467,13 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
- [x] **Step 21.3 — Engine: Dispatch Office, modes, orders.** Save version 5: pilots get `mode` (manual/independent) and `orders` (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets `dispatchOffice` and `npcSpendingFloor`. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: `Pilot.mode` and `Pilot.orders`; `PlayerState.dispatchOffice` holds `owned` and `spendingFloor`. Commands (`packages/engine/src/game/independent.ts`): `buyDispatchOffice` (after the tutorial, once, for its price), `setPilotMode` (Independent needs the office and is never the avatar), `setPilotOrders` (checked against locations, cargo classes and the player's bays), and `setSpendingFloor`. New error codes: `dispatch-office-required`, `dispatch-office-owned`, `avatar-not-independent`, `unknown-location`, `unknown-cargo-class`. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (`packages/engine/test/independent-commands.test.ts`): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. `packages/schema/test/save.test.ts`: 4 → 5 as above. Web and schema fixtures gained the new fields (`pilotExtras` in `apps/web/test/fixtures.ts`).
- [x] **Step 21.4 — Engine: event-queue catch-up.** `catchUp` in `packages/engine/src/game/catchup.ts` (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's `resolveDue` now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' `nextDecisionAt` and `emptyPeriods`, and dispatches' `independent` flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (`packages/engine/test/catch-up.test.ts`): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.
- [x] **Step 21.5 — Engine: live play.** While the app is open, the resolution loop's pass is the catch-up of Step 21.4, so pilots decide every few seconds. `ApiClient.setViewedContract` (the engine client calls `GameEngine.setViewedContract`) is set by the dispatch sheet while it's open and cleared when it closes; catch-up passes it to the sim, which skips that contract. Tests (`packages/engine/test/live-play.test.ts`): with the best contract open a pilot takes the next one; after it closes, another pilot takes it; the loop's tick makes an Independent pilot's decision and refreshes the screen. `apps/web/test/viewed-contract.test.tsx`: opening the dispatch sheet reports its contract, and Cancel clears it.
-- [ ] Step 21.6 — Client: Dispatch Office and pilot controls
+- [x] **Step 21.6 — Client: Dispatch Office and pilot controls.** `apps/web/src/components/IndependentPilots.tsx`:
+ - **`DispatchOfficePanel`** on the Bridge: what it does and "Buy Dispatch Office (36,000 cr)"; once owned, a pointer to Pilots.
+ - **`PilotIndependence`** on each hired pilot's card (none for the avatar): "Flies: When you send them / Independent", disabled before the office with "Buy a Dispatch Office on the Bridge…"; the status line from `independentStatus` ("Independent: flying Medical Supplies to Moon", "Waiting for new contracts at Mars", "Resting until 14:20"); and an **Orders** editor (anywhere you know or chosen places, cargo classes, Priority, least profit, home HQ), saved together.
+ - **`SpendingFloor`** in Settings → Company.
+ - **`IndependentBadge`** on Fleet cards, beside the pilot's name.
+
+ Screenshots: `docs/screenshots/21-6-dispatch-office-desktop.png`, `21-6-dispatch-office-owned-phone.png`, `21-6-pilot-orders-phone.png`, `21-6-fleet-badge-phone.png`. Tests (`apps/web/test/independent-controls.test.tsx`): the toggle disabled with its reason before the office and absent for the avatar; Independent sent to the engine once bought; edited orders saved; each status line; the badge only on an Independent crew; buying the office on the Bridge and saving the spending floor.
- [ ] Step 21.7 — Client: board and logbook
- [ ] Step 21.8 — Balance
- [ ] Step 21.9 — Plan amendments and regression