Step 10A.16: lock undiscovered locations and show reachability

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:31:35 -04:00
parent afdd41635e
commit 28cb919c05
8 changed files with 113 additions and 4 deletions

View File

@ -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. - **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. - **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.

View File

@ -78,7 +78,8 @@ export function MapCanvas({
labels.set(location.id, label); labels.set(location.id, label);
app.stage.addChild(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.style.fill = color;
label.position.set(p.x + 8, p.y - 7); label.position.set(p.x + 8, p.y - 7);

16
apps/web/src/map/reach.ts Normal file
View File

@ -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,
);
}

View File

@ -10,6 +10,7 @@ import { distanceAt } from "../game";
import { DEFAULT_CAMERA, useCamera } from "../map/camera"; import { DEFAULT_CAMERA, useCamera } from "../map/camera";
import type { MapSelection } from "../map/hitTest"; import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas"; import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth } from "../map/reach";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
const TYPE_LABELS: Record<Location["type"], string> = { const TYPE_LABELS: Record<Location["type"], string> = {
@ -78,6 +79,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
const location = content.location(locationId)!; const location = content.location(locationId)!;
const discovered = state.locations.some((l) => l.id === locationId && l.discovered); const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
const hasBay = state.bays.some((b) => b.locationId === locationId); const hasBay = state.bays.some((b) => b.locationId === locationId);
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
return ( return (
<Panel title={location.name} onClose={onClose}> <Panel title={location.name} onClose={onClose}>
@ -105,7 +107,14 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
)} )}
</> </>
) : ( ) : (
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p> <>
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
<p aria-label="Reachable with" className="mt-1 text-slate-300">
{reachable.length > 0
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
: "No ship can reach it from Earth right now."}
</p>
</>
)} )}
</Panel> </Panel>
); );

View File

@ -19,6 +19,7 @@ export function MapCanvas({
{scene.locations.map((l) => ( {scene.locations.map((l) => (
<button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}> <button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}>
Tap {l.name} Tap {l.name}
{l.discovered ? "" : " (locked)"}
</button> </button>
))} ))}
{scene.flights.map((f) => ( {scene.flights.map((f) => (

View File

@ -120,7 +120,7 @@ describe("map panels", () => {
it("tapping an undiscovered location hides its details", async () => { it("tapping an undiscovered location hides its details", async () => {
const canvas = await openMap(); 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( expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent(
"Undiscovered — deliver here to unlock.", "Undiscovered — deliver here to unlock.",
); );

View File

@ -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();
});
});

View File

@ -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.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.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. - [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.17 — Client: something waiting on return
- [ ] Step 10A.18 — Plan amendments and regression - [ ] Step 10A.18 — Plan amendments and regression