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 ( +
+
+ + Flies + +
+ {(["manual", "independent"] as const).map((mode) => ( + + ))} +
+ {!owned && ( +

+ Buy a Dispatch Office on the Bridge to let {pilot.name} run jobs on their own. +

+ )} +
+ {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 + +
{ + e.preventDefault(); + save.mutate({ pilotId: pilot.id, orders: draft }); + }} + > +
+ Destinations + + {!allPlaces && ( +
+ {discovered.map((id) => ( + + ))} +
+ )} +
+
+ Cargo +
+ {content.cargoTypes.map((c) => ( + + ))} +
+
+ + + + + {save.isSuccess && !save.isPending && ( + + Saved + + )} + {save.isError && ( +

+ {errorMessage(save.error)} +

+ )} +
+
+ ); +} + +/** 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 ( +
{ + e.preventDefault(); + if (valid) save.mutate({ credits: value }); + }} + > + + + {save.isError && ( +

+ {errorMessage(save.error)} +

+ )} +
+ ); +} + +/** 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() {
+ {!state.tutorial && } {!state.tutorial && state.company.loanBalance > 0 && }
diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index f76f927..e045087 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -1,5 +1,6 @@ import { content } from "@deadhead/content"; -import type { Dispatch, Ship } from "@deadhead/schema"; +import { IndependentBadge } from "../components/IndependentPilots"; +import type { Dispatch, Pilot, Ship } from "@deadhead/schema"; import { dispatchProgress, tripFuel, @@ -69,6 +70,7 @@ export function Fleet() { ship={ship} dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)} pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null} + pilot={state.pilots.find((p) => p.id === ship.pilotId)} discoveredIds={discoveredIds} onDeadhead={() => setDeadheading(ship)} onUpgrades={() => setUpgrading(ship.id)} @@ -88,6 +90,7 @@ function ShipRow(props: { ship: Ship; dispatch: Dispatch | undefined; pilotName: string | null; + pilot: Pilot | undefined; discoveredIds: ReadonlySet; onDeadhead: () => void; onUpgrades: () => void; @@ -127,14 +130,18 @@ function ShipRow(props: { )}
{shipModelName(ship.modelId)}
-
- {dispatch - ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}` - : `At ${locationName(ship.locationId ?? "")}`} - {" · "} - - {pilotName ?? "No pilot"} +
+ + {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