diff --git a/DECISIONS.md b/DECISIONS.md
index 7ebb3cc..aa9035e 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -431,3 +431,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Reposition is offered only for idle, crewed ships at one of the player's bays, when another bay exists.** The ship's assigned pilot flies it. Other cases do not show the button, rather than showing a button that always fails.
- **Flight progress comes from sim `dispatchProgress`** with the synced clock, updating every second.
+
+## Step 9.7
+
+- **No "unassign" button yet.** The plan's Pilots-screen actions are hire, assign and spend skill point; the endpoint exists for a later UI. Hired pilots start unassigned, and the avatar is unassigned when the tutorial ends, so assignment always has a path.
+- **The avatar shows "Flying the company loaner" during the tutorial** and has no assign control (decision 0.5.23).
+- **XP progress** is shown as XP gained since the current level's threshold over the gap to the next threshold; at max level it shows total XP.
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 8c4a888..dbd1250 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -9,6 +9,7 @@ import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet";
import { HQ } from "./screens/HQ";
+import { Pilots } from "./screens/Pilots";
import { Placeholder } from "./screens/Placeholder";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player";
@@ -18,6 +19,7 @@ const SCREEN_COMPONENTS: Partial ,
"/contracts": ,
"/fleet": ,
+ "/pilots": ,
};
/** Syncs the clock with the server once, then ticks it every second. */
diff --git a/apps/web/src/screens/Pilots.tsx b/apps/web/src/screens/Pilots.tsx
new file mode 100644
index 0000000..d63cd20
--- /dev/null
+++ b/apps/web/src/screens/Pilots.tsx
@@ -0,0 +1,200 @@
+import { content } from "@deadhead/content";
+import type { Pilot } from "@deadhead/schema";
+import { useState } from "react";
+import { formatCredits, locationName, shipModelName } from "../format";
+import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
+
+/** XP needed for the pilot's current and next level, or null at max level. */
+export function levelProgress(xp: number, level: number) {
+ const thresholds = content.config.pilotLevelXpThresholds;
+ const next = thresholds[level];
+ if (next === undefined) return null;
+ const previous = level === 0 ? 0 : thresholds[level - 1]!;
+ return { xp: xp - previous, needed: next - previous };
+}
+
+/** Pilots: levels, skills, assignment; hire, assign and spend skill points (Step 9.7). */
+export function Pilots() {
+ const state = useLoadedPlayer();
+ return (
+
+ Pilots
+
+
+ {state.pilots.map((pilot) => (
+
+ ))}
+
+
+ );
+}
+
+function HirePilot() {
+ const state = useLoadedPlayer();
+ const hire = useGameAction("hirePilot");
+ const [bay, setBay] = useState(state.bays[0]?.locationId ?? "");
+ const cost = content.config.pilotHireCost;
+ const hasBay = state.bays.some((b) => b.locationId === bay);
+
+ return (
+
+ {state.bays.length === 0 ? (
+
Lease a bay to hire pilots there.
+ ) : (
+
+
+ Hire at
+ setBay(e.target.value)}
+ className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
+ >
+ {state.bays.map((b) => (
+
+ {locationName(b.locationId)}
+
+ ))}
+
+
+ hire.mutate({ locationId: bay })}
+ className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
+ >
+ Hire pilot ({formatCredits(cost)})
+
+
+ )}
+ {hire.isError && (
+
+ {errorMessage(hire.error)}
+
+ )}
+
+ );
+}
+
+function PilotCard({ pilot }: { pilot: Pilot }) {
+ const state = useLoadedPlayer();
+ const spend = useGameAction("spendSkillPoint");
+ const progress = levelProgress(pilot.xp, pilot.level);
+ const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
+ const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
+
+ return (
+
+
+
+ {pilot.name}
+ {pilot.isAvatar && (you) }
+
+ Level {pilot.level}
+
+
+
+ {progress
+ ? `${progress.xp} / ${progress.needed} XP to next level`
+ : `${pilot.xp} XP · max level`}
+
+
+ {pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`}
+ {" · "}
+ {lockedToLoaner
+ ? "Flying the company loaner"
+ : ship
+ ? `${ship.name} (${shipModelName(ship.modelId)})`
+ : "No ship"}
+
+
+
+
+ Skill points:{" "}
+ {pilot.skillPointsAvailable}
+
+
+ {content.skills.map((skill) => {
+ const level = pilot.skills[skill.id] ?? 0;
+ const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
+ return (
+
+
+ {skill.name} {level}/{skill.maxLevel}
+
+ {canRaise && (
+ spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
+ className="rounded border border-amber-500 px-1.5 text-amber-300"
+ >
+ +
+
+ )}
+
+ );
+ })}
+
+ {spend.isError && (
+
+ {errorMessage(spend.error)}
+
+ )}
+
+
+ {!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
+
+ )}
+
+ );
+}
+
+/** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */
+function AssignShip({ pilot }: { pilot: Pilot }) {
+ const state = useLoadedPlayer();
+ const assign = useGameAction("assignPilot");
+ const ships = state.ships.filter(
+ (s) => s.status === "idle" && s.pilotId === null && s.locationId === pilot.locationId,
+ );
+ const [shipId, setShipId] = useState("");
+
+ if (ships.length === 0) {
+ return (
+
+ No uncrewed ships at {locationName(pilot.locationId ?? "")}.
+
+ );
+ }
+ return (
+
+
setShipId(e.target.value)}
+ className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
+ >
+ Choose a ship…
+ {ships.map((s) => (
+
+ {s.name} ({shipModelName(s.modelId)})
+
+ ))}
+
+
assign.mutate({ pilotId: pilot.id, shipId })}
+ className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
+ >
+ Assign
+
+ {assign.isError && (
+
+ {errorMessage(assign.error)}
+
+ )}
+
+ );
+}
diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx
new file mode 100644
index 0000000..5d2c9a1
--- /dev/null
+++ b/apps/web/test/pilots.test.tsx
@@ -0,0 +1,133 @@
+import { content } from "@deadhead/content";
+import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { levelProgress } from "../src/screens/Pilots";
+import { fakeApi, renderApp, samplePlayerState } from "./render";
+
+const ship = (overrides: Partial): Ship => ({
+ id: "s",
+ modelId: "courier",
+ name: "Ship",
+ status: "idle",
+ locationId: "moon",
+ dispatchId: null,
+ pilotId: null,
+ ...overrides,
+});
+
+const rae: Pilot = {
+ id: "p-rae",
+ name: "Rae Okafor",
+ isAvatar: false,
+ xp: 260,
+ level: 2,
+ skills: { "heavy-cargo": 1 },
+ skillPointsAvailable: 3,
+ assignedShipId: null,
+ status: "idle",
+ locationId: "moon",
+};
+
+function pilotsState(overrides: Partial = {}): PlayerState {
+ const base = samplePlayerState({ tutorial: null });
+ return {
+ ...base,
+ company: { ...base.company, credits: 50_000 },
+ bays: [
+ { id: "b1", locationId: "earth" },
+ { id: "b2", locationId: "moon" },
+ ],
+ pilots: [{ ...base.pilots[0]!, assignedShipId: "s-earth" }, rae],
+ ships: [
+ ship({ id: "s-earth", name: "Earth Crewed", locationId: "earth", pilotId: "avatar" }),
+ ship({ id: "s-moon-free", name: "Moon Free" }),
+ ship({ id: "s-moon-crewed", name: "Moon Crewed", pilotId: "someone" }),
+ ship({ id: "s-earth-free", name: "Earth Free", locationId: "earth" }),
+ ship({ id: "s-flying", name: "Flying", status: "in-flight", locationId: null }),
+ ],
+ ...overrides,
+ };
+}
+
+describe("pilots screen", () => {
+ it("shows level, XP progress, skills and points", async () => {
+ renderApp(fakeApi({ getPlayerState: () => pilotsState() }), "/pilots");
+ const card = (await screen.findByText("Rae Okafor")).closest("li")!;
+ const { xp, needed } = levelProgress(260, 2)!;
+ expect(card).toHaveTextContent("Level 2");
+ expect(card).toHaveTextContent(`${xp} / ${needed} XP to next level`);
+ expect(card).toHaveTextContent("Heavy Cargo 1/5");
+ expect(within(card).getByLabelText("Rae Okafor skill points")).toHaveTextContent("3");
+ });
+
+ it("spends a skill point through the endpoint and shows the updated pilot", async () => {
+ let spent = false;
+ const api = fakeApi({
+ getPlayerState: () =>
+ pilotsState({
+ pilots: [
+ pilotsState().pilots[0]!,
+ spent ? { ...rae, skills: { "heavy-cargo": 2 }, skillPointsAvailable: 2 } : rae,
+ ],
+ }),
+ spendSkillPoint: ({ pilotId, skillId }) => {
+ spent = true;
+ return {
+ pilot: { ...rae, id: pilotId, skills: { [skillId]: 2 }, skillPointsAvailable: 2 },
+ };
+ },
+ });
+ renderApp(api, "/pilots");
+ await userEvent.click(
+ await screen.findByRole("button", { name: "Raise Heavy Cargo for Rae Okafor" }),
+ );
+
+ expect(api.call).toHaveBeenCalledWith("spendSkillPoint", {
+ pilotId: "p-rae",
+ skillId: "heavy-cargo",
+ });
+ const card = screen.getByText("Rae Okafor").closest("li")!;
+ await waitFor(() => expect(card).toHaveTextContent("Heavy Cargo 2/5"));
+ expect(within(card).getByLabelText("Rae Okafor skill points")).toHaveTextContent("2");
+ });
+
+ it("disables hiring when the player cannot afford a pilot", async () => {
+ const cost = content.config.pilotHireCost;
+ renderApp(
+ fakeApi({
+ getPlayerState: () => pilotsState({ company: { id: "c", name: "D", credits: cost - 1 } }),
+ }),
+ "/pilots",
+ );
+ const hire = await screen.findByRole("button", { name: /Hire pilot/ });
+ expect(hire).toBeDisabled();
+ expect(hire).toHaveTextContent(`${cost.toLocaleString("en-US")} cr`);
+ });
+
+ it("hires at the chosen bay", async () => {
+ const api = fakeApi({ getPlayerState: () => pilotsState() });
+ renderApp(api, "/pilots");
+ await userEvent.selectOptions(await screen.findByLabelText("Hire at"), "moon");
+ await userEvent.click(screen.getByRole("button", { name: /Hire pilot/ }));
+ expect(api.call).toHaveBeenCalledWith("hirePilot", { locationId: "moon" });
+ });
+
+ it("offers only idle, uncrewed ships at the pilot's location for assignment", async () => {
+ const api = fakeApi({ getPlayerState: () => pilotsState() });
+ renderApp(api, "/pilots");
+ const select = await screen.findByLabelText("Assign Rae Okafor to");
+ const options = within(select)
+ .getAllByRole("option")
+ .map((o) => o.textContent);
+ expect(options).toEqual(["Choose a ship…", "Moon Free (Courier)"]);
+
+ await userEvent.selectOptions(select, "s-moon-free");
+ await userEvent.click(screen.getByRole("button", { name: "Assign" }));
+ expect(api.call).toHaveBeenCalledWith("assignPilot", {
+ pilotId: "p-rae",
+ shipId: "s-moon-free",
+ });
+ });
+});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 82e6937..6247a1e 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -91,7 +91,7 @@
- [x] **Step 9.4 — Bay contract boards.** After the tutorial the Contracts screen shows `BayBoards`: a bay selector and, per contract, route, cargo (and tier), mass, current distance, payout, required skills, live expiry countdown and a first-contact badge.
- [x] **Step 9.5 — Dispatch flow.** `src/screens/DispatchSheet.tsx`: each idle ship at the pickup with its pilot, checked with sim `validateDispatch`; eligible pairs show trip time, fuel, net profit and game/real ETA; ineligible pairs are disabled with a short reason; Confirm sends the dispatch and shows server errors.
- [x] **Step 9.6 — Fleet screen.** `src/screens/Fleet.tsx`: ships with model, location or route, pilot; in-flight progress bar (sim `dispatchProgress`) and countdown; "Reposition" for idle crewed ships at a bay opens a picker of the player's other bays.
-- [ ] Step 9.7 — Pilots screen
+- [x] **Step 9.7 — Pilots screen.** `src/screens/Pilots.tsx`: level, XP progress to next level, skills, points, assignment and status per pilot; hire at a chosen bay (cost shown, disabled when unaffordable); assign to idle uncrewed ships at the pilot's location; "+" to spend a skill point.
- [ ] Step 9.8 — Market screen
- [ ] Step 9.9 — Locations screen
- [ ] Step 9.10 — Empty and error states