From 28cb919c05d705dd3a78d4f951058d336523f70b Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 11:31:35 -0400 Subject: [PATCH] Step 10A.16: lock undiscovered locations and show reachability Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/map/MapCanvas.tsx | 3 +- apps/web/src/map/reach.ts | 16 ++++++ apps/web/src/screens/MapScreen.tsx | 11 +++- apps/web/test/map-canvas-double.tsx | 1 + apps/web/test/map-interactions.test.tsx | 2 +- apps/web/test/map-locked.test.tsx | 76 +++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 8 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/map/reach.ts create mode 100644 apps/web/test/map-locked.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index c0a8fc1..9df2e1a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -581,3 +581,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **HQ shows the next three unclaimed goals in content order,** whether ready or not, so the player always sees what comes next; claimed goals drop out and the following goal takes their place. - **Goal titles and rewards come from content;** the API only sends each goal's id and status. + +## Step 10A.16 + +- **The map was already reachable during the tutorial** (its tab exists from the first screen); this step adds the lock and reachability. +- **Locked locations are labelled "🔒 Name"** on the canvas (they were dimmed already), replacing the earlier "Name?". +- **Reachability uses sim `checkRoute` (range and landing) from Earth at the current server time,** over `content.purchasableShipModels`, so it changes as planets move. When nothing can reach it the panel says so. diff --git a/apps/web/src/map/MapCanvas.tsx b/apps/web/src/map/MapCanvas.tsx index 0a2e95b..5162e5d 100644 --- a/apps/web/src/map/MapCanvas.tsx +++ b/apps/web/src/map/MapCanvas.tsx @@ -78,7 +78,8 @@ export function MapCanvas({ labels.set(location.id, label); app.stage.addChild(label); } - label.text = location.discovered ? location.name : `${location.name}?`; + // Undiscovered locations are locked until a delivery reaches them (Step 10A.16). + label.text = location.discovered ? location.name : `🔒 ${location.name}`; label.style.fill = color; label.position.set(p.x + 8, p.y - 7); diff --git a/apps/web/src/map/reach.ts b/apps/web/src/map/reach.ts new file mode 100644 index 0000000..990ed54 --- /dev/null +++ b/apps/web/src/map/reach.ts @@ -0,0 +1,16 @@ +import { content, type ShipModel } from "@deadhead/content"; +import { checkRoute, realToGame } from "@deadhead/sim"; + +/** + * Purchasable ship models that can fly from Earth to `locationId` at real time + * `now` (range and landing, via the sim's route check) (Step 10A.16). + */ +export function modelsReachingFromEarth(locationId: string, now: number): ShipModel[] { + const earth = content.location("earth"); + const target = content.location(locationId); + if (!earth || !target) return []; + const gameTime = realToGame(now, content.config); + return content.purchasableShipModels.filter( + (model) => checkRoute(model, earth, target, gameTime).ok, + ); +} diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 7fe6386..8f45057 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -10,6 +10,7 @@ import { distanceAt } from "../game"; import { DEFAULT_CAMERA, useCamera } from "../map/camera"; import type { MapSelection } from "../map/hitTest"; import { MapCanvas } from "../map/MapCanvas"; +import { modelsReachingFromEarth } from "../map/reach"; import { useLoadedPlayer } from "../state/actions"; const TYPE_LABELS: Record = { @@ -78,6 +79,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: ( const location = content.location(locationId)!; const discovered = state.locations.some((l) => l.id === locationId && l.discovered); const hasBay = state.bays.some((b) => b.locationId === locationId); + const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now); return ( @@ -105,7 +107,14 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: ( )} ) : ( -

Undiscovered — deliver here to unlock.

+ <> +

Undiscovered — deliver here to unlock.

+

+ {reachable.length > 0 + ? `Reachable with: ${reachable.map((m) => m.name).join(", ")}` + : "No ship can reach it from Earth right now."} +

+ )}
); diff --git a/apps/web/test/map-canvas-double.tsx b/apps/web/test/map-canvas-double.tsx index a2e80ce..8c4f718 100644 --- a/apps/web/test/map-canvas-double.tsx +++ b/apps/web/test/map-canvas-double.tsx @@ -19,6 +19,7 @@ export function MapCanvas({ {scene.locations.map((l) => ( ))} {scene.flights.map((f) => ( diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx index 0f257a8..2439887 100644 --- a/apps/web/test/map-interactions.test.tsx +++ b/apps/web/test/map-interactions.test.tsx @@ -120,7 +120,7 @@ describe("map panels", () => { it("tapping an undiscovered location hides its details", async () => { const canvas = await openMap(); - await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars" })); + await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars (locked)" })); expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent( "Undiscovered — deliver here to unlock.", ); diff --git a/apps/web/test/map-locked.test.tsx b/apps/web/test/map-locked.test.tsx new file mode 100644 index 0000000..04171e2 --- /dev/null +++ b/apps/web/test/map-locked.test.tsx @@ -0,0 +1,76 @@ +import { content } from "@deadhead/content"; +import { checkRoute, realToGame } from "@deadhead/sim"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +describe("whole map from the start", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + const openMap = async () => { + // A brand-new tutorial player. + renderApp( + fakeApi({ + getPlayerState: () => samplePlayerState(), + getTutorialBoard: () => sampleTutorialBoard(), + }), + "/map", + ); + return screen.findByTestId("map-canvas"); + }; + + it("is available during the tutorial and shows every location", async () => { + const canvas = await openMap(); + expect(within(canvas).getAllByRole("button", { name: /^Tap / })).toHaveLength( + content.locations.length + 1, + ); + }); + + it("renders undiscovered locations as locked and discovered ones normally", async () => { + const canvas = await openMap(); + for (const id of ["mars", "ceres-station", "jupiter-cloud-platform"]) { + const name = content.location(id)!.name; + expect( + within(canvas).getByRole("button", { name: `Tap ${name} (locked)` }), + ).toBeInTheDocument(); + } + expect(within(canvas).getByRole("button", { name: "Tap Earth" })).toBeInTheDocument(); + expect(within(canvas).getByRole("button", { name: "Tap Moon" })).toBeInTheDocument(); + }); + + it("lists the purchasable ships that can reach Mars from Earth now, per the sim's range check", async () => { + const canvas = await openMap(); + await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars (locked)" })); + const panel = screen.getByRole("dialog", { name: "Mars" }); + expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock."); + + const gameTime = realToGame(NOW, content.config); + const expected = content.purchasableShipModels + .filter( + (m) => checkRoute(m, content.location("earth")!, content.location("mars")!, gameTime).ok, + ) + .map((m) => m.name); + expect(expected.length).toBeGreaterThan(0); + expect(expected).not.toContain("Courier"); + expect(screen.getByLabelText("Reachable with")).toHaveTextContent( + `Reachable with: ${expected.join(", ")}`, + ); + }); + + it("shows details, not reachability, for a discovered location", 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(screen.queryByLabelText("Reachable with")).not.toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 708c602..e1e45e9 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -121,6 +121,6 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.13 — Client: the deal.** `src/components/DealModal.tsx`: when `tutorial.dealAvailable` (and no delivery summary is open), a dialog with the Halvorsen story beat, the Courier and Earth bay, the loan amount and repayment share, and one "Shake on it" button that calls `acceptDeal`, stores the returned state and goes to HQ. - [x] **Step 10A.14 — Client: loan on HQ.** HQ's savings goal is replaced: during the tutorial a "Company runs: n of 4" card; afterwards `LoanCard` (balance, paid-off bar, "Pay down" with amounts above credits or balance blocked in the UI). Delivery summaries show "X cr went to the lender". Step 9.1 tutorial-goal tests replaced. - [x] **Step 10A.15 — Client: goals on HQ.** `src/components/GoalsCard.tsx` (`#goals`): the next three unclaimed goals with rewards; ready goals have "Claim", which calls `claimGoal`; the refreshed state brings the next goal into view. Hidden during the tutorial. -- [ ] Step 10A.16 — Client: whole map from the start +- [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`). - [ ] Step 10A.17 — Client: something waiting on return - [ ] Step 10A.18 — Plan amendments and regression