From c627bb99541e4b4c5d0d81ab646e2c4fe22f1016 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 10 Oct 2026 01:05:13 -0400 Subject: [PATCH] Step 15.3: content: fuel Ships get a tank capacity and a burn rate instead of range and fuel cost; locations get a fixed fuel price and a sellsFuel flag instead of the launch cost multiplier. A leg burns burn rate x the economic distance of (distance / origin factor), and reach is a full tank's worth. Tanks are sized so every reach answer is unchanged. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 +++ apps/web/e2e/support.ts | 2 +- apps/web/src/game.ts | 6 ++- apps/web/src/map/reach.ts | 2 +- apps/web/src/screens/DispatchSheet.tsx | 9 +++- apps/web/src/screens/Locations.tsx | 1 - apps/web/src/screens/MapScreen.tsx | 1 - apps/web/src/screens/Market.tsx | 18 ++++--- apps/web/src/screens/ShipUpgrades.tsx | 5 +- apps/web/src/screens/TutorialBoard.tsx | 1 - apps/web/test/fixtures.ts | 3 +- apps/web/test/map-locked.test.tsx | 5 +- apps/web/test/map-reach-origins.test.tsx | 2 +- apps/web/test/market.test.tsx | 3 +- apps/web/test/stranding-reason.test.tsx | 2 +- memory-bank/architecture.md | 4 +- memory-bank/progress.md | 2 +- packages/content/src/data/config.ts | 4 ++ packages/content/src/data/locations.ts | 18 ++++--- packages/content/src/data/ships.ts | 39 ++++++++------- packages/content/src/data/upgrades.ts | 16 +++--- packages/content/src/schemas.ts | 26 +++++++--- packages/content/test/locations.test.ts | 15 +++--- packages/content/test/schemas.test.ts | 9 ++-- packages/content/test/ships.test.ts | 10 ++-- packages/content/test/upgrades.test.ts | 7 +-- packages/engine/src/game/state.ts | 3 +- packages/engine/test/ported/dev-tools.test.ts | 4 +- .../test/ported/lighter-clipper.test.ts | 2 +- packages/schema/src/entities.ts | 3 +- packages/schema/test/schemas.test.ts | 3 +- packages/sim/balance/simulate.ts | 6 ++- packages/sim/src/board.ts | 2 +- packages/sim/src/dispatch.ts | 6 +-- packages/sim/src/eligibility.ts | 20 +++++--- packages/sim/src/fuel.ts | 49 ++++++++++++++++--- packages/sim/src/index.ts | 9 +++- packages/sim/test/board-fleet.test.ts | 2 +- packages/sim/test/courier-mars.test.ts | 16 +++--- packages/sim/test/eligibility.test.ts | 20 ++++---- packages/sim/test/fuel.test.ts | 43 ++++++++++++++-- packages/sim/test/no-strand.test.ts | 11 ++++- packages/sim/test/range-from.test.ts | 24 +++++---- packages/sim/test/reach-table.test.ts | 2 +- packages/sim/test/upgrade-caps.test.ts | 8 +-- 45 files changed, 303 insertions(+), 148 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index f60fc35..f6acdb8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1451,3 +1451,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Every time argument that only located planets is removed**, not just those on `locationPosition` and `routeDistance`: `checkRoute`, `tripFuelCost`, `contractTripGameHours`, `priorityWindow`, `wouldStrand` and the web helpers built on them. Keeping unused time parameters would invite callers to think the answer depends on when. - **`wouldStrand` takes only the ship, the destination and what is discovered**: its origin, cargo mass and departure time existed to compute the arrival time and the planets' positions then. The configured 48-game-hour margin (`noStrandMarginGameHours`, decision 0.5.79) is removed with them; 15.5 adds fuel to the rule. - **The Market's reach keeps its shape for one step**: `shipReach` now checks each leg once, so "other dates" is always empty; the field and its "Other dates" line go together in 15.8, the step that removes date wording from the client. + +## Step 15.3 + +- **Tank sizes come from the old ranges**: tank = burn × economic distance(old range), so full-tank reach from Earth is what the range was (Courier 4.9, Lighter 9.8, Clipper 252, Freighter 460, Bulk Hauler 1,251M km) and the §4 table holds unchanged. Burn rates are the old per-M-km fuel costs ÷ 10 (the base price), so fuel stays in proportion between models; the Clipper still burns most per M km. +- **Tank upgrades use smaller percentages** than the range ones did: reach grows as capacity^(1 ÷ 0.65) on the economic-distance curve, so +20 %/level of tank would have let a maxed Clipper reach Jupiter from Mars. Each override keeps the old maxed reach: default 11 % (× 1.55 reach, as +20 % range), Freighter 9 % (≈ 664M km, short of Jupiter from Earth), Courier 380 % (level 3 ≈ 234M km reaches Mars from Earth; level 2 ≈ 133M km doesn't, from Earth or the Moon). +- **`checkRoute` and `rangeFrom` take the config**, since reach depends on the economic-distance curve; every caller already had the content bundle at hand. +- **A trip is still charged at departure in this step**, now its leg fuel at the origin's price: the tank state arrives in 15.4. The balance targets all pass with it. +- **The Market shows "Reach" (a full tank from Earth)** instead of "Range"; the upgrade screen shows the tank in fu. The fuel gauge and prices come in 15.7. diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index 4377231..be9ecf9 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -110,7 +110,7 @@ export function nextMarsWindow(from: number): number { const gameDay = (24 * 3_600_000) / content.config.timeCompression; for (let t = from; t < from + 1_500 * gameDay; t += gameDay) { if ( - checkRoute(clipper, earth, mars).ok && + checkRoute(clipper, earth, mars, content.config).ok && !wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content) ) { // Well inside the window: a few game days in. diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index d2b1a7f..bcc7ad8 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -72,7 +72,8 @@ function legsInRange(model: ShipModel): Set { legs = new Set(); for (const from of content.locations) { for (const to of content.locations) { - if (from.id !== to.id && checkRoute(model, from, to).ok) legs.add(legKey(from.id, to.id)); + if (from.id !== to.id && checkRoute(model, from, to, content.config).ok) + legs.add(legKey(from.id, to.id)); } } legCache.set(model.id, legs); @@ -102,7 +103,8 @@ export function shipReach(model: ShipModel, originIds: string[]): ShipReach { const nowLegs = originIds.map((originId) => ({ originId, destinationIds: ids.filter( - (to) => to !== originId && checkRoute(model, location(originId), location(to)).ok, + (to) => + to !== originId && checkRoute(model, location(originId), location(to), content.config).ok, ), })); const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds)); diff --git a/apps/web/src/map/reach.ts b/apps/web/src/map/reach.ts index ec3d691..1500ee2 100644 --- a/apps/web/src/map/reach.ts +++ b/apps/web/src/map/reach.ts @@ -39,7 +39,7 @@ export function reachersByOrigin( .map((originId) => { const origin = content.location(originId)!; const reaches = (stats: Parameters[0]) => - checkRoute(stats, origin, target).ok; + checkRoute(stats, origin, target, content.config).ok; const models = content.purchasableShipModels.filter(reaches).map((m) => m.name); const upgraded = state.ships .filter((ship: Ship) => { diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 4319a8e..a17c8c1 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -97,11 +97,16 @@ export function explainProblems( if (contract.massTonnes > model.cargoCapacityTonnes) { problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); } - const route = checkRoute(model, location(contract.originId), location(contract.destinationId)); + const route = checkRoute( + model, + location(contract.originId), + location(contract.destinationId), + content.config, + ); if (!route.ok) { problems.push( route.reason === "out-of-range" - ? `Out of range right now (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId)))}M km from ${locationName(contract.originId)})` + ? `Out of range right now (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId), content.config))}M km from ${locationName(contract.originId)})` : INELIGIBLE_REASONS["cannot-land"], ); } else if ( diff --git a/apps/web/src/screens/Locations.tsx b/apps/web/src/screens/Locations.tsx index 819b055..f716f09 100644 --- a/apps/web/src/screens/Locations.tsx +++ b/apps/web/src/screens/Locations.tsx @@ -1,6 +1,5 @@ import { content, type Location } from "@deadhead/content"; import { Link } from "react-router"; -import {} from "../clock/clock"; import { cargoName, formatCredits, formatMillionKm } from "../format"; import { distanceAt } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index c03c0b7..fdd7af7 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -2,7 +2,6 @@ import { content, type Location } from "@deadhead/content"; import { realToGame } from "@deadhead/sim"; import { useState, type ReactNode } from "react"; import { Link } from "react-router"; -import {} from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; import { Countdown } from "../components/Countdown"; import { cargoName, formatMillionKm, locationName } from "../format"; diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index 83ec553..bc611f3 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -1,7 +1,7 @@ import { content, type ShipModel } from "@deadhead/content"; +import { fullTankReach } from "@deadhead/sim"; import { useState } from "react"; import { formatCredits, formatMillionKm, locationName } from "../format"; -import {} from "../clock/clock"; import { shipReach, shipStatsOf } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui"; @@ -9,12 +9,15 @@ import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" }; const MODELS = content.purchasableShipModels; +/** How far a full tank flies from Earth, in million km (decision 0.5.95). */ +const reachFromEarth = (model: ShipModel) => + fullTankReach(model, content.location("earth")!, content.config); const best = { speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)), capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)), - range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)), + range: Math.max(...MODELS.map(reachFromEarth)), }; -/** Range spans 5 to 1,250M km, so its bar uses a log scale to stay readable. */ +/** Reach spans 5 to 1,250M km, so its bar uses a log scale to stay readable. */ const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range); /** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */ @@ -81,9 +84,9 @@ export function Market() { /> @@ -234,8 +237,7 @@ function OwnedUpgraded({ modelId }: { modelId: string }) { return (
  • Your {ship.name}: {formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h ·{" "} - {Math.round(s.cargoCapacityTonnes * 10) / 10} t ·{" "} - {formatMillionKm(s.fuelRangeMillionKm)} + {Math.round(s.cargoCapacityTonnes * 10) / 10} t · {formatMillionKm(reachFromEarth(s))}
  • ); })} diff --git a/apps/web/src/screens/ShipUpgrades.tsx b/apps/web/src/screens/ShipUpgrades.tsx index 07d5a90..93622a1 100644 --- a/apps/web/src/screens/ShipUpgrades.tsx +++ b/apps/web/src/screens/ShipUpgrades.tsx @@ -37,7 +37,10 @@ const STAT_LABELS: Record `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`, }, - fuelRangeMillionKm: { label: "Range", format: (s) => formatMillionKm(s.fuelRangeMillionKm) }, + tankCapacityFu: { + label: "Tank", + format: (s) => `${Math.round(s.tankCapacityFu).toLocaleString("en-US")} fu`, + }, overheadGameHours: { label: "Takeoff/landing", format: (s) => `${s.overheadGameHours.toFixed(2)} h`, diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index bbc53f4..6c4f071 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -2,7 +2,6 @@ import { content } from "@deadhead/content"; import { deliveryXp, loanerModel } from "@deadhead/sim"; import { useQuery } from "@tanstack/react-query"; import { useApi } from "../api/context"; -import {} from "../clock/clock"; import { formatCredits, formatDuration, locationName } from "../format"; import { goodName } from "../components/Goods"; import { distanceAt, tripRealMs } from "../game"; diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index 1081241..ceb9d6a 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -20,7 +20,8 @@ export function shipExtras( stats: { cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour, cargoCapacityTonnes: s.cargoCapacityTonnes, - fuelRangeMillionKm: s.fuelRangeMillionKm, + tankCapacityFu: s.tankCapacityFu, + burnFuPerMillionKm: s.burnFuPerMillionKm, overheadGameHours: s.overheadGameHours, handlingGameHoursPerTonne: s.handlingGameHoursPerTonne, }, diff --git a/apps/web/test/map-locked.test.tsx b/apps/web/test/map-locked.test.tsx index bdc1d84..efb1cc4 100644 --- a/apps/web/test/map-locked.test.tsx +++ b/apps/web/test/map-locked.test.tsx @@ -54,7 +54,10 @@ describe("whole map from the start", () => { expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock."); const expected = content.purchasableShipModels - .filter((m) => checkRoute(m, content.location("earth")!, content.location("mars")!).ok) + .filter( + (m) => + checkRoute(m, content.location("earth")!, content.location("mars")!, content.config).ok, + ) .map((m) => m.name); expect(expected.length).toBeGreaterThan(0); expect(expected).not.toContain("Courier"); diff --git a/apps/web/test/map-reach-origins.test.tsx b/apps/web/test/map-reach-origins.test.tsx index f06d4a8..f3ba0f1 100644 --- a/apps/web/test/map-reach-origins.test.tsx +++ b/apps/web/test/map-reach-origins.test.tsx @@ -16,7 +16,7 @@ const [mars, ceres] = [content.location("mars")!, content.location("ceres-statio const NOW = (() => { for (let i = 0; i <= 16 * 12; i++) { const t = gameStart + i * MONTH; - if (checkRoute(clipper, mars, ceres).ok) return gameToReal(t, content.config); + if (checkRoute(clipper, mars, ceres, content.config).ok) return gameToReal(t, content.config); } throw new Error("no such date"); })(); diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx index 34d69c5..7dbd3eb 100644 --- a/apps/web/test/market.test.tsx +++ b/apps/web/test/market.test.tsx @@ -18,7 +18,8 @@ describe("market screen", () => { expect(courier).toHaveTextContent("Courier"); expect(courier).toHaveTextContent("3.00M km/h"); expect(courier).toHaveTextContent("Capacity20 t"); - expect(courier).toHaveTextContent("Range5.00M km"); + // A full tank from Earth (decision 0.5.95). + expect(courier).toHaveTextContent("Reach4.86M km"); expect(courier).toHaveTextContent("LandingAtmospheric"); }); diff --git a/apps/web/test/stranding-reason.test.tsx b/apps/web/test/stranding-reason.test.tsx index d2e76c4..15aa3f4 100644 --- a/apps/web/test/stranding-reason.test.tsx +++ b/apps/web/test/stranding-reason.test.tsx @@ -18,7 +18,7 @@ const STRANDED = "Would be stranded — can't fly out of Jupiter Cloud Platform // The fixed map (CR-015): Jupiter is in range from the Moon (667 × 1.3), but from // Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered. if ( - !checkRoute(freighter, moon, jupiter).ok || + !checkRoute(freighter, moon, jupiter, content.config).ok || !wouldStrand(freighter, jupiter, discovered, content) ) { throw new Error("the scenario no longer strands"); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 1402db6..dc6ae04 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format | Package | Role | Depends on | |---|---|---| | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | -| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. | content | +| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. Fuel (`fuel.ts`, CR-015): `legFuel` = burn × economic distance(distance ÷ origin factor), `fullTankReach` (its inverse for a full tank), `fuelPriceAt`; `checkRoute(ship, from, to, config)` compares the distance with full-tank reach. | content | | `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim | | `packages/schema` | Zod schemas for every API request, response and error. Shared by the engine and the client. | sim (error codes), zod | | `apps/web` | React client; runs the engine on the device (CR-014). | content, engine, sim, schema | @@ -25,7 +25,7 @@ Living reference for how the code is organised. Update it after any save format |---|---| | `ServerTime` | `realTime`, `gameTime` | | `Company` | `id`, `name`, `credits`, `loanBalance` | -| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) | +| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, tank capacity and burn rate (CR-015), overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) | | `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Bay` | `id`, `locationId` | | `LocationState` | `id`, `discovered` | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 5991cdb..d199158 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -372,7 +372,7 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as - [x] **Step 15.1 — Content: fixed positions.** Locations lose `body` and gain `position` (`{ radiusMillionKm, angleDegrees }`): Earth 150/210°, Mars 228/270°, Ceres Station 414/285°, Jupiter Cloud Platform 778/300°; the Moon is Earth's position moved 0.384M km straight out (`offsetPosition`), recorded as `orbitParent: { locationId: "earth", offsetMillionKm: 0.384 }`. GameConfig's epoch keeps only a displayed start date (`gameStart`, was `ephemerisDate`; same value). `astronomy-engine` is removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim's `locationPosition` already reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (`bodyPosition` is gone; the Moon is placed from `orbitParent`). Fixed distances broke CR-012's tuning, so ranges move into CR-015 §4's bands now: Clipper 198 → 250M km (it couldn't reach Mars at 201), Freighter 450 → 460, the Courier's fuel-tank override 450 % → 1,400 % per level (5 → 75 → 145 → 215: Mars from Earth only at level 3). The balance re-check then put the first Market purchase at 86.7 min (quantized by the Clipper's first Mars landing), so the Lighter's price drops 42,400 → 36,000 (first purchase 53.3, four ships 115 vs 130 min, loan 135 min). Tests: `packages/content/test/fixed-map.test.ts` (every location positioned; the Moon defined as an offset from Earth; child bodies match their parent plus offset; the six §2 distances within 2 % and symmetric; no `astronomy-engine` in the lockfile). Date-sampled tests became yes/no: `reach-table.test.ts` is now CR-015 §4, one test per cell; `courier-mars`, `eligibility`, the distance test ("varies" replaced by "the same at every time"), the upgrade-cap value; the engine's would-strand test and the web stranding test use a maxed-tank Freighter at the Moon going to Jupiter with Mars and Ceres undiscovered (no base ship can strand on the fixed map); the Market test for "Other dates" is removed (the wording goes in 15.8). - [x] **Step 15.2 — Sim: static positions and distances.** No time arguments left where a time only fed a position: `locationPosition(location)`, `routeDistance(from, to)`, `checkRoute(ship, from, to)`, `tripFuelCost(model, origin, destination, config)`, `contractTripGameHours(model, contract, content)`, `priorityWindow(origin, destination, mass, content)`, and `wouldStrand(model, destination, discoveredIds, content)` (it had taken cargo mass and departure only to find where planets would be on arrival). `dispatchPosition` runs along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig's `noStrandMarginGameHours` is gone (nothing moves in 48 game hours now). Web: `distanceAt`, `tripRealMs`, `locationMapPoint`, `reachersByOrigin`, `tooSlowForAll` and `shipReach` lose their time argument; `shipReach` checks each leg once instead of sampling 16 game years (its `otherDates` is always empty and goes with its wording in 15.8). Callers were updated with a small argument-dropping codemod, then the unused time variables removed. Tests: `distance.test.ts` per the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone), `positions.test.ts` ("never moves"), new `static-routes.test.ts` (an Earth → Ceres contract pays the same in any board period; a Freighter's trip takes the same time, distance and fuel whenever it departs); `no-strand.test.ts` now checks that no base ship is ever blocked on a leg it can fly; `range-from` and `upgrade-caps` lose their date loops. -- [ ] Step 15.3 — Content: fuel +- [x] **Step 15.3 — Content: fuel.** ShipModel: `fuelRangeMillionKm` and `fuelCostPerMillionKm` replaced by `tankCapacityFu` and `burnFuPerMillionKm` (Courier 40/20, Lighter 110/35, Clipper 1,170/45, Freighter 1,540/40, Bulk Hauler 5,900/80, loaner 40/20). Location: `launchCostMultiplier` replaced by `fuelPrice` (× base: Jupiter 0.6, Ceres 0.9, Moon 1.1, Mars 1.4, Earth 1.6) and `sellsFuel` (all true). GameConfig: `baseFuelPricePerFu` 10, `emergencyFuelMultiplier` 2.5. Extended Fuel Tanks raises `tankCapacityFu`: 11 %/level by default, Courier 380 %, Freighter 9 %. Sim (`fuel.ts`): `legFuel` (burn × economic distance of distance ÷ origin factor, decision 0.5.95 as the user decided), `plainDistance` (the curve's inverse), `fullTankReach` (factor × plainDistance(tank ÷ burn)), `fuelPriceAt`; `rangeFrom` and `checkRoute` now take the config and use full-tank reach. Until Step 15.4 adds tanks to ships, a trip is still charged at departure: its fuel at the origin's price. API `stats` carry `tankCapacityFu` and `burnFuPerMillionKm` instead of range. Web: the Market's "Range" is now "Reach" (a full tank from Earth), the upgrade screen shows "Tank" in fu. Balance re-check passes: first purchase 64 min, discovery 64, loan 156, four ships 151 vs 158. Tests: `locations.test.ts` (fuel prices ordered Jupiter < Ceres < Moon < Mars < Earth; every base location sells fuel); `fuel.test.ts` (leg fuel, the curve's inverse, full-tank reach burns exactly a tank and scales by the origin factor, the interim cost by hand); the §4 table (`reach-table.test.ts`) now runs on full-tank reach; `courier-mars`, `upgrade-caps` and the content distinctness test in tank form; `ships.test.ts` (the Clipper burns most per M km). Also removed four empty `import {}` lines left by Step 15.2's cleanup. - [ ] Step 15.4 — Sim: tank, burn, and refuel - [ ] Step 15.5 — Sim: stranding and emergency fuel - [ ] Step 15.6 — Engine: commands and save migration diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 9fb0835..aa48fcf 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -28,6 +28,10 @@ export const gameConfig: GameConfig = { // Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips. boardRetentionPeriods: 24, savedDispatchRetentionRealHours: 168, + // Fuel (CR-015): credits per fu before each location's price multiple, and + // the emergency fuel markup (decisions 0.5.96, 0.5.99). + baseFuelPricePerFu: 10, + emergencyFuelMultiplier: 2.5, // Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84). economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 }, // Ship upgrades (CR-009): level 1, 2, 3. diff --git a/packages/content/src/data/locations.ts b/packages/content/src/data/locations.ts index 04f9272..5f668af 100644 --- a/packages/content/src/data/locations.ts +++ b/packages/content/src/data/locations.ts @@ -4,7 +4,8 @@ import { goodsDemandedAt, goodsProducedAt } from "./goods"; /** * The fixed map (decision 0.5.93, CR-015 §2): real mean orbital radii, angles * chosen so the reach table (CR-015 §4) holds with clean margins. Only the - * angles between bodies matter. + * angles between bodies matter. Fuel prices (decision 0.5.96): cheap at Jupiter's + * refineries and Ceres's ice crackers, dear at Earth. */ const EARTH: FixedPosition = { radiusMillionKm: 150, angleDegrees: 210 }; const MOON_OFFSET_MILLION_KM = 0.384; @@ -29,7 +30,8 @@ export const locations: Location[] = [ landingRequirement: "atmospheric", position: EARTH, sprite: "body-earth", - launchCostMultiplier: 2.1, + fuelPrice: 1.6, + sellsFuel: true, launchRangeFactor: 1, startsDiscovered: true, bayLeasePrice: 1000, @@ -51,7 +53,8 @@ export const locations: Location[] = [ position: offsetPosition(EARTH, MOON_OFFSET_MILLION_KM), orbitParent: { locationId: "earth", offsetMillionKm: MOON_OFFSET_MILLION_KM }, sprite: "body-moon", - launchCostMultiplier: 0.5, + fuelPrice: 1.1, + sellsFuel: true, launchRangeFactor: 1.3, startsDiscovered: true, bayLeasePrice: 800, @@ -70,7 +73,8 @@ export const locations: Location[] = [ landingRequirement: "atmospheric", position: { radiusMillionKm: 228, angleDegrees: 270 }, sprite: "body-mars", - launchCostMultiplier: 1.5, + fuelPrice: 1.4, + sellsFuel: true, launchRangeFactor: 1.15, startsDiscovered: false, bayLeasePrice: 20000, @@ -86,7 +90,8 @@ export const locations: Location[] = [ landingRequirement: "vacuum", position: { radiusMillionKm: 414, angleDegrees: 285 }, sprite: "body-ceres", - launchCostMultiplier: 0.3, + fuelPrice: 0.9, + sellsFuel: true, launchRangeFactor: 1.3, startsDiscovered: false, bayLeasePrice: 40000, @@ -103,7 +108,8 @@ export const locations: Location[] = [ position: { radiusMillionKm: 778, angleDegrees: 300 }, sprite: "body-jupiter", badge: "badge-cloud-platform", - launchCostMultiplier: 2, + fuelPrice: 0.6, + sellsFuel: true, launchRangeFactor: 1, startsDiscovered: false, bayLeasePrice: 80000, diff --git a/packages/content/src/data/ships.ts b/packages/content/src/data/ships.ts index e5c17b7..0d05123 100644 --- a/packages/content/src/data/ships.ts +++ b/packages/content/src/data/ships.ts @@ -2,8 +2,9 @@ import type { ShipModel } from "../schemas"; /** * Base-game ship models (GDD §8). All are atmospheric-rated (decision 0.5.1). - * Ranges against approximate Earth distances: Moon ~0.4M km, Mars at closest - * ~55M km, Ceres at farthest ~600M km, Jupiter ~590M–970M km. + * Tanks and burn rates (CR-015): full-tank reach from Earth is the distance + * whose economic distance × burn fills the tank, so it lands in CR-015 §4's + * bands against the fixed map (Moon 0.4, Mars 201, Ceres 402, Jupiter 792M km). */ export const shipModels: ShipModel[] = [ { @@ -14,12 +15,12 @@ export const shipModels: ShipModel[] = [ price: 4000, cruiseSpeedKmPerGameHour: 3_000_000, cargoCapacityTonnes: 20, - // Moon only. - fuelRangeMillionKm: 5, + // Reach ≈ 4.9M km: the Moon only. + tankCapacityFu: 40, + burnFuPerMillionKm: 20, hullRating: 1, landingClass: "atmospheric", overheadGameHours: 2, - fuelCostPerMillionKm: 200, purchasable: true, role: "Fast and cheap: small loads around Earth and the Moon.", }, @@ -34,14 +35,14 @@ export const shipModels: ShipModel[] = [ // Slower than the Freighter, faster than the Bulk Hauler. cruiseSpeedKmPerGameHour: 1_600_000, cargoCapacityTonnes: 80, - // Earth and the Moon only, even with maxed tanks (CR-012 §3). - fuelRangeMillionKm: 10, + // Reach ≈ 9.8M km: Earth and the Moon only, even with maxed tanks. + tankCapacityFu: 110, + burnFuPerMillionKm: 35, hullRating: 2, landingClass: "atmospheric", overheadGameHours: 2, // Big cargo doors: the fastest loading in the fleet. handlingGameHoursPerTonne: 0.012, - fuelCostPerMillionKm: 350, purchasable: true, role: "Short-haul heavy lifter: big loads between Earth and the Moon, loaded fast.", }, @@ -55,12 +56,12 @@ export const shipModels: ShipModel[] = [ // The fastest ship in the game. cruiseSpeedKmPerGameHour: 6_500_000, cargoCapacityTonnes: 30, - // Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4). - fuelRangeMillionKm: 250, + // Reach ≈ 252M km: Mars, and Ceres by way of Mars (CR-015 §4). + tankCapacityFu: 1170, + burnFuPerMillionKm: 45, hullRating: 1, landingClass: "atmospheric", overheadGameHours: 2, - fuelCostPerMillionKm: 450, purchasable: true, role: "Fast long-legged runner: the cheapest way to Mars, built for Priority work.", }, @@ -72,12 +73,12 @@ export const shipModels: ShipModel[] = [ price: 100000, cruiseSpeedKmPerGameHour: 2_000_000, cargoCapacityTonnes: 100, - // Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25). - fuelRangeMillionKm: 460, + // Reach ≈ 460M km: Mars and Ceres from Earth, Jupiter only from Ceres. + tankCapacityFu: 1540, + burnFuPerMillionKm: 40, hullRating: 2, landingClass: "atmospheric", overheadGameHours: 4, - fuelCostPerMillionKm: 400, purchasable: true, role: "The all-rounder: big loads to Mars and Ceres, Jupiter by way of Ceres.", }, @@ -89,12 +90,12 @@ export const shipModels: ShipModel[] = [ price: 400000, cruiseSpeedKmPerGameHour: 1_200_000, cargoCapacityTonnes: 400, - // Jupiter at its farthest. - fuelRangeMillionKm: 1250, + // Reach ≈ 1,250M km: every leg, Jupiter from Earth included. + tankCapacityFu: 5900, + burnFuPerMillionKm: 80, hullRating: 3, landingClass: "atmospheric", overheadGameHours: 8, - fuelCostPerMillionKm: 800, purchasable: true, role: "Slow and enormous: the only ship that can fly any leg in the system.", }, @@ -108,11 +109,11 @@ export const shipModels: ShipModel[] = [ price: 4000, cruiseSpeedKmPerGameHour: 6_000_000, cargoCapacityTonnes: 20, - fuelRangeMillionKm: 5, + tankCapacityFu: 40, + burnFuPerMillionKm: 20, hullRating: 1, landingClass: "atmospheric", overheadGameHours: 0.3, - fuelCostPerMillionKm: 200, purchasable: false, role: "Halvorsen's loaner for the company runs.", }, diff --git a/packages/content/src/data/upgrades.ts b/packages/content/src/data/upgrades.ts index 93a8af9..fd2aeb0 100644 --- a/packages/content/src/data/upgrades.ts +++ b/packages/content/src/data/upgrades.ts @@ -4,6 +4,9 @@ import type { Upgrade } from "../schemas"; * Ship upgrades (CR-009 §2). Percentages are of the model's base stat and add * up across levels. Overrides keep models distinct (decision 0.5.67) and give * only a maxed Courier the reach to Mars from Earth (decision 0.5.68, CR-015). + * Extended Fuel Tanks raise tank capacity; reach grows as capacity^(1 ÷ 0.65) + * on the economic-distance curve, so its percentages are smaller than the old + * range ones for the same maxed reach. */ export const upgrades: Upgrade[] = [ { @@ -23,14 +26,15 @@ export const upgrades: Upgrade[] = [ { id: "fuel-tanks", name: "Extended Fuel Tanks", - stat: "fuelRangeMillionKm", - percentPerLevel: 20, + stat: "tankCapacityFu", + // Maxed: × 1.33 capacity, ≈ × 1.55 reach (as +20 % range a level did). + percentPerLevel: 11, maxLevel: 3, modelOverrides: { - // 5 → 75 → 145 → 215 M km: Earth → Mars (≈ 201) only at level 3; level 2 can't reach Mars from the Moon either (145 × 1.3 < 200). - courier: { percentPerLevel: 1400 }, - // 460 → 667 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth (≈ 792). - freighter: { percentPerLevel: 15 }, + // Reach 4.9 → 54 → 133 → 234 M km: Earth → Mars (≈ 201) only at level 3; level 2 can't reach Mars from the Moon either (133 × 1.3 < 200). + courier: { percentPerLevel: 380 }, + // Reach 460 → ≈ 664 M km at level 3: short of a base Bulk Hauler's 1,250 and of Jupiter from Earth (≈ 792). + freighter: { percentPerLevel: 9 }, }, }, { diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 7d03ef1..696d12e 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -47,11 +47,13 @@ export const locationSchema = z.object({ orbitParent: z .object({ locationId: idSchema, offsetMillionKm: z.number().positive() }) .optional(), - /** Multiplies fuel cost for launches from this location. */ - launchCostMultiplier: z.number().positive(), + /** Fuel price here as a multiple of the base price (decision 0.5.96). */ + fuelPrice: z.number().positive(), + /** Whether ships can buy fuel here (all base locations do; decision 0.5.96). */ + sellsFuel: z.boolean(), /** - * Multiplies a ship's range for legs launched from here (decision 0.5.78): - * low gravity stretches it. Separate from the fuel-cost multiplier. + * Multiplies a ship's reach for legs launched from here (decision 0.5.78): + * low gravity stretches it, so a leg from here burns less fuel (0.5.95). */ launchRangeFactor: z.number().positive(), startsDiscovered: z.boolean(), @@ -92,14 +94,18 @@ export const shipModelSchema = z.object({ price: positiveInt, cruiseSpeedKmPerGameHour: z.number().positive(), cargoCapacityTonnes: z.number().positive(), - fuelRangeMillionKm: z.number().positive(), + /** Fuel the tank holds, in fu (decision 0.5.94). */ + tankCapacityFu: z.number().positive(), + /** + * fu burned per million km of economic distance (decisions 0.5.84, 0.5.95): + * a leg burns `burn × economicDistance(distance ÷ origin range factor)`. + */ + burnFuPerMillionKm: z.number().positive(), /** Stored only; no effect in the base game (decision 0.5.13). */ hullRating: nonNegativeInt, landingClass: landingClassSchema, /** Fixed takeoff + landing time added to every trip. */ overheadGameHours: z.number().nonnegative(), - /** Credits per million km, before the origin's launch multiplier. */ - fuelCostPerMillionKm: z.number().nonnegative(), /** False for company-only ships such as the tutorial loaner (decision 0.5.29). */ purchasable: z.boolean(), /** One line on what the ship is for, shown in the Market (CR-012). */ @@ -119,7 +125,7 @@ export type ShipModel = z.infer; export const SHIP_STATS = [ "cruiseSpeedKmPerGameHour", "cargoCapacityTonnes", - "fuelRangeMillionKm", + "tankCapacityFu", "overheadGameHours", "handlingGameHoursPerTonne", ] as const; @@ -242,6 +248,10 @@ export const gameConfigSchema = z * Pay and fuel use `reference × (distance / reference)^exponent` (decision * 0.5.84), so long legs aren't worth hundreds of short ones. */ + /** Credits per fu before a location's price multiple (decision 0.5.96). */ + baseFuelPricePerFu: z.number().positive(), + /** Emergency fuel costs this many times the local price (decision 0.5.99). */ + emergencyFuelMultiplier: z.number().min(1), economicDistance: z.object({ referenceMillionKm: z.number().positive(), exponent: z.number().positive().max(1), diff --git a/packages/content/test/locations.test.ts b/packages/content/test/locations.test.ts index 9c327ca..bf22afe 100644 --- a/packages/content/test/locations.test.ts +++ b/packages/content/test/locations.test.ts @@ -35,15 +35,18 @@ describe("locations", () => { for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0); }); - it("gives Earth the highest launch multiplier", () => { - const earth = locations.find((l) => l.id === "earth")!; - for (const location of locations) { - if (location !== earth) { - expect(earth.launchCostMultiplier).toBeGreaterThan(location.launchCostMultiplier); - } + it("orders fuel prices Jupiter < Ceres < Moon < Mars < Earth (decision 0.5.96)", () => { + const price = (id: string) => locations.find((l) => l.id === id)!.fuelPrice; + const order = ["jupiter-cloud-platform", "ceres-station", "moon", "mars", "earth"]; + for (let i = 1; i < order.length; i++) { + expect(price(order[i - 1]!), order[i]).toBeLessThan(price(order[i]!)); } }); + it("sells fuel at every base location (CR-015 §3)", () => { + for (const location of locations) expect(location.sellsFuel, location.id).toBe(true); + }); + it("gives every location its own map sprite key (CR-003)", () => { const sprites = locations.map((x) => x.sprite); for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index efb6ce0..804c9f3 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -17,7 +17,8 @@ const validLocation = { type: "atmospheric-world", landingRequirement: "atmospheric", position: { radiusMillionKm: 150, angleDegrees: 210 }, - launchCostMultiplier: 3, + fuelPrice: 1.6, + sellsFuel: true, launchRangeFactor: 1, startsDiscovered: true, bayLeasePrice: 5000, @@ -47,11 +48,11 @@ const validShipModel = { price: 20000, cruiseSpeedKmPerGameHour: 300000, cargoCapacityTonnes: 10, - fuelRangeMillionKm: 5, + tankCapacityFu: 40, + burnFuPerMillionKm: 20, hullRating: 1, landingClass: "atmospheric", overheadGameHours: 2, - fuelCostPerMillionKm: 100, purchasable: true, role: "Fast and cheap.", sprite: "ship-courier", @@ -110,6 +111,8 @@ const validGameConfig = { loanRepaymentShare: 0.3, boardRetentionPeriods: 24, savedDispatchRetentionRealHours: 168, + baseFuelPricePerFu: 10, + emergencyFuelMultiplier: 2.5, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, }; diff --git a/packages/content/test/ships.test.ts b/packages/content/test/ships.test.ts index 8d3062b..18902a7 100644 --- a/packages/content/test/ships.test.ts +++ b/packages/content/test/ships.test.ts @@ -61,15 +61,13 @@ describe("ship models", () => { expect(lighterShare).toBeLessThanOrEqual(0.9); }); - it("gives the Lighter the fastest loading and the Clipper costly fuel", () => { + it("gives the Lighter the fastest loading and the Clipper a thirsty burn rate", () => { for (const m of shipModels) { if (m.id !== "lighter") expect(handling("lighter")).toBeLessThan(handling(m.id)); } - expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan( - ship("courier").fuelCostPerMillionKm, - ); - expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan( - ship("freighter").fuelCostPerMillionKm, + expect(ship("clipper").burnFuPerMillionKm).toBeGreaterThan(ship("courier").burnFuPerMillionKm); + expect(ship("clipper").burnFuPerMillionKm).toBeGreaterThan( + ship("freighter").burnFuPerMillionKm, ); }); diff --git a/packages/content/test/upgrades.test.ts b/packages/content/test/upgrades.test.ts index 622397f..264e7cd 100644 --- a/packages/content/test/upgrades.test.ts +++ b/packages/content/test/upgrades.test.ts @@ -68,9 +68,10 @@ describe("ship upgrades content (CR-009)", () => { expect(maxed("bulk-hauler", "cruiseSpeedKmPerGameHour")).toBeLessThan( model("freighter").cruiseSpeedKmPerGameHour, ); - expect(maxed("freighter", "fuelRangeMillionKm")).toBeLessThan( - model("bulk-hauler").fuelRangeMillionKm, - ); + // Reach grows with tank ÷ burn (decision 0.5.95), so compare that. + expect( + maxed("freighter", "tankCapacityFu") / model("freighter").burnFuPerMillionKm, + ).toBeLessThan(model("bulk-hauler").tankCapacityFu / model("bulk-hauler").burnFuPerMillionKm); // Amended by CR-012 (the Lighter's Mars cap is a sim test: upgrade-caps.test.ts). expect(maxed("clipper", "cargoCapacityTonnes")).toBeLessThan( model("freighter").cargoCapacityTonnes, diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 2af888f..37283f3 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -147,7 +147,8 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S stats: { cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour, cargoCapacityTonnes: stats.cargoCapacityTonnes, - fuelRangeMillionKm: stats.fuelRangeMillionKm, + tankCapacityFu: stats.tankCapacityFu, + burnFuPerMillionKm: stats.burnFuPerMillionKm, overheadGameHours: stats.overheadGameHours, handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne, }, diff --git a/packages/engine/test/ported/dev-tools.test.ts b/packages/engine/test/ported/dev-tools.test.ts index 7e0e2fc..5a96d20 100644 --- a/packages/engine/test/ported/dev-tools.test.ts +++ b/packages/engine/test/ported/dev-tools.test.ts @@ -118,8 +118,8 @@ describe("developer tools (Step 10K.11)", () => { for (const upgrade of content.upgrades) { expect(maxed.upgrades[upgrade.id], upgrade.id).toBe(upgradeMaxLevel(upgrade, "courier")); } - expect(maxed.stats.fuelRangeMillionKm).toBeGreaterThan( - content.shipModel("courier")!.fuelRangeMillionKm, + expect(maxed.stats.tankCapacityFu).toBeGreaterThan( + content.shipModel("courier")!.tankCapacityFu, ); expect((await ship({ action: "reset-upgrades" })).upgrades).toEqual({}); diff --git a/packages/engine/test/ported/lighter-clipper.test.ts b/packages/engine/test/ported/lighter-clipper.test.ts index 70909fd..5d75d6d 100644 --- a/packages/engine/test/ported/lighter-clipper.test.ts +++ b/packages/engine/test/ported/lighter-clipper.test.ts @@ -54,7 +54,7 @@ describe("Lighter and Clipper (Step 10L.6)", () => { content.location("moon")!, content.location("jupiter-cloud-platform")!, ]; - expect(checkRoute(freighter, moon, jupiter).ok).toBe(true); + expect(checkRoute(freighter, moon, jupiter, content.config).ok).toBe(true); const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]); expect(wouldStrand(freighter, jupiter, discovered, content)).toBe(true); diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index a376485..08deb6d 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -31,7 +31,8 @@ export type Company = z.infer; export const effectiveStatsSchema = z.object({ cruiseSpeedKmPerGameHour: z.number().positive(), cargoCapacityTonnes: z.number().positive(), - fuelRangeMillionKm: z.number().positive(), + tankCapacityFu: z.number().positive(), + burnFuPerMillionKm: z.number().positive(), overheadGameHours: z.number().nonnegative(), handlingGameHoursPerTonne: z.number().nonnegative(), }); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 7dc6813..913385b 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -62,7 +62,8 @@ const ship = { stats: { cruiseSpeedKmPerGameHour: 3_000_000, cargoCapacityTonnes: 23, - fuelRangeMillionKm: 5, + tankCapacityFu: 40, + burnFuPerMillionKm: 20, overheadGameHours: 2, handlingGameHoursPerTonne: 0.02, }, diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index 913b792..b1f6e98 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -11,6 +11,7 @@ import { content as defaultContent, type ShipModel } from "@deadhead/content"; import { boardPeriodIndex, + fullTankReach, boardPeriodMs, checkRoute, completedGoals, @@ -481,7 +482,8 @@ export function simulate(options: SimulationOptions): SimulationResult { const longHaul = pilot.skills["long-haul"] ?? 0; const cargo = pilot.skills[cargoSkill] ?? 0; const wantsLongHaul = - stats.fuelRangeMillionKm > config.longHaulCutoffsMillionKm[0]! && longHaul <= cargo; + fullTankReach(stats, loc("earth"), config) > config.longHaulCutoffsMillionKm[0]! && + longHaul <= cargo; const skill = wantsLongHaul ? "long-haul" : cargoSkill; pilot.skills[skill] = (pilot.skills[skill] ?? 0) + 1; pilot.spent++; @@ -586,7 +588,7 @@ export function simulate(options: SimulationOptions): SimulationResult { const target = deadheads .map((to) => { const best = board(to) - .filter((c) => checkRoute(stats, loc(to), loc(c.destinationId)).ok) + .filter((c) => checkRoute(stats, loc(to), loc(c.destinationId), content.config).ok) .map((c) => c.payout) .sort((a, b) => b - a)[0]; return { to, best: best ?? 0 }; diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index 4c8e574..d1ff3e7 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -132,7 +132,7 @@ function fitJob( const { model, skills } = able[(start + k) % able.length]!; const reachable = discovered.filter( (d) => - checkRoute(model, origin, d).ok && + checkRoute(model, origin, d, content.config).ok && requiredLongHaulLevel(routeDistance(origin, d), content.config) <= skillLevel(skills, LONG_HAUL_SKILL_ID), ); diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 9a5b799..d45499e 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -128,7 +128,7 @@ export function wouldStrand( (next) => next.id !== destination.id && (discoveredIds?.has(next.id) ?? true) && - checkRoute(model, destination, next).ok, + checkRoute(model, destination, next, content.config).ok, ); } @@ -151,7 +151,7 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent } if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity"); - const route = checkRoute(model, origin, destination); + const route = checkRoute(model, origin, destination, content.config); if (!route.ok) return fail(route.reason); if (wouldStrand(model, destination, proposal.discoveredIds, content)) { return fail("would-strand"); @@ -308,7 +308,7 @@ export function validateReposition( const model = shipStats(ship, content); const origin = findLocation(ship.locationId, content); const destination = findLocation(destinationId, content); - const route = checkRoute(model, origin, destination); + const route = checkRoute(model, origin, destination, content.config); if (!route.ok) return failWith(route.reason); if (wouldStrand(model, destination, discoveredIds, content)) { return failWith("would-strand"); diff --git a/packages/sim/src/eligibility.ts b/packages/sim/src/eligibility.ts index 18ebbf9..831b730 100644 --- a/packages/sim/src/eligibility.ts +++ b/packages/sim/src/eligibility.ts @@ -1,5 +1,6 @@ -import type { LandingClass, Location, ShipModel } from "@deadhead/content"; +import type { GameConfig, LandingClass, Location, ShipModel } from "@deadhead/content"; import { routeDistance } from "./distance"; +import { fullTankReach } from "./fuel"; const LANDING_RANK: Record = { vacuum: 0, atmospheric: 1, hardened: 2 }; @@ -11,20 +12,25 @@ export function canLandAt(ship: ShipModel, location: Location): boolean { } /** - * 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. + * A ship's reach for a leg launched from `from` on a full tank (decisions 0.5.77, + * 0.5.78, 0.5.95), in million km. */ -export function rangeFrom(ship: ShipModel, from: Location): number { - return ship.fuelRangeMillionKm * from.launchRangeFactor; +export function rangeFrom(ship: ShipModel, from: Location, config: GameConfig): number { + return fullTankReach(ship, from, config); } /** * Whether a ship can fly from one location to another, or why not. Range is * per leg, from the origin (decision 0.5.77); the map is fixed (0.5.93). */ -export function checkRoute(ship: ShipModel, from: Location, to: Location): RouteCheck { +export function checkRoute( + ship: ShipModel, + from: Location, + to: Location, + config: GameConfig, +): RouteCheck { if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" }; - if (routeDistance(from, to) > rangeFrom(ship, from)) { + if (routeDistance(from, to) > rangeFrom(ship, from, config)) { return { ok: false, reason: "out-of-range" }; } return { ok: true }; diff --git a/packages/sim/src/fuel.ts b/packages/sim/src/fuel.ts index c21926c..4e2e96a 100644 --- a/packages/sim/src/fuel.ts +++ b/packages/sim/src/fuel.ts @@ -4,16 +4,53 @@ import type { GameConfig, Location, ShipModel } from "@deadhead/content"; * The distance pay and fuel are charged on (decision 0.5.84): the route distance * scaled down for long legs, `reference × (distance / reference)^exponent`. * A leg of the reference length (Earth–Moon) is unchanged; an exponent of 1 - * makes it the plain distance. Range, travel time and XP use the plain distance. + * makes it the plain distance. Travel time and XP use the plain distance. */ export function economicDistance(distanceMillionKm: number, config: GameConfig): number { const { referenceMillionKm, exponent } = config.economicDistance; return referenceMillionKm * (distanceMillionKm / referenceMillionKm) ** exponent; } +/** The plain distance whose economic distance is `economic` (the inverse of the curve). */ +export function plainDistance(economic: number, config: GameConfig): number { + const { referenceMillionKm, exponent } = config.economicDistance; + return referenceMillionKm * (economic / referenceMillionKm) ** (1 / exponent); +} + /** - * Fuel cost in credits: economic distance × the ship's cost per million km × - * the origin's launch multiplier. + * Fuel a leg burns, in fu (decision 0.5.95): burn rate × the economic distance + * of the leg shortened by the origin's range factor, so launches from + * low-gravity bodies burn less. + */ +export function legFuel( + ship: ShipModel, + origin: Location, + distanceMillionKm: number, + config: GameConfig, +): number { + return ( + ship.burnFuPerMillionKm * economicDistance(distanceMillionKm / origin.launchRangeFactor, config) + ); +} + +/** + * How far a full tank flies from an origin, in million km (decisions 0.5.78, + * 0.5.95): the plain distance whose leg fuel fills the tank. + */ +export function fullTankReach(ship: ShipModel, origin: Location, config: GameConfig): number { + return ( + plainDistance(ship.tankCapacityFu / ship.burnFuPerMillionKm, config) * origin.launchRangeFactor + ); +} + +/** A location's fuel price in credits per fu (decision 0.5.96). */ +export function fuelPriceAt(location: Location, config: GameConfig): number { + return config.baseFuelPricePerFu * location.fuelPrice; +} + +/** + * Fuel cost in credits for a leg: its fuel at the origin's price. (Step 15.3; + * Step 15.4 replaces the charge with fuel burned from the ship's tank.) */ export function fuelCost( ship: ShipModel, @@ -21,9 +58,5 @@ export function fuelCost( distanceMillionKm: number, config: GameConfig, ): number { - return ( - economicDistance(distanceMillionKm, config) * - ship.fuelCostPerMillionKm * - origin.launchCostMultiplier - ); + return legFuel(ship, origin, distanceMillionKm, config) * fuelPriceAt(origin, config); } diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index f118878..ad5fec5 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -12,7 +12,14 @@ export type { Position } from "./positions"; export { routeDistance } from "./distance"; export { travelTimeGameHours } from "./travel"; -export { economicDistance, fuelCost } from "./fuel"; +export { + economicDistance, + fuelCost, + fuelPriceAt, + fullTankReach, + legFuel, + plainDistance, +} from "./fuel"; export { canLandAt, checkRoute, rangeFrom } from "./eligibility"; export type { RouteCheck } from "./eligibility"; diff --git a/packages/sim/test/board-fleet.test.ts b/packages/sim/test/board-fleet.test.ts index fa49bb1..4d11270 100644 --- a/packages/sim/test/board-fleet.test.ts +++ b/packages/sim/test/board-fleet.test.ts @@ -23,7 +23,7 @@ function fits(contract: Contract, member: FleetMember): boolean { return ( !contract.firstContact && contract.massTonnes <= model.cargoCapacityTonnes && - checkRoute(model, earth, content.location(contract.destinationId)!).ok && + checkRoute(model, earth, content.location(contract.destinationId)!, content.config).ok && checkPilotEligibility(member.skills, contract).ok && // Priority (CR-007): the ship must make the delivery window. (!contract.priority || diff --git a/packages/sim/test/courier-mars.test.ts b/packages/sim/test/courier-mars.test.ts index 37cac2b..82c8b0d 100644 --- a/packages/sim/test/courier-mars.test.ts +++ b/packages/sim/test/courier-mars.test.ts @@ -8,23 +8,27 @@ const earth = content.location("earth")!; const moon = content.location("moon")!; const mars = content.location("mars")!; -/** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */ +/** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of the base tank). */ const withTanks = (level: number): ShipModel => ({ ...courier, - fuelRangeMillionKm: - courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100), + tankCapacityFu: + courier.tankCapacityFu * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100), }); // Decision 0.5.68 as amended by CR-015: a plain yes or no on the fixed map. describe("Courier to Mars (decision 0.5.68)", () => { it("reaches Mars from Earth with maxed fuel tanks", () => { - expect(checkRoute(withTanks(3), earth, mars)).toEqual({ ok: true }); + expect(checkRoute(withTanks(3), earth, mars, content.config)).toEqual({ ok: true }); }); it("can't reach Mars from Earth or the Moon with fuel tanks at level 2 or less", () => { for (const level of [0, 1, 2]) { - expect(checkRoute(withTanks(level), earth, mars).ok, `level ${level}`).toBe(false); - expect(checkRoute(withTanks(level), moon, mars).ok, `level ${level}`).toBe(false); + expect(checkRoute(withTanks(level), earth, mars, content.config).ok, `level ${level}`).toBe( + false, + ); + expect(checkRoute(withTanks(level), moon, mars, content.config).ok, `level ${level}`).toBe( + false, + ); } }); }); diff --git a/packages/sim/test/eligibility.test.ts b/packages/sim/test/eligibility.test.ts index dcd15b4..ae2a502 100644 --- a/packages/sim/test/eligibility.test.ts +++ b/packages/sim/test/eligibility.test.ts @@ -20,32 +20,32 @@ const outOfRange = { ok: false, reason: "out-of-range" }; // 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)).toEqual({ ok: true }); - expect(checkRoute(courier, moon, earth)).toEqual({ ok: true }); + expect(checkRoute(courier, earth, moon, content.config)).toEqual({ ok: true }); + expect(checkRoute(courier, moon, earth, content.config)).toEqual({ ok: true }); }); it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter", () => { for (const to of [mars, ceres, jupiter]) { - expect(checkRoute(courier, earth, to)).toEqual(outOfRange); + expect(checkRoute(courier, earth, to, content.config)).toEqual(outOfRange); } }); it("rejects a vacuum-only ship at Earth because it cannot land", () => { - expect(checkRoute(vacuumShip, moon, earth)).toEqual({ + expect(checkRoute(vacuumShip, moon, earth, content.config)).toEqual({ ok: false, reason: "cannot-land", }); - expect(checkRoute(vacuumShip, earth, moon)).toEqual({ ok: true }); + expect(checkRoute(vacuumShip, earth, moon, content.config)).toEqual({ ok: true }); }); it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => { - expect(checkRoute(freighter, earth, mars)).toEqual({ ok: true }); - expect(checkRoute(freighter, earth, ceres)).toEqual({ ok: true }); + expect(checkRoute(freighter, earth, mars, content.config)).toEqual({ ok: true }); + expect(checkRoute(freighter, earth, ceres, content.config)).toEqual({ ok: true }); }); it("lets only the Bulk Hauler reach Jupiter from Earth", () => { - expect(checkRoute(bulkHauler, earth, jupiter)).toEqual({ ok: true }); - expect(checkRoute(freighter, earth, jupiter)).toEqual(outOfRange); - expect(checkRoute(courier, earth, jupiter)).toEqual(outOfRange); + expect(checkRoute(bulkHauler, earth, jupiter, content.config)).toEqual({ ok: true }); + expect(checkRoute(freighter, earth, jupiter, content.config)).toEqual(outOfRange); + expect(checkRoute(courier, earth, jupiter, content.config)).toEqual(outOfRange); }); }); diff --git a/packages/sim/test/fuel.test.ts b/packages/sim/test/fuel.test.ts index acad4c8..a8d3b9f 100644 --- a/packages/sim/test/fuel.test.ts +++ b/packages/sim/test/fuel.test.ts @@ -1,6 +1,13 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { economicDistance, fuelCost, routeDistance } from "../src/index"; +import { + economicDistance, + fuelCost, + fullTankReach, + legFuel, + plainDistance, + routeDistance, +} from "../src/index"; const { config } = content; const courier = content.shipModel("courier")!; @@ -40,9 +47,37 @@ describe("fuel cost", () => { ); }); - it("matches a hand calculation", () => { - // Economic distance of 0.5M km × 200 credits/M km × Earth multiplier 2.1. - const expected = ref * (0.5 / ref) ** exponent * 200 * 2.1; + it("matches a hand calculation (Step 15.3: the leg's fuel at the origin's price)", () => { + // 20 fu per M km × economic distance of 0.5M km, at 10 cr/fu × Earth's 1.6. + const expected = 20 * ref * (0.5 / ref) ** exponent * 10 * 1.6; expect(fuelCost(courier, earth, 0.5, config)).toBeCloseTo(expected); }); }); + +describe("tanks and reach (decisions 0.5.94, 0.5.95)", () => { + it("burns the burn rate × the economic distance of the leg ÷ the origin's range factor", () => { + expect(legFuel(courier, earth, 0.5, config)).toBeCloseTo(20 * economicDistance(0.5, config)); + expect(legFuel(courier, moon, 0.5, config)).toBeCloseTo( + 20 * economicDistance(0.5 / 1.3, config), + ); + }); + + it("inverts the economic-distance curve", () => { + for (const d of [0.2, 0.384, 5, 201, 792]) { + expect(plainDistance(economicDistance(d, config), config)).toBeCloseTo(d, 6); + } + }); + + it("reaches exactly as far as a full tank burns, stretched by the origin's factor", () => { + for (const origin of content.locations) { + for (const ship of content.shipModels) { + const reach = fullTankReach(ship, origin, config); + expect(legFuel(ship, origin, reach, config), `${ship.id} from ${origin.id}`).toBeCloseTo( + ship.tankCapacityFu, + 6, + ); + expect(reach).toBeCloseTo(fullTankReach(ship, earth, config) * origin.launchRangeFactor, 6); + } + } + }); +}); diff --git a/packages/sim/test/no-strand.test.ts b/packages/sim/test/no-strand.test.ts index fd54677..76f3fb4 100644 --- a/packages/sim/test/no-strand.test.ts +++ b/packages/sim/test/no-strand.test.ts @@ -2,6 +2,7 @@ import { content, type Location } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { checkRoute, + economicDistance, effectiveStats, validateDispatch, validateReposition, @@ -36,7 +37,13 @@ const fixture: SimContent = { locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station], shipModels: [ ...content.shipModels, - { ...content.shipModel("courier")!, id: "hopper", fuelRangeMillionKm: 0.45 }, + { + ...content.shipModel("courier")!, + id: "hopper", + // A full tank reaches 0.45M km from Earth (decision 0.5.95). + tankCapacityFu: 20 * economicDistance(0.45, content.config), + burnFuPerMillionKm: 20, + }, ], }; const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false }; @@ -97,7 +104,7 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => { const model = effectiveStats(base, {}, content); for (const from of ids) { for (const to of ids) { - if (from === to || !checkRoute(model, loc(from), loc(to)).ok) continue; + if (from === to || !checkRoute(model, loc(from), loc(to), content.config).ok) continue; expect(wouldStrand(model, loc(to), undefined, content), `${model.id} ${from}→${to}`).toBe( false, ); diff --git a/packages/sim/test/range-from.test.ts b/packages/sim/test/range-from.test.ts index 446ab74..c228cfb 100644 --- a/packages/sim/test/range-from.test.ts +++ b/packages/sim/test/range-from.test.ts @@ -1,27 +1,31 @@ import { content, type Location, type ShipModel } from "@deadhead/content"; import { describe, expect, it } from "vitest"; -import { checkRoute, rangeFrom, routeDistance } from "../src/index"; +import { checkRoute, economicDistance, rangeFrom, routeDistance } from "../src/index"; const loc = (id: string) => content.location(id)!; const courier = content.shipModel("courier")!; -const withRange = (range: number): ShipModel => ({ ...courier, fuelRangeMillionKm: range }); +/** A Courier whose full tank reaches exactly `range` from a factor-1 origin (decision 0.5.95). */ +const withRange = (range: number): ShipModel => ({ + ...courier, + tankCapacityFu: courier.burnFuPerMillionKm * economicDistance(range, content.config), +}); 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); + expect(rangeFrom(withRange(100), loc("earth"), content.config)).toBeCloseTo(100); + expect(rangeFrom(withRange(100), loc("moon"), content.config)).toBeCloseTo(130); + expect(rangeFrom(withRange(100), loc("mars"), content.config)).toBeCloseTo(115); }); it("passes a leg from the Moon that fails from Earth when it is between 1× and 1.3× range", () => { const d = routeDistance(loc("moon"), loc("mars")); const ship = withRange(d / 1.2); - expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(ship.fuelRangeMillionKm); - expect(checkRoute(ship, loc("earth"), loc("mars"))).toEqual({ + expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(d / 1.2); + expect(checkRoute(ship, loc("earth"), loc("mars"), content.config)).toEqual({ ok: false, reason: "out-of-range", }); - expect(checkRoute(ship, loc("moon"), loc("mars"))).toEqual({ ok: true }); + expect(checkRoute(ship, loc("moon"), loc("mars"), content.config)).toEqual({ ok: true }); }); it("matches the old check exactly when every factor is 1", () => { @@ -32,7 +36,9 @@ describe("range from the origin (Step 10L.2)", () => { for (const to of ids) { if (from === to) continue; const old = routeDistance(loc(from), loc(to)) <= range; - expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to))).ok).toBe(old); + expect( + checkRoute(withRange(range), flat(loc(from)), flat(loc(to)), content.config).ok, + ).toBe(old); } } } diff --git a/packages/sim/test/reach-table.test.ts b/packages/sim/test/reach-table.test.ts index 2cac9ef..e8dc010 100644 --- a/packages/sim/test/reach-table.test.ts +++ b/packages/sim/test/reach-table.test.ts @@ -17,7 +17,7 @@ const ids = Object.keys(NAMES); function reaches(modelId: string, fromId: string, toId: string): boolean { const ship = content.shipModel(modelId)!; - return checkRoute(ship, content.location(fromId)!, content.location(toId)!).ok; + return checkRoute(ship, content.location(fromId)!, content.location(toId)!, content.config).ok; } /** [model, from, to] for every leg the table says yes or no to. */ diff --git a/packages/sim/test/upgrade-caps.test.ts b/packages/sim/test/upgrade-caps.test.ts index cce4cc7..ca0711c 100644 --- a/packages/sim/test/upgrade-caps.test.ts +++ b/packages/sim/test/upgrade-caps.test.ts @@ -19,7 +19,7 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => { it("a maxed Lighter never reaches Mars from Earth or the Moon", () => { const lighter = maxedStats("lighter"); for (const from of [earth, moon]) { - expect(checkRoute(lighter, from, mars).ok).toBe(false); + expect(checkRoute(lighter, from, mars, content.config).ok).toBe(false); } }); @@ -37,12 +37,12 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => { it("only Bulk Haulers reach Jupiter from Earth, even maxed", () => { for (const ship of content.shipModels) { - const reaches = checkRoute(maxedStats(ship.id), earth, jupiter).ok; + const reaches = checkRoute(maxedStats(ship.id), earth, jupiter, content.config).ok; expect(reaches, ship.id).toBe(ship.id === "bulk-hauler"); } }); - it("gives the Courier 1400 % range per tanks level (CR-015)", () => { - expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(1400); + it("gives the Courier 380 % tank per tanks level (CR-015)", () => { + expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(380); }); });