From c9e3c9c15d526b73c309d83fade258bfe72d9042 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 02:15:38 -0400 Subject: [PATCH] Step 9.7: add pilots screen Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 + apps/web/src/App.tsx | 2 + apps/web/src/screens/Pilots.tsx | 200 ++++++++++++++++++++++++++++++++ apps/web/test/pilots.test.tsx | 133 +++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/screens/Pilots.tsx create mode 100644 apps/web/test/pilots.test.tsx 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.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.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 ( +
    + + + {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