Step 10L.4: reach table tuning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:32:01 -04:00
parent 60e29f7b1d
commit 776a890757
6 changed files with 119 additions and 12 deletions

View File

@ -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. - **`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. - **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". - **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.

View File

@ -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. 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. 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. 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. 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. 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. 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.

View File

@ -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.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.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. - [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.5 — Upgrade caps for the new lineup
- [ ] Step 10L.6 — Schema and server - [ ] Step 10L.6 — Schema and server
- [ ] Step 10L.7 — Client: Market shows roles and reach - [ ] Step 10L.7 — Client: Market shows roles and reach

View File

@ -72,7 +72,7 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 2_000_000, cruiseSpeedKmPerGameHour: 2_000_000,
cargoCapacityTonnes: 100, cargoCapacityTonnes: 100,
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25). // Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
fuelRangeMillionKm: 650, fuelRangeMillionKm: 450,
hullRating: 2, hullRating: 2,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 4, overheadGameHours: 4,
@ -89,7 +89,7 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 1_200_000, cruiseSpeedKmPerGameHour: 1_200_000,
cargoCapacityTonnes: 400, cargoCapacityTonnes: 400,
// Jupiter at its farthest. // Jupiter at its farthest.
fuelRangeMillionKm: 1000, fuelRangeMillionKm: 1250,
hullRating: 3, hullRating: 3,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 8, overheadGameHours: 8,

View File

@ -32,13 +32,14 @@ function extremeTime(to: typeof mars, pick: "closest" | "farthest"): number {
return best; return best;
} }
// Origins are named in every test (decision 0.5.77: range is per leg, from its origin).
describe("route eligibility", () => { describe("route eligibility", () => {
it("lets a Courier fly Earth→Moon and Moon→Earth", () => { it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
expect(checkRoute(courier, earth, moon, start)).toEqual({ ok: true }); expect(checkRoute(courier, earth, moon, start)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth, 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]) { for (const to of [mars, ceres, jupiter]) {
expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({ expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({
ok: false, ok: false,
@ -55,22 +56,28 @@ describe("route eligibility", () => {
expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true }); expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true });
}); });
it("lets a Freighter reach Mars and Ceres even at their farthest", () => { it("lets a Freighter from Earth reach Mars even at its farthest", () => {
for (const to of [mars, ceres]) { expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).toEqual({ ok: true });
expect(checkRoute(freighter, earth, to, extremeTime(to, "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"); const t = extremeTime(jupiter, "farthest");
expect(checkRoute(bulkHauler, earth, jupiter, t)).toEqual({ ok: true }); expect(checkRoute(bulkHauler, earth, jupiter, t)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, jupiter, t).ok).toBe(false); expect(checkRoute(freighter, earth, jupiter, t).ok).toBe(false);
expect(checkRoute(courier, 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({ expect(checkRoute(freighter, earth, jupiter, extremeTime(jupiter, "closest"))).toEqual({
ok: true, ok: false,
reason: "out-of-range",
}); });
}); });
}); });

View File

@ -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");
});
});
});