Step 15.8: client: fixed map
CI / check (push) Has been cancelled
Details
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:
parent
7a061d0e40
commit
420d4c47f0
|
|
@ -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.
|
- **"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.
|
- **"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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ import {
|
||||||
board,
|
board,
|
||||||
dismissLogbook,
|
dismissLogbook,
|
||||||
logbook,
|
logbook,
|
||||||
nextMarsWindow,
|
|
||||||
playToDeal,
|
playToDeal,
|
||||||
run,
|
run,
|
||||||
serverNow,
|
|
||||||
state,
|
state,
|
||||||
tryDispatch,
|
tryDispatch,
|
||||||
} from "./support";
|
} from "./support";
|
||||||
|
|
@ -23,8 +21,7 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
const clipper = owner.ships.find((s) => s.modelId === "clipper")!;
|
const clipper = owner.ships.find((s) => s.modelId === "clipper")!;
|
||||||
const veteran = owner.pilots.find((p) => p.isVeteran)!;
|
const veteran = owner.pilots.find((p) => p.isVeteran)!;
|
||||||
|
|
||||||
// A date when the Clipper can reach Mars, Long Haul for the trip, and fuel money.
|
// Long Haul for the trip, and fuel money (Mars is always in a Clipper's reach, CR-015).
|
||||||
await advanceTo(page, nextMarsWindow(await serverNow(page)));
|
|
||||||
await page.goto("/pilots");
|
await page.goto("/pilots");
|
||||||
const raise = page.getByRole("button", { name: `Raise Long Haul for ${veteran.name}` });
|
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) {
|
while ((await state(page)).pilots.find((p) => p.id === veteran.id)!.skillPointsAvailable > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
|
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
|
||||||
import { checkRoute, effectiveStats, wouldStrand } from "@deadhead/sim";
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,27 +98,6 @@ export async function playToDeal(page: Page) {
|
||||||
return state(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). */
|
/** Tries to dispatch; returns the flight, or null if the engine refuses (not eligible). */
|
||||||
export async function tryDispatch(
|
export async function tryDispatch(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|
|
||||||
|
|
@ -83,32 +83,27 @@ function legsInRange(model: ShipModel): Set<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShipReach {
|
export interface ShipReach {
|
||||||
/** Per origin, the locations in range right now. */
|
/** Per origin, the locations in range (decision 0.5.93: always the same). */
|
||||||
now: { originId: string; destinationIds: string[] }[];
|
fromOrigins: { originId: string; destinationIds: string[] }[];
|
||||||
/** Reachable directly from an origin, but not right now. */
|
|
||||||
otherDates: string[];
|
|
||||||
/** Reachable only through other locations. */
|
/** Reachable only through other locations. */
|
||||||
byHopping: string[];
|
byHopping: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in
|
* 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
|
* range from each origin, and places it can reach only by hopping.
|
||||||
* map (decision 0.5.93) `otherDates` is always empty; it goes with its wording
|
|
||||||
* in Step 15.8.
|
|
||||||
*/
|
*/
|
||||||
export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
|
export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
|
||||||
const ever = legsInRange(model);
|
const ever = legsInRange(model);
|
||||||
const ids = content.locations.map((l) => l.id);
|
const ids = content.locations.map((l) => l.id);
|
||||||
const origins = new Set(originIds);
|
const origins = new Set(originIds);
|
||||||
const nowLegs = originIds.map((originId) => ({
|
const fromOrigins = originIds.map((originId) => ({
|
||||||
originId,
|
originId,
|
||||||
destinationIds: ids.filter(
|
destinationIds: ids.filter(
|
||||||
(to) =>
|
(to) =>
|
||||||
to !== originId && checkRoute(model, location(originId), location(to), content.config).ok,
|
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 direct = new Set(ids.filter((to) => originIds.some((o) => ever.has(legKey(o, to)))));
|
||||||
const reached = new Set(originIds);
|
const reached = new Set(originIds);
|
||||||
const queue = [...originIds];
|
const queue = [...originIds];
|
||||||
|
|
@ -122,8 +117,7 @@ export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
now: nowLegs,
|
fromOrigins,
|
||||||
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)),
|
byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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
|
* 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
|
* origin that get there only thanks to their upgrades (CR-009). Origins with
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export function buildScene(state: PlayerState, now: number): Scene {
|
||||||
const idleAt = (locationId: string) =>
|
const idleAt = (locationId: string) =>
|
||||||
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
|
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
|
||||||
|
|
@ -91,13 +108,13 @@ export function buildScene(state: PlayerState, now: number): Scene {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locations: content.locations.map((loc) => {
|
locations: content.locations.map((loc) => {
|
||||||
const sun = locationPosition(loc);
|
const body = BODIES.get(loc.id)!;
|
||||||
return {
|
return {
|
||||||
id: loc.id,
|
id: loc.id,
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
point: locationMapPoint(anchorLocation(loc.id)),
|
point: body.point,
|
||||||
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
|
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),
|
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
|
||||||
hasBay: state.bays.some((b) => b.locationId === loc.id),
|
hasBay: state.bays.some((b) => b.locationId === loc.id),
|
||||||
idleShips: idleAt(loc.id),
|
idleShips: idleAt(loc.id),
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export function explainProblems(
|
||||||
if (!route.ok) {
|
if (!route.ok) {
|
||||||
problems.push(
|
problems.push(
|
||||||
route.reason === "out-of-range"
|
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"],
|
: INELIGIBLE_REASONS["cannot-land"],
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
||||||
? reach
|
? reach
|
||||||
.map((o) => `From ${locationName(o.originId)}: ${o.reachers.join(", ")}`)
|
.map((o) => `From ${locationName(o.originId)}: ${o.reachers.join(", ")}`)
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
: "No ship can reach it from your bases right now."}
|
: "No ship can reach it from your bases."}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* 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 }) {
|
function Reach({ model }: { model: ShipModel }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const origins = [...new Set(state.bays.map((b) => b.locationId))];
|
const origins = [...new Set(state.bays.map((b) => b.locationId))];
|
||||||
const reach = shipReach(model, origins);
|
const reach = shipReach(model, origins);
|
||||||
const parts = [
|
const parts = [
|
||||||
...reach.now.map(
|
...reach.fromOrigins.map(
|
||||||
(leg) =>
|
(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)}`] : []),
|
...(reach.byHopping.length > 0 ? [`By hopping: ${names(reach.byHopping)}`] : []),
|
||||||
];
|
];
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
it("lists Mars from Earth for a Clipper, and Ceres by hopping", async () => {
|
||||||
const near = await reachOf("Clipper", NOW);
|
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");
|
expect(near).toContain("By hopping: Ceres Station");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists only the Moon for a Lighter", async () => {
|
it("lists only the Moon for a Lighter", async () => {
|
||||||
const reach = await reachOf("Lighter", NOW);
|
const reach = await reachOf("Lighter", NOW);
|
||||||
expect(reach).toBe("Reach From Earth now: Moon");
|
expect(reach).toBe("Reach From Earth: Moon");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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
|
- [ ] Step 15.9 — Plan amendments and regression
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue