Step 9.8: add market screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 02:16:33 -04:00
parent c9e3c9c15d
commit 78fb6e7d69
5 changed files with 136 additions and 1 deletions

View File

@ -437,3 +437,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 9.8
- **Market lists every content ship model** with its stats straight from `@deadhead/content`; no API call is needed until Buy. After a purchase the player-state refresh adds the ship to Fleet, and a status line says where it was delivered.

View File

@ -9,6 +9,7 @@ import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet";
import { HQ } from "./screens/HQ";
import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots";
import { Placeholder } from "./screens/Placeholder";
import { StartScreen } from "./screens/StartScreen";
@ -20,6 +21,7 @@ const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactN
"/contracts": <Contracts />,
"/fleet": <Fleet />,
"/pilots": <Pilots />,
"/market": <Market />,
};
/** Syncs the clock with the server once, then ticks it every second. */

View File

@ -0,0 +1,60 @@
import { content } from "@deadhead/content";
import { formatCredits, formatMillionKm } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
/** Ship models for sale; new ships appear at Earth (Step 9.8). */
export function Market() {
const state = useLoadedPlayer();
const buy = useGameAction("buyShip");
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Market</h1>
<p className="text-sm text-slate-400">New ships are delivered to Earth with no pilot.</p>
{buy.isError && (
<p role="alert" className="text-sm text-red-400">
{errorMessage(buy.error)}
</p>
)}
{buy.isSuccess && (
<p role="status" className="text-sm text-emerald-400">
{buy.data.ship.name} is waiting at Earth.
</p>
)}
<ul className="space-y-2">
{content.shipModels.map((model) => {
const affordable = state.company.credits >= model.price;
return (
<li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{model.name}</span>
<span className="font-semibold tabular-nums">{formatCredits(model.price)}</span>
</div>
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 text-slate-300">
<dt className="text-slate-400">Speed</dt>
<dd>{formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h</dd>
<dt className="text-slate-400">Capacity</dt>
<dd>{model.cargoCapacityTonnes} t</dd>
<dt className="text-slate-400">Range</dt>
<dd>{formatMillionKm(model.fuelRangeMillionKm)}</dd>
<dt className="text-slate-400">Landing</dt>
<dd>{LANDING_LABELS[model.landingClass]}</dd>
</dl>
<button
type="button"
aria-label={`Buy ${model.name}`}
disabled={!affordable || buy.isPending}
onClick={() => buy.mutate({ modelId: model.id })}
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
>
{affordable ? "Buy" : "Not enough credits"}
</button>
</li>
);
})}
</ul>
</section>
);
}

View File

@ -0,0 +1,69 @@
import { content } from "@deadhead/content";
import type { 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 { fakeApi, renderApp, samplePlayerState } from "./render";
const courierPrice = content.shipModel("courier")!.price;
const freighterPrice = content.shipModel("freighter")!.price;
describe("market screen", () => {
it("lists every ship model with its stats", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market");
const items = await screen.findAllByRole("listitem");
expect(items).toHaveLength(content.shipModels.length);
const courier = items[0]!;
expect(courier).toHaveTextContent("Courier");
expect(courier).toHaveTextContent("3.00M km/h");
expect(courier).toHaveTextContent("Capacity20 t");
expect(courier).toHaveTextContent("Range5.00M km");
expect(courier).toHaveTextContent("LandingAtmospheric");
});
it("disables Buy for ships the player cannot afford", async () => {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({ company: { id: "c", name: "D", credits: courierPrice } }),
}),
"/market",
);
expect(await screen.findByRole("button", { name: "Buy Courier" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Buy Freighter" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Buy Bulk Hauler" })).toBeDisabled();
});
it("buys a ship, which then appears in the Fleet", async () => {
const bought: Ship = {
id: "s-new",
modelId: "freighter",
name: "Lucky Heron",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: null,
};
let owned: Ship[] = [];
const api = fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
ships: owned,
company: { id: "c", name: "D", credits: owned.length ? 1 : freighterPrice },
}),
buyShip: () => {
owned = [bought];
return { ship: bought };
},
});
renderApp(api, "/market");
await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" }));
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter" });
expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at Earth.");
const nav = screen.getByRole("navigation", { name: "Main" });
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
expect(await screen.findByText("Lucky Heron")).toBeInTheDocument();
});
});

View File

@ -92,6 +92,6 @@
- [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.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
- [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh.
- [ ] Step 9.9 — Locations screen
- [ ] Step 9.10 — Empty and error states