From 0e5faab454d4cfbe0027c5a3b7deef93c725c873 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:43:25 -0400 Subject: [PATCH] Step 3.7: add range and landing eligibility Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++ memory-bank/progress.md | 2 +- packages/sim/src/eligibility.ts | 26 +++++++++ packages/sim/src/index.ts | 1 + packages/sim/test/eligibility.test.ts | 76 +++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/eligibility.ts create mode 100644 packages/sim/test/eligibility.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index f453b6a..a9bbd33 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -102,3 +102,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 3.5 - **Travel time takes the distance as a number**, not two locations and a time. The caller measures the distance at departure, so the function cannot accidentally use a mid-flight distance. + +## Step 3.7 + +- **Landing is checked before range**, so a ship that can neither land nor reach reports `cannot-land`. +- **The route check looks only at the destination's landing requirement.** A ship is always already sitting at its origin, so it can take off from there. +- **Range tests find each planet's closest and farthest point from Earth by sampling** every 4 days over 12 years. All Step 2.3 range choices hold, including decision 0.5.25 (a Freighter reaches Jupiter at its closest). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c81f167..21fad6d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -25,7 +25,7 @@ - [x] **Step 3.4 — Route distance.** `src/distance.ts`: `routeDistance(from, to, gameTime)`, straight-line million km. - [x] **Step 3.5 — Travel time.** `src/travel.ts`: `travelTimeGameHours(ship, distance)` = cruise time + overhead. - [x] **Step 3.6 — Fuel cost.** `src/fuel.ts`: `fuelCost(ship, origin, distance)` = distance × cost per million km × origin launch multiplier. -- [ ] Step 3.7 — Range and landing eligibility +- [x] **Step 3.7 — Range and landing eligibility.** `src/eligibility.ts`: `checkRoute` returns `ok` or `cannot-land` / `out-of-range`; `canLandAt` ranks vacuum < atmospheric < hardened. Step 2.3 ranges confirmed against sampled real distances. - [ ] Step 3.8 — Contract model and payout - [ ] Step 3.9 — Contract board generation - [ ] Step 3.10 — Tutorial contract board diff --git a/packages/sim/src/eligibility.ts b/packages/sim/src/eligibility.ts new file mode 100644 index 0000000..01caaf8 --- /dev/null +++ b/packages/sim/src/eligibility.ts @@ -0,0 +1,26 @@ +import type { LandingClass, Location, ShipModel } from "@deadhead/content"; +import type { GameTime } from "./clock"; +import { routeDistance } from "./distance"; + +const LANDING_RANK: Record = { vacuum: 0, atmospheric: 1, hardened: 2 }; + +export type RouteCheck = { ok: true } | { ok: false; reason: "cannot-land" | "out-of-range" }; + +/** Whether a ship's landing class meets a location's landing requirement. */ +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. */ +export function checkRoute( + ship: ShipModel, + from: Location, + to: Location, + gameTime: GameTime, +): RouteCheck { + if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" }; + if (routeDistance(from, to, gameTime) > ship.fuelRangeMillionKm) { + 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 9c17246..aefca18 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -4,3 +4,4 @@ export * from "./positions"; export * from "./distance"; export * from "./travel"; export * from "./fuel"; +export * from "./eligibility"; diff --git a/packages/sim/test/eligibility.test.ts b/packages/sim/test/eligibility.test.ts new file mode 100644 index 0000000..bccd327 --- /dev/null +++ b/packages/sim/test/eligibility.test.ts @@ -0,0 +1,76 @@ +import { content, type ShipModel } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { checkRoute, routeDistance } from "../src/index"; + +const earth = content.location("earth")!; +const moon = content.location("moon")!; +const mars = content.location("mars")!; +const ceres = content.location("ceres-station")!; +const jupiter = content.location("jupiter-cloud-platform")!; +const courier = content.shipModel("courier")!; +const freighter = content.shipModel("freighter")!; +const bulkHauler = content.shipModel("bulk-hauler")!; + +/** Test-only ship that cannot land on atmospheric worlds. */ +const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" }; + +// Every 4 days across 12 years (one Jupiter orbit). +const start = Date.parse("2100-01-01T00:00:00Z"); +const samples = Array.from({ length: 1096 }, (_, i) => start + i * 4 * 86_400_000); + +/** The sampled time when `to` is closest to (or farthest from) Earth. */ +function extremeTime(to: typeof mars, pick: "closest" | "farthest"): number { + let best = samples[0]!; + let bestDistance = routeDistance(earth, to, best); + for (const t of samples) { + const d = routeDistance(earth, to, t); + if (pick === "closest" ? d < bestDistance : d > bestDistance) { + best = t; + bestDistance = d; + } + } + return best; +} + +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", () => { + for (const to of [mars, ceres, jupiter]) { + expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({ + ok: false, + reason: "out-of-range", + }); + } + }); + + it("rejects a vacuum-only ship at Earth because it cannot land", () => { + expect(checkRoute(vacuumShip, moon, earth, start)).toEqual({ + ok: false, + reason: "cannot-land", + }); + 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 only the Bulk Hauler reach Jupiter 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)", () => { + expect(checkRoute(freighter, earth, jupiter, extremeTime(jupiter, "closest"))).toEqual({ + ok: true, + }); + }); +});