+ Market
+ New ships are delivered to Earth with no pilot.
+ {buy.isError && (
+
+ {errorMessage(buy.error)}
+
+ )}
+ {buy.isSuccess && (
+
+ {buy.data.ship.name} is waiting at Earth.
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx
new file mode 100644
index 0000000..662394e
--- /dev/null
+++ b/apps/web/test/market.test.tsx
@@ -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();
+ });
+});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 6247a1e..ffa4cf9 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -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