Compare commits

..

2 Commits

Author SHA1 Message Date
fireshaper d6bca5b1a2 Step 15.1: content: fixed positions
CI / check (push) Successful in 7m40s Details
Locations get fixed polar coordinates (the Moon as an offset from
Earth) and astronomy-engine is removed, so positions now come from
content. Ranges move into CR-015's reach bands for the fixed distances,
and the Lighter's price drops to 36,000 to keep the balance targets.
Date-sampled tests become yes/no.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:43:09 -04:00
fireshaper 23b3d7ec97 Record CR-015: fixed map and fuel tanks (Phase 15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:30:40 -04:00
31 changed files with 545 additions and 330 deletions

View File

@ -1430,3 +1430,18 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **CR-014 §7's translation table is plan §0.6**, next to the decisions, because it governs how every later change request (starting with CR-015, Fixed Map and Fuel Tanks) is read.
- **`tech-stack.md` is rewritten, not annotated**: it is a description of the current stack, not a history. The old server stack is one row in "Alternatives considered", with the reason it went.
- **The phone check stays manual** (airplane mode and OS notifications can't be driven from CI); `progress.md` scripts it step by step for the user.
## CR-015 recorded (Fixed Map and Fuel Tanks)
- **Numbering:** received as "CR-014 — Fixed Map and Fuel Tanks" for "Phase 14"; stored as **CR-015**, built as **Phase 15** (its 14.n = 15.n). Its decisions CR-014.D1–D7 are **0.5.93–0.5.99**. Its references: CR-006 → CR-009, CR-007 → CR-010, CR-009 → CR-012 (10I.n → 10L.n), CR-011 → CR-014; decisions 0.5.24 → 0.5.32, 0.5.49/0.5.50 → 0.5.67/0.5.68, 0.5.60 → 0.5.78, 0.5.61 → 0.5.79, 0.5.63 → 0.5.81. The numbering note at the top of the CR file has the full map.
- **Fuel burn follows the pay curve** (user decision, amends D3 = 0.5.95): fuel for a leg = burn rate × economic distance(distance ÷ origin range factor). Burning by plain distance while pay uses economic distance (0.5.84) would make a Bulk Hauler's Earth→Jupiter leg burn ~2,000× an Earth→Moon leg's fuel for ~140× the pay. With the curve, long runs keep today's profitability and full-tank reach in plain km is still exactly the old effective range, so the CR's §4 table holds unchanged.
## Step 15.1
- **Positions are computed in content, not in the sim**: a location carries its own fixed position, and the Moon's is Earth's moved 0.384M km straight out from the Sun (`offsetPosition`), with `orbitParent` recording the link for the map and a test that checks it. The sim stays a pure function of the location it is given.
- **The Moon's offset points away from the Sun**, a fixed choice (before, it was a fixed +x offset off a moving Earth). Moon–Mars is then 0.4M km longer than Earth–Mars, which changes no reach answer.
- **`epoch.ephemerisDate` is renamed `gameStart`**, same value: the game clock and its displayed date are unchanged; only the planets stopped depending on it.
- **Positions switched in this step**, not 15.2: removing `astronomy-engine` (this step's test) leaves nothing else to compute them. The sim's time arguments stay, unused, until 15.2 removes them.
- **Ranges moved into CR-015 §4's bands here**, ahead of 15.3's tank conversion, because the fixed map otherwise breaks the game at once (the deal's Clipper, at 198M km, couldn't reach Mars at 201). Chosen with margins inside each band: Clipper 250 (band 201–302 from the §4 cells), Freighter 460 (402–515), Courier tanks +1,400 %/level (level 2 must stay under 154 so it can't reach Mars from the Moon; level 3 at least 201). 15.3 turns these into tank capacity ÷ burn rate with the same reach.
- **Lighter price 42,400 → 36,000** (balance re-check, decision 0.5.84's targets): with Mars always 201M km away, purchases land on multiples of the Clipper's ~43-minute first Mars leg, so the median first Market purchase was 86.7 min, outside 45–75. Of the knobs tried (Clipper speed, first-contact pay, Lighter price), the price alone fixes it without touching the reach bands: first purchase 53.3 min, discovery 43.7, loan paid off 135 min, four ships 115 (Lighter-first) vs 130 (Freighter-first). 38,000 was too high (91.9) and 34,000 pushed Lighter-first four ships to 156; 36,000 sits between.
- **No base ship can strand on the fixed map** (every destination can reach some discovered place back), so the would-strand tests use the one case that still can: a Freighter with maxed tanks flies Moon→Jupiter (867 ≥ 792) but from Jupiter (667) reaches neither Earth nor the Moon while Mars and Ceres are undiscovered.

View File

@ -1,5 +1,6 @@
import type { Location } from "@deadhead/content";
import { bodyPosition, type GameTime, type Position } from "@deadhead/sim";
import { content } from "@deadhead/content";
import { locationPosition, type GameTime, type Position } from "@deadhead/sim";
/**
* Map coordinates: the Sun at (0, 0), the edge of the map at radius 1, y down.
@ -35,13 +36,16 @@ export function toMapPoint(position: Position): MapPoint {
}
/**
* Where a location is drawn. The Moon sits a fixed small distance from Earth
* in its true direction, since at map scale it would overlap Earth.
* Where a location is drawn. A child body (the Moon) sits a fixed small
* distance from its parent in its true direction, since at map scale it would
* overlap it.
*/
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
if (location.body !== "moon") return toMapPoint(bodyPosition(location.body, gameTime));
const earth = bodyPosition("earth", gameTime);
const moon = bodyPosition("moon", gameTime);
const parentId = location.orbitParent?.locationId;
const parent = parentId ? content.location(parentId) : undefined;
if (!parent) return toMapPoint(locationPosition(location, gameTime));
const earth = locationPosition(parent, gameTime);
const moon = locationPosition(location, gameTime);
const dx = moon.x - earth.x;
const dy = -(moon.y - earth.y);
const length = Math.hypot(dx, dy) || 1;

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Dispatch, PlayerState } from "@deadhead/schema";
import { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim";
import { dispatchProgress, locationPosition, loanerModel, realToGame } from "@deadhead/sim";
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
import { mapStyle } from "./style";
@ -98,13 +98,13 @@ export function buildScene(state: PlayerState, now: number): Scene {
return {
locations: content.locations.map((loc) => {
const sun = bodyPosition(loc.body, gameTime);
const sun = locationPosition(loc, gameTime);
return {
id: loc.id,
name: loc.name,
point: locationMapPoint(anchorLocation(loc.id), gameTime),
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)),
orbitRadius: loc.orbitParent ? null : radialScale(Math.hypot(sun.x, sun.y)),
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
hasBay: state.bays.some((b) => b.locationId === loc.id),
idleShips: idleAt(loc.id),

View File

@ -99,7 +99,9 @@ describe("effective stats in the client (Step 10I.7)", () => {
content.location("mars")!,
realToGame(NOW, content.config),
);
expect(distance).toBeLessThan(72.5);
// Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map.
expect(distance).toBeGreaterThan(145);
expect(distance).toBeLessThan(215);
const open = async () => {
const card = (await screen.findByText("Dusty Mule")).closest("li")!;

View File

@ -1,23 +1,11 @@
import { content } from "@deadhead/content";
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const MONTH = 30.44 * 24 * 3_600_000;
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const clipper = content.shipModel("clipper")!;
const [earth, mars] = [content.location("earth")!, content.location("mars")!];
/** The first monthly date on which Earth→Mars is (or isn't) in a Clipper's range. */
function dateWhenMars(inRange: boolean): number {
for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH;
if (checkRoute(clipper, earth, mars, t).ok === inRange) return gameToReal(t, content.config);
}
throw new Error("no such date");
}
// The map is fixed (CR-015): any time gives the same reach.
const NOW = Date.parse(content.config.epoch.realStart) + 3_600_000;
async function reachOf(modelName: string, now: number) {
vi.useFakeTimers({ toFake: ["Date"] });
@ -43,22 +31,14 @@ describe("market roles and reach (Step 10L.7)", () => {
}
});
it("lists Mars from Earth for a Clipper only when Mars is in range, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", dateWhenMars(true));
it("lists Mars from Earth for a Clipper, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", NOW);
expect(near).toContain("From Earth now: Moon, Mars");
expect(near).toContain("By hopping: Ceres Station");
});
it("puts Mars under other dates for a Clipper when it is out of range", async () => {
const far = await reachOf("Clipper", dateWhenMars(false));
expect(far).toContain("From Earth now: Moon");
expect(far).not.toContain("From Earth now: Moon, Mars");
expect(far).toContain("Other dates: Mars");
expect(far).toContain("By hopping: Ceres Station");
});
it("lists only the Moon for a Lighter", async () => {
const reach = await reachOf("Lighter", dateWhenMars(true));
const reach = await reachOf("Lighter", NOW);
expect(reach).toBe("Reach From Earth now: Moon");
});
});

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { checkRoute, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -8,28 +8,24 @@ import { useClock } from "../src/clock/clock";
import { pairOptions } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const DAY = 24 * 3_600_000;
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const clipper = content.shipModel("clipper")!;
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
const discovered = new Set(["earth", "moon", "mars"]);
const TANKS = { "fuel-tanks": 3 };
const freighter = effectiveStats(content.shipModel("freighter")!, TANKS, content);
const [moon, jupiter] = [content.location("moon")!, content.location("jupiter-cloud-platform")!];
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
const NOW = Date.parse(content.config.epoch.realStart) + 3_600_000;
const STRANDED = "Would be stranded — can't fly out of Jupiter Cloud Platform from there";
/** A date on which a Clipper can hop Moon→Mars but couldn't leave Mars (CR-012). */
const NOW = (() => {
for (let d = 0; d < 16 * 365; d += 5) {
const t = gameStart + d * DAY;
// 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.
const gameNow = realToGame(NOW, content.config);
if (
checkRoute(clipper, moon, mars, t).ok &&
wouldStrand(clipper, moon, mars, 10, t, discovered, content)
!checkRoute(freighter, moon, jupiter, gameNow).ok ||
!wouldStrand(freighter, moon, jupiter, 10, gameNow, discovered, content)
) {
return gameToReal(t, content.config);
throw new Error("the scenario no longer strands");
}
}
throw new Error("no stranding date");
})();
const STRANDED = "Would be stranded — can't fly out of Mars from there";
/** A crewed Clipper at the Moon, with Mars discovered. */
/** A crewed Freighter with maxed tanks at the Moon, with Jupiter discovered. */
function state(): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
@ -40,13 +36,13 @@ function state(): PlayerState {
ships: [
{
id: "s1",
modelId: "clipper",
modelId: "freighter",
name: "Swift Heron",
status: "idle",
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
...shipExtras("freighter", TANKS),
pilotId: "avatar",
},
],
@ -54,10 +50,10 @@ function state(): PlayerState {
};
}
const toMars: Contract = {
const toJupiter: Contract = {
id: "c1",
originId: "moon",
destinationId: "mars",
destinationId: "jupiter-cloud-platform",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
@ -82,7 +78,7 @@ describe("stranding reason (Step 10L.9)", () => {
afterEach(() => vi.useRealTimers());
it("marks a would-strand ship and pilot pair with the reason", () => {
const [pair] = pairOptions(state(), toMars, NOW);
const [pair] = pairOptions(state(), toJupiter, NOW);
expect(pair!.problems).toEqual([STRANDED]);
});
@ -91,7 +87,7 @@ describe("stranding reason (Step 10L.9)", () => {
const card = (await screen.findByText("Swift Heron")).closest("li")!;
await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ }));
const dialog = screen.getByRole("dialog", { name: "Deadhead Swift Heron" });
const option = within(dialog).getByRole("button", { name: "Mars" });
const option = within(dialog).getByRole("button", { name: "Jupiter Cloud Platform" });
expect(option).toBeDisabled();
expect(option).toHaveTextContent(STRANDED);
expect(within(dialog).getByRole("button", { name: "Earth" })).toBeEnabled();

View File

@ -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. | content, astronomy-engine |
| `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/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 |

View File

@ -0,0 +1,194 @@
# Change Request CR-015 — Fixed Map and Fuel Tanks
> **Numbering note (added when recorded):** this request arrived titled "CR-014 — Fixed Map and Fuel Tanks" for "Phase 14". CR-014 (local-first) and Phase 14 already existed, so it is stored as **CR-015** and done as **Phase 15**: every "14.n" below means **15.n**. Its references map to this repo as: "CR-006 ship upgrades" = **CR-009**, "CR-007 veteran pilot" = **CR-010**, "CR-009 new ships and range" = **CR-012** (its steps "10I.n" are **10L.n**), "CR-011 local-first" = **CR-014**; "CR-001" is CR-001. Decisions: its 0.5.9 and 0.5.12 are the same numbers; its "0.5.24" (deal loan repayment) is **0.5.32**; "0.5.49/0.5.50" (models stay distinct, Courier to Mars) are **0.5.67/0.5.68**; "0.5.60" (effective range from origin) is **0.5.78**; "0.5.61" (no-strand) is **0.5.79**; "0.5.63" (reach targets) is **0.5.81**. Its new decisions CR-014.D1–D7 are recorded as **0.5.93 Fixed map, 0.5.94 Fuel tanks, 0.5.95 Burn and reach, 0.5.96 Fuel prices, 0.5.97 Refueling, 0.5.98 Fuel cost of a leg, 0.5.99 Emergency fuel**. Error codes use this repo's kebab case (`not-enough-fuel`, `would-strand`).
>
> **User decision (2026-10-10), amending D3 (0.5.95):** a leg's fuel follows the same economic-distance curve as pay (decision 0.5.84), so long runs keep today's profitability: **fuel for a leg = burn rate × economic distance(distance ÷ origin range factor)**. Full-tank reach in plain million km is still exactly the old effective range from the origin, so §4 holds as written; only the fu burned per leg grows more slowly than the distance. Wherever §3–§4 below say "distance × burn rate ÷ origin range factor", read this formula.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-013 complete
**Do this:** as **Phase 14**
**Related docs:** `implementation-plan.md`, `change-request-ship-upgrades.md` (CR-006), `change-request-veteran-pilot.md` (CR-007), `change-request-new-ships-and-range.md` (CR-009), `change-request-local-first.md` (CR-011), `game-design-document.md`, `DECISIONS.md`
This change request is written for the local-first engine (CR-011): "command" means an engine command, and "save field" means a field in the save document with a save migration.
---
## 1. Why
Two connected changes:
1. **The planets stop orbiting.** This is soft sci-fi, and in a trucking game part of the fun is learning the map: Earth to Ceres is always the same run. Moving planets make every route a lookup, make ETAs depend on launch date, and fight the new fuel system. Locations get **fixed positions**, so every distance, travel time, and payout for a route is constant.
2. **Ships carry fuel.** Instead of paying for exactly the fuel each trip needs, every ship has a **tank**. Legs burn fuel from the tank; ships **refuel instantly** at locations, ATS-style, at that location's **fixed fuel price** (cheap at Jupiter's refineries, expensive at Earth). Where you fill up becomes a real decision. By default ships top up automatically on landing; any ship can be switched to manual so the player can wait for cheaper fuel.
## 2. Fixed map
### Positions
Locations sit at fixed points, given in content as polar coordinates around the Sun (radius in million km, angle in degrees). Radii are the real average orbital radii. Angles are chosen so the reach rules in §4 work with clean margins. Only the angles **between** bodies matter; rotate the whole layout freely for looks.
| Location | Radius (M km) | Angle (suggested) | Notes |
|---|---|---|---|
| Earth | 150 | 210° | |
| Moon | — | — | Fixed offset from Earth (0.38M km), as now |
| Mars | 228 | 270° | |
| Ceres Station | 414 | 285° | |
| Jupiter Cloud Platform | 778 | 300° | At Jupiter |
Resulting distances (approximate, check with the sim): Earth–Mars ~201M km, Mars–Ceres ~202M km, Earth–Ceres ~402M km, Ceres–Jupiter ~393M km, Mars–Jupiter ~592M km, Earth–Jupiter ~792M km.
### What goes away
- `astronomy-engine` and all time-based positions.
- The ephemeris half of the game epoch (decision 0.5.9). The game clock stays for timers and the displayed game date.
- Every "on some dates", "at closest approach", and "sampled over N game years" rule. Each becomes a plain yes or no (§4).
### The map
- Planets and stations stay in place. Orbit rings are still drawn as static decoration.
- Routes are fixed straight lines; in-flight ships move along them as before.
- Map reachability text (CR-009 Step 10I.8) no longer mentions dates: "From Mars: Clipper, Freighter".
## 3. Fuel tanks
### The model
| Concept | Rule |
|---|---|
| Fuel units | One generic unit, shown as "fu". No fuel mass: fuel never affects cargo capacity or speed. |
| Tank capacity | Each ship model has a tank capacity in fu. The Extended Fuel Tanks upgrade (CR-006) raises capacity instead of range. |
| Burn rate | Each ship model burns fu per million km. |
| Fuel for a leg | `distance × burn rate ÷ origin range factor`. The gravity-well range factor (CR-009) now makes launches from low-gravity bodies more fuel-efficient. |
| Full-tank reach | `tank capacity ÷ burn rate × origin range factor`. This is exactly the old "effective range from origin", so the CR-009 reach rules carry over. |
| Flying a leg | Allowed only if fuel on board ≥ fuel for the leg. The leg's fuel is removed at departure. |
| Fuel cost of a leg | Fuel burned × the tank's **average purchase price** (the tank tracks a weighted average as fuel is added). Used for pilot pay (CR-007) and profit display. |
| New ships | Delivered with a full tank, included in the price. |
| Tutorial | Tutorial runs use no fuel (the company pays), as before. |
### Fuel prices (fixed per location, in content)
| Location | Price per fu (× base) | Why |
|---|---|---|
| Jupiter Cloud Platform | 0.6 | The system's refineries |
| Ceres Station | 0.9 | Belt ice cracked for fuel |
| Moon | 1.1 | Lunar LOX plants |
| Mars | 1.4 | Imported fuel, small market |
| Earth | 1.6 | Deep gravity well, high demand |
All five locations sell fuel. A `sellsFuel` flag exists in content for future locations that don't.
### Refueling
- **Instant**, like ATS. No time cost.
- **Refuel policy per ship:** *Auto top-up* (default) fills the tank on every landing at a location that sells fuel; *Manual* never refuels on its own.
- If credits can't cover a full top-up, auto top-up buys what the player can afford.
- **Manual refuel command:** fill to full, or buy a chosen amount, at the ship's current location.
- The dispatch flow offers **"Refuel here first"** when the ship doesn't have enough fuel for the leg.
### Stranding and emergency fuel
- **No-strand rule (CR-009 0.5.61), updated:** a dispatch is rejected with `would_strand` if, from the destination, the ship couldn't reach any other discovered location using the fuel it could have there: a full tank if the destination sells fuel, otherwise the fuel left after the leg. With all five locations selling fuel, this mostly guards future dry locations.
- **Emergency fuel:** if a ship is idle somewhere and its tank can't reach any other discovered location, and the player can't afford enough fuel, the player can order emergency fuel: enough for the cheapest reachable leg, at **2.5× the local price**, **added to the loan balance** instead of paid in credits. Repayment then works as for the deal loan (CR-001 0.5.24).
## 4. Reach rules, now yes/no
These replace the CR-009 §3 reach table. They are about full-tank reach for base ships (no upgrades), using the §2 distances and the CR-009 range factors (Earth 1.0, Mars 1.15, Moon 1.3, Ceres 1.3, Jupiter platform 1.0).
| Ship | Yes | No |
|---|---|---|
| Courier | Earth↔Moon | Earth→Mars, Moon→Mars |
| Lighter | Earth↔Moon | Earth→Mars, Moon→Mars |
| Clipper | Earth↔Moon, Earth↔Mars, Moon→Mars, Mars→Ceres | Earth→Ceres, Moon→Ceres, any leg to Jupiter |
| Freighter | Everything the Clipper can do, plus Earth↔Ceres and Ceres↔Jupiter | Earth→Jupiter, Moon→Jupiter, Mars→Jupiter |
| Bulk Hauler | Every leg between the five locations | — |
**Upgrades:** a Courier with maxed Extended Fuel Tanks **can** fly Earth→Mars; one at level 2 can't (replaces CR-006 decision 0.5.50 and its CR-009 amendment). All other CR-006/CR-009 distinctness caps still apply, restated as yes/no.
With the suggested angles, these hold with roughly: Courier and Lighter full-tank reach under 150M km; Clipper 210–300M km; Freighter 410–510M km; Bulk Hauler 800M km or more. Pick tank capacities and burn rates that land in those bands.
## 5. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- Existing saves get a save migration: positions and distances are now static; every ship gets a tank, filled to full; every ship's refuel policy is Auto top-up. In-flight dispatches keep their stored arrival times.
- Update existing tests whose expectations change, including every date-sampling test (§7). Update `architecture.md`, `tech-stack.md`, and `progress.md` as you go.
- Stop for the user's review at the end of Phase 14.
## 6. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5. New decisions are labeled CR-014.D1–D7 here; give them the next free numbers in §0.5 and note the mapping in `DECISIONS.md`.
- **CR-014.D1 Fixed map.** Locations have fixed positions from content; distances never change. `astronomy-engine` is removed. Replaces the ephemeris part of 0.5.9.
- **CR-014.D2 Fuel tanks.** Ships carry fuel per §3. Fuel has no mass.
- **CR-014.D3 Burn and reach.** Fuel for a leg = distance × burn rate ÷ origin range factor. Replaces "effective range from origin" (0.5.60) as the range check; full-tank reach equals the old effective range.
- **CR-014.D4 Fuel prices.** Fixed per location (§3). Replaces the launch fuel-cost multiplier: launching from Earth is no longer charged extra, because Earth's fuel is the most expensive.
- **CR-014.D5 Refueling.** Instant. Per-ship policy: Auto top-up (default) or Manual. Auto buys what the player can afford.
- **CR-014.D6 Fuel cost of a leg.** Fuel burned × the tank's average purchase price. Pilot profit share (CR-007 0.5.12) uses this instead of the old per-trip fuel cost.
- **CR-014.D7 Emergency fuel.** Per §3: enough for the cheapest reachable leg, at 2.5× local price, added to the loan.
- **0.5.61 (amended) No-strand rule.** Uses fuel available at the destination per §3.
- **0.5.63 (amended) Reach targets.** The §4 yes/no table replaces the CR-009 §3 table.
- **0.5.50 (amended) Courier to Mars.** Maxed tanks: yes from Earth; level 2: no.
## 7. Steps — Phase 14
### Step 14.1 — Content: fixed positions
- Replace each location's astronomy body with fixed polar coordinates (§2). Remove the ephemeris epoch field from GameConfig. Remove `astronomy-engine` from the project.
- **Test:** Every location has coordinates; Moon is defined as an offset from Earth. Distances computed from the suggested coordinates match the §2 table within 2%. `astronomy-engine` is no longer in the lockfile.
### Step 14.2 — Sim: static positions and distances
- Location position and route distance no longer take a time argument. Travel time, payout, board generation, reachability, and map positions use static distances.
- Replace the Step 3.3 and 3.4 tests: positions are constant; distance A→B equals B→A; Earth→Moon is under 1M km. Remove the "Earth–Mars distance varies" test.
- **Test:** Calling position or distance for the same inputs always returns the same value. A contract's payout and travel time for a given route and ship are the same no matter when it's offered or dispatched.
### Step 14.3 — Content: fuel
- ShipModel: replace "fuel range" with **tank capacity** (fu) and **burn rate** (fu per million km); remove "fuel cost per million km". Location: add **fuel price** (§3) and `sellsFuel`; remove the launch fuel-cost multiplier. GameConfig: base fuel price, emergency fuel multiplier (2.5).
- Change the CR-006 Extended Fuel Tanks upgrade to raise tank capacity.
- Choose tank capacities and burn rates so the §4 table holds.
- **Test:** Content validates. Fuel prices are ordered Jupiter < Ceres < Moon < Mars < Earth. One test per cell of the §4 table using full-tank reach. The upgrade tests for 0.5.50 (amended) and the other CR-006/CR-009 caps pass in yes/no form.
### Step 14.4 — Sim: tank, burn, and refuel
- Pure functions: fuel needed for a leg; full-tank reach from an origin; refuel (adds fuel up to capacity, returns cost and the new weighted average price); affordable refuel (largest amount up to full that credits cover); leg fuel cost (burned × average price).
- Dispatch validation gains `not_enough_fuel` (fuel on board < fuel needed). Dispatch removes the leg's fuel at departure instead of charging credits.
- Resolution: on arrival, if the ship's policy is Auto top-up and the location sells fuel, apply the affordable refuel.
- Pilot pay (CR-007) uses leg fuel cost.
- **Test:** Fuel needed from the Moon is the Earth amount ÷ 1.3. Refueling 50 fu at price 1.0 into a tank holding 50 fu bought at 2.0 leaves an average of 1.5. A ship with too little fuel fails with `not_enough_fuel`; after refueling, it passes. Dispatch no longer changes credits. On arrival with Auto top-up, the tank is full and credits drop by the right amount; with Manual, nothing changes. With credits for only half a tank, Auto buys exactly what's affordable. Pilot pay for a leg uses burned fuel × average price. Tutorial dispatches don't touch fuel.
### Step 14.5 — Sim: stranding and emergency fuel
- Update the `would_strand` check per §3.
- Add an emergency fuel check (is it available for this ship?) and calculation (amount = fuel for the cheapest reachable leg minus fuel on board; cost = amount × local price × 2.5).
- **Test:** With all locations selling fuel, no base ship is ever blocked by `would_strand` on a §4 "yes" leg. A test fixture with a non-selling destination blocks a leg that would leave too little fuel to get out. Emergency fuel is offered only when the ship can't reach anywhere and the player can't afford the needed fuel, and its cost matches the formula.
### Step 14.6 — Engine: commands and save migration
- Save fields: per ship, fuel on board, tank average price, and refuel policy.
- Commands: refuel (full or amount), set refuel policy, order emergency fuel (adds fuel, adds cost to the loan balance).
- Save migration per §5.
- Buying a ship delivers it with a full tank at no extra cost.
- **Test:** An older save migrates with every ship full, on Auto top-up. Refuel deducts credits and fills correctly; refuel with no credits fails. Ordering emergency fuel when not available fails; when available, it adds fuel and increases the loan by exactly the cost. A new ship arrives full.
### Step 14.7 — Client: fuel in the UI
- **Fleet:** each ship card shows a fuel gauge (fu and %) and its refuel policy. Ship detail has Refuel (Fill / Amount), a policy toggle, and, when available, "Emergency fuel (added to loan)".
- **Dispatch sheet:** shows fuel needed, fuel on board, and fuel left after the leg. If short, the pair shows "Not enough fuel" with a "Refuel here first" button that fills the tank and keeps the sheet open. The cost line becomes "Fuel used (at your average price)".
- **Deadhead picker:** same fuel lines and refuel button.
- **Locations screen and map panels:** show each location's fuel price, highlighting the cheapest.
- **Delivery summaries:** show auto top-ups as their own line ("Topped up 120 fu at Jupiter, 72 cr").
- **Test:** Component tests: the gauge matches state; "Refuel here first" fills and enables the pair; the fuel-used line matches the sim; the cheapest fuel location is highlighted; an emergency fuel button appears only when available.
### Step 14.8 — Client: fixed map
- Remove all time-based position updates from the map. Draw static orbit rings for decoration. Remove date-based wording from reachability and Market reach lines (CR-009 Steps 10I.7–10I.8).
- **Test:** Component test: body screen positions don't change as the mocked clock advances. Reachability text contains no date-dependent wording.
### Step 14.9 — Plan amendments and regression
- Apply the amendments in §8.
- Run the full suite.
- **Test:** CI is green. The Phase 12 balance script re-checks all timing targets with fuel tanks and fixed distances, and confirms no simulated ship ever needs emergency fuel when the player follows Auto top-up with positive credits. Manual check: set the Freighter to Manual, fly to Jupiter, fill up cheaply, return, and confirm the next legs show a lower "fuel used" cost than fuel bought at Earth.
## 8. Amendments
| Where | Change |
|---|---|
| Plan 0.5 | Add CR-014.D1–D7 (next free numbers); amend 0.5.9, 0.5.50, 0.5.60, 0.5.61, 0.5.63, 0.5.12 per §6. |
| Plan 0.3 | Remove "fuel price variation" from deferred (now fixed per-location prices). Price drift and HQ fuel storage remain deferred. |
| Plan 2.1, 2.2 | Location gains coordinates, fuel price, `sellsFuel`; loses astronomy body and launch cost multiplier. ShipModel gains tank capacity and burn rate; loses fuel range and fuel cost. |
| Plan 3.3, 3.4 | Static positions and distances (14.2). |
| Plan 3.6 | Fuel cost per trip replaced by tank burn and refuel (14.4). |
| Plan 3.12 | Validation gains `not_enough_fuel` (14.4). |
| Plan 9.5, 9.6, 9.9, 10.x | Fuel UI and fixed map (14.7, 14.8). |
| CR-006 | Extended Fuel Tanks raises tank capacity; caps restated as yes/no. |
| CR-007 | Pilot pay uses leg fuel cost (CR-014.D6). |
| CR-009 | Reach table replaced by §4; no-strand rule amended; Market and map reach text without dates. |
| `tech-stack.md` | Remove `astronomy-engine` (Planetary Positions section superseded by fixed coordinates). |
## 9. Out of scope
Fuel price drift and depot shortages, fuel storage at HQ bays, fuel mass, throttle settings, refueling time, locations that don't sell fuel (the flag exists, but all five sell), a "refuel only below a price" policy, and anything else in `implementation-plan.md` §0.3.

View File

@ -148,6 +148,13 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
90. **Single writer** (CR-014). All state changes go through one mutation queue. In the browser, only one tab may run the game at a time (Web Locks; other tabs show "Deadhead is open in another tab" with "Use here").
91. **Local notifications** (CR-014). On iOS and Android the app schedules a local notification for each arrival, each pilot's rest ending and each upgrade finishing, cancelled if the event changes; several in the same minute become one. Permission is asked right after the deal. The web build has none.
92. **Save export/import** (CR-014). Export gives one text code (the save, compressed and encoded) to copy or share as a file; import validates and migrates it and replaces the current company after confirmation.
93. **Fixed map** (CR-015). Locations have fixed positions from content (polar coordinates around the Sun; the Moon is an offset from Earth); distances never change. `astronomy-engine` is removed. Replaces the ephemeris part of 0.5.9.
94. **Fuel tanks** (CR-015). Each ship carries fuel in a tank (generic units, "fu"), with a capacity and a burn rate per model. Fuel has no mass. New ships come with a full tank. Tutorial runs use no fuel.
95. **Burn and reach** (CR-015, user decision on the curve). Fuel for a leg = burn rate × economic distance (0.5.84) of (distance ÷ origin range factor). A leg can be flown only with at least that much fuel on board; it is removed at departure. Full-tank reach from an origin in million km equals the old effective range (0.5.78), which this replaces as the range check.
96. **Fuel prices** (CR-015). Fixed per location (base price × Jupiter 0.6, Ceres 0.9, Moon 1.1, Mars 1.4, Earth 1.6); a `sellsFuel` flag allows dry locations later. Replaces the launch fuel-cost multiplier.
97. **Refueling** (CR-015). Instant. Per ship: Auto top-up (default; fills on landing where fuel is sold, buying what the player can afford) or Manual. A refuel command fills or buys an amount at the ship's location.
98. **Fuel cost of a leg** (CR-015). Fuel burned × the tank's weighted average purchase price. Pilot profit share (0.5.12 as replaced by CR-010) uses it instead of the per-trip fuel charge.
99. **Emergency fuel** (CR-015). When an idle ship can't reach any other discovered location and the player can't afford the fuel, they can order enough for the cheapest reachable leg at 2.5× the local price, added to the loan balance (repaid as in 0.5.32).
### 0.6 Translating change requests written for the server (CR-014 §7)
@ -763,6 +770,12 @@ Steps 14.0–14.12 are specified in `memory-bank/change-requests/CR-014-local-fi
---
## Phase 15 — Fixed Map and Fuel Tanks (CR-015)
Steps 15.1–15.9 are specified in `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` §7 (written there as 14.1–14.9), with its §8 amendments. Stop for review at the end.
---
## Definition of Done (Base Game)
- All step tests pass in CI.

View File

@ -365,3 +365,17 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
The automated parts of this already run in CI: `e2e/12-4` (closed while it landed, reopened past the arrival), `e2e/14-7` (whole opening with every outside request blocked) and `e2e/14-11` (production build loads only its own files).
**Phase 14 complete. Stopped for the user's review.**
## Phase 15 — Fixed Map and Fuel Tanks (CR-015)
Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as "CR-014 … Phase 14"). New decisions recorded as plan §0.5.93–0.5.99. User decision: a leg's fuel follows the economic-distance curve, like pay.
- [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).
- [ ] Step 15.2 — Sim: static positions and distances
- [ ] Step 15.3 — Content: fuel
- [ ] Step 15.4 — Sim: tank, burn, and refuel
- [ ] Step 15.5 — Sim: stranding and emergency fuel
- [ ] Step 15.6 — Engine: commands and save migration
- [ ] Step 15.7 — Client: fuel in the UI
- [ ] Step 15.8 — Client: fixed map
- [ ] Step 15.9 — Plan amendments and regression

View File

@ -21,7 +21,7 @@ Companion to `game-design-document.md`. Updated for CR-014: the game runs entire
| Notifications | Local notifications via `@capacitor/local-notifications` (iOS/Android); none on the web |
| PWA | `vite-plugin-pwa` (Workbox) |
| Native wrapper | Capacitor (iOS/Android) |
| Planetary positions | `astronomy-engine` |
| Planetary positions | Fixed coordinates in content (CR-015) |
| Seeded RNG | Small pure-TS PRNG; world seeds and ids from the platform's crypto random source |
| Testing | Vitest (sim, engine, components), Playwright (end to end) |
| Deployment | Static files (Caddy on a home server, or any static host) |
@ -67,7 +67,7 @@ The game is overwhelmingly **UI**: contract boards, fleet lists, pilot sheets, b
### PixiJS for the system map
The one truly "game-like" view is the solar system map with ships moving along routes. PixiJS gives GPU-accelerated 2D rendering that stays smooth on phones, embedded inside the React app as a single component.
- Planets positioned from `astronomy-engine` (scaled/compressed for readability — soft sci-fi).
- Planets at fixed positions from content (scaled/compressed for readability — soft sci-fi).
- Ships interpolated along routes with `sim` every frame.
- Pan/zoom with touch support.
@ -106,10 +106,10 @@ The schedule is worked out from the save after every change: one notification pe
## 5. Planetary Positions
`astronomy-engine` computes real planetary positions for any date, in pure JS, no API calls.
- Run it in `packages/sim` so the engine and the screens agree on distances.
- Use a fixed **game epoch** mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default).
- Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits.
The map is fixed (CR-015, decision 0.5.93): every location has a position in `packages/content` (radius from the Sun in million km and an angle), so a route's distance, travel time and pay never change. Radii are the real mean orbital radii; angles are chosen so the reach table works with clean margins.
- Child bodies (the Moon) sit at their parent's position moved a small fixed distance straight out from the Sun.
- The game clock still runs (1 real min = 1 game hour by default) for timers and the displayed in-game date.
- `astronomy-engine` was used for real ephemeris positions until CR-015; it is gone.
- For interstellar (late game), real star positions from a static dataset in `packages/content`.
## 6. Deployment

View File

@ -4,10 +4,10 @@ import type { GameConfig } from "../schemas";
export const gameConfig: GameConfig = {
// 1 real minute = 1 game hour.
timeCompression: 60,
// Placeholders: the design docs do not fix an in-game start date yet.
// The in-game date shown when the clock started (decision 0.5.9).
epoch: {
realStart: "2026-01-01T00:00:00Z",
ephemerisDate: "2100-01-01T00:00:00Z",
gameStart: "2100-01-01T00:00:00Z",
},
// A small cushion: a couple of Courier fuel loads from Earth (CR-001).
startingCredits: 500,

View File

@ -1,6 +1,19 @@
import type { Location } from "../schemas";
import type { FixedPosition, Location } from "../schemas";
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.
*/
const EARTH: FixedPosition = { radiusMillionKm: 150, angleDegrees: 210 };
const MOON_OFFSET_MILLION_KM = 0.384;
/** `parent` moved straight out from the Sun by `offset` million km. */
export function offsetPosition(parent: FixedPosition, offset: number): FixedPosition {
return { radiusMillionKm: parent.radiusMillionKm + offset, angleDegrees: parent.angleDegrees };
}
/**
* Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the
* Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote
@ -14,7 +27,7 @@ export const locations: Location[] = [
name: "Earth",
type: "atmospheric-world",
landingRequirement: "atmospheric",
body: "earth",
position: EARTH,
sprite: "body-earth",
launchCostMultiplier: 2.1,
launchRangeFactor: 1,
@ -35,7 +48,8 @@ export const locations: Location[] = [
name: "Moon",
type: "airless-body",
landingRequirement: "vacuum",
body: "moon",
position: offsetPosition(EARTH, MOON_OFFSET_MILLION_KM),
orbitParent: { locationId: "earth", offsetMillionKm: MOON_OFFSET_MILLION_KM },
sprite: "body-moon",
launchCostMultiplier: 0.5,
launchRangeFactor: 1.3,
@ -54,7 +68,7 @@ export const locations: Location[] = [
name: "Mars",
type: "atmospheric-world",
landingRequirement: "atmospheric",
body: "mars",
position: { radiusMillionKm: 228, angleDegrees: 270 },
sprite: "body-mars",
launchCostMultiplier: 1.5,
launchRangeFactor: 1.15,
@ -70,7 +84,7 @@ export const locations: Location[] = [
name: "Ceres Station",
type: "belt-station",
landingRequirement: "vacuum",
body: "ceres",
position: { radiusMillionKm: 414, angleDegrees: 285 },
sprite: "body-ceres",
launchCostMultiplier: 0.3,
launchRangeFactor: 1.3,
@ -86,7 +100,7 @@ export const locations: Location[] = [
name: "Jupiter Cloud Platform",
type: "cloud-platform",
landingRequirement: "atmospheric",
body: "jupiter",
position: { radiusMillionKm: 778, angleDegrees: 300 },
sprite: "body-jupiter",
badge: "badge-cloud-platform",
launchCostMultiplier: 2,

View File

@ -28,8 +28,9 @@ export const shipModels: ShipModel[] = [
sprite: "ship-lighter",
name: "Lighter",
archetype: "lighter",
// About 40 % of the way from Courier to Freighter.
price: 42_400,
// About a third of the way from Courier to Freighter; 36,000 (was 42,400)
// keeps the first Market purchase in 45–75 minutes on the fixed map (Step 15.1).
price: 36_000,
// Slower than the Freighter, faster than the Bulk Hauler.
cruiseSpeedKmPerGameHour: 1_600_000,
cargoCapacityTonnes: 80,
@ -55,7 +56,7 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 6_500_000,
cargoCapacityTonnes: 30,
// Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4).
fuelRangeMillionKm: 198,
fuelRangeMillionKm: 250,
hullRating: 1,
landingClass: "atmospheric",
overheadGameHours: 2,
@ -72,7 +73,7 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 2_000_000,
cargoCapacityTonnes: 100,
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
fuelRangeMillionKm: 450,
fuelRangeMillionKm: 460,
hullRating: 2,
landingClass: "atmospheric",
overheadGameHours: 4,

View File

@ -2,8 +2,8 @@ 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 a
* maxed Courier a reach to Mars near closest approach only (decision 0.5.68).
* 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).
*/
export const upgrades: Upgrade[] = [
{
@ -27,9 +27,9 @@ export const upgrades: Upgrade[] = [
percentPerLevel: 20,
maxLevel: 3,
modelOverrides: {
// 5 → 27.5 → 50 → 72.5 M km: Mars from Earth (≈ 59–400 M km) only near closest approach, and only at level 3.
courier: { percentPerLevel: 450 },
// 450 → 652.5 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth at its farthest (≈ 965).
// 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 },
},
},

View File

@ -26,16 +26,27 @@ export const locationTypeSchema = z.enum([
]);
export type LocationType = z.infer<typeof locationTypeSchema>;
/** Astronomical body a location's position is derived from. */
export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]);
export type AstronomyBody = z.infer<typeof astronomyBodySchema>;
/** A fixed point around the Sun (decision 0.5.93): radius in million km, angle in degrees. */
export const fixedPositionSchema = z.object({
radiusMillionKm: z.number().nonnegative(),
angleDegrees: z.number(),
});
export type FixedPosition = z.infer<typeof fixedPositionSchema>;
export const locationSchema = z.object({
id: idSchema,
name: z.string().min(1),
type: locationTypeSchema,
landingRequirement: landingClassSchema,
body: astronomyBodySchema,
/** Where it sits, for good (decision 0.5.93). */
position: fixedPositionSchema,
/**
* Set for a body that rides with another (the Moon with Earth): its position
* is the parent's moved straight out from the Sun by the offset.
*/
orbitParent: z
.object({ locationId: idSchema, offsetMillionKm: z.number().positive() })
.optional(),
/** Multiplies fuel cost for launches from this location. */
launchCostMultiplier: z.number().positive(),
/**
@ -181,10 +192,13 @@ export const gameConfigSchema = z
.object({
/** Game hours per real hour (60 means 1 real minute = 1 game hour). */
timeCompression: z.number().positive(),
/** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */
/**
* Decision 0.5.9: the real instant the game clock starts and the in-game
* date shown at that instant (only a displayed date since the map is fixed, 0.5.93).
*/
epoch: z.object({
realStart: z.iso.datetime(),
ephemerisDate: z.iso.datetime(),
gameStart: z.iso.datetime(),
}),
startingCredits: nonNegativeInt,
/** Fraction of a normal payout the player receives for a tutorial run. */

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { content, type FixedPosition } from "../src/index";
import { offsetPosition } from "../src/data/locations";
// Vite reads the lockfile as text at test time.
import lockfile from "../../../pnpm-lock.yaml?raw";
function point(p: FixedPosition) {
const a = (p.angleDegrees * Math.PI) / 180;
return { x: p.radiusMillionKm * Math.cos(a), y: p.radiusMillionKm * Math.sin(a) };
}
function distance(fromId: string, toId: string): number {
const a = point(content.location(fromId)!.position);
const b = point(content.location(toId)!.position);
return Math.hypot(b.x - a.x, b.y - a.y);
}
describe("fixed map (Step 15.1, decision 0.5.93)", () => {
it("gives every location a fixed position", () => {
for (const location of content.locations) {
expect(location.position.radiusMillionKm, location.id).toBeGreaterThan(0);
expect(Number.isFinite(location.position.angleDegrees), location.id).toBe(true);
}
});
it("defines the Moon as an offset from Earth", () => {
const moon = content.location("moon")!;
expect(moon.orbitParent).toEqual({ locationId: "earth", offsetMillionKm: 0.384 });
expect(moon.position).toEqual(offsetPosition(content.location("earth")!.position, 0.384));
expect(distance("earth", "moon")).toBeCloseTo(0.384, 6);
});
it("puts every child body at its parent plus its offset", () => {
for (const location of content.locations.filter((l) => l.orbitParent)) {
const { locationId, offsetMillionKm } = location.orbitParent!;
const parent = content.location(locationId)!;
expect(location.position, location.id).toEqual(
offsetPosition(parent.position, offsetMillionKm),
);
}
});
it.each([
["earth", "mars", 201],
["mars", "ceres-station", 202],
["earth", "ceres-station", 402],
["ceres-station", "jupiter-cloud-platform", 393],
["mars", "jupiter-cloud-platform", 592],
["earth", "jupiter-cloud-platform", 792],
])("puts %s–%s at about %i M km (CR-015 §2, within 2 %)", (from, to, expected) => {
const d = distance(from, to);
expect(Math.abs(d - expected) / expected).toBeLessThan(0.02);
expect(distance(to, from)).toBeCloseTo(d, 9);
});
it("no longer depends on astronomy-engine", () => {
expect(lockfile).not.toContain("astronomy-engine");
});
});

5
packages/content/test/raw.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/** Vite's `?raw` imports: a file's text. */
declare module "*?raw" {
const text: string;
export default text;
}

View File

@ -16,7 +16,7 @@ const validLocation = {
name: "Earth",
type: "atmospheric-world",
landingRequirement: "atmospheric",
body: "earth",
position: { radiusMillionKm: 150, angleDegrees: 210 },
launchCostMultiplier: 3,
launchRangeFactor: 1,
startsDiscovered: true,
@ -74,7 +74,7 @@ const validSkill = {
const validGameConfig = {
timeCompression: 60,
epoch: { realStart: "2026-01-01T00:00:00Z", ephemerisDate: "2100-01-01T00:00:00Z" },
epoch: { realStart: "2026-01-01T00:00:00Z", gameStart: "2100-01-01T00:00:00Z" },
startingCredits: 0,
tutorialPayoutShare: 0.2,
contractBoardSize: 8,

View File

@ -1,7 +1,7 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { checkRoute, effectiveStats, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { TEST_NOW, useTestApp, call, getState, edit, setCredits, signUpOwner } from "../support";
@ -30,43 +30,41 @@ describe("Lighter and Clipper (Step 10L.6)", () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 100_000);
// A Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there.
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000;
clock.now = gameToReal(departure, content.config);
// A Freighter with maxed tanks at the Moon, with Jupiter discovered but not
// Mars or Ceres (CR-015 fixed map): Jupiter is in range from the Moon
// (667 × 1.3 ≥ 792), but from Jupiter (667 × 1.0) neither Earth nor the Moon is.
await edit(app, (save) => {
Object.assign(
save.ships.find((s) => s.id === owner.shipId)!,
{
modelId: "clipper",
modelId: "freighter",
locationId: "moon",
upgrades: { "fuel-tanks": 3 },
},
);
save.discoveredLocations.push({ locationId: "mars", discoveredAt: TEST_NOW });
save.discoveredLocations.push({
locationId: "jupiter-cloud-platform",
discoveredAt: TEST_NOW,
});
});
// The scenario as the sim sees it.
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true);
expect(
wouldStrand(
clipper,
moon,
mars,
0,
realToGame(clock.now, content.config),
undefined,
content,
),
).toBe(true);
const freighter = effectiveStats(content.shipModel("freighter")!, { "fuel-tanks": 3 }, content);
const [moon, jupiter] = [
content.location("moon")!,
content.location("jupiter-cloud-platform")!,
];
const now = realToGame(clock.now, content.config);
expect(checkRoute(freighter, moon, jupiter, now).ok).toBe(true);
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
expect(wouldStrand(freighter, moon, jupiter, 0, now, discovered, content)).toBe(true);
const before = await getState(app, owner);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "reposition",
shipId: owner.shipId,
pilotId: owner.avatarId,
destinationId: "mars",
destinationId: "jupiter-cloud-platform",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("would-strand");

View File

@ -10,8 +10,7 @@
"balance": "tsx balance/run.ts"
},
"dependencies": {
"@deadhead/content": "workspace:*",
"astronomy-engine": "^2.1.19"
"@deadhead/content": "workspace:*"
},
"devDependencies": {
"tsx": "^4.23.15"

View File

@ -2,7 +2,7 @@ import type { GameConfig } from "@deadhead/content";
/**
* Times are milliseconds since the Unix epoch. A real time is a wall-clock
* instant; a game time is the instant of the in-game (ephemeris) date, so
* instant; a game time is the instant of the in-game date, so
* `new Date(gameTime)` is the in-game date.
*/
export type RealTime = number;
@ -14,13 +14,13 @@ export const MS_PER_HOUR = 3_600_000;
/** Converts a real instant to the in-game instant it maps to. */
export function realToGame(real: RealTime, config: GameConfig): GameTime {
const realStart = Date.parse(config.epoch.realStart);
const gameStart = Date.parse(config.epoch.ephemerisDate);
const gameStart = Date.parse(config.epoch.gameStart);
return gameStart + (real - realStart) * config.timeCompression;
}
/** Converts an in-game instant back to the real instant it maps to. */
export function gameToReal(game: GameTime, config: GameConfig): RealTime {
const realStart = Date.parse(config.epoch.realStart);
const gameStart = Date.parse(config.epoch.ephemerisDate);
const gameStart = Date.parse(config.epoch.gameStart);
return realStart + (game - gameStart) / config.timeCompression;
}

View File

@ -7,7 +7,7 @@ export type { GameTime, RealTime } from "./clock";
export { createRng, deriveSeed } from "./rng";
export type { Rng, Seed } from "./rng";
export { bodyPosition, locationPosition } from "./positions";
export { fixedPoint, locationPosition } from "./positions";
export type { Position } from "./positions";
export { routeDistance } from "./distance";

View File

@ -1,60 +1,25 @@
import type { AstronomyBody, Location } from "@deadhead/content";
import { Body, HelioVector, KM_PER_AU, RotateVector, Rotation_EQJ_ECL } from "astronomy-engine";
import type { FixedPosition, Location } from "@deadhead/content";
import type { GameTime } from "./clock";
/** A heliocentric point in the ecliptic plane, in million km. */
/** A heliocentric point in the plane of the map, in million km. */
export interface Position {
x: number;
y: number;
}
const MILLION_KM_PER_AU = KM_PER_AU / 1e6;
const MS_PER_DAY = 86_400_000;
const J2000 = Date.UTC(2000, 0, 1, 12);
/** Fixed Earth→Moon offset (mean lunar distance); the Moon's orbit is ignored. */
const MOON_OFFSET_MILLION_KM = 0.384;
/** A fixed position as x/y (decision 0.5.93). */
export function fixedPoint(position: FixedPosition): Position {
const angle = (position.angleDegrees * Math.PI) / 180;
return {
x: position.radiusMillionKm * Math.cos(angle),
y: position.radiusMillionKm * Math.sin(angle),
};
}
/**
* Ceres is not in astronomy-engine, so it follows a circular orbit with its
* real mean radius and period, starting from its approximate J2000 mean longitude.
* A location's position in million km from the Sun. The map is fixed
* (decision 0.5.93); the game time is ignored and goes away in Step 15.2.
*/
const CERES_RADIUS_AU = 2.7675;
const CERES_PERIOD_DAYS = 1680.5;
const CERES_J2000_LONGITUDE_DEG = 160;
const eqjToEcliptic = Rotation_EQJ_ECL();
function planetPosition(body: Body, gameTime: GameTime): Position {
const ecliptic = RotateVector(eqjToEcliptic, HelioVector(body, new Date(gameTime)));
return { x: ecliptic.x * MILLION_KM_PER_AU, y: ecliptic.y * MILLION_KM_PER_AU };
}
function ceresPosition(gameTime: GameTime): Position {
const days = (gameTime - J2000) / MS_PER_DAY;
const angle = ((CERES_J2000_LONGITUDE_DEG + (360 * days) / CERES_PERIOD_DAYS) * Math.PI) / 180;
const radius = CERES_RADIUS_AU * MILLION_KM_PER_AU;
return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) };
}
export function bodyPosition(body: AstronomyBody, gameTime: GameTime): Position {
switch (body) {
case "earth":
return planetPosition(Body.Earth, gameTime);
case "moon": {
const earth = planetPosition(Body.Earth, gameTime);
return { x: earth.x + MOON_OFFSET_MILLION_KM, y: earth.y };
}
case "mars":
return planetPosition(Body.Mars, gameTime);
case "ceres":
return ceresPosition(gameTime);
case "jupiter":
return planetPosition(Body.Jupiter, gameTime);
}
}
/** A location's 2D position at a game time, in million km from the Sun. */
export function locationPosition(location: Location, gameTime: GameTime): Position {
return bodyPosition(location.body, gameTime);
export function locationPosition(location: Location, _gameTime?: GameTime): Position {
return fixedPoint(location.position);
}

View File

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { MS_PER_HOUR, MS_PER_MINUTE, gameToReal, realToGame } from "../src/index";
const realStart = Date.parse(gameConfig.epoch.realStart);
const gameStart = Date.parse(gameConfig.epoch.ephemerisDate);
const gameStart = Date.parse(gameConfig.epoch.gameStart);
describe("game clock", () => {
it("maps the real epoch to the game epoch", () => {

View File

@ -1,11 +1,13 @@
import { content, upgradePercentPerLevel, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, realToGame } from "../src/index";
import { checkRoute } from "../src/index";
const courier = content.shipModel("courier")!;
const tanks = content.upgrade("fuel-tanks")!;
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const mars = content.location("mars")!;
const now = Date.parse("2100-01-01T00:00:00Z");
/** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */
const withTanks = (level: number): ShipModel => ({
@ -14,22 +16,16 @@ const withTanks = (level: number): ShipModel => ({
courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100),
});
/** Monthly samples over 16 game years (CR-012). */
const MONTH = 30.44 * 24 * 3_600_000;
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
const inRange = (ship: ShipModel) =>
samples.map((t) => checkRoute(ship, earth, mars, t)).map((r) => r.ok);
// Measured from Earth (decision 0.5.68 as amended by CR-012).
describe("Courier to Mars from Earth (decision 0.5.68)", () => {
it("reaches Mars with maxed fuel tanks near closest approach, but not at its farthest", () => {
const reach = inRange(withTanks(3));
expect(reach.some(Boolean)).toBe(true);
expect(reach.some((ok) => !ok)).toBe(true);
// 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, now)).toEqual({ ok: true });
});
it("never reaches Mars with fuel tanks at level 2 or less", () => {
for (const level of [0, 1, 2]) expect(inRange(withTanks(level)).some(Boolean)).toBe(false);
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, now).ok, `level ${level}`).toBe(false);
expect(checkRoute(withTanks(level), moon, mars, now).ok, `level ${level}`).toBe(false);
}
});
});

View File

@ -18,9 +18,9 @@ describe("route distance", () => {
}
});
it("varies Earth→Mars by at least 200 million km over two years", () => {
const distances = monthly.map((t) => routeDistance(earth, mars, t));
expect(Math.max(...distances) - Math.min(...distances)).toBeGreaterThanOrEqual(200);
it("gives the same distance at every time (decision 0.5.93)", () => {
const first = routeDistance(earth, mars, monthly[0]!);
for (const t of monthly) expect(routeDistance(earth, mars, t)).toBe(first);
});
it("is symmetric", () => {

View File

@ -1,6 +1,6 @@
import { content, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, routeDistance } from "../src/index";
import { checkRoute } from "../src/index";
const earth = content.location("earth")!;
const moon = content.location("moon")!;
@ -14,70 +14,39 @@ const bulkHauler = content.shipModel("bulk-hauler")!;
/** Test-only ship that cannot land on atmospheric worlds. */
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
// Every 4 days across 12 years (one Jupiter orbit).
const start = Date.parse("2100-01-01T00:00:00Z");
const samples = Array.from({ length: 1096 }, (_, i) => start + i * 4 * 86_400_000);
/** The sampled time when `to` is closest to (or farthest from) Earth. */
function extremeTime(to: typeof mars, pick: "closest" | "farthest"): number {
let best = samples[0]!;
let bestDistance = routeDistance(earth, to, best);
for (const t of samples) {
const d = routeDistance(earth, to, t);
if (pick === "closest" ? d < bestDistance : d > bestDistance) {
best = t;
bestDistance = d;
}
}
return best;
}
// The map is fixed (decision 0.5.93), so any time gives the same answer.
const now = Date.parse("2100-01-01T00:00:00Z");
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, start)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth, start)).toEqual({ ok: true });
expect(checkRoute(courier, earth, moon, now)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth, now)).toEqual({ ok: true });
});
it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter even at their closest", () => {
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, extremeTime(to, "closest"))).toEqual({
ok: false,
reason: "out-of-range",
});
expect(checkRoute(courier, earth, to, now)).toEqual(outOfRange);
}
});
it("rejects a vacuum-only ship at Earth because it cannot land", () => {
expect(checkRoute(vacuumShip, moon, earth, start)).toEqual({
expect(checkRoute(vacuumShip, moon, earth, now)).toEqual({
ok: false,
reason: "cannot-land",
});
expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true });
expect(checkRoute(vacuumShip, earth, moon, now)).toEqual({ ok: true });
});
it("lets a Freighter from Earth reach Mars even at its farthest", () => {
expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).toEqual({ ok: true });
it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => {
expect(checkRoute(freighter, earth, mars, now)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, ceres, now)).toEqual({ ok: true });
});
it("lets a Freighter from Earth reach Ceres at its closest but not its farthest (CR-012)", () => {
expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "closest"))).toEqual({
ok: true,
});
expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "farthest")).ok).toBe(false);
});
it("lets only the Bulk Hauler reach Jupiter from Earth at its farthest", () => {
const t = extremeTime(jupiter, "farthest");
expect(checkRoute(bulkHauler, earth, jupiter, t)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, jupiter, t).ok).toBe(false);
expect(checkRoute(courier, earth, jupiter, t).ok).toBe(false);
});
it("keeps a Freighter from Earth out of Jupiter's range even at its closest (CR-012 amends 0.5.25)", () => {
expect(checkRoute(freighter, earth, jupiter, extremeTime(jupiter, "closest"))).toEqual({
ok: false,
reason: "out-of-range",
});
it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true });
expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange);
expect(checkRoute(courier, earth, jupiter, now)).toEqual(outOfRange);
});
});

View File

@ -1,91 +1,77 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, realToGame } from "../src/index";
import { checkRoute } from "../src/index";
/** CR-012 §3: reach targets for base ships, monthly over 16 game years (decision 0.5.81). */
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const MONTH = 30.44 * 24 * 3_600_000;
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
const ids = content.locations.map((l) => l.id);
/** Share of sampled dates on which the leg is in range for the base ship. */
function share(modelId: string, fromId: string, toId: string): number {
const ship = content.shipModel(modelId)!;
const from = content.location(fromId)!;
const to = content.location(toId)!;
return samples.filter((t) => checkRoute(ship, from, to, t).ok).length / samples.length;
}
const always = (m: string, from: string, to: string) => expect(share(m, from, to)).toBe(1);
const never = (m: string, from: string, to: string) => expect(share(m, from, to)).toBe(0);
const some = (m: string, from: string, to: string) => {
const s = share(m, from, to);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
return s;
/**
* CR-015 §4: reach for base ships on the fixed map, a plain yes or no per leg
* (decision 0.5.81 as amended). Replaces CR-012's date-sampled table.
*/
const now = Date.parse("2100-01-01T00:00:00Z");
const NAMES: Record<string, string> = {
earth: "Earth",
moon: "Moon",
mars: "Mars",
"ceres-station": "Ceres",
"jupiter-cloud-platform": "Jupiter",
};
const ids = Object.keys(NAMES);
describe("reach table (Step 10L.4)", () => {
for (const model of ["courier", "lighter"]) {
describe(model, () => {
it("flies Earth↔Moon always", () => {
always(model, "earth", "moon");
always(model, "moon", "earth");
});
it("never reaches Mars from Earth", () => never(model, "earth", "mars"));
it("never reaches Mars from the Moon", () => never(model, "moon", "mars"));
});
function reaches(modelId: string, fromId: string, toId: string): boolean {
const ship = content.shipModel(modelId)!;
return checkRoute(ship, content.location(fromId)!, content.location(toId)!, now).ok;
}
describe("clipper", () => {
it("reaches Mars from Earth on some dates, at least 30 %", () => {
expect(some("clipper", "earth", "mars")).toBeGreaterThanOrEqual(0.3);
});
it("reaches Mars from the Moon on some dates, more than from Earth", () => {
expect(some("clipper", "moon", "mars")).toBeGreaterThan(share("clipper", "earth", "mars"));
});
it("reaches Ceres from Mars on some dates", () => some("clipper", "mars", "ceres-station"));
it("never reaches Ceres from Earth", () => never("clipper", "earth", "ceres-station"));
it("never reaches Ceres from the Moon", () => never("clipper", "moon", "ceres-station"));
it("never reaches Jupiter from anywhere", () => {
for (const from of ids)
if (from !== "jupiter-cloud-platform") {
never("clipper", from, "jupiter-cloud-platform");
}
});
/** [model, from, to] for every leg the table says yes or no to. */
type Leg = [string, string, string];
const both = (model: string, a: string, b: string): Leg[] => [
[model, a, b],
[model, b, a],
];
const toJupiterFromAnywhere = (model: string): Leg[] =>
ids
.filter((id) => id !== "jupiter-cloud-platform")
.map((from) => [model, from, "jupiter-cloud-platform"]);
const yes: Leg[] = [
...both("courier", "earth", "moon"),
...both("lighter", "earth", "moon"),
...both("clipper", "earth", "moon"),
...both("clipper", "earth", "mars"),
["clipper", "moon", "mars"],
["clipper", "mars", "ceres-station"],
// The Freighter does everything the Clipper does, plus Earth↔Ceres and Ceres↔Jupiter.
...both("freighter", "earth", "moon"),
...both("freighter", "earth", "mars"),
["freighter", "moon", "mars"],
["freighter", "mars", "ceres-station"],
...both("freighter", "earth", "ceres-station"),
...both("freighter", "ceres-station", "jupiter-cloud-platform"),
...ids.flatMap((from) =>
ids.filter((to) => to !== from).map((to): Leg => ["bulk-hauler", from, to]),
),
];
const no: Leg[] = [
["courier", "earth", "mars"],
["courier", "moon", "mars"],
["lighter", "earth", "mars"],
["lighter", "moon", "mars"],
["clipper", "earth", "ceres-station"],
["clipper", "moon", "ceres-station"],
...toJupiterFromAnywhere("clipper"),
["freighter", "earth", "jupiter-cloud-platform"],
["freighter", "moon", "jupiter-cloud-platform"],
["freighter", "mars", "jupiter-cloud-platform"],
];
const label = ([model, from, to]: Leg) => `${model} ${NAMES[from]}→${NAMES[to]}`;
describe("reach table (CR-015 §4)", () => {
it.each(yes.map((leg) => [label(leg), leg] as const))("yes: %s", (_name, [m, from, to]) => {
expect(reaches(m, from, to)).toBe(true);
});
describe("freighter", () => {
it("reaches Mars from Earth always", () => always("freighter", "earth", "mars"));
it("reaches Ceres from Earth on most dates", () => {
expect(some("freighter", "earth", "ceres-station")).toBeGreaterThan(0.5);
});
it("reaches Jupiter from Ceres on some dates", () =>
some("freighter", "ceres-station", "jupiter-cloud-platform"));
it("never reaches Jupiter from Earth", () =>
never("freighter", "earth", "jupiter-cloud-platform"));
it("never reaches Jupiter from the Moon", () =>
never("freighter", "moon", "jupiter-cloud-platform"));
it("never reaches Jupiter from Mars", () =>
never("freighter", "mars", "jupiter-cloud-platform"));
});
describe("bulk-hauler", () => {
it("reaches Jupiter from Earth always", () =>
always("bulk-hauler", "earth", "jupiter-cloud-platform"));
it("flies every other leg between the five locations always", () => {
for (const from of ids)
for (const to of ids) if (from !== to) always("bulk-hauler", from, to);
});
});
describe("hops", () => {
it("a Clipper can't reach Ceres from Earth on any date but can from Mars on some", () => {
never("clipper", "earth", "ceres-station");
some("clipper", "mars", "ceres-station");
});
it("a Freighter can reach Jupiter from Ceres on some dates but never directly from Earth", () => {
some("freighter", "ceres-station", "jupiter-cloud-platform");
never("freighter", "earth", "jupiter-cloud-platform");
});
it.each(no.map((leg) => [label(leg), leg] as const))("no: %s", (_name, [m, from, to]) => {
expect(reaches(m, from, to)).toBe(false);
});
});

View File

@ -48,7 +48,7 @@ describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => {
}
});
it("gives the Courier 450 % range per tanks level", () => {
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(450);
it("gives the Courier 1400 % range per tanks level (CR-015)", () => {
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(1400);
});
});

View File

@ -175,9 +175,6 @@ importers:
'@deadhead/content':
specifier: workspace:*
version: link:../content
astronomy-engine:
specifier: ^2.1.19
version: 2.1.19
devDependencies:
tsx:
specifier: ^4.23.15
@ -1854,9 +1851,6 @@ packages:
ast-v8-to-istanbul@1.0.7:
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
astronomy-engine@2.1.19:
resolution: {integrity: sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@ -5004,8 +4998,6 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 10.0.0
astronomy-engine@2.1.19: {}
async-function@1.0.0: {}
async@3.2.6: {}