diff --git a/DECISIONS.md b/DECISIONS.md index 719e0cb..0617316 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/src/screens/Locations.tsx b/apps/web/src/screens/Locations.tsx index 8d5ea8f..5aea0ae 100644 --- a/apps/web/src/screens/Locations.tsx +++ b/apps/web/src/screens/Locations.tsx @@ -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 = { "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,18 +64,27 @@ export function Locations() {
Demands
{location.demands.map(cargoName).join(", ") || "—"}
+ + View contracts + {hasBay ? ( -

Bay owned

+

Your HQ is here

) : ( - + <> +

{HQ_EXPLANATION}

+ + )} ); diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 9215e81..989c254 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -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"}

- {hasBay ? ( - - View contract board - - ) : ( -

No bay here yet.

- )} + + View contracts + +

{hasBay ? "Your HQ is here." : "No HQ here."}

) : ( <> diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index 6cb82d6..6af3185 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -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(null); return (

Market

-

New ships are delivered to Earth with no pilot.

+

+ New ships are delivered with no pilot to one of your HQs. +

{buy.isError && (

{errorMessage(buy.error)} @@ -21,7 +24,7 @@ export function Market() { )} {buy.isSuccess && (

- {buy.data.ship.name} is waiting at Earth. + {buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.

)}
    @@ -46,8 +49,8 @@ export function Market() {
+ {buying && ( + + buy.mutate({ modelId: buying.id, bayId }, { onSettled: () => setBuying(null) }) + } + onClose={() => setBuying(null)} + /> + )}
); } + +/** "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 ( +
+
+

+ Deliver to which HQ? +

+

+ Your {props.model.name} will wait there, idle and without a pilot. +

+
+ HQ + {state.bays.map((bay) => ( + + ))} +
+ + +
+
+ ); +} diff --git a/apps/web/test/locations.test.tsx b/apps/web/test/locations.test.tsx index 3ade5f3..aff0dd2 100644 --- a/apps/web/test/locations.test.tsx +++ b/apps/web/test/locations.test.tsx @@ -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(); diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx index 049afe3..b9131b9 100644 --- a/apps/web/test/map-interactions.test.tsx +++ b/apps/web/test/map-interactions.test.tsx @@ -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(); }); }); diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx index 47abee8..7836da1 100644 --- a/apps/web/test/market.test.tsx +++ b/apps/web/test/market.test.tsx @@ -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."); + }); }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a2ab2e8..62fcea2 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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