Step 21.6: client: Dispatch Office and pilot controls
CI / check (push) Successful in 6m44s Details

Buy the Dispatch Office on the Bridge; set hired pilots Independent,
see what they're doing and edit their orders on Pilots; the spending
floor in Settings; an Independent badge on Fleet (CR-021).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 23:48:02 -04:00
parent 32603d5d7e
commit 956a621b12
12 changed files with 557 additions and 9 deletions

View File

@ -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. - **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. - **"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".

View File

@ -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 (
<Panel tone="cyan" className="text-sm" aria-label="Dispatch Office">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-cyan-100">
<Icon name="send" className="size-4" />
Dispatch Office
</p>
<p className="mt-1 text-ink-300">
Your hired pilots can run jobs on their own. Set them to Independent on{" "}
<Link to="/pilots" className="font-semibold text-amber-300 underline">
Pilots
</Link>
.
</p>
</Panel>
);
}
return (
<Panel tone="amber" feature className="text-sm" aria-label="Dispatch Office">
<p className="font-display font-semibold tracking-wide text-amber-100">Dispatch Office</p>
<p className="mt-1 text-ink-300">
Lets your hired pilots run jobs on their own, so the company keeps earning while you&apos;re
away.
</p>
<Button
className="mt-3"
disabled={state.company.credits < price || buy.isPending}
onClick={() => buy.mutate({})}
>
Buy Dispatch Office ({formatCredits(price)})
</Button>
{buy.isError && (
<p role="alert" className="mt-2 text-rose-300">
{errorMessage(buy.error)}
</p>
)}
</Panel>
);
}
/**
* 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 (
<div className="mt-3 rounded-lg bg-space-950/50 p-2.5 ring-1 ring-space-700">
<fieldset>
<legend className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Flies
</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
{(["manual", "independent"] as const).map((mode) => (
<label
key={mode}
className={`flex-1 px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2 ${owned ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}
>
<input
type="radio"
name={`mode-${pilot.id}`}
value={mode}
checked={pilot.mode === mode}
disabled={!owned || setMode.isPending}
onChange={() => setMode.mutate({ pilotId: pilot.id, mode })}
className="sr-only"
/>
{mode === "manual" ? "When you send them" : "Independent"}
</label>
))}
</div>
{!owned && (
<p className="mt-1 text-xs text-ink-400">
Buy a Dispatch Office on the Bridge to let {pilot.name} run jobs on their own.
</p>
)}
</fieldset>
{status && (
<p aria-label={`${pilot.name} status`} className="mt-2 text-cyan-100">
{status}
</p>
)}
{setMode.isError && (
<p role="alert" className="mt-1 text-rose-300">
{errorMessage(setMode.error)}
</p>
)}
{owned && <OrdersEditor pilot={pilot} />}
</div>
);
}
/** 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<PilotOrders>(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 (
<details className="mt-2">
<summary className="cursor-pointer font-display text-xs tracking-wider text-amber-200 uppercase">
Orders
</summary>
<form
aria-label={`Orders for ${pilot.name}`}
className="mt-2 space-y-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ pilotId: pilot.id, orders: draft });
}}
>
<fieldset>
<legend className="text-ink-400">Destinations</legend>
<label className="mt-1 flex items-center gap-2">
<input
type="checkbox"
checked={allPlaces}
onChange={(e) =>
setDraft({ ...draft, destinationIds: e.target.checked ? null : discovered })
}
/>
Anywhere you know
</label>
{!allPlaces && (
<div className="mt-1 grid grid-cols-2 gap-1">
{discovered.map((id) => (
<label key={id} className="flex items-center gap-2">
<input
type="checkbox"
checked={draft.destinationIds!.includes(id)}
onChange={(e) =>
setDraft({
...draft,
destinationIds: toggle(draft.destinationIds!, id, e.target.checked),
})
}
/>
{locationName(id)}
</label>
))}
</div>
)}
</fieldset>
<fieldset>
<legend className="text-ink-400">Cargo</legend>
<div className="mt-1 grid grid-cols-2 gap-1">
{content.cargoTypes.map((c) => (
<label key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={draft.cargoClassIds.includes(c.id)}
onChange={(e) =>
setDraft({
...draft,
cargoClassIds: toggle(draft.cargoClassIds, c.id, e.target.checked),
})
}
/>
{c.name}
</label>
))}
</div>
</fieldset>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={draft.takePriority}
onChange={(e) => setDraft({ ...draft, takePriority: e.target.checked })}
/>
Take Priority jobs
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-ink-400">Least profit per job (cr)</span>
<input
type="number"
min={0}
step={50}
inputMode="numeric"
value={draft.minProfit}
onChange={(e) =>
setDraft({
...draft,
minProfit: Math.max(0, Math.floor(Number(e.target.value) || 0)),
})
}
className={`${inputClass} w-28`}
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="text-ink-400">Home HQ</span>
<select
value={draft.homeLocationId}
onChange={(e) => setDraft({ ...draft, homeLocationId: e.target.value })}
className={inputClass}
>
{state.bays.map((b) => (
<option key={b.id} value={b.locationId}>
{locationName(b.locationId)}
</option>
))}
</select>
</label>
<Button size="sm" type="submit" disabled={save.isPending}>
Save orders
</Button>
{save.isSuccess && !save.isPending && (
<span role="status" className="ml-2 text-xs text-emerald-300">
Saved
</span>
)}
{save.isError && (
<p role="alert" className="text-rose-300">
{errorMessage(save.error)}
</p>
)}
</form>
</details>
);
}
/** 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 (
<form
className="flex flex-wrap items-end justify-between gap-2 text-sm"
onSubmit={(e) => {
e.preventDefault();
if (valid) save.mutate({ credits: value });
}}
>
<label className="flex-1">
<span className="text-ink-400">Pilots&apos; spending floor (cr)</span>
<span className="block text-xs text-ink-500">
Independent pilots never buy fuel that would leave the company below this.
</span>
<input
type="number"
min={0}
step={100}
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`${inputClass} mt-1 w-32`}
/>
</label>
<Button size="sm" type="submit" disabled={!valid || save.isPending}>
Save floor
</Button>
{save.isError && (
<p role="alert" className="w-full text-rose-300">
{errorMessage(save.error)}
</p>
)}
</form>
);
}
/** 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 (
<Pill tone="cyan" icon="send">
Independent
</Pill>
);
}

View File

@ -1,4 +1,5 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
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";
import { Link } from "react-router"; import { Link } from "react-router";
@ -216,6 +217,7 @@ export function Dashboard() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<DispatchOfficePanel />
{!state.tutorial && <GoalsCard />} {!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />} {!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div> </div>

View File

@ -1,5 +1,6 @@
import { content } from "@deadhead/content"; 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 { import {
dispatchProgress, dispatchProgress,
tripFuel, tripFuel,
@ -69,6 +70,7 @@ export function Fleet() {
ship={ship} ship={ship}
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)} dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null} pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
pilot={state.pilots.find((p) => p.id === ship.pilotId)}
discoveredIds={discoveredIds} discoveredIds={discoveredIds}
onDeadhead={() => setDeadheading(ship)} onDeadhead={() => setDeadheading(ship)}
onUpgrades={() => setUpgrading(ship.id)} onUpgrades={() => setUpgrading(ship.id)}
@ -88,6 +90,7 @@ function ShipRow(props: {
ship: Ship; ship: Ship;
dispatch: Dispatch | undefined; dispatch: Dispatch | undefined;
pilotName: string | null; pilotName: string | null;
pilot: Pilot | undefined;
discoveredIds: ReadonlySet<string>; discoveredIds: ReadonlySet<string>;
onDeadhead: () => void; onDeadhead: () => void;
onUpgrades: () => void; onUpgrades: () => void;
@ -127,14 +130,18 @@ function ShipRow(props: {
)} )}
</div> </div>
<div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div> <div className="text-xs text-ink-400">{shipModelName(ship.modelId)}</div>
<div className="mt-1 text-sm text-ink-200"> <div className="mt-1 flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-ink-200">
{dispatch <span>
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}` {dispatch
: `At ${locationName(ship.locationId ?? "")}`} ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
{" · "} : `At ${locationName(ship.locationId ?? "")}`}
<span className={pilotName ? "text-ink-300" : "text-amber-300"}> {" · "}
{pilotName ?? "No pilot"} <span className={pilotName ? "text-ink-300" : "text-amber-300"}>
{pilotName ?? "No pilot"}
</span>
</span> </span>
{/* A pilot who flies on their own (CR-021). */}
<IndependentBadge pilot={props.pilot} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { PilotIndependence } from "../components/IndependentPilots";
import type { Pilot } from "@deadhead/schema"; import type { Pilot } from "@deadhead/schema";
import { useState } from "react"; import { useState } from "react";
import { MS_PER_HOUR } from "@deadhead/sim"; import { MS_PER_HOUR } from "@deadhead/sim";
@ -216,6 +217,8 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
)} )}
</div> </div>
<PilotIndependence pilot={pilot} />
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} /> <Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && ( {!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (

View File

@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SpendingFloor } from "../components/IndependentPilots";
import { resetMapView } from "../map/camera"; import { resetMapView } from "../map/camera";
import { About } from "./AboutSettings"; import { About } from "./AboutSettings";
import { CompanyName, NewCompany } from "./CompanySettings"; import { CompanyName, NewCompany } from "./CompanySettings";
@ -24,6 +25,7 @@ export function Settings() {
<Section title="Company"> <Section title="Company">
<CompanyName /> <CompanyName />
<SpendingFloor />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<ExportSave /> <ExportSave />
<ImportSave hasCompany /> <ImportSave hasCompany />

View File

@ -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> = {}): 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> = {}): 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 });
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@ -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.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.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. - [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.7 — Client: board and logbook
- [ ] Step 21.8 — Balance - [ ] Step 21.8 — Balance
- [ ] Step 21.9 — Plan amendments and regression - [ ] Step 21.9 — Plan amendments and regression