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:
parent
2ba6721200
commit
3a3bb84c29
|
|
@ -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 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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { content, type Location } from "@deadhead/content";
|
import { content, type Location } from "@deadhead/content";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
||||||
import { distanceAt } from "../game";
|
import { distanceAt } from "../game";
|
||||||
|
|
@ -10,9 +11,13 @@ const TYPE_LABELS: Record<Location["type"], string> = {
|
||||||
"cloud-platform": "Cloud platform",
|
"cloud-platform": "Cloud platform",
|
||||||
"belt-station": "Belt station",
|
"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" };
|
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() {
|
export function Locations() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
|
|
@ -59,9 +64,17 @@ export function Locations() {
|
||||||
<dt className="text-slate-400">Demands</dt>
|
<dt className="text-slate-400">Demands</dt>
|
||||||
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
<Link
|
||||||
|
to={`/contracts?location=${location.id}`}
|
||||||
|
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||||
|
>
|
||||||
|
View contracts
|
||||||
|
</Link>
|
||||||
{hasBay ? (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Lease bay at ${location.name}`}
|
aria-label={`Lease bay at ${location.name}`}
|
||||||
|
|
@ -71,6 +84,7 @@ export function Locations() {
|
||||||
>
|
>
|
||||||
Lease bay ({formatCredits(location.bayLeasePrice)})
|
Lease bay ({formatCredits(location.bayLeasePrice)})
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
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{" "}
|
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||||
{location.demands.map(cargoName).join(", ") || "nothing"}
|
{location.demands.map(cargoName).join(", ") || "nothing"}
|
||||||
</p>
|
</p>
|
||||||
{hasBay ? (
|
|
||||||
<Link
|
<Link
|
||||||
to={`/contracts?location=${location.id}`}
|
to={`/contracts?location=${location.id}`}
|
||||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||||
>
|
>
|
||||||
View contract board
|
View contracts
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
<p className="mt-1 text-slate-400">{hasBay ? "Your HQ is here." : "No HQ here."}</p>
|
||||||
<p className="mt-2 text-slate-400">No bay here yet.</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content, type ShipModel } from "@deadhead/content";
|
||||||
import { formatCredits, formatMillionKm } from "../format";
|
import { useState } from "react";
|
||||||
|
import { formatCredits, formatMillionKm, locationName } from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
|
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() {
|
export function Market() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const buy = useGameAction("buyShip");
|
const buy = useGameAction("buyShip");
|
||||||
const bay = state.bays[0];
|
const [buying, setBuying] = useState<ShipModel | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<section className="space-y-3 p-4">
|
||||||
<h1 className="text-xl font-semibold">Market</h1>
|
<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 && (
|
{buy.isError && (
|
||||||
<p role="alert" className="text-sm text-red-400">
|
<p role="alert" className="text-sm text-red-400">
|
||||||
{errorMessage(buy.error)}
|
{errorMessage(buy.error)}
|
||||||
|
|
@ -21,7 +24,7 @@ export function Market() {
|
||||||
)}
|
)}
|
||||||
{buy.isSuccess && (
|
{buy.isSuccess && (
|
||||||
<p role="status" className="text-sm text-emerald-400">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
|
|
@ -46,8 +49,8 @@ export function Market() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Buy ${model.name}`}
|
aria-label={`Buy ${model.name}`}
|
||||||
disabled={!affordable || !bay || buy.isPending}
|
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
||||||
onClick={() => bay && buy.mutate({ modelId: model.id, bayId: bay.id })}
|
onClick={() => setBuying(model)}
|
||||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
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"}
|
{affordable ? "Buy" : "Not enough credits"}
|
||||||
|
|
@ -56,6 +59,77 @@ export function Market() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
{buying && (
|
||||||
|
<DeliveryPicker
|
||||||
|
model={buying}
|
||||||
|
pending={buy.isPending}
|
||||||
|
onBuy={(bayId) =>
|
||||||
|
buy.mutate({ modelId: buying.id, bayId }, { onSettled: () => setBuying(null) })
|
||||||
|
}
|
||||||
|
onClose={() => setBuying(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { content } from "@deadhead/content";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { HQ_EXPLANATION } from "../src/screens/Locations";
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
const item = (name: string) => screen.getByText(name).closest("li")!;
|
const item = (name: string) => screen.getByText(name).closest("li")!;
|
||||||
|
|
@ -27,7 +28,7 @@ describe("locations screen", () => {
|
||||||
expect(item("Earth")).toHaveTextContent("Home");
|
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({
|
const api = fakeApi({
|
||||||
getPlayerState: () =>
|
getPlayerState: () =>
|
||||||
samplePlayerState({
|
samplePlayerState({
|
||||||
|
|
@ -38,8 +39,12 @@ describe("locations screen", () => {
|
||||||
renderApp(api, "/locations");
|
renderApp(api, "/locations");
|
||||||
await screen.findByText("Moon");
|
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(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 moonPrice = content.location("moon")!.bayLeasePrice;
|
||||||
const lease = within(item("Moon")).getByRole("button", { name: "Lease bay at Moon" });
|
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();
|
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 () => {
|
it("disables leasing the player cannot afford", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
|
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
|
||||||
expect(await screen.findByRole("button", { name: "Lease bay at Earth" })).toBeDisabled();
|
expect(await screen.findByRole("button", { name: "Lease bay at Earth" })).toBeDisabled();
|
||||||
|
|
|
||||||
|
|
@ -99,24 +99,28 @@ describe("map panels", () => {
|
||||||
return screen.findByTestId("map-canvas");
|
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();
|
const canvas = await openMap();
|
||||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||||
const panel = screen.getByRole("dialog", { name: "Moon" });
|
const panel = screen.getByRole("dialog", { name: "Moon" });
|
||||||
expect(panel).toHaveTextContent("Airless body");
|
expect(panel).toHaveTextContent("Airless body");
|
||||||
expect(panel).toHaveTextContent(/from Earth/);
|
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",
|
"href",
|
||||||
"/contracts?location=moon",
|
"/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();
|
const canvas = await openMap();
|
||||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
||||||
const panel = screen.getByRole("dialog", { name: "Earth" });
|
const panel = screen.getByRole("dialog", { name: "Earth" });
|
||||||
expect(panel).toHaveTextContent("No bay here yet.");
|
expect(panel).toHaveTextContent("No HQ here.");
|
||||||
expect(within(panel).queryByRole("link")).not.toBeInTheDocument();
|
expect(within(panel).getByRole("link", { name: "View contracts" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/contracts?location=earth",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tapping an undiscovered location hides its details", async () => {
|
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 () => {
|
it("the board link opens Contracts with that location selected", async () => {
|
||||||
const canvas = await openMap();
|
const canvas = await openMap();
|
||||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
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();
|
expect(await screen.findByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ describe("market screen", () => {
|
||||||
});
|
});
|
||||||
renderApp(api, "/market");
|
renderApp(api, "/market");
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" }));
|
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(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter", bayId: "b-earth" });
|
||||||
expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at 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" }));
|
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
||||||
expect(await screen.findByText("Lucky Heron")).toBeInTheDocument();
|
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.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.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.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`.
|
- [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
|
- [ ] Step 10B.10 — Plan amendments and regression
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue