From 78fb6e7d69cce6fa5f817ac55f6ce379eadcc2db Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 02:16:33 -0400 Subject: [PATCH] Step 9.8: add market screen Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 4 ++ apps/web/src/App.tsx | 2 + apps/web/src/screens/Market.tsx | 60 ++++++++++++++++++++++++++++ apps/web/test/market.test.tsx | 69 +++++++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/screens/Market.tsx create mode 100644 apps/web/test/market.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index aa9035e..2c9618e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dbd1250..fe9182a 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 { 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, "/fleet": , "/pilots": , + "/market": , }; /** Syncs the clock with the server once, then ticks it every second. */ diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx new file mode 100644 index 0000000..e6e9513 --- /dev/null +++ b/apps/web/src/screens/Market.tsx @@ -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 ( +
+

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. +

+ )} +
    + {content.shipModels.map((model) => { + const affordable = state.company.credits >= model.price; + return ( +
  • +
    + {model.name} + {formatCredits(model.price)} +
    +
    +
    Speed
    +
    {formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h
    +
    Capacity
    +
    {model.cargoCapacityTonnes} t
    +
    Range
    +
    {formatMillionKm(model.fuelRangeMillionKm)}
    +
    Landing
    +
    {LANDING_LABELS[model.landingClass]}
    +
    + +
  • + ); + })} +
+
+ ); +} 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