From 776a8907574ac3acbb70957ce5ed3bf283db9f51 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 01:32:01 -0400 Subject: [PATCH] Step 10L.4: reach table tuning Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 +++ memory-bank/implementation-plan.md | 2 +- memory-bank/progress.md | 2 +- packages/content/src/data/ships.ts | 4 +- packages/sim/test/eligibility.test.ts | 23 ++++--- packages/sim/test/reach-table.test.ts | 91 +++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 packages/sim/test/reach-table.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 35d560f..a188012 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1148,3 +1148,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **`discoveredIds` is optional on a contract proposal**; absent means every location counts. Tutorial runs and older callers keep working, and the client's eligibility (which doesn't pass it yet) is lenient until 10L.9; the server's check is the one that counts. - **The rule only looks at arrival plus margin**, as the CR says. A ship may land somewhere it can't leave at once but can within the margin: with today's ranges a Clipper flying Moon→Mars can land on a date when it can't leave Mars right away but can 48 hours later. The exhaustive test checks the margin time, not the arrival. - **Tests use a fixture** (a Moon with factor 0.5 and a "moon-station" sharing its orbit) because no real ship and date strands cleanly enough to also show "passes once another location is discovered". + +## Step 10L.4 + +- **Ranges come from the measured leg distances over 16 game years** (monthly): Earth–Mars 58.9–399.6, Moon–Mars 58.5–400.0, Earth–Ceres 262.7–565.8, Moon–Ceres 262.8–565.9, Mars–Ceres 175.5–662.8, Ceres–Jupiter 378.9–1,216.8, Earth–Jupiter 592.8–964.8, Moon–Jupiter 592.4–965.1, Mars–Jupiter 526.9–1,029.8 M km. +- **Freighter 650 → 450.** It must cover Earth→Mars at its farthest (≥ 400), but never Moon→Jupiter (450 × 1.3 = 585 < 592.4) or Mars→Jupiter (450 × 1.15 = 517.5 < 526.9); from Ceres (585) it reaches Jupiter on some dates. 450 also clears Earth→Ceres on most dates. +- **Bulk Hauler 1000 → 1250**, so every leg is always in range, including Jupiter→Ceres at its farthest (1,216.8) with a factor of 1. +- **Clipper stays at 198**: Earth→Mars is in range on just over 30 % of dates, and Moon→Ceres (198 × 1.3 = 257.4) stays below Ceres's closest approach (262.8). The margins are small, so the Clipper's range shouldn't move without re-running this table. +- **Decision 0.5.25 is superseded** (noted in the plan): the Freighter no longer reaches Jupiter from Earth at all. +- **No other stats changed.** The existing upgrade-cap tests (0.5.67) and the Courier-to-Mars test (0.5.68) still pass at these values; 10L.5 adds the new caps. diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index 42d4c05..b1ae356 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -80,7 +80,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 22. **Pilot endpoints.** The route catalog includes "unassign pilot", and "hire pilot" takes the bay location to hire at. 23. **Avatar during tutorial.** The avatar is locked to the loaner and cannot be assigned to an owned ship until the tutorial ends. 24. **Repositions need no skills.** A reposition checks range and landing only; no Long Haul or cargo skill is required. -25. **Freighter range** covers Ceres at its farthest from Earth (≈600M km), so it can also reach Jupiter at its closest. A ship left out of range of every bay waits until distances shrink; this is intended. +25. **Freighter range** covers Ceres at its farthest from Earth (≈600M km), so it can also reach Jupiter at its closest. *(Superseded by CR-012, decision 0.5.81: a Freighter reaches Ceres from Earth on most dates, not all, and never reaches Jupiter from Earth; it gets there by hopping via Ceres.)* A ship left out of range of every bay waits until distances shrink; this is intended. 26. **Loaner in player state.** Player state has a `tutorial` object with the loaner's location and whether a run is in flight. The loaner is not in the ships list. 27. **Pilot location storage.** Only unassigned pilots store a location; an assigned pilot's location is derived from their ship. 28. **Git hosting.** The repo is on a self-hosted Gitea instance: `http://192.168.1.107:3000/rory/deadhead-space-freight-tycoon`. CI uses Gitea Actions. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d60d38a..e2a7dff 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -272,7 +272,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as - [x] **Step 10L.1 — Content: range factors and new ships.** `Location.launchRangeFactor` (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). `ShipModel.role` (all six) and optional `ShipModel.handlingGameHoursPerTonne` (the sim's effective stats now start from it). New models: **Lighter** (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and **Clipper** (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes `lighter`, `clipper`. Config `noStrandMarginGameHours` 48. Manifest lists `ship-lighter` / `ship-clipper` as pending art. Tests: the CR's orderings replace Step 2.3's (`ships.test.ts`), range factors (`locations.test.ts`), Lighter handling (`effective-stats.test.ts`). - [x] **Step 10L.2 — Sim: effective range from origin.** `rangeFrom(ship, from)` = range × origin's `launchRangeFactor`; `checkRoute` compares the leg against it (dispatch, reposition, board eligibility and the map all go through it). The dispatch sheet quotes range from the origin. Tests (`range-from.test.ts`): 100 → 130 from the Moon and 115 from Mars; a leg between 1× and 1.3× fails from Earth and passes from the Moon; with every factor 1 the result matches the old check over 16 years of samples. - [x] **Step 10L.3 — Sim: no-strand rule.** `wouldStrand(stats, origin, destination, mass, departure, discoveredIds, content)`: at arrival plus `noStrandMarginGameHours` (48), can the ship reach any other discovered location from the destination with its range from there? `validateDispatch` (new optional `discoveredIds`; absent = all) and `validateReposition` fail with `would-strand` after range and landing. The server passes the player's discovered ids for contract dispatches; server and web reason maps gained the code (full wording in 10L.6/10L.9). Tests (`no-strand.test.ts`): a fixture heavy-Moon hop is refused, then passes once a reachable location is discovered; the same for a contract; across all six models, every ordered pair and monthly samples over 16 years, an allowed trip always leaves somewhere to fly at arrival plus margin. -- [ ] Step 10L.4 — Content tuning: reach table +- [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. - [ ] Step 10L.5 — Upgrade caps for the new lineup - [ ] Step 10L.6 — Schema and server - [ ] Step 10L.7 — Client: Market shows roles and reach diff --git a/packages/content/src/data/ships.ts b/packages/content/src/data/ships.ts index 26655a6..7f55a23 100644 --- a/packages/content/src/data/ships.ts +++ b/packages/content/src/data/ships.ts @@ -72,7 +72,7 @@ export const shipModels: ShipModel[] = [ cruiseSpeedKmPerGameHour: 2_000_000, cargoCapacityTonnes: 100, // Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25). - fuelRangeMillionKm: 650, + fuelRangeMillionKm: 450, hullRating: 2, landingClass: "atmospheric", overheadGameHours: 4, @@ -89,7 +89,7 @@ export const shipModels: ShipModel[] = [ cruiseSpeedKmPerGameHour: 1_200_000, cargoCapacityTonnes: 400, // Jupiter at its farthest. - fuelRangeMillionKm: 1000, + fuelRangeMillionKm: 1250, hullRating: 3, landingClass: "atmospheric", overheadGameHours: 8, diff --git a/packages/sim/test/eligibility.test.ts b/packages/sim/test/eligibility.test.ts index bccd327..f668bcb 100644 --- a/packages/sim/test/eligibility.test.ts +++ b/packages/sim/test/eligibility.test.ts @@ -32,13 +32,14 @@ function extremeTime(to: typeof mars, pick: "closest" | "farthest"): number { return best; } +// Origins are named in every test (decision 0.5.77: range is per leg, from its origin). describe("route eligibility", () => { it("lets a Courier fly Earth→Moon and Moon→Earth", () => { expect(checkRoute(courier, earth, moon, start)).toEqual({ ok: true }); expect(checkRoute(courier, moon, earth, start)).toEqual({ ok: true }); }); - it("keeps a Courier out of range of Mars, Ceres and Jupiter even at their closest", () => { + it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter even at their closest", () => { for (const to of [mars, ceres, jupiter]) { expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({ ok: false, @@ -55,22 +56,28 @@ describe("route eligibility", () => { expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true }); }); - it("lets a Freighter reach Mars and Ceres even at their farthest", () => { - for (const to of [mars, ceres]) { - expect(checkRoute(freighter, earth, to, extremeTime(to, "farthest"))).toEqual({ ok: true }); - } + it("lets a Freighter from Earth reach Mars even at its farthest", () => { + expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).toEqual({ ok: true }); }); - it("lets only the Bulk Hauler reach Jupiter at its farthest", () => { + it("lets a Freighter from Earth reach Ceres at its closest but not its farthest (CR-012)", () => { + expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "closest"))).toEqual({ + ok: true, + }); + expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "farthest")).ok).toBe(false); + }); + + it("lets only the Bulk Hauler reach Jupiter from Earth at its farthest", () => { const t = extremeTime(jupiter, "farthest"); expect(checkRoute(bulkHauler, earth, jupiter, t)).toEqual({ ok: true }); expect(checkRoute(freighter, earth, jupiter, t).ok).toBe(false); expect(checkRoute(courier, earth, jupiter, t).ok).toBe(false); }); - it("lets a Freighter reach Jupiter at its closest (decision 0.5.25)", () => { + it("keeps a Freighter from Earth out of Jupiter's range even at its closest (CR-012 amends 0.5.25)", () => { expect(checkRoute(freighter, earth, jupiter, extremeTime(jupiter, "closest"))).toEqual({ - ok: true, + ok: false, + reason: "out-of-range", }); }); }); diff --git a/packages/sim/test/reach-table.test.ts b/packages/sim/test/reach-table.test.ts new file mode 100644 index 0000000..525dbe2 --- /dev/null +++ b/packages/sim/test/reach-table.test.ts @@ -0,0 +1,91 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { checkRoute, realToGame } from "../src/index"; + +/** CR-012 §3: reach targets for base ships, monthly over 16 game years (decision 0.5.81). */ +const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); +const MONTH = 30.44 * 24 * 3_600_000; +const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH); +const ids = content.locations.map((l) => l.id); + +/** Share of sampled dates on which the leg is in range for the base ship. */ +function share(modelId: string, fromId: string, toId: string): number { + const ship = content.shipModel(modelId)!; + const from = content.location(fromId)!; + const to = content.location(toId)!; + return samples.filter((t) => checkRoute(ship, from, to, t).ok).length / samples.length; +} +const always = (m: string, from: string, to: string) => expect(share(m, from, to)).toBe(1); +const never = (m: string, from: string, to: string) => expect(share(m, from, to)).toBe(0); +const some = (m: string, from: string, to: string) => { + const s = share(m, from, to); + expect(s).toBeGreaterThan(0); + expect(s).toBeLessThan(1); + return s; +}; + +describe("reach table (Step 10L.4)", () => { + for (const model of ["courier", "lighter"]) { + describe(model, () => { + it("flies Earth↔Moon always", () => { + always(model, "earth", "moon"); + always(model, "moon", "earth"); + }); + it("never reaches Mars from Earth", () => never(model, "earth", "mars")); + it("never reaches Mars from the Moon", () => never(model, "moon", "mars")); + }); + } + + describe("clipper", () => { + it("reaches Mars from Earth on some dates, at least 30 %", () => { + expect(some("clipper", "earth", "mars")).toBeGreaterThanOrEqual(0.3); + }); + it("reaches Mars from the Moon on some dates, more than from Earth", () => { + expect(some("clipper", "moon", "mars")).toBeGreaterThan(share("clipper", "earth", "mars")); + }); + it("reaches Ceres from Mars on some dates", () => some("clipper", "mars", "ceres-station")); + it("never reaches Ceres from Earth", () => never("clipper", "earth", "ceres-station")); + it("never reaches Ceres from the Moon", () => never("clipper", "moon", "ceres-station")); + it("never reaches Jupiter from anywhere", () => { + for (const from of ids) + if (from !== "jupiter-cloud-platform") { + never("clipper", from, "jupiter-cloud-platform"); + } + }); + }); + + describe("freighter", () => { + it("reaches Mars from Earth always", () => always("freighter", "earth", "mars")); + it("reaches Ceres from Earth on most dates", () => { + expect(some("freighter", "earth", "ceres-station")).toBeGreaterThan(0.5); + }); + it("reaches Jupiter from Ceres on some dates", () => + some("freighter", "ceres-station", "jupiter-cloud-platform")); + it("never reaches Jupiter from Earth", () => + never("freighter", "earth", "jupiter-cloud-platform")); + it("never reaches Jupiter from the Moon", () => + never("freighter", "moon", "jupiter-cloud-platform")); + it("never reaches Jupiter from Mars", () => + never("freighter", "mars", "jupiter-cloud-platform")); + }); + + describe("bulk-hauler", () => { + it("reaches Jupiter from Earth always", () => + always("bulk-hauler", "earth", "jupiter-cloud-platform")); + it("flies every other leg between the five locations always", () => { + for (const from of ids) + for (const to of ids) if (from !== to) always("bulk-hauler", from, to); + }); + }); + + describe("hops", () => { + it("a Clipper can't reach Ceres from Earth on any date but can from Mars on some", () => { + never("clipper", "earth", "ceres-station"); + some("clipper", "mars", "ceres-station"); + }); + it("a Freighter can reach Jupiter from Ceres on some dates but never directly from Earth", () => { + some("freighter", "ceres-station", "jupiter-cloud-platform"); + never("freighter", "earth", "jupiter-cloud-platform"); + }); + }); +});