Step 15.3: content: fuel
CI / check (push) Successful in 5m43s
Details
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:
parent
0a1ca1d3ea
commit
c627bb9954
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ function legsInRange(model: ShipModel): Set<string> {
|
|||
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));
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function reachersByOrigin(
|
|||
.map((originId) => {
|
||||
const origin = content.location(originId)!;
|
||||
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 upgraded = state.ships
|
||||
.filter((ship: Ship) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
<Spec
|
||||
icon="range"
|
||||
label="Range"
|
||||
value={formatMillionKm(model.fuelRangeMillionKm)}
|
||||
share={rangeShare(model.fuelRangeMillionKm)}
|
||||
label="Reach"
|
||||
value={formatMillionKm(reachFromEarth(model))}
|
||||
share={rangeShare(reachFromEarth(model))}
|
||||
/>
|
||||
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
|
||||
</dl>
|
||||
|
|
@ -234,8 +237,7 @@ function OwnedUpgraded({ modelId }: { modelId: string }) {
|
|||
return (
|
||||
<li key={ship.id}>
|
||||
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))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ const STAT_LABELS: Record<ShipStat, { label: string; format: (s: EffectiveStats)
|
|||
label: "Capacity",
|
||||
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: {
|
||||
label: "Takeoff/landing",
|
||||
format: (s) => `${s.overheadGameHours.toFixed(2)} h`,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<typeof shipModelSchema>;
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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-]*$/);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ export type Company = z.infer<typeof companySchema>;
|
|||
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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ const ship = {
|
|||
stats: {
|
||||
cruiseSpeedKmPerGameHour: 3_000_000,
|
||||
cargoCapacityTonnes: 23,
|
||||
fuelRangeMillionKm: 5,
|
||||
tankCapacityFu: 40,
|
||||
burnFuPerMillionKm: 20,
|
||||
overheadGameHours: 2,
|
||||
handlingGameHoursPerTonne: 0.02,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<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):
|
||||
* 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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue