diff --git a/DECISIONS.md b/DECISIONS.md index b5d3387..f3e3d27 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/e2e/12-5-discovery.e2e.ts b/apps/web/e2e/12-5-discovery.e2e.ts index 111f52a..93a8268 100644 --- a/apps/web/e2e/12-5-discovery.e2e.ts +++ b/apps/web/e2e/12-5-discovery.e2e.ts @@ -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) { diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index abef0e5..e65cf1f 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -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, diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index 97aa17c..fafd2c0 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -83,32 +83,27 @@ function legsInRange(model: ShipModel): Set { } 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)), }; } diff --git a/apps/web/src/map/reach.ts b/apps/web/src/map/reach.ts index 1500ee2..5639a47 100644 --- a/apps/web/src/map/reach.ts +++ b/apps/web/src/map/reach.ts @@ -22,7 +22,7 @@ export function playerOrigins(state: Pick { + 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), diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index a33fca7..70bbf95 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -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 ( diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index c7fe65d..34500f5 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -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."}

)} diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index bc611f3..f60ebc5 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -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; diff --git a/apps/web/test/fixed-map.test.tsx b/apps/web/test/fixed-map.test.tsx new file mode 100644 index 0000000..cb3d768 --- /dev/null +++ b/apps/web/test/fixed-map.test.tsx @@ -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); + }); +}); diff --git a/apps/web/test/market-reach.test.tsx b/apps/web/test/market-reach.test.tsx index 72eac23..9ebfeed 100644 --- a/apps/web/test/market-reach.test.tsx +++ b/apps/web/test/market-reach.test.tsx @@ -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"); }); }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8b409e2..da21e6f 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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