Step 10L.7: Market shows roles and reach

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:48:09 -04:00
parent 6bd358b52d
commit 750ce54b7d
5 changed files with 177 additions and 3 deletions

View File

@ -1169,3 +1169,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **No schema change was needed** beyond the sim's failure lists: `ERROR_CODES` spreads `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, so `would-strand` arrived with 10L.3 (with its server message and the contract-dispatch discovered ids). - **No schema change was needed** beyond the sim's failure lists: `ERROR_CODES` spreads `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, so `would-strand` arrived with 10L.3 (with its server message and the contract-dispatch discovered ids).
- **The server's stranding test uses a real date** (1,420 game days after the test clock's start) on which a base Clipper can hop Moon→Mars but can't leave Mars within the margin, and asserts that with the sim first so a future range change fails loudly rather than silently. - **The server's stranding test uses a real date** (1,420 game days after the test clock's start) on which a base Clipper can hop Moon→Mars but can't leave Mars within the margin, and asserts that with the sim first so a future range change fails loudly rather than silently.
## Step 10L.7
- **A third part, "Other dates", was added** to the CR's "From Earth now … · By hopping …": without it a Clipper on a far-Mars date would show Mars nowhere, though it can fly there directly later. It lists places an HQ reaches directly on some date but not today.
- **"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.

View File

@ -1,12 +1,14 @@
import { content, type Location, type ShipModel } from "@deadhead/content"; import { content, type Location, type ShipModel } from "@deadhead/content";
import type { Ship } from "@deadhead/schema"; import type { Ship } from "@deadhead/schema";
import { import {
checkRoute,
effectiveStats, effectiveStats,
MS_PER_HOUR, MS_PER_HOUR,
realToGame, realToGame,
routeDistance, routeDistance,
travelTimeGameHours, travelTimeGameHours,
type EffectiveStats, type EffectiveStats,
type RealTime,
type ShipState, type ShipState,
} from "@deadhead/sim"; } from "@deadhead/sim";
@ -66,3 +68,74 @@ export function simShip(ship: Ship): ShipState {
inShopUntil: ship.inShopUntil, inShopUntil: ship.inShopUntil,
}; };
} }
/** Monthly samples over 16 game years from the epoch: the reach horizon (CR-012). */
const REACH_SAMPLES = (() => {
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const month = 30.44 * 24 * MS_PER_HOUR;
return Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * month);
})();
const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`;
const everCache = new Map<string, Set<string>>();
/** Legs a model flies on at least one sampled date. */
function everInRange(model: ShipModel): Set<string> {
let legs = everCache.get(model.id);
if (!legs) {
legs = new Set();
for (const from of content.locations) {
for (const to of content.locations) {
if (from.id !== to.id && REACH_SAMPLES.some((t) => checkRoute(model, from, to, t).ok)) {
legs.add(legKey(from.id, to.id));
}
}
}
everCache.set(model.id, legs);
}
return legs;
}
export interface ShipReach {
/** Per origin, the locations in range right now. */
now: { originId: string; destinationIds: string[] }[];
/** Reachable directly from an origin, but not right now. */
otherDates: string[];
/** Reachable only through other locations. */
byHopping: string[];
}
/**
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in range
* at `now`, legs in range on other dates, and places it can reach only by
* hopping (each leg in range on some date over 16 game years).
*/
export function shipReach(model: ShipModel, originIds: string[], now: RealTime): ShipReach {
const t = realToGame(now, content.config);
const ever = everInRange(model);
const ids = content.locations.map((l) => l.id);
const origins = new Set(originIds);
const nowLegs = originIds.map((originId) => ({
originId,
destinationIds: ids.filter(
(to) => to !== originId && checkRoute(model, location(originId), location(to), t).ok,
),
}));
const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds));
const direct = new Set(ids.filter((to) => originIds.some((o) => ever.has(legKey(o, to)))));
const reached = new Set(originIds);
const queue = [...originIds];
while (queue.length > 0) {
const from = queue.shift()!;
for (const to of ids) {
if (!reached.has(to) && ever.has(legKey(from, to))) {
reached.add(to);
queue.push(to);
}
}
}
return {
now: nowLegs,
otherDates: ids.filter((id) => direct.has(id) && !inRangeNow.has(id) && !origins.has(id)),
byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)),
};
}

View File

@ -1,7 +1,8 @@
import { content, type ShipModel } from "@deadhead/content"; import { content, type ShipModel } from "@deadhead/content";
import { useState } from "react"; import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format"; import { formatCredits, formatMillionKm, locationName } from "../format";
import { shipStatsOf } from "../game"; import { serverNow } from "../clock/clock";
import { shipReach, shipStatsOf } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui"; import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
@ -13,7 +14,7 @@ const best = {
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)), capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)),
range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)), range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)),
}; };
/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */ /** Range spans 5 to 1,250M km, so its bar uses a log scale to stay readable. */
const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range); const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range);
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */ /** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
@ -64,6 +65,7 @@ export function Market() {
{formatCredits(model.price)} {formatCredits(model.price)}
</span> </span>
</div> </div>
<p className="text-ink-300 italic">{model.role}</p>
<dl className="mt-3 space-y-2 text-ink-200"> <dl className="mt-3 space-y-2 text-ink-200">
<Spec <Spec
icon="speed" icon="speed"
@ -85,6 +87,7 @@ export function Market() {
/> />
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} /> <Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
</dl> </dl>
<Reach model={model} />
<OwnedUpgraded modelId={model.id} /> <OwnedUpgraded modelId={model.id} />
<Button <Button
aria-label={`Buy ${model.name}`} aria-label={`Buy ${model.name}`}
@ -136,6 +139,33 @@ function Spec(props: { icon: IconName; label: string; value: string; share?: num
); );
} }
const names = (ids: string[]) => ids.map(locationName).join(", ");
/**
* Where this model can fly from the player's HQs (CR-012), e.g.
* "From Earth now: Moon, Mars · By hopping: Ceres". Computed with the sim.
*/
function Reach({ model }: { model: ShipModel }) {
const state = useLoadedPlayer();
const origins = [...new Set(state.bays.map((b) => b.locationId))];
const reach = shipReach(model, origins, serverNow());
const parts = [
...reach.now.map(
(leg) =>
`From ${locationName(leg.originId)} now: ${leg.destinationIds.length > 0 ? names(leg.destinationIds) : "nowhere"}`,
),
...(reach.otherDates.length > 0 ? [`Other dates: ${names(reach.otherDates)}`] : []),
...(reach.byHopping.length > 0 ? [`By hopping: ${names(reach.byHopping)}`] : []),
];
if (parts.length === 0) return null;
return (
<p aria-label="Reach" className="mt-3 text-xs text-ink-300">
<span className="font-display tracking-[0.14em] text-ink-400 uppercase">Reach </span>
{parts.join(" · ")}
</p>
);
}
/** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */ /** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */
function DeliveryPicker(props: { function DeliveryPicker(props: {
model: ShipModel; model: ShipModel;

View File

@ -0,0 +1,64 @@
import { content } from "@deadhead/content";
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } 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 [earth, mars] = [content.location("earth")!, content.location("mars")!];
/** The first monthly date on which Earth→Mars is (or isn't) in a Clipper's range. */
function dateWhenMars(inRange: boolean): number {
for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH;
if (checkRoute(clipper, earth, mars, t).ok === inRange) return gameToReal(t, content.config);
}
throw new Error("no such date");
}
async function reachOf(modelName: string, now: number) {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
useClock.setState({ offsetMs: 0, synced: true, now });
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState({ bays: [{ id: "b-earth", locationId: "earth" }] }),
}),
"/market",
);
const card = (await screen.findByRole("button", { name: `Buy ${modelName}` })).closest("li")!;
return within(card).getByLabelText("Reach").textContent ?? "";
}
describe("market roles and reach (Step 10L.7)", () => {
afterEach(() => vi.useRealTimers());
it("shows each model's role", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market");
for (const model of content.purchasableShipModels) {
expect(await screen.findByText(model.role)).toBeInTheDocument();
}
});
it("lists Mars from Earth for a Clipper only when Mars is in range, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", dateWhenMars(true));
expect(near).toContain("From Earth now: Moon, Mars");
expect(near).toContain("By hopping: Ceres Station");
});
it("puts Mars under other dates for a Clipper when it is out of range", async () => {
const far = await reachOf("Clipper", dateWhenMars(false));
expect(far).toContain("From Earth now: Moon");
expect(far).not.toContain("From Earth now: Moon, Mars");
expect(far).toContain("Other dates: Mars");
expect(far).toContain("By hopping: Ceres Station");
});
it("lists only the Moon for a Lighter", async () => {
const reach = await reachOf("Lighter", dateWhenMars(true));
expect(reach).toBe("Reach From Earth now: Moon");
});
});

View File

@ -275,7 +275,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as
- [x] **Step 10L.4 — Content tuning: reach table.** Ranges: Courier 5, Lighter 10, Clipper 198 (unchanged from 10L.1), **Freighter 650 → 450**, **Bulk Hauler 1000 → 1250**. Step 3.7's range tests (`eligibility.test.ts`) now name their origin; the Freighter-to-Ceres-at-farthest and Freighter-to-Jupiter-at-closest tests become "reaches Ceres at its closest, not its farthest" and "never reaches Jupiter from Earth". Tests (`reach-table.test.ts`): one per §3 cell over monthly samples for 16 game years, plus the two hop tests. - [x] **Step 10L.4 — Content tuning: reach table.** Ranges: Courier 5, Lighter 10, Clipper 198 (unchanged from 10L.1), **Freighter 650 → 450**, **Bulk Hauler 1000 → 1250**. Step 3.7's range tests (`eligibility.test.ts`) now name their origin; the Freighter-to-Ceres-at-farthest and Freighter-to-Jupiter-at-closest tests become "reaches Ceres at its closest, not its farthest" and "never reaches Jupiter from Earth". Tests (`reach-table.test.ts`): one per §3 cell over monthly samples for 16 game years, plus the two hop tests.
- [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.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.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.
- [ ] Step 10L.7 — Client: Market shows roles and reach - [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 - [ ] Step 10L.8 — Client: reachability from where ships actually are
- [ ] Step 10L.9 — Client: stranding reason - [ ] Step 10L.9 — Client: stranding reason
- [ ] Step 10L.10 — Art - [ ] Step 10L.10 — Art