Step 15.8: client: fixed map
CI / check (push) Has been cancelled Details

The map places every body once and moves only flights; orbit rings
stay as decoration. Reach wording loses its dates: "From Earth: Moon,
Mars", "Out of range (...)", and the Market's "Other dates" line is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 01:58:13 -04:00
parent 7a061d0e40
commit 420d4c47f0
12 changed files with 162 additions and 51 deletions

View File

@ -1488,3 +1488,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Refuel here first" fills the tank if the player can pay for it, else buys only the leg's shortfall**, so the button works whenever the leg is affordable at all. The engine's all-or-nothing refuel (15.6) then never refuses it for want of credits unless even the shortfall is unaffordable, which is what emergency fuel is for.
- **"Blocked only by fuel" is read from the sim**: fuel is `validateDispatch`'s last check, so a `not-enough-fuel` result means every other rule passed. The deadhead picker shows one "Refuel here first" for the ship, sized for its hungriest destination that is short only of fuel.
- **The cheapest fuel is marked in text ("· cheapest") as well as colour**, so it reads without the colour.
## Step 15.8
- **Body positions are computed once per app load**, not per frame: nothing about them can change. The per-frame scene still rebuilds flights, idle counts and discovery, which do.
- **The date-free wording drops "now" too** ("From Earth: …" rather than "From Earth now: …"): with a fixed map there is no other time to contrast it with.

View File

@ -7,10 +7,8 @@ import {
board,
dismissLogbook,
logbook,
nextMarsWindow,
playToDeal,
run,
serverNow,
state,
tryDispatch,
} from "./support";
@ -23,8 +21,7 @@ test.describe("end to end (Phase 12)", () => {
const clipper = owner.ships.find((s) => s.modelId === "clipper")!;
const veteran = owner.pilots.find((p) => p.isVeteran)!;
// A date when the Clipper can reach Mars, Long Haul for the trip, and fuel money.
await advanceTo(page, nextMarsWindow(await serverNow(page)));
// Long Haul for the trip, and fuel money (Mars is always in a Clipper's reach, CR-015).
await page.goto("/pilots");
const raise = page.getByRole("button", { name: `Raise Long Haul for ${veteran.name}` });
while ((await state(page)).pilots.find((p) => p.id === veteran.id)!.skillPointsAvailable > 0) {

View File

@ -1,6 +1,5 @@
import { content } from "@deadhead/content";
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim";
import { expect, type Page } from "@playwright/test";
/**
@ -99,27 +98,6 @@ export async function playToDeal(page: Page) {
return state(page);
}
/**
* The next real time (from `from`, stepping a game day at a time) at which a
* base Clipper can fly Earth → Mars and fly on from there (CR-012), so a Mars
* test doesn't depend on today's planet positions.
*/
export function nextMarsWindow(from: number): number {
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [earth, mars] = [content.location("earth")!, content.location("mars")!];
const gameDay = (24 * 3_600_000) / content.config.timeCompression;
for (let t = from; t < from + 1_500 * gameDay; t += gameDay) {
if (
checkRoute(clipper, earth, mars, content.config).ok &&
!wouldStrand(clipper, mars, 0, new Set(["earth", "moon", "mars"]), content)
) {
// Well inside the window: a few game days in.
return t + 3 * gameDay;
}
}
throw new Error("no Mars window found");
}
/** Tries to dispatch; returns the flight, or null if the engine refuses (not eligible). */
export async function tryDispatch(
page: Page,

View File

@ -83,32 +83,27 @@ function legsInRange(model: ShipModel): Set<string> {
}
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[];
/** Per origin, the locations in range (decision 0.5.93: always the same). */
fromOrigins: { originId: string; destinationIds: string[] }[];
/** Reachable only through other locations. */
byHopping: string[];
}
/**
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in
* range from each origin, and places it can reach only by hopping. On the fixed
* map (decision 0.5.93) `otherDates` is always empty; it goes with its wording
* in Step 15.8.
* range from each origin, and places it can reach only by hopping.
*/
export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
const ever = legsInRange(model);
const ids = content.locations.map((l) => l.id);
const origins = new Set(originIds);
const nowLegs = originIds.map((originId) => ({
const fromOrigins = originIds.map((originId) => ({
originId,
destinationIds: ids.filter(
(to) =>
to !== originId && checkRoute(model, location(originId), location(to), content.config).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];
@ -122,8 +117,7 @@ export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
}
}
return {
now: nowLegs,
otherDates: ids.filter((id) => direct.has(id) && !inRangeNow.has(id) && !origins.has(id)),
fromOrigins,
byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)),
};
}

View File

@ -22,7 +22,7 @@ export function playerOrigins(state: Pick<PlayerState, "bays" | "ships" | "locat
}
/**
* Who can fly to `locationId` right now, per origin (Steps 10A.16, 10L.8):
* Who can fly to `locationId`, 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

View File

@ -81,7 +81,24 @@ export function flightMarker(dispatch: Dispatch, now: number, modelId: string):
};
}
/** Everything the map draws at real time `now`, in map coordinates. */
/**
* Where each body sits on the map and its orbit ring's radius: worked out once,
* since the map is fixed (decision 0.5.93). Rings are decoration only.
*/
const BODIES = new Map(
content.locations.map((loc) => {
const sun = locationPosition(loc);
return [
loc.id,
{
point: locationMapPoint(anchorLocation(loc.id)),
orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)),
},
];
}),
);
/** Everything the map draws at real time `now`, in map coordinates; only flights move. */
export function buildScene(state: PlayerState, now: number): Scene {
const idleAt = (locationId: string) =>
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
@ -91,13 +108,13 @@ export function buildScene(state: PlayerState, now: number): Scene {
return {
locations: content.locations.map((loc) => {
const sun = locationPosition(loc);
const body = BODIES.get(loc.id)!;
return {
id: loc.id,
name: loc.name,
point: locationMapPoint(anchorLocation(loc.id)),
point: body.point,
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)),
orbitRadius: body.orbitRadius,
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
hasBay: state.bays.some((b) => b.locationId === loc.id),
idleShips: idleAt(loc.id),

View File

@ -108,7 +108,7 @@ export function explainProblems(
if (!route.ok) {
problems.push(
route.reason === "out-of-range"
? `Out of range right now (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId), content.config))}M km from ${locationName(contract.originId)})`
? `Out of range (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId), content.config))}M km from ${locationName(contract.originId)})`
: INELIGIBLE_REASONS["cannot-land"],
);
} else if (

View File

@ -146,7 +146,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
? reach
.map((o) => `From ${locationName(o.originId)}: ${o.reachers.join(", ")}`)
.join(" · ")
: "No ship can reach it from your bases right now."}
: "No ship can reach it from your bases."}
</p>
</>
)}

View File

@ -146,18 +146,17 @@ 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.
* "From Earth: 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);
const parts = [
...reach.now.map(
...reach.fromOrigins.map(
(leg) =>
`From ${locationName(leg.originId)} now: ${leg.destinationIds.length > 0 ? names(leg.destinationIds) : "nowhere"}`,
`From ${locationName(leg.originId)}: ${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;

View File

@ -0,0 +1,121 @@
import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema";
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 { layoutScene } from "../src/map/layout";
import { reachersByOrigin } from "../src/map/reach";
import { buildScene } from "../src/map/scene";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const DAY = 24 * 3_600_000;
const view = { width: 1280, height: 720 };
const camera = { zoom: 1.5, panX: 40, panY: -25 };
/** Date words the fixed map has no use for (CR-015 §2). */
const DATED = /\bnow\b|right now|other dates|on some dates|closest|farthest|opposition/i;
describe("the fixed map (Step 15.8, decision 0.5.93)", () => {
it("keeps every body in the same place on screen as the clock advances", () => {
const state = samplePlayerState();
const at = (t: number) =>
layoutScene(buildScene(state, t), view, camera).locations.map((l) => ({
id: l.id,
center: l.center,
}));
const first = at(NOW);
// Real minutes, game years: before CR-015 the planets moved over these.
for (const t of [NOW + 60_000, NOW + 30 * DAY, NOW + 400 * DAY]) {
expect(at(t)).toEqual(first);
}
});
it("draws an orbit ring for every body but the Moon, which rides with Earth", () => {
const scene = buildScene(samplePlayerState(), NOW);
for (const loc of scene.locations) {
if (loc.id === "moon") expect(loc.orbitRadius).toBeNull();
else expect(loc.orbitRadius, loc.id).toBeGreaterThan(0);
}
});
});
describe("reach wording without dates (Step 15.8)", () => {
afterEach(() => vi.useRealTimers());
it("says only where each ship can fly from, in the Market", async () => {
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState({ bays: [{ id: "b", locationId: "earth" }] }),
}),
"/market",
);
const clipper = (await screen.findByRole("button", { name: "Buy Clipper" })).closest("li")!;
const reach = within(clipper).getByLabelText("Reach").textContent ?? "";
expect(reach).toBe("Reach From Earth: Moon, Mars · By hopping: Ceres Station");
expect(reach).not.toMatch(DATED);
});
it("names who can reach an undiscovered place, per base, on the map", () => {
const lines = reachersByOrigin(
samplePlayerState({ bays: [{ id: "b", locationId: "earth" }] }),
"mars",
);
expect(lines).toEqual([
{ originId: "earth", reachers: ["Clipper", "Freighter", "Bulk Hauler"] },
]);
});
it("gives an out-of-range pair its reach without a date", async () => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
const marsJob: Contract = {
id: "c-mars",
originId: "earth",
destinationId: "mars",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 5,
payout: 50_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
const state = samplePlayerState({
tutorial: null,
ships: [
{
id: "s1",
name: "Dusty Mule",
modelId: "courier",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar",
...shipExtras("courier"),
},
],
pilots: [{ ...samplePlayerState().pilots[0]!, assignedShipId: "s1" }],
locations: content.locations.map((l) => ({ id: l.id, discovered: true })),
});
renderApp(
fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts: [marsJob], periodEndsAt: NOW + 3_600_000 }),
}),
"/contracts",
);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const problems = within(screen.getByRole("dialog")).getByLabelText("Problems");
expect(problems).toHaveTextContent(/Out of range \(Courier reaches \d+M km from Earth\)/);
expect(problems.textContent).not.toMatch(DATED);
});
});

View File

@ -33,12 +33,12 @@ describe("market roles and reach (Step 10L.7)", () => {
it("lists Mars from Earth for a Clipper, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", NOW);
expect(near).toContain("From Earth now: Moon, Mars");
expect(near).toContain("From Earth: Moon, Mars");
expect(near).toContain("By hopping: Ceres Station");
});
it("lists only the Moon for a Lighter", async () => {
const reach = await reachOf("Lighter", NOW);
expect(reach).toBe("Reach From Earth now: Moon");
expect(reach).toBe("Reach From Earth: Moon");
});
});

View File

@ -377,5 +377,5 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as
- [x] **Step 15.5 — Sim: stranding and emergency fuel.** `wouldStrand(model, destination, fuelOnArrivalFu, discoveredIds, content)` (decision 0.5.79 as amended): from the destination, some other discovered location must be within reach on a full tank where fuel is sold, otherwise on what is left after the leg (`fuelAfterLeg`). Dispatch and deadhead validation pass the fuel left; the dispatch sheet's explanation does too. `emergencyFuel({ ship, credits, discoveredIds })` (decision 0.5.99) returns `{ amountFu, price, cost }` or null: offered only for an idle ship whose fuel can't reach any other discovered location and whose company can't afford the shortfall for the cheapest leg it could fly on a full tank; the amount is that shortfall, the price 2.5 × local. Tests: `no-strand.test.ts` (no base ship is blocked on any leg it can fly between the five locations; with a fixture Mars that sells no fuel, a full-tank Clipper Earth → Mars is refused as `would-strand`, the same leg passes where Mars sells fuel, and fuel to spare after the leg passes); `emergency-fuel.test.ts` (offered when stuck and broke, with the exact amount, price and cost; not when the tank reaches somewhere, not when the credits cover the shortfall (one credit short does offer it), not in flight; only the cheapest leg's fuel when there are several ways out).
- [x] **Step 15.6 — Engine: commands and save migration.** (The save fields, the 1 → 2 migration and full tanks on new ships came in 15.4.) New commands in the route catalog: `refuelShip` (`{ shipId, amountFu? }`: fill, or buy an amount, at the local price; the ship must be idle at a location that sells fuel; refused with `insufficient-funds` unless the player can pay for all of it, `tank-full`, `no-fuel-here`, `ship-not-idle-here`), `setRefuelPolicy` (`{ shipId, policy }`), `orderEmergencyFuel` (`{ shipId }`: only when the sim offers it, else `emergency-fuel-unavailable`; adds the fuel at 2.5 × the local price into the tank's average and the cost to the loan). Tests (`packages/engine/test/fuel.test.ts`): an older (version-1) save imported through the engine has every ship full on Auto; refuel of an amount charges the local price and keeps the weighted average, fill tops up to capacity, a full tank is refused; refuel with no credits fails and changes nothing; the policy toggles; emergency fuel is refused when not needed, and when needed adds exactly the shortfall and raises the loan by exactly its cost, credits untouched. A new ship arriving full is tested since 15.4.
- [x] **Step 15.7 — Client: fuel in the UI.** `src/components/Fuel.tsx`: `FuelGauge` (a meter from empty to capacity, "N fu · P%", the refuel policy), `ShipFuel` sheet (price here; Fill with its cost; buy an amount; Auto top-up / Manual; "Emergency fuel (added to loan)" with its amount and cost, shown only when the sim offers it), `FuelPrice` (a location's price, the cheapest marked "cheapest" in green), `refuelForLeg` ("Refuel here first" buys a full tank when the credits cover it, otherwise just the leg's shortfall). Fleet: every ship card shows the gauge and a Fuel button beside Upgrades. Dispatch sheet: every pair shows "Fuel needed · on board · left after"; the cost cell is "Fuel used (at your average price)"; a pair blocked only by fuel (the sim's last check) gets "Refuel here first", which refuels, keeps the sheet open and picks the pair once flyable. Deadhead picker: fuel on board, per destination "Needs N fu · left after M fu", and "Refuel here first" when any destination is short only of fuel. Locations screen and map panel: a Fuel line per location. Delivery summaries: "Topped up N fu at X, C cr" for Auto top-ups; deadheads say "fuel used". Tests (`apps/web/test/fuel-ui.test.tsx`): the gauge's values and text match the ship; "Refuel here first" fills the tank and enables and picks the pair with the sheet still open; it buys just the shortfall when a fill is unaffordable; the fuel-used cell matches the sim at the tank's average price; Jupiter's fuel is marked cheapest and Earth's isn't; emergency fuel shows (with the sim's cost) only for a stranded, broke ship.
- [ ] Step 15.8 — Client: fixed map
- [x] **Step 15.8 — Client: fixed map.** The map works out each body's position and orbit ring once (`BODIES` in `src/map/scene.ts`); each frame only flights move along their fixed lines. Orbit rings stay as decoration (none for the Moon). Reach wording without dates: the Market says "From Earth: Moon, Mars · By hopping: Ceres Station" (`shipReach` returns `fromOrigins` and `byHopping`; `otherDates` is gone), the map panel says "No ship can reach it from your bases.", and the dispatch sheet's "Out of range right now (…)" is "Out of range (…)". The browser tests' `nextMarsWindow` date search is gone. Tests (`apps/web/test/fixed-map.test.tsx`): every body's on-screen centre is identical a minute, a month and 400 days on; orbit rings for every body but the Moon; the Market reach line is exactly "From Earth: Moon, Mars · By hopping: Ceres Station" with no date words; the map lists "From Earth: Clipper, Freighter, Bulk Hauler" for Mars; an out-of-range pair names the ship's reach without a date.
- [ ] Step 15.9 — Plan amendments and regression