Step 10B.9: bays as HQs in the Market, Locations and map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 15:12:46 -04:00
parent 2ba6721200
commit 3a3bb84c29
8 changed files with 199 additions and 39 deletions

View File

@ -662,3 +662,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The picker runs `validateReposition` per destination**, so the client and server agree on what is possible. Fuel is `tripFuelCost` and time is `tripRealMs` with zero cargo, the same inputs the sim's reposition record uses.
- **The action shows on every idle ship, crewed or not.** An uncrewed ship's options are all disabled with "No pilot assigned", which tells the player what to fix rather than hiding the action.
- **Disabled destinations still show fuel and time**, so the player can see how far off they are.
## Step 10B.9
- **The delivery picker is a dialog with radio buttons and a "Buy for N cr" confirm**, so buying always takes two taps, even with one bay (preselected). The confirm is disabled until a bay is chosen.
- **"Bay owned" now reads "Your HQ is here"**, and the map panel says "Your HQ is here." or "No HQ here."; the player-facing word for a bay is HQ, while the action keeps "Lease bay" as in the CR.
- **The success message names the delivery location** ("… is waiting at Moon.") instead of always Earth.

View File

@ -1,4 +1,5 @@
import { content, type Location } from "@deadhead/content";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { cargoName, formatCredits, formatMillionKm } from "../format";
import { distanceAt } from "../game";
@ -10,9 +11,13 @@ const TYPE_LABELS: Record<Location["type"], string> = {
"cloud-platform": "Cloud platform",
"belt-station": "Belt station",
};
/** What a bay is for (CR-002: bays are HQs, not access to contracts). */
export const HQ_EXPLANATION =
"Open an HQ here: hire pilots and take delivery of new ships at this location.";
const LANDING_LABELS = { vacuum: "Any ship", atmospheric: "Atmospheric", hardened: "Hardened" };
/** Every location; details and bay leasing only once discovered (Step 9.9). */
/** Every location; details, contracts and HQ leasing once discovered (Steps 9.9, 10B.9). */
export function Locations() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
@ -59,9 +64,17 @@ export function Locations() {
<dt className="text-slate-400">Demands</dt>
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
</dl>
<Link
to={`/contracts?location=${location.id}`}
className="mt-2 inline-block font-medium text-amber-400 underline"
>
View contracts
</Link>
{hasBay ? (
<p className="mt-2 font-medium text-emerald-400">Bay owned</p>
<p className="mt-2 font-medium text-emerald-400">Your HQ is here</p>
) : (
<>
<p className="mt-2 text-slate-400">{HQ_EXPLANATION}</p>
<button
type="button"
aria-label={`Lease bay at ${location.name}`}
@ -71,6 +84,7 @@ export function Locations() {
>
Lease bay ({formatCredits(location.bayLeasePrice)})
</button>
</>
)}
</li>
);

View File

@ -72,7 +72,7 @@ function Panel({
);
}
/** A tapped location: details if discovered, and a link to its board if there is a bay. */
/** A tapped location: details and a link to its contracts once discovered (CR-002). */
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
@ -95,16 +95,13 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
{location.demands.map(cargoName).join(", ") || "nothing"}
</p>
{hasBay ? (
<Link
to={`/contracts?location=${location.id}`}
className="mt-2 inline-block font-medium text-amber-400 underline"
>
View contract board
View contracts
</Link>
) : (
<p className="mt-2 text-slate-400">No bay here yet.</p>
)}
<p className="mt-1 text-slate-400">{hasBay ? "Your HQ is here." : "No HQ here."}</p>
</>
) : (
<>

View File

@ -1,19 +1,22 @@
import { content } from "@deadhead/content";
import { formatCredits, formatMillionKm } from "../format";
import { content, type ShipModel } from "@deadhead/content";
import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
/** Ship models for sale, delivered to the first bay until Step 10B.9's bay picker (Step 9.8). */
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
export function Market() {
const state = useLoadedPlayer();
const buy = useGameAction("buyShip");
const bay = state.bays[0];
const [buying, setBuying] = useState<ShipModel | null>(null);
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>
<p className="text-sm text-slate-400">
New ships are delivered with no pilot to one of your HQs.
</p>
{buy.isError && (
<p role="alert" className="text-sm text-red-400">
{errorMessage(buy.error)}
@ -21,7 +24,7 @@ export function Market() {
)}
{buy.isSuccess && (
<p role="status" className="text-sm text-emerald-400">
{buy.data.ship.name} is waiting at Earth.
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
</p>
)}
<ul className="space-y-2">
@ -46,8 +49,8 @@ export function Market() {
<button
type="button"
aria-label={`Buy ${model.name}`}
disabled={!affordable || !bay || buy.isPending}
onClick={() => bay && buy.mutate({ modelId: model.id, bayId: bay.id })}
disabled={!affordable || state.bays.length === 0 || buy.isPending}
onClick={() => setBuying(model)}
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"}
@ -56,6 +59,77 @@ export function Market() {
);
})}
</ul>
{buying && (
<DeliveryPicker
model={buying}
pending={buy.isPending}
onBuy={(bayId) =>
buy.mutate({ modelId: buying.id, bayId }, { onSettled: () => setBuying(null) })
}
onClose={() => setBuying(null)}
/>
)}
</section>
);
}
/** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */
function DeliveryPicker(props: {
model: ShipModel;
pending: boolean;
onBuy: (bayId: string) => void;
onClose: () => void;
}) {
const state = useLoadedPlayer();
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
return (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="delivery-title"
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
>
<h2 id="delivery-title" className="text-lg font-semibold">
Deliver to which HQ?
</h2>
<p className="text-sm text-slate-400">
Your {props.model.name} will wait there, idle and without a pilot.
</p>
<fieldset className="mt-3 space-y-2">
<legend className="sr-only">HQ</legend>
{state.bays.map((bay) => (
<label
key={bay.id}
className="flex items-center gap-2 rounded-lg border border-slate-700 p-3 text-sm"
>
<input
type="radio"
name="hq"
checked={bayId === bay.id}
onChange={() => setBayId(bay.id)}
/>
{locationName(bay.locationId)}
</label>
))}
</fieldset>
<button
type="button"
disabled={bayId === null || props.pending}
onClick={() => bayId && props.onBuy(bayId)}
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
>
Buy for {formatCredits(props.model.price)}
</button>
<button
type="button"
onClick={props.onClose}
className="mt-2 w-full rounded-lg border border-slate-700 py-2"
>
Cancel
</button>
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { content } from "@deadhead/content";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { HQ_EXPLANATION } from "../src/screens/Locations";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const item = (name: string) => screen.getByText(name).closest("li")!;
@ -27,7 +28,7 @@ describe("locations screen", () => {
expect(item("Earth")).toHaveTextContent("Home");
});
it("offers leasing only at discovered locations without a bay", async () => {
it("offers leasing, with the HQ explanation, only at discovered locations without a bay", async () => {
const api = fakeApi({
getPlayerState: () =>
samplePlayerState({
@ -38,8 +39,12 @@ describe("locations screen", () => {
renderApp(api, "/locations");
await screen.findByText("Moon");
expect(item("Earth")).toHaveTextContent("Bay owned");
expect(item("Earth")).toHaveTextContent("Your HQ is here");
expect(within(item("Earth")).queryByRole("button")).not.toBeInTheDocument();
expect(item("Earth")).not.toHaveTextContent(HQ_EXPLANATION);
expect(item("Moon")).toHaveTextContent(
"Open an HQ here: hire pilots and take delivery of new ships at this location.",
);
const moonPrice = content.location("moon")!.bayLeasePrice;
const lease = within(item("Moon")).getByRole("button", { name: "Lease bay at Moon" });
@ -50,6 +55,26 @@ describe("locations screen", () => {
expect(screen.queryByRole("button", { name: "Lease bay at Mars" })).not.toBeInTheDocument();
});
it("links every discovered location to its contracts, with or without a bay", async () => {
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState({ bays: [{ id: "b1", locationId: "earth" }] }),
}),
"/locations",
);
await screen.findByText("Moon");
for (const [name, id] of [
["Earth", "earth"],
["Moon", "moon"],
] as const) {
expect(within(item(name)).getByRole("link", { name: "View contracts" })).toHaveAttribute(
"href",
`/contracts?location=${id}`,
);
}
expect(within(item("Mars")).queryByRole("link")).not.toBeInTheDocument();
});
it("disables leasing the player cannot afford", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
expect(await screen.findByRole("button", { name: "Lease bay at Earth" })).toBeDisabled();

View File

@ -99,24 +99,28 @@ describe("map panels", () => {
return screen.findByTestId("map-canvas");
};
it("tapping a location with a bay shows its details and a link to its contract board", async () => {
it("tapping a location with a bay shows its details, its contracts link and the HQ", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
const panel = screen.getByRole("dialog", { name: "Moon" });
expect(panel).toHaveTextContent("Airless body");
expect(panel).toHaveTextContent(/from Earth/);
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
expect(within(panel).getByRole("link", { name: "View contracts" })).toHaveAttribute(
"href",
"/contracts?location=moon",
);
expect(panel).toHaveTextContent("Your HQ is here.");
});
it("tapping a discovered location without a bay shows no board link", async () => {
it("tapping a discovered location without a bay still links to its contracts", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
const panel = screen.getByRole("dialog", { name: "Earth" });
expect(panel).toHaveTextContent("No bay here yet.");
expect(within(panel).queryByRole("link")).not.toBeInTheDocument();
expect(panel).toHaveTextContent("No HQ here.");
expect(within(panel).getByRole("link", { name: "View contracts" })).toHaveAttribute(
"href",
"/contracts?location=earth",
);
});
it("tapping an undiscovered location hides its details", async () => {
@ -150,7 +154,7 @@ describe("map panels", () => {
it("the board link opens Contracts with that location selected", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
await userEvent.click(screen.getByRole("link", { name: "View contracts" }));
expect(await screen.findByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
});
});

View File

@ -70,6 +70,9 @@ describe("market screen", () => {
});
renderApp(api, "/market");
await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" }));
const picker = screen.getByRole("dialog", { name: "Deliver to which HQ?" });
expect(within(picker).getByRole("radio", { name: "Earth" })).toBeChecked();
await userEvent.click(within(picker).getByRole("button", { name: /Buy for/ }));
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter", bayId: "b-earth" });
expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at Earth.");
@ -77,4 +80,41 @@ describe("market screen", () => {
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
expect(await screen.findByText("Lucky Heron")).toBeInTheDocument();
});
it("asks which HQ to deliver to and sends the chosen bay", async () => {
const api = fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
bays: [
{ id: "b-earth", locationId: "earth" },
{ id: "b-moon", locationId: "moon" },
],
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
}),
buyShip: ({ bayId }) => ({
ship: {
id: "s-new",
modelId: "courier",
name: "Moon Moth",
status: "idle",
locationId: bayId === "b-moon" ? "moon" : "earth",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
},
}),
});
renderApp(api, "/market");
await userEvent.click(await screen.findByRole("button", { name: "Buy Courier" }));
const picker = screen.getByRole("dialog", { name: "Deliver to which HQ?" });
const confirm = within(picker).getByRole("button", { name: /Buy for/ });
expect(confirm).toBeDisabled();
expect(within(picker).getByRole("radio", { name: "Earth" })).not.toBeChecked();
await userEvent.click(within(picker).getByRole("radio", { name: "Moon" }));
await userEvent.click(confirm);
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "courier", bayId: "b-moon" });
expect(await screen.findByRole("status")).toHaveTextContent("Moon Moth is waiting at Moon.");
});
});

View File

@ -142,5 +142,5 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
- [x] **Step 10B.6 — Server: ship delivery to a bay.** `buyShip` takes `{ modelId, bayId }`; the ship appears idle and uncrewed at that bay's location. Missing `bayId` is `400 validation-error`; a bay the player does not own is `404 not-found` with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return `earthBayId`.
- [x] **Step 10B.7 — Client: Contracts screen by location.** `LocationBoards` (was `BayBoards`) shows a Location button group of every discovered location with an idle-ship count badge, defaulting to the most recently arrived idle ship's location (else Earth); `?location=` selects one (map link and Fleet's new "Find work here"). API: `Ship.lastArrivalAt`. Tests: `location-boards.test.tsx`, `last-arrival.test.ts`.
- [x] **Step 10B.8 — Client: deadhead action.** Fleet's "Reposition" is now "Deadhead (fly empty)" on every idle ship. The picker lists discovered locations except the ship's own, each with sim fuel cost and trip time; destinations the sim rejects (out of range, can't land, fuel, no pilot) are disabled with the reason. Test: `fleet.test.tsx`.
- [ ] Step 10B.9 — Client: bays as HQs
- [x] **Step 10B.9 — Client: bays as HQs.** Market Buy opens "Deliver to which HQ?" (radio list of bays, preselected when there is one) and sends `bayId`. Locations and the map panel link every discovered location to "View contracts" and say whether the HQ is there; the lease action shows "Open an HQ here: hire pilots and take delivery of new ships at this location." Tests: `market`, `locations`, `map-interactions`.
- [ ] Step 10B.10 — Plan amendments and regression