Step 9.7: add pilots screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9ef45fec79
commit
c9e3c9c15d
|
|
@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { SCREENS } from "./routes";
|
||||||
import { Contracts } from "./screens/Contracts";
|
import { Contracts } from "./screens/Contracts";
|
||||||
import { Fleet } from "./screens/Fleet";
|
import { Fleet } from "./screens/Fleet";
|
||||||
import { HQ } from "./screens/HQ";
|
import { HQ } from "./screens/HQ";
|
||||||
|
import { Pilots } from "./screens/Pilots";
|
||||||
import { Placeholder } from "./screens/Placeholder";
|
import { Placeholder } from "./screens/Placeholder";
|
||||||
import { StartScreen } from "./screens/StartScreen";
|
import { StartScreen } from "./screens/StartScreen";
|
||||||
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
|
|
@ -18,6 +19,7 @@ const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactN
|
||||||
"/": <HQ />,
|
"/": <HQ />,
|
||||||
"/contracts": <Contracts />,
|
"/contracts": <Contracts />,
|
||||||
"/fleet": <Fleet />,
|
"/fleet": <Fleet />,
|
||||||
|
"/pilots": <Pilots />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Syncs the clock with the server once, then ticks it every second. */
|
/** Syncs the clock with the server once, then ticks it every second. */
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<section className="space-y-3 p-4">
|
||||||
|
<h1 className="text-xl font-semibold">Pilots</h1>
|
||||||
|
<HirePilot />
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{state.pilots.map((pilot) => (
|
||||||
|
<PilotCard key={pilot.id} pilot={pilot} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||||
|
{state.bays.length === 0 ? (
|
||||||
|
<p className="text-slate-400">Lease a bay to hire pilots there.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">Hire at</span>
|
||||||
|
<select
|
||||||
|
aria-label="Hire at"
|
||||||
|
value={bay}
|
||||||
|
onChange={(e) => setBay(e.target.value)}
|
||||||
|
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||||
|
>
|
||||||
|
{state.bays.map((b) => (
|
||||||
|
<option key={b.id} value={b.locationId}>
|
||||||
|
{locationName(b.locationId)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
||||||
|
onClick={() => 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)})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hire.isError && (
|
||||||
|
<p role="alert" className="mt-2 text-red-400">
|
||||||
|
{errorMessage(hire.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="rounded-lg border border-slate-800 p-3 text-sm">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{pilot.name}
|
||||||
|
{pilot.isAvatar && <span className="ml-1 text-xs text-amber-300">(you)</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">Level {pilot.level}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 text-slate-400">
|
||||||
|
{progress
|
||||||
|
? `${progress.xp} / ${progress.needed} XP to next level`
|
||||||
|
: `${pilot.xp} XP · max level`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-slate-300">
|
||||||
|
{pilot.status === "in-flight" ? "In flight" : `At ${locationName(pilot.locationId ?? "")}`}
|
||||||
|
{" · "}
|
||||||
|
{lockedToLoaner
|
||||||
|
? "Flying the company loaner"
|
||||||
|
: ship
|
||||||
|
? `${ship.name} (${shipModelName(ship.modelId)})`
|
||||||
|
: "No ship"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
Skill points:{" "}
|
||||||
|
<span aria-label={`${pilot.name} skill points`}>{pilot.skillPointsAvailable}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1 grid grid-cols-2 gap-1">
|
||||||
|
{content.skills.map((skill) => {
|
||||||
|
const level = pilot.skills[skill.id] ?? 0;
|
||||||
|
const canRaise = pilot.skillPointsAvailable > 0 && level < skill.maxLevel;
|
||||||
|
return (
|
||||||
|
<li key={skill.id} className="flex items-center justify-between gap-1">
|
||||||
|
<span>
|
||||||
|
{skill.name} {level}/{skill.maxLevel}
|
||||||
|
</span>
|
||||||
|
{canRaise && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
||||||
|
disabled={spend.isPending}
|
||||||
|
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
||||||
|
className="rounded border border-amber-500 px-1.5 text-amber-300"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{spend.isError && (
|
||||||
|
<p role="alert" className="mt-1 text-red-400">
|
||||||
|
{errorMessage(spend.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
|
||||||
|
<AssignShip pilot={pilot} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<p className="mt-2 text-xs text-slate-400">
|
||||||
|
No uncrewed ships at {locationName(pilot.locationId ?? "")}.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label={`Assign ${pilot.name} to`}
|
||||||
|
value={shipId}
|
||||||
|
onChange={(e) => setShipId(e.target.value)}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="">Choose a ship…</option>
|
||||||
|
{ships.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({shipModelName(s.modelId)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!shipId || assign.isPending}
|
||||||
|
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
|
||||||
|
className="rounded-md border border-slate-700 px-3 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
{assign.isError && (
|
||||||
|
<p role="alert" className="text-red-400">
|
||||||
|
{errorMessage(assign.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>): 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> = {}): 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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.
|
- [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.8 — Market screen
|
||||||
- [ ] Step 9.9 — Locations screen
|
- [ ] Step 9.9 — Locations screen
|
||||||
- [ ] Step 9.10 — Empty and error states
|
- [ ] Step 9.10 — Empty and error states
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue