Step 10A.16: lock undiscovered locations and show reachability
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
afdd41635e
commit
28cb919c05
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue