Step 15.3: content: fuel
CI / check (push) Successful in 5m43s Details

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 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 01:05:13 -04:00
parent 0a1ca1d3ea
commit c627bb9954
45 changed files with 303 additions and 148 deletions

View File

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

View File

@ -110,7 +110,7 @@ export function nextMarsWindow(from: number): number {
const gameDay = (24 * 3_600_000) / content.config.timeCompression; const gameDay = (24 * 3_600_000) / content.config.timeCompression;
for (let t = from; t < from + 1_500 * gameDay; t += gameDay) { for (let t = from; t < from + 1_500 * gameDay; t += gameDay) {
if ( if (
checkRoute(clipper, earth, mars).ok && checkRoute(clipper, earth, mars, content.config).ok &&
!wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content) !wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content)
) { ) {
// Well inside the window: a few game days in. // Well inside the window: a few game days in.

View File

@ -72,7 +72,8 @@ function legsInRange(model: ShipModel): Set<string> {
legs = new Set(); legs = new Set();
for (const from of content.locations) { for (const from of content.locations) {
for (const to 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); legCache.set(model.id, legs);
@ -102,7 +103,8 @@ export function shipReach(model: ShipModel, originIds: string[]): ShipReach {
const nowLegs = originIds.map((originId) => ({ const nowLegs = originIds.map((originId) => ({
originId, originId,
destinationIds: ids.filter( 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)); const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds));

View File

@ -39,7 +39,7 @@ export function reachersByOrigin(
.map((originId) => { .map((originId) => {
const origin = content.location(originId)!; const origin = content.location(originId)!;
const reaches = (stats: Parameters<typeof checkRoute>[0]) => const reaches = (stats: Parameters<typeof checkRoute>[0]) =>
checkRoute(stats, origin, target).ok; checkRoute(stats, origin, target, content.config).ok;
const models = content.purchasableShipModels.filter(reaches).map((m) => m.name); const models = content.purchasableShipModels.filter(reaches).map((m) => m.name);
const upgraded = state.ships const upgraded = state.ships
.filter((ship: Ship) => { .filter((ship: Ship) => {

View File

@ -97,11 +97,16 @@ export function explainProblems(
if (contract.massTonnes > model.cargoCapacityTonnes) { if (contract.massTonnes > model.cargoCapacityTonnes) {
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); 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) { if (!route.ok) {
problems.push( problems.push(
route.reason === "out-of-range" 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"], : INELIGIBLE_REASONS["cannot-land"],
); );
} else if ( } else if (

View File

@ -1,6 +1,5 @@
import { content, type Location } from "@deadhead/content"; import { content, type Location } from "@deadhead/content";
import { Link } from "react-router"; import { Link } from "react-router";
import {} from "../clock/clock";
import { cargoName, formatCredits, formatMillionKm } from "../format"; import { cargoName, formatCredits, formatMillionKm } from "../format";
import { distanceAt } from "../game"; import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";

View File

@ -2,7 +2,6 @@ import { content, type Location } from "@deadhead/content";
import { realToGame } from "@deadhead/sim"; import { realToGame } from "@deadhead/sim";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import {} from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format"; import { cargoName, formatMillionKm, locationName } from "../format";

View File

@ -1,7 +1,7 @@
import { content, type ShipModel } from "@deadhead/content"; import { content, type ShipModel } from "@deadhead/content";
import { fullTankReach } from "@deadhead/sim";
import { useState } from "react"; import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format"; import { formatCredits, formatMillionKm, locationName } from "../format";
import {} from "../clock/clock";
import { shipReach, shipStatsOf } from "../game"; import { shipReach, shipStatsOf } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui"; 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 LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
const MODELS = content.purchasableShipModels; 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 = { const best = {
speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)), speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)),
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)), 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); 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). */ /** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
@ -81,9 +84,9 @@ export function Market() {
/> />
<Spec <Spec
icon="range" icon="range"
label="Range" label="Reach"
value={formatMillionKm(model.fuelRangeMillionKm)} value={formatMillionKm(reachFromEarth(model))}
share={rangeShare(model.fuelRangeMillionKm)} share={rangeShare(reachFromEarth(model))}
/> />
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} /> <Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
</dl> </dl>
@ -234,8 +237,7 @@ function OwnedUpgraded({ modelId }: { modelId: string }) {
return ( return (
<li key={ship.id}> <li key={ship.id}>
Your {ship.name}: {formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h ·{" "} Your {ship.name}: {formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h ·{" "}
{Math.round(s.cargoCapacityTonnes * 10) / 10} t ·{" "} {Math.round(s.cargoCapacityTonnes * 10) / 10} t · {formatMillionKm(reachFromEarth(s))}
{formatMillionKm(s.fuelRangeMillionKm)}
</li> </li>
); );
})} })}

View File

@ -37,7 +37,10 @@ const STAT_LABELS: Record<ShipStat, { label: string; format: (s: EffectiveStats)
label: "Capacity", label: "Capacity",
format: (s) => `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`, format: (s) => `${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: { overheadGameHours: {
label: "Takeoff/landing", label: "Takeoff/landing",
format: (s) => `${s.overheadGameHours.toFixed(2)} h`, format: (s) => `${s.overheadGameHours.toFixed(2)} h`,

View File

@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
import { deliveryXp, loanerModel } from "@deadhead/sim"; import { deliveryXp, loanerModel } from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import {} from "../clock/clock";
import { formatCredits, formatDuration, locationName } from "../format"; import { formatCredits, formatDuration, locationName } from "../format";
import { goodName } from "../components/Goods"; import { goodName } from "../components/Goods";
import { distanceAt, tripRealMs } from "../game"; import { distanceAt, tripRealMs } from "../game";

View File

@ -20,7 +20,8 @@ export function shipExtras(
stats: { stats: {
cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour, cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: s.cargoCapacityTonnes, cargoCapacityTonnes: s.cargoCapacityTonnes,
fuelRangeMillionKm: s.fuelRangeMillionKm, tankCapacityFu: s.tankCapacityFu,
burnFuPerMillionKm: s.burnFuPerMillionKm,
overheadGameHours: s.overheadGameHours, overheadGameHours: s.overheadGameHours,
handlingGameHoursPerTonne: s.handlingGameHoursPerTonne, handlingGameHoursPerTonne: s.handlingGameHoursPerTonne,
}, },

View File

@ -54,7 +54,10 @@ describe("whole map from the start", () => {
expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock."); expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock.");
const expected = content.purchasableShipModels 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); .map((m) => m.name);
expect(expected.length).toBeGreaterThan(0); expect(expected.length).toBeGreaterThan(0);
expect(expected).not.toContain("Courier"); expect(expected).not.toContain("Courier");

View File

@ -16,7 +16,7 @@ const [mars, ceres] = [content.location("mars")!, content.location("ceres-statio
const NOW = (() => { const NOW = (() => {
for (let i = 0; i <= 16 * 12; i++) { for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH; 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"); throw new Error("no such date");
})(); })();

View File

@ -18,7 +18,8 @@ describe("market screen", () => {
expect(courier).toHaveTextContent("Courier"); expect(courier).toHaveTextContent("Courier");
expect(courier).toHaveTextContent("3.00M km/h"); expect(courier).toHaveTextContent("3.00M km/h");
expect(courier).toHaveTextContent("Capacity20 t"); 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"); expect(courier).toHaveTextContent("LandingAtmospheric");
}); });

View File

@ -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 // 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. // Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered.
if ( if (
!checkRoute(freighter, moon, jupiter).ok || !checkRoute(freighter, moon, jupiter, content.config).ok ||
!wouldStrand(freighter, jupiter, discovered, content) !wouldStrand(freighter, jupiter, discovered, content)
) { ) {
throw new Error("the scenario no longer strands"); throw new Error("the scenario no longer strands");

View File

@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format
| Package | Role | Depends on | | 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/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/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 | | `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 | | `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` | | `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` | | `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) | | `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` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |

View File

@ -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.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. - [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.4 — Sim: tank, burn, and refuel
- [ ] Step 15.5 — Sim: stranding and emergency fuel - [ ] Step 15.5 — Sim: stranding and emergency fuel
- [ ] Step 15.6 — Engine: commands and save migration - [ ] Step 15.6 — Engine: commands and save migration

View File

@ -28,6 +28,10 @@ export const gameConfig: GameConfig = {
// Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips. // Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips.
boardRetentionPeriods: 24, boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168, 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). // Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84).
economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 }, economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 },
// Ship upgrades (CR-009): level 1, 2, 3. // Ship upgrades (CR-009): level 1, 2, 3.

View File

@ -4,7 +4,8 @@ import { goodsDemandedAt, goodsProducedAt } from "./goods";
/** /**
* The fixed map (decision 0.5.93, CR-015 §2): real mean orbital radii, angles * 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 * 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 EARTH: FixedPosition = { radiusMillionKm: 150, angleDegrees: 210 };
const MOON_OFFSET_MILLION_KM = 0.384; const MOON_OFFSET_MILLION_KM = 0.384;
@ -29,7 +30,8 @@ export const locations: Location[] = [
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
position: EARTH, position: EARTH,
sprite: "body-earth", sprite: "body-earth",
launchCostMultiplier: 2.1, fuelPrice: 1.6,
sellsFuel: true,
launchRangeFactor: 1, launchRangeFactor: 1,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 1000, bayLeasePrice: 1000,
@ -51,7 +53,8 @@ export const locations: Location[] = [
position: offsetPosition(EARTH, MOON_OFFSET_MILLION_KM), position: offsetPosition(EARTH, MOON_OFFSET_MILLION_KM),
orbitParent: { locationId: "earth", offsetMillionKm: MOON_OFFSET_MILLION_KM }, orbitParent: { locationId: "earth", offsetMillionKm: MOON_OFFSET_MILLION_KM },
sprite: "body-moon", sprite: "body-moon",
launchCostMultiplier: 0.5, fuelPrice: 1.1,
sellsFuel: true,
launchRangeFactor: 1.3, launchRangeFactor: 1.3,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 800, bayLeasePrice: 800,
@ -70,7 +73,8 @@ export const locations: Location[] = [
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
position: { radiusMillionKm: 228, angleDegrees: 270 }, position: { radiusMillionKm: 228, angleDegrees: 270 },
sprite: "body-mars", sprite: "body-mars",
launchCostMultiplier: 1.5, fuelPrice: 1.4,
sellsFuel: true,
launchRangeFactor: 1.15, launchRangeFactor: 1.15,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 20000, bayLeasePrice: 20000,
@ -86,7 +90,8 @@ export const locations: Location[] = [
landingRequirement: "vacuum", landingRequirement: "vacuum",
position: { radiusMillionKm: 414, angleDegrees: 285 }, position: { radiusMillionKm: 414, angleDegrees: 285 },
sprite: "body-ceres", sprite: "body-ceres",
launchCostMultiplier: 0.3, fuelPrice: 0.9,
sellsFuel: true,
launchRangeFactor: 1.3, launchRangeFactor: 1.3,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 40000, bayLeasePrice: 40000,
@ -103,7 +108,8 @@ export const locations: Location[] = [
position: { radiusMillionKm: 778, angleDegrees: 300 }, position: { radiusMillionKm: 778, angleDegrees: 300 },
sprite: "body-jupiter", sprite: "body-jupiter",
badge: "badge-cloud-platform", badge: "badge-cloud-platform",
launchCostMultiplier: 2, fuelPrice: 0.6,
sellsFuel: true,
launchRangeFactor: 1, launchRangeFactor: 1,
startsDiscovered: false, startsDiscovered: false,
bayLeasePrice: 80000, bayLeasePrice: 80000,

View File

@ -2,8 +2,9 @@ import type { ShipModel } from "../schemas";
/** /**
* Base-game ship models (GDD §8). All are atmospheric-rated (decision 0.5.1). * 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 * Tanks and burn rates (CR-015): full-tank reach from Earth is the distance
* ~55M km, Ceres at farthest ~600M km, Jupiter ~590M–970M km. * 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[] = [ export const shipModels: ShipModel[] = [
{ {
@ -14,12 +15,12 @@ export const shipModels: ShipModel[] = [
price: 4000, price: 4000,
cruiseSpeedKmPerGameHour: 3_000_000, cruiseSpeedKmPerGameHour: 3_000_000,
cargoCapacityTonnes: 20, cargoCapacityTonnes: 20,
// Moon only. // Reach ≈ 4.9M km: the Moon only.
fuelRangeMillionKm: 5, tankCapacityFu: 40,
burnFuPerMillionKm: 20,
hullRating: 1, hullRating: 1,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
fuelCostPerMillionKm: 200,
purchasable: true, purchasable: true,
role: "Fast and cheap: small loads around Earth and the Moon.", 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. // Slower than the Freighter, faster than the Bulk Hauler.
cruiseSpeedKmPerGameHour: 1_600_000, cruiseSpeedKmPerGameHour: 1_600_000,
cargoCapacityTonnes: 80, cargoCapacityTonnes: 80,
// Earth and the Moon only, even with maxed tanks (CR-012 §3). // Reach ≈ 9.8M km: Earth and the Moon only, even with maxed tanks.
fuelRangeMillionKm: 10, tankCapacityFu: 110,
burnFuPerMillionKm: 35,
hullRating: 2, hullRating: 2,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
// Big cargo doors: the fastest loading in the fleet. // Big cargo doors: the fastest loading in the fleet.
handlingGameHoursPerTonne: 0.012, handlingGameHoursPerTonne: 0.012,
fuelCostPerMillionKm: 350,
purchasable: true, purchasable: true,
role: "Short-haul heavy lifter: big loads between Earth and the Moon, loaded fast.", 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. // The fastest ship in the game.
cruiseSpeedKmPerGameHour: 6_500_000, cruiseSpeedKmPerGameHour: 6_500_000,
cargoCapacityTonnes: 30, cargoCapacityTonnes: 30,
// Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4). // Reach ≈ 252M km: Mars, and Ceres by way of Mars (CR-015 §4).
fuelRangeMillionKm: 250, tankCapacityFu: 1170,
burnFuPerMillionKm: 45,
hullRating: 1, hullRating: 1,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
fuelCostPerMillionKm: 450,
purchasable: true, purchasable: true,
role: "Fast long-legged runner: the cheapest way to Mars, built for Priority work.", role: "Fast long-legged runner: the cheapest way to Mars, built for Priority work.",
}, },
@ -72,12 +73,12 @@ export const shipModels: ShipModel[] = [
price: 100000, price: 100000,
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). // Reach ≈ 460M km: Mars and Ceres from Earth, Jupiter only from Ceres.
fuelRangeMillionKm: 460, tankCapacityFu: 1540,
burnFuPerMillionKm: 40,
hullRating: 2, hullRating: 2,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 4, overheadGameHours: 4,
fuelCostPerMillionKm: 400,
purchasable: true, purchasable: true,
role: "The all-rounder: big loads to Mars and Ceres, Jupiter by way of Ceres.", 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, price: 400000,
cruiseSpeedKmPerGameHour: 1_200_000, cruiseSpeedKmPerGameHour: 1_200_000,
cargoCapacityTonnes: 400, cargoCapacityTonnes: 400,
// Jupiter at its farthest. // Reach ≈ 1,250M km: every leg, Jupiter from Earth included.
fuelRangeMillionKm: 1250, tankCapacityFu: 5900,
burnFuPerMillionKm: 80,
hullRating: 3, hullRating: 3,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 8, overheadGameHours: 8,
fuelCostPerMillionKm: 800,
purchasable: true, purchasable: true,
role: "Slow and enormous: the only ship that can fly any leg in the system.", 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, price: 4000,
cruiseSpeedKmPerGameHour: 6_000_000, cruiseSpeedKmPerGameHour: 6_000_000,
cargoCapacityTonnes: 20, cargoCapacityTonnes: 20,
fuelRangeMillionKm: 5, tankCapacityFu: 40,
burnFuPerMillionKm: 20,
hullRating: 1, hullRating: 1,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 0.3, overheadGameHours: 0.3,
fuelCostPerMillionKm: 200,
purchasable: false, purchasable: false,
role: "Halvorsen's loaner for the company runs.", role: "Halvorsen's loaner for the company runs.",
}, },

View File

@ -4,6 +4,9 @@ import type { Upgrade } from "../schemas";
* Ship upgrades (CR-009 §2). Percentages are of the model's base stat and add * 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 * 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). * 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[] = [ export const upgrades: Upgrade[] = [
{ {
@ -23,14 +26,15 @@ export const upgrades: Upgrade[] = [
{ {
id: "fuel-tanks", id: "fuel-tanks",
name: "Extended Fuel Tanks", name: "Extended Fuel Tanks",
stat: "fuelRangeMillionKm", stat: "tankCapacityFu",
percentPerLevel: 20, // Maxed: × 1.33 capacity, ≈ × 1.55 reach (as +20 % range a level did).
percentPerLevel: 11,
maxLevel: 3, maxLevel: 3,
modelOverrides: { 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). // 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: 1400 }, courier: { percentPerLevel: 380 },
// 460 → 667 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth (≈ 792). // 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: 15 }, freighter: { percentPerLevel: 9 },
}, },
}, },
{ {

View File

@ -47,11 +47,13 @@ export const locationSchema = z.object({
orbitParent: z orbitParent: z
.object({ locationId: idSchema, offsetMillionKm: z.number().positive() }) .object({ locationId: idSchema, offsetMillionKm: z.number().positive() })
.optional(), .optional(),
/** Multiplies fuel cost for launches from this location. */ /** Fuel price here as a multiple of the base price (decision 0.5.96). */
launchCostMultiplier: z.number().positive(), 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): * Multiplies a ship's reach for legs launched from here (decision 0.5.78):
* low gravity stretches it. Separate from the fuel-cost multiplier. * low gravity stretches it, so a leg from here burns less fuel (0.5.95).
*/ */
launchRangeFactor: z.number().positive(), launchRangeFactor: z.number().positive(),
startsDiscovered: z.boolean(), startsDiscovered: z.boolean(),
@ -92,14 +94,18 @@ export const shipModelSchema = z.object({
price: positiveInt, price: positiveInt,
cruiseSpeedKmPerGameHour: z.number().positive(), cruiseSpeedKmPerGameHour: z.number().positive(),
cargoCapacityTonnes: 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). */ /** Stored only; no effect in the base game (decision 0.5.13). */
hullRating: nonNegativeInt, hullRating: nonNegativeInt,
landingClass: landingClassSchema, landingClass: landingClassSchema,
/** Fixed takeoff + landing time added to every trip. */ /** Fixed takeoff + landing time added to every trip. */
overheadGameHours: z.number().nonnegative(), 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). */ /** False for company-only ships such as the tutorial loaner (decision 0.5.29). */
purchasable: z.boolean(), purchasable: z.boolean(),
/** One line on what the ship is for, shown in the Market (CR-012). */ /** One line on what the ship is for, shown in the Market (CR-012). */
@ -119,7 +125,7 @@ export type ShipModel = z.infer<typeof shipModelSchema>;
export const SHIP_STATS = [ export const SHIP_STATS = [
"cruiseSpeedKmPerGameHour", "cruiseSpeedKmPerGameHour",
"cargoCapacityTonnes", "cargoCapacityTonnes",
"fuelRangeMillionKm", "tankCapacityFu",
"overheadGameHours", "overheadGameHours",
"handlingGameHoursPerTonne", "handlingGameHoursPerTonne",
] as const; ] as const;
@ -242,6 +248,10 @@ export const gameConfigSchema = z
* Pay and fuel use `reference × (distance / reference)^exponent` (decision * Pay and fuel use `reference × (distance / reference)^exponent` (decision
* 0.5.84), so long legs aren't worth hundreds of short ones. * 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({ economicDistance: z.object({
referenceMillionKm: z.number().positive(), referenceMillionKm: z.number().positive(),
exponent: z.number().positive().max(1), exponent: z.number().positive().max(1),

View File

@ -35,15 +35,18 @@ describe("locations", () => {
for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0); for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0);
}); });
it("gives Earth the highest launch multiplier", () => { it("orders fuel prices Jupiter < Ceres < Moon < Mars < Earth (decision 0.5.96)", () => {
const earth = locations.find((l) => l.id === "earth")!; const price = (id: string) => locations.find((l) => l.id === id)!.fuelPrice;
for (const location of locations) { const order = ["jupiter-cloud-platform", "ceres-station", "moon", "mars", "earth"];
if (location !== earth) { for (let i = 1; i < order.length; i++) {
expect(earth.launchCostMultiplier).toBeGreaterThan(location.launchCostMultiplier); 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)", () => { it("gives every location its own map sprite key (CR-003)", () => {
const sprites = locations.map((x) => x.sprite); const sprites = locations.map((x) => x.sprite);
for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/); for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/);

View File

@ -17,7 +17,8 @@ const validLocation = {
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
position: { radiusMillionKm: 150, angleDegrees: 210 }, position: { radiusMillionKm: 150, angleDegrees: 210 },
launchCostMultiplier: 3, fuelPrice: 1.6,
sellsFuel: true,
launchRangeFactor: 1, launchRangeFactor: 1,
startsDiscovered: true, startsDiscovered: true,
bayLeasePrice: 5000, bayLeasePrice: 5000,
@ -47,11 +48,11 @@ const validShipModel = {
price: 20000, price: 20000,
cruiseSpeedKmPerGameHour: 300000, cruiseSpeedKmPerGameHour: 300000,
cargoCapacityTonnes: 10, cargoCapacityTonnes: 10,
fuelRangeMillionKm: 5, tankCapacityFu: 40,
burnFuPerMillionKm: 20,
hullRating: 1, hullRating: 1,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
fuelCostPerMillionKm: 100,
purchasable: true, purchasable: true,
role: "Fast and cheap.", role: "Fast and cheap.",
sprite: "ship-courier", sprite: "ship-courier",
@ -110,6 +111,8 @@ const validGameConfig = {
loanRepaymentShare: 0.3, loanRepaymentShare: 0.3,
boardRetentionPeriods: 24, boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168, savedDispatchRetentionRealHours: 168,
baseFuelPricePerFu: 10,
emergencyFuelMultiplier: 2.5,
economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 },
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
}; };

View File

@ -61,15 +61,13 @@ describe("ship models", () => {
expect(lighterShare).toBeLessThanOrEqual(0.9); 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) { for (const m of shipModels) {
if (m.id !== "lighter") expect(handling("lighter")).toBeLessThan(handling(m.id)); if (m.id !== "lighter") expect(handling("lighter")).toBeLessThan(handling(m.id));
} }
expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan( expect(ship("clipper").burnFuPerMillionKm).toBeGreaterThan(ship("courier").burnFuPerMillionKm);
ship("courier").fuelCostPerMillionKm, expect(ship("clipper").burnFuPerMillionKm).toBeGreaterThan(
); ship("freighter").burnFuPerMillionKm,
expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan(
ship("freighter").fuelCostPerMillionKm,
); );
}); });

View File

@ -68,9 +68,10 @@ describe("ship upgrades content (CR-009)", () => {
expect(maxed("bulk-hauler", "cruiseSpeedKmPerGameHour")).toBeLessThan( expect(maxed("bulk-hauler", "cruiseSpeedKmPerGameHour")).toBeLessThan(
model("freighter").cruiseSpeedKmPerGameHour, model("freighter").cruiseSpeedKmPerGameHour,
); );
expect(maxed("freighter", "fuelRangeMillionKm")).toBeLessThan( // Reach grows with tank ÷ burn (decision 0.5.95), so compare that.
model("bulk-hauler").fuelRangeMillionKm, 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). // Amended by CR-012 (the Lighter's Mars cap is a sim test: upgrade-caps.test.ts).
expect(maxed("clipper", "cargoCapacityTonnes")).toBeLessThan( expect(maxed("clipper", "cargoCapacityTonnes")).toBeLessThan(
model("freighter").cargoCapacityTonnes, model("freighter").cargoCapacityTonnes,

View File

@ -147,7 +147,8 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S
stats: { stats: {
cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour, cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: stats.cargoCapacityTonnes, cargoCapacityTonnes: stats.cargoCapacityTonnes,
fuelRangeMillionKm: stats.fuelRangeMillionKm, tankCapacityFu: stats.tankCapacityFu,
burnFuPerMillionKm: stats.burnFuPerMillionKm,
overheadGameHours: stats.overheadGameHours, overheadGameHours: stats.overheadGameHours,
handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne, handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne,
}, },

View File

@ -118,8 +118,8 @@ describe("developer tools (Step 10K.11)", () => {
for (const upgrade of content.upgrades) { for (const upgrade of content.upgrades) {
expect(maxed.upgrades[upgrade.id], upgrade.id).toBe(upgradeMaxLevel(upgrade, "courier")); expect(maxed.upgrades[upgrade.id], upgrade.id).toBe(upgradeMaxLevel(upgrade, "courier"));
} }
expect(maxed.stats.fuelRangeMillionKm).toBeGreaterThan( expect(maxed.stats.tankCapacityFu).toBeGreaterThan(
content.shipModel("courier")!.fuelRangeMillionKm, content.shipModel("courier")!.tankCapacityFu,
); );
expect((await ship({ action: "reset-upgrades" })).upgrades).toEqual({}); expect((await ship({ action: "reset-upgrades" })).upgrades).toEqual({});

View File

@ -54,7 +54,7 @@ describe("Lighter and Clipper (Step 10L.6)", () => {
content.location("moon")!, content.location("moon")!,
content.location("jupiter-cloud-platform")!, 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"]); const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
expect(wouldStrand(freighter, jupiter, discovered, content)).toBe(true); expect(wouldStrand(freighter, jupiter, discovered, content)).toBe(true);

View File

@ -31,7 +31,8 @@ export type Company = z.infer<typeof companySchema>;
export const effectiveStatsSchema = z.object({ export const effectiveStatsSchema = z.object({
cruiseSpeedKmPerGameHour: z.number().positive(), cruiseSpeedKmPerGameHour: z.number().positive(),
cargoCapacityTonnes: z.number().positive(), cargoCapacityTonnes: z.number().positive(),
fuelRangeMillionKm: z.number().positive(), tankCapacityFu: z.number().positive(),
burnFuPerMillionKm: z.number().positive(),
overheadGameHours: z.number().nonnegative(), overheadGameHours: z.number().nonnegative(),
handlingGameHoursPerTonne: z.number().nonnegative(), handlingGameHoursPerTonne: z.number().nonnegative(),
}); });

View File

@ -62,7 +62,8 @@ const ship = {
stats: { stats: {
cruiseSpeedKmPerGameHour: 3_000_000, cruiseSpeedKmPerGameHour: 3_000_000,
cargoCapacityTonnes: 23, cargoCapacityTonnes: 23,
fuelRangeMillionKm: 5, tankCapacityFu: 40,
burnFuPerMillionKm: 20,
overheadGameHours: 2, overheadGameHours: 2,
handlingGameHoursPerTonne: 0.02, handlingGameHoursPerTonne: 0.02,
}, },

View File

@ -11,6 +11,7 @@
import { content as defaultContent, type ShipModel } from "@deadhead/content"; import { content as defaultContent, type ShipModel } from "@deadhead/content";
import { import {
boardPeriodIndex, boardPeriodIndex,
fullTankReach,
boardPeriodMs, boardPeriodMs,
checkRoute, checkRoute,
completedGoals, completedGoals,
@ -481,7 +482,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
const longHaul = pilot.skills["long-haul"] ?? 0; const longHaul = pilot.skills["long-haul"] ?? 0;
const cargo = pilot.skills[cargoSkill] ?? 0; const cargo = pilot.skills[cargoSkill] ?? 0;
const wantsLongHaul = 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; const skill = wantsLongHaul ? "long-haul" : cargoSkill;
pilot.skills[skill] = (pilot.skills[skill] ?? 0) + 1; pilot.skills[skill] = (pilot.skills[skill] ?? 0) + 1;
pilot.spent++; pilot.spent++;
@ -586,7 +588,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
const target = deadheads const target = deadheads
.map((to) => { .map((to) => {
const best = board(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) .map((c) => c.payout)
.sort((a, b) => b - a)[0]; .sort((a, b) => b - a)[0];
return { to, best: best ?? 0 }; return { to, best: best ?? 0 };

View File

@ -132,7 +132,7 @@ function fitJob(
const { model, skills } = able[(start + k) % able.length]!; const { model, skills } = able[(start + k) % able.length]!;
const reachable = discovered.filter( const reachable = discovered.filter(
(d) => (d) =>
checkRoute(model, origin, d).ok && checkRoute(model, origin, d, content.config).ok &&
requiredLongHaulLevel(routeDistance(origin, d), content.config) <= requiredLongHaulLevel(routeDistance(origin, d), content.config) <=
skillLevel(skills, LONG_HAUL_SKILL_ID), skillLevel(skills, LONG_HAUL_SKILL_ID),
); );

View File

@ -128,7 +128,7 @@ export function wouldStrand(
(next) => (next) =>
next.id !== destination.id && next.id !== destination.id &&
(discoveredIds?.has(next.id) ?? true) && (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"); 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 (!route.ok) return fail(route.reason);
if (wouldStrand(model, destination, proposal.discoveredIds, content)) { if (wouldStrand(model, destination, proposal.discoveredIds, content)) {
return fail("would-strand"); return fail("would-strand");
@ -308,7 +308,7 @@ export function validateReposition(
const model = shipStats(ship, content); const model = shipStats(ship, content);
const origin = findLocation(ship.locationId, content); const origin = findLocation(ship.locationId, content);
const destination = findLocation(destinationId, 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 (!route.ok) return failWith(route.reason);
if (wouldStrand(model, destination, discoveredIds, content)) { if (wouldStrand(model, destination, discoveredIds, content)) {
return failWith("would-strand"); return failWith("would-strand");

View File

@ -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 { routeDistance } from "./distance";
import { fullTankReach } from "./fuel";
const LANDING_RANK: Record<LandingClass, number> = { vacuum: 0, atmospheric: 1, hardened: 2 }; const LANDING_RANK: Record<LandingClass, number> = { 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): * A ship's reach for a leg launched from `from` on a full tank (decisions 0.5.77,
* its (effective) range times the origin's launch range factor. * 0.5.78, 0.5.95), in million km.
*/ */
export function rangeFrom(ship: ShipModel, from: Location): number { export function rangeFrom(ship: ShipModel, from: Location, config: GameConfig): number {
return ship.fuelRangeMillionKm * from.launchRangeFactor; return fullTankReach(ship, from, config);
} }
/** /**
* Whether a ship can fly from one location to another, or why not. Range is * 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). * 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 (!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: false, reason: "out-of-range" };
} }
return { ok: true }; return { ok: true };

View File

@ -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 * The distance pay and fuel are charged on (decision 0.5.84): the route distance
* scaled down for long legs, `reference × (distance / reference)^exponent`. * scaled down for long legs, `reference × (distance / reference)^exponent`.
* A leg of the reference length (Earth–Moon) is unchanged; an exponent of 1 * 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 { export function economicDistance(distanceMillionKm: number, config: GameConfig): number {
const { referenceMillionKm, exponent } = config.economicDistance; const { referenceMillionKm, exponent } = config.economicDistance;
return referenceMillionKm * (distanceMillionKm / referenceMillionKm) ** exponent; 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 × * Fuel a leg burns, in fu (decision 0.5.95): burn rate × the economic distance
* the origin's launch multiplier. * 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( export function fuelCost(
ship: ShipModel, ship: ShipModel,
@ -21,9 +58,5 @@ export function fuelCost(
distanceMillionKm: number, distanceMillionKm: number,
config: GameConfig, config: GameConfig,
): number { ): number {
return ( return legFuel(ship, origin, distanceMillionKm, config) * fuelPriceAt(origin, config);
economicDistance(distanceMillionKm, config) *
ship.fuelCostPerMillionKm *
origin.launchCostMultiplier
);
} }

View File

@ -12,7 +12,14 @@ export type { Position } from "./positions";
export { routeDistance } from "./distance"; export { routeDistance } from "./distance";
export { travelTimeGameHours } from "./travel"; 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 { canLandAt, checkRoute, rangeFrom } from "./eligibility";
export type { RouteCheck } from "./eligibility"; export type { RouteCheck } from "./eligibility";

View File

@ -23,7 +23,7 @@ function fits(contract: Contract, member: FleetMember): boolean {
return ( return (
!contract.firstContact && !contract.firstContact &&
contract.massTonnes <= model.cargoCapacityTonnes && 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 && checkPilotEligibility(member.skills, contract).ok &&
// Priority (CR-007): the ship must make the delivery window. // Priority (CR-007): the ship must make the delivery window.
(!contract.priority || (!contract.priority ||

View File

@ -8,23 +8,27 @@ const earth = content.location("earth")!;
const moon = content.location("moon")!; const moon = content.location("moon")!;
const mars = content.location("mars")!; 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 => ({ const withTanks = (level: number): ShipModel => ({
...courier, ...courier,
fuelRangeMillionKm: tankCapacityFu:
courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100), 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. // 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)", () => { describe("Courier to Mars (decision 0.5.68)", () => {
it("reaches Mars from Earth with maxed fuel tanks", () => { 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", () => { 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]) { for (const level of [0, 1, 2]) {
expect(checkRoute(withTanks(level), earth, mars).ok, `level ${level}`).toBe(false); expect(checkRoute(withTanks(level), earth, mars, content.config).ok, `level ${level}`).toBe(
expect(checkRoute(withTanks(level), moon, mars).ok, `level ${level}`).toBe(false); false,
);
expect(checkRoute(withTanks(level), moon, mars, content.config).ok, `level ${level}`).toBe(
false,
);
} }
}); });
}); });

View File

@ -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). // 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)).toEqual({ ok: true }); expect(checkRoute(courier, earth, moon, content.config)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth)).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", () => { it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter", () => {
for (const to of [mars, ceres, 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", () => { 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, ok: false,
reason: "cannot-land", 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)", () => { 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, mars, content.config)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, ceres)).toEqual({ ok: true }); expect(checkRoute(freighter, earth, ceres, content.config)).toEqual({ ok: true });
}); });
it("lets only the Bulk Hauler reach Jupiter from Earth", () => { it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
expect(checkRoute(bulkHauler, earth, jupiter)).toEqual({ ok: true }); expect(checkRoute(bulkHauler, earth, jupiter, content.config)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, jupiter)).toEqual(outOfRange); expect(checkRoute(freighter, earth, jupiter, content.config)).toEqual(outOfRange);
expect(checkRoute(courier, earth, jupiter)).toEqual(outOfRange); expect(checkRoute(courier, earth, jupiter, content.config)).toEqual(outOfRange);
}); });
}); });

View File

@ -1,6 +1,13 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; 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 { config } = content;
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
@ -40,9 +47,37 @@ describe("fuel cost", () => {
); );
}); });
it("matches a hand calculation", () => { it("matches a hand calculation (Step 15.3: the leg's fuel at the origin's price)", () => {
// Economic distance of 0.5M km × 200 credits/M km × Earth multiplier 2.1. // 20 fu per M km × economic distance of 0.5M km, at 10 cr/fu × Earth's 1.6.
const expected = ref * (0.5 / ref) ** exponent * 200 * 2.1; const expected = 20 * ref * (0.5 / ref) ** exponent * 10 * 1.6;
expect(fuelCost(courier, earth, 0.5, config)).toBeCloseTo(expected); 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);
}
}
});
});

View File

@ -2,6 +2,7 @@ import { content, type Location } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
checkRoute, checkRoute,
economicDistance,
effectiveStats, effectiveStats,
validateDispatch, validateDispatch,
validateReposition, validateReposition,
@ -36,7 +37,13 @@ const fixture: SimContent = {
locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station], locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station],
shipModels: [ shipModels: [
...content.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 }; 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); const model = effectiveStats(base, {}, content);
for (const from of ids) { for (const from of ids) {
for (const to 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( expect(wouldStrand(model, loc(to), undefined, content), `${model.id} ${from}→${to}`).toBe(
false, false,
); );

View File

@ -1,27 +1,31 @@
import { content, type Location, type ShipModel } from "@deadhead/content"; import { content, type Location, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest"; 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 loc = (id: string) => content.location(id)!;
const courier = content.shipModel("courier")!; 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)", () => { describe("range from the origin (Step 10L.2)", () => {
it("stretches a 100M km range to 130 from the Moon and 115 from Mars", () => { 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("earth"), content.config)).toBeCloseTo(100);
expect(rangeFrom(withRange(100), loc("moon"))).toBeCloseTo(130); expect(rangeFrom(withRange(100), loc("moon"), content.config)).toBeCloseTo(130);
expect(rangeFrom(withRange(100), loc("mars"))).toBeCloseTo(115); 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", () => { 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 d = routeDistance(loc("moon"), loc("mars"));
const ship = withRange(d / 1.2); const ship = withRange(d / 1.2);
expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(ship.fuelRangeMillionKm); expect(routeDistance(loc("earth"), loc("mars"))).toBeGreaterThan(d / 1.2);
expect(checkRoute(ship, loc("earth"), loc("mars"))).toEqual({ expect(checkRoute(ship, loc("earth"), loc("mars"), content.config)).toEqual({
ok: false, ok: false,
reason: "out-of-range", 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", () => { 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) { for (const to of ids) {
if (from === to) continue; if (from === to) continue;
const old = routeDistance(loc(from), loc(to)) <= range; 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);
} }
} }
} }

View File

@ -17,7 +17,7 @@ const ids = Object.keys(NAMES);
function reaches(modelId: string, fromId: string, toId: string): boolean { function reaches(modelId: string, fromId: string, toId: string): boolean {
const ship = content.shipModel(modelId)!; 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. */ /** [model, from, to] for every leg the table says yes or no to. */

View File

@ -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", () => { it("a maxed Lighter never reaches Mars from Earth or the Moon", () => {
const lighter = maxedStats("lighter"); const lighter = maxedStats("lighter");
for (const from of [earth, moon]) { 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", () => { it("only Bulk Haulers reach Jupiter from Earth, even maxed", () => {
for (const ship of content.shipModels) { 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"); expect(reaches, ship.id).toBe(ship.id === "bulk-hauler");
} }
}); });
it("gives the Courier 1400 % range per tanks level (CR-015)", () => { it("gives the Courier 380 % tank per tanks level (CR-015)", () => {
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(1400); expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(380);
}); });
}); });