Step 10L.8: map reachability per origin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:52:44 -04:00
parent 750ce54b7d
commit 0658c9984d
6 changed files with 130 additions and 43 deletions

View File

@ -1176,3 +1176,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"By hopping" means reachable only through other locations**, over legs each in range on some date in the 16-year horizon; places an HQ reaches directly never appear there. Hops are not checked for timing (that is the player's job), and undiscovered places are included because the Market describes the ship, not the player's map.
- **The horizon starts at the game epoch** and is computed once per model, then cached; only the "now" part depends on the current date. The line is computed at render from the server clock, not ticked, since range changes over game days.
- **Origins are the player's HQ locations** (one part each); a player with no bays sees no Reach line.
## Step 10L.8
- **Origins are discovered locations with an HQ or an idle ship**, in content order. In-flight ships don't count (they aren't anywhere yet). A player with neither, as in the tutorial, is measured from Earth, as before.
- **Upgraded ships are listed under the origin they are at**, only when their base model can't make the leg from there but their effective stats can ("your Swift Heron (upgraded)"). Before, they were checked from Earth wherever they were.
- **Origins with nobody are left out**; if none can reach, the panel says "No ship can reach it from your bases right now."

View File

@ -1,40 +1,57 @@
import { content, type ShipModel } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { checkRoute, effectiveStats, 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,
);
export interface OriginReach {
originId: string;
/** Purchasable models in range from there, then the player's upgraded ships there. */
reachers: string[];
}
/**
* The player's own ships that reach `locationId` from Earth only thanks to
* their upgrades (effective stats, CR-009): the base model can't, they can.
* Where the player could fly from: discovered locations with one of their HQs
* or idle ships (CR-012). Earth when there are none yet (the tutorial).
*/
export function upgradedShipsReachingFromEarth(
ships: readonly Ship[],
export function playerOrigins(state: Pick<PlayerState, "bays" | "ships" | "locations">): string[] {
const used = new Set([
...state.bays.map((b) => b.locationId),
...state.ships.flatMap((s) => (s.locationId ? [s.locationId] : [])),
]);
const origins = state.locations.filter((l) => l.discovered && used.has(l.id)).map((l) => l.id);
return origins.length > 0 ? origins : ["earth"];
}
/**
* Who can fly to `locationId` right now, per origin (Steps 10A.16, 10L.8):
* purchasable models whose range from that origin covers the leg (the sim's
* route check, with the origin's range factor), and the player's ships at that
* origin that get there only thanks to their upgrades (CR-009). Origins with
* nobody are left out.
*/
export function reachersByOrigin(
state: Pick<PlayerState, "bays" | "ships" | "locations">,
locationId: string,
now: number,
): Ship[] {
const earth = content.location("earth");
): OriginReach[] {
const target = content.location(locationId);
if (!earth || !target) return [];
if (!target) return [];
const gameTime = realToGame(now, content.config);
return ships.filter((ship) => {
const model = content.shipModel(ship.modelId);
if (!model?.purchasable || Object.keys(ship.upgrades).length === 0) return false;
return (
!checkRoute(model, earth, target, gameTime).ok &&
checkRoute(effectiveStats(model, ship.upgrades, content), earth, target, gameTime).ok
);
});
return playerOrigins(state)
.filter((id) => id !== locationId)
.map((originId) => {
const origin = content.location(originId)!;
const reaches = (stats: Parameters<typeof checkRoute>[0]) =>
checkRoute(stats, origin, target, gameTime).ok;
const models = content.purchasableShipModels.filter(reaches).map((m) => m.name);
const upgraded = state.ships
.filter((ship: Ship) => {
const model = content.shipModel(ship.modelId);
if (ship.locationId !== originId || !model?.purchasable) return false;
if (Object.keys(ship.upgrades).length === 0 || reaches(model)) return false;
return reaches(effectiveStats(model, ship.upgrades, content));
})
.map((ship) => `your ${ship.name} (upgraded)`);
return { originId, reachers: [...models, ...upgraded] };
})
.filter((o) => o.reachers.length > 0);
}

View File

@ -10,7 +10,7 @@ import { distanceAt } from "../game";
import { resetMapView } from "../map/camera";
import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
import { reachersByOrigin } from "../map/reach";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Art, buttonClass, Icon } from "../ui";
@ -96,13 +96,8 @@ 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);
// Owned ships whose upgrades get them there when their model can't (CR-009).
const upgraded = discovered ? [] : upgradedShipsReachingFromEarth(state.ships, locationId, now);
const reachers = [
...reachable.map((m) => m.name),
...upgraded.map((s) => `your ${s.name} (upgraded)`),
];
// Per origin the player has a base at (CR-012), e.g. "From Mars: Clipper · From Earth: Freighter".
const reach = discovered ? [] : reachersByOrigin(state, locationId, now);
return (
<Panel
@ -145,9 +140,11 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
Undiscovered — deliver here to unlock.
</p>
<p aria-label="Reachable with" className="text-ink-200">
{reachers.length > 0
? `Reachable with: ${reachers.join(", ")}`
: "No ship can reach it from Earth right now."}
{reach.length > 0
? reach
.map((o) => `From ${locationName(o.originId)}: ${o.reachers.join(", ")}`)
.join(" · ")
: "No ship can reach it from your bases right now."}
</p>
</>
)}

View File

@ -47,7 +47,7 @@ describe("whole map from the start", () => {
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 () => {
it("lists the purchasable ships that can reach Mars from Earth now (no bases yet), 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" });
@ -61,8 +61,9 @@ describe("whole map from the start", () => {
.map((m) => m.name);
expect(expected.length).toBeGreaterThan(0);
expect(expected).not.toContain("Courier");
// A tutorial player has no bases yet, so reach is measured from Earth (CR-012).
expect(screen.getByLabelText("Reachable with")).toHaveTextContent(
`Reachable with: ${expected.join(", ")}`,
`From Earth: ${expected.join(", ")}`,
);
});

View File

@ -0,0 +1,66 @@
import { content } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const MONTH = 30.44 * 24 * 3_600_000;
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const clipper = content.shipModel("clipper")!;
const [mars, ceres] = [content.location("mars")!, content.location("ceres-station")!];
/** The first monthly date on which a Clipper at Mars can reach Ceres. */
const NOW = (() => {
for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH;
if (checkRoute(clipper, mars, ceres, t).ok) return gameToReal(t, content.config);
}
throw new Error("no such date");
})();
const atMars: Ship = {
id: "s1",
modelId: "clipper",
name: "Swift Heron",
status: "idle",
locationId: "mars",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
};
describe("map reachability per origin (Step 10L.8)", () => {
afterEach(() => vi.useRealTimers());
it("lists the Clipper from Mars, where it is, and not from Earth", async () => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
const state = samplePlayerState({
bays: [{ id: "b-earth", locationId: "earth" }],
ships: [atMars],
});
state.locations = state.locations.map((l) =>
l.id === "mars" ? { ...l, discovered: true } : l,
);
renderApp(fakeApi({ getPlayerState: () => state }), "/map");
const canvas = await screen.findByTestId("map-canvas");
await userEvent.click(
within(canvas).getByRole("button", { name: "Tap Ceres Station (locked)" }),
);
const text = screen.getByLabelText("Reachable with").textContent ?? "";
const parts = Object.fromEntries(
text.split(" · ").map((part) => {
const [origin, list] = part.split(": ");
return [origin!, list!.split(", ")];
}),
);
expect(parts["From Mars"]).toContain("Clipper");
expect(parts["From Earth"] ?? []).not.toContain("Clipper");
});
});

View File

@ -276,7 +276,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as
- [x] **Step 10L.5 — Upgrade caps for the new lineup.** Courier fuel-tanks override 600 → **450 %** per level (5 → 27.5 → 50 → 72.5M km); the Freighter override stays at 15 % (450 → 652.5). No Lighter or Clipper caps were needed. Tests: the new 0.5.67 inequalities (content `upgrades.test.ts`; sim `upgrade-caps.test.ts`: a maxed Lighter never reaches Mars from Earth or the Moon, only a Bulk Hauler reaches Jupiter from Earth at its farthest even with every ship maxed); `courier-mars.test.ts` now samples 16 years from Earth. The web deadhead-to-Mars test moved to month 176, Mars's closest approach.
- [x] **Step 10L.6 — Schema and server.** `would-strand` is in `ERROR_CODES` (it comes from the sim's failure lists) with a server message; buy-ship already accepts any purchasable model. Tests (`apps/server/test/lighter-clipper.test.ts`): buying a Lighter and a Clipper places them idle at the chosen HQ; a Clipper at the Moon deadheading to Mars on a stranding date gets 409 `would-strand` and credits, ships and flights are unchanged.
- [x] **Step 10L.7 — Client: Market shows roles and reach.** Each Market card shows the model's role and a Reach line from `shipReach` (`apps/web/src/game.ts`, sim `checkRoute`): "From Earth now: Moon, Mars · Other dates: … · By hopping: Ceres Station", with one "From X now" part per HQ location. "Other dates" lists places reachable directly from an HQ but not today; "By hopping" lists places reachable only through other locations (each leg in range on some monthly date over 16 game years). Tests (`market-reach.test.tsx`): roles; the Clipper lists Mars from Earth only on a date when it is in range (otherwise under Other dates) and always lists Ceres by hopping; the Lighter lists only the Moon.
- [ ] Step 10L.8 — Client: reachability from where ships actually are
- [x] **Step 10L.8 — Client: reachability from where ships actually are.** An undiscovered location's map panel now reads "From Mars: Clipper, Freighter · From Earth: Freighter": one part per discovered location with an HQ or idle ship (`playerOrigins`, Earth when there are none yet), listing purchasable models in range from there now plus the player's ships there that reach it only thanks to upgrades (`reachersByOrigin` in `map/reach.ts`, replacing the Earth-only helpers). Tests: `map-reach-origins.test.tsx` (a Clipper at Mars on a date Mars→Ceres is in range: Ceres lists Clipper from Mars, not from Earth); `map-locked.test.tsx` now expects "From Earth: …" for a tutorial player.
- [ ] Step 10L.9 — Client: stranding reason
- [ ] Step 10L.10 — Art
- [ ] Step 10L.11 — Plan amendments and regression