diff --git a/DECISIONS.md b/DECISIONS.md index 54dda70..29450a5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 0dbfb49..67be7b1 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -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"], ); } diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e7a076f..0657655 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/sim/src/eligibility.ts b/packages/sim/src/eligibility.ts index 01caaf8..1d68b17 100644 --- a/packages/sim/src/eligibility.ts +++ b/packages/sim/src/eligibility.ts @@ -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 }; diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 508ad09..7c0f982 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -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"; diff --git a/packages/sim/test/range-from.test.ts b/packages/sim/test/range-from.test.ts new file mode 100644 index 0000000..dae3a70 --- /dev/null +++ b/packages/sim/test/range-from.test.ts @@ -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); + } + } + } + } + }); +});