Step 9.8: add market screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c9e3c9c15d
commit
78fb6e7d69
|
|
@ -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.
|
- **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).
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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 { Market } from "./screens/Market";
|
||||||
import { Pilots } from "./screens/Pilots";
|
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";
|
||||||
|
|
@ -20,6 +21,7 @@ const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactN
|
||||||
"/contracts": <Contracts />,
|
"/contracts": <Contracts />,
|
||||||
"/fleet": <Fleet />,
|
"/fleet": <Fleet />,
|
||||||
"/pilots": <Pilots />,
|
"/pilots": <Pilots />,
|
||||||
|
"/market": <Market />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.
|
||||||
- [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.
|
- [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.9 — Locations screen
|
||||||
- [ ] Step 9.10 — Empty and error states
|
- [ ] Step 9.10 — Empty and error states
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue