Step 10L.2: effective range from origin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:18:50 -04:00
parent faa23d2945
commit 0a0189b3e7
6 changed files with 68 additions and 5 deletions

View File

@ -1135,3 +1135,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Handling time becomes per model where set** (`ShipModel.handlingGameHoursPerTonne`, optional, defaulting to the game-wide value); upgrades scale the model's own base.
- **Roles are content** (`role`), one line per model including the loaner, for the Market (10L.7).
- **The new sprites are listed in the manifest as pending** (`file: null`) so the "every ship has a sprite in the manifest" test holds; they use the placeholder until art arrives (10L.10).
## Step 10L.2
- **The factor applies to the whole leg from the origin** (decision 0.5.78): `checkRoute` is the single range check, so dispatch, reposition, the board's eligibility and the map pick it up together. The destination's factor plays no part in that leg.
- **The dispatch sheet quotes range from the origin**, rounded, e.g. "reaches 130M km from Moon", so the number matches what the check used.

View File

@ -4,6 +4,7 @@ import {
checkRoute,
contractTripGameHours,
isInShop,
rangeFrom,
pilotShare,
dutyAfterTrip,
LONG_HAUL_SKILL_ID,
@ -92,7 +93,7 @@ export function explainProblems(
if (!route.ok) {
problems.push(
route.reason === "out-of-range"
? `Out of range right now (${model.name} reaches ${model.fuelRangeMillionKm}M km)`
? `Out of range right now (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId)))}M km from ${locationName(contract.originId)})`
: INELIGIBLE_REASONS["cannot-land"],
);
}

View File

@ -270,7 +270,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as "CR-009 … Phase 10I"). New decisions recorded as plan §0.5.77–0.5.81; §0.5.67 and §0.5.68 amended.
- [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`).
- [ ] Step 10L.2 — Sim: effective range from origin
- [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.
- [ ] Step 10L.3 — Sim: no-strand rule
- [ ] Step 10L.4 — Content tuning: reach table
- [ ] Step 10L.5 — Upgrade caps for the new lineup

View File

@ -11,7 +11,18 @@ export function canLandAt(ship: ShipModel, location: Location): boolean {
return LANDING_RANK[ship.landingClass] >= LANDING_RANK[location.landingRequirement];
}
/** Whether a ship can fly from one location to another at a game time, or why not. */
/**
* A ship's range for a leg launched from `from` (decisions 0.5.77, 0.5.78):
* its (effective) range times the origin's launch range factor.
*/
export function rangeFrom(ship: ShipModel, from: Location): number {
return ship.fuelRangeMillionKm * from.launchRangeFactor;
}
/**
* Whether a ship can fly from one location to another at a game time, or why
* not. Range is per leg, from the origin, at departure (decision 0.5.77).
*/
export function checkRoute(
ship: ShipModel,
from: Location,
@ -19,7 +30,7 @@ export function checkRoute(
gameTime: GameTime,
): RouteCheck {
if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" };
if (routeDistance(from, to, gameTime) > ship.fuelRangeMillionKm) {
if (routeDistance(from, to, gameTime) > rangeFrom(ship, from)) {
return { ok: false, reason: "out-of-range" };
}
return { ok: true };

View File

@ -14,7 +14,7 @@ export { routeDistance } from "./distance";
export { travelTimeGameHours } from "./travel";
export { fuelCost } from "./fuel";
export { canLandAt, checkRoute } from "./eligibility";
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
export type { RouteCheck } from "./eligibility";
export { contractPayout } from "./contract";

View File

@ -0,0 +1,46 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, rangeFrom, realToGame, routeDistance } from "../src/index";
const loc = (id: string) => content.location(id)!;
const courier = content.shipModel("courier")!;
const withRange = (range: number): ShipModel => ({ ...courier, fuelRangeMillionKm: range });
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);
describe("range from the origin (Step 10L.2)", () => {
it("stretches a 100M km range to 130 from the Moon and 115 from Mars", () => {
expect(rangeFrom(withRange(100), loc("earth"))).toBe(100);
expect(rangeFrom(withRange(100), loc("moon"))).toBeCloseTo(130);
expect(rangeFrom(withRange(100), loc("mars"))).toBeCloseTo(115);
});
it("passes a leg from the Moon that fails from Earth when it is between 1× and 1.3× range", () => {
const t = samples[0]!;
const d = routeDistance(loc("moon"), loc("mars"), t);
const ship = withRange(d / 1.2);
expect(routeDistance(loc("earth"), loc("mars"), t)).toBeGreaterThan(ship.fuelRangeMillionKm);
expect(checkRoute(ship, loc("earth"), loc("mars"), t)).toEqual({
ok: false,
reason: "out-of-range",
});
expect(checkRoute(ship, loc("moon"), loc("mars"), t)).toEqual({ ok: true });
});
it("matches the old check exactly when every factor is 1", () => {
const flat = (l: Location): Location => ({ ...l, launchRangeFactor: 1 });
const ids = ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"];
for (const range of [5, 198, 450, 1250]) {
for (const from of ids) {
for (const to of ids) {
if (from === to) continue;
for (const t of samples) {
const old = routeDistance(loc(from), loc(to), t) <= range;
expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to)), t).ok).toBe(old);
}
}
}
}
});
});