Compare commits

..

No commits in common. "d6bca5b1a264a786653fe77fb9e99ee61a7f0aa5" and "13eb0580529e17efd71f00140ed06b331452c24d" have entirely different histories.

31 changed files with 328 additions and 543 deletions

View File

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

View File

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

View File

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

View File

@ -1,11 +1,23 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react"; import { screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState } from "./render";
// The map is fixed (CR-015): any time gives the same reach. const MONTH = 30.44 * 24 * 3_600_000;
const NOW = Date.parse(content.config.epoch.realStart) + 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");
}
async function reachOf(modelName: string, now: number) { async function reachOf(modelName: string, now: number) {
vi.useFakeTimers({ toFake: ["Date"] }); vi.useFakeTimers({ toFake: ["Date"] });
@ -31,14 +43,22 @@ describe("market roles and reach (Step 10L.7)", () => {
} }
}); });
it("lists Mars from Earth for a Clipper, and Ceres by hopping", async () => { it("lists Mars from Earth for a Clipper only when Mars is in range, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", NOW); const near = await reachOf("Clipper", dateWhenMars(true));
expect(near).toContain("From Earth now: Moon, Mars"); expect(near).toContain("From Earth now: Moon, Mars");
expect(near).toContain("By hopping: Ceres Station"); 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 () => { it("lists only the Moon for a Lighter", async () => {
const reach = await reachOf("Lighter", NOW); const reach = await reachOf("Lighter", dateWhenMars(true));
expect(reach).toBe("Reach From Earth now: Moon"); expect(reach).toBe("Reach From Earth now: Moon");
}); });
}); });

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema"; import type { Contract, PlayerState } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim"; import { checkRoute, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { screen, within } from "@testing-library/react"; import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -8,24 +8,28 @@ import { useClock } from "../src/clock/clock";
import { pairOptions } from "../src/screens/DispatchSheet"; import { pairOptions } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const TANKS = { "fuel-tanks": 3 }; const DAY = 24 * 3_600_000;
const freighter = effectiveStats(content.shipModel("freighter")!, TANKS, content); const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const [moon, jupiter] = [content.location("moon")!, content.location("jupiter-cloud-platform")!]; const clipper = content.shipModel("clipper")!;
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]); const [moon, mars] = [content.location("moon")!, content.location("mars")!];
const NOW = Date.parse(content.config.epoch.realStart) + 3_600_000; const discovered = new Set(["earth", "moon", "mars"]);
const STRANDED = "Would be stranded — can't fly out of Jupiter Cloud Platform from there";
// The fixed map (CR-015): Jupiter is in range from the Moon (667 × 1.3), but from /** A date on which a Clipper can hop Moon→Mars but couldn't leave Mars (CR-012). */
// Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered. const NOW = (() => {
const gameNow = realToGame(NOW, content.config); for (let d = 0; d < 16 * 365; d += 5) {
const t = gameStart + d * DAY;
if ( if (
!checkRoute(freighter, moon, jupiter, gameNow).ok || checkRoute(clipper, moon, mars, t).ok &&
!wouldStrand(freighter, moon, jupiter, 10, gameNow, discovered, content) wouldStrand(clipper, moon, mars, 10, t, discovered, content)
) { ) {
throw new Error("the scenario no longer strands"); return gameToReal(t, content.config);
} }
}
throw new Error("no stranding date");
})();
const STRANDED = "Would be stranded — can't fly out of Mars from there";
/** A crewed Freighter with maxed tanks at the Moon, with Jupiter discovered. */ /** A crewed Clipper at the Moon, with Mars discovered. */
function state(): PlayerState { function state(): PlayerState {
const base = samplePlayerState({ tutorial: null }); const base = samplePlayerState({ tutorial: null });
return { return {
@ -36,13 +40,13 @@ function state(): PlayerState {
ships: [ ships: [
{ {
id: "s1", id: "s1",
modelId: "freighter", modelId: "clipper",
name: "Swift Heron", name: "Swift Heron",
status: "idle", status: "idle",
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("freighter", TANKS), ...shipExtras("clipper"),
pilotId: "avatar", pilotId: "avatar",
}, },
], ],
@ -50,10 +54,10 @@ function state(): PlayerState {
}; };
} }
const toJupiter: Contract = { const toMars: Contract = {
id: "c1", id: "c1",
originId: "moon", originId: "moon",
destinationId: "jupiter-cloud-platform", destinationId: "mars",
cargoTypeId: "standard", cargoTypeId: "standard",
goodId: "consumer-goods", goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
@ -78,7 +82,7 @@ describe("stranding reason (Step 10L.9)", () => {
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it("marks a would-strand ship and pilot pair with the reason", () => { it("marks a would-strand ship and pilot pair with the reason", () => {
const [pair] = pairOptions(state(), toJupiter, NOW); const [pair] = pairOptions(state(), toMars, NOW);
expect(pair!.problems).toEqual([STRANDED]); expect(pair!.problems).toEqual([STRANDED]);
}); });
@ -87,7 +91,7 @@ describe("stranding reason (Step 10L.9)", () => {
const card = (await screen.findByText("Swift Heron")).closest("li")!; const card = (await screen.findByText("Swift Heron")).closest("li")!;
await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ })); await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ }));
const dialog = screen.getByRole("dialog", { name: "Deadhead Swift Heron" }); const dialog = screen.getByRole("dialog", { name: "Deadhead Swift Heron" });
const option = within(dialog).getByRole("button", { name: "Jupiter Cloud Platform" }); const option = within(dialog).getByRole("button", { name: "Mars" });
expect(option).toBeDisabled(); expect(option).toBeDisabled();
expect(option).toHaveTextContent(STRANDED); expect(option).toHaveTextContent(STRANDED);
expect(within(dialog).getByRole("button", { name: "Earth" })).toBeEnabled(); 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 | | Package | Role | Depends on |
|---|---|---| |---|---|---|
| `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod |
| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. | content | | `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. | content, astronomy-engine |
| `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim | | `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim |
| `packages/schema` | Zod schemas for every API request, response and error. Shared by the engine and the client. | sim (error codes), zod | | `packages/schema` | Zod schemas for every API request, response and error. Shared by the engine and the client. | sim (error codes), zod |
| `apps/web` | React client; runs the engine on the device (CR-014). | content, engine, sim, schema | | `apps/web` | React client; runs the engine on the device (CR-014). | content, engine, sim, schema |

View File

@ -1,194 +0,0 @@
# 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,13 +148,6 @@ 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"). 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. 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. 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) ### 0.6 Translating change requests written for the server (CR-014 §7)
@ -770,12 +763,6 @@ 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) ## Definition of Done (Base Game)
- All step tests pass in CI. - All step tests pass in CI.

View File

@ -365,17 +365,3 @@ 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). 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 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 | | Notifications | Local notifications via `@capacitor/local-notifications` (iOS/Android); none on the web |
| PWA | `vite-plugin-pwa` (Workbox) | | PWA | `vite-plugin-pwa` (Workbox) |
| Native wrapper | Capacitor (iOS/Android) | | Native wrapper | Capacitor (iOS/Android) |
| Planetary positions | Fixed coordinates in content (CR-015) | | Planetary positions | `astronomy-engine` |
| Seeded RNG | Small pure-TS PRNG; world seeds and ids from the platform's crypto random source | | 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) | | Testing | Vitest (sim, engine, components), Playwright (end to end) |
| Deployment | Static files (Caddy on a home server, or any static host) | | 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 ### 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. 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 at fixed positions from content (scaled/compressed for readability — soft sci-fi). - Planets positioned from `astronomy-engine` (scaled/compressed for readability — soft sci-fi).
- Ships interpolated along routes with `sim` every frame. - Ships interpolated along routes with `sim` every frame.
- Pan/zoom with touch support. - 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 ## 5. Planetary Positions
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. `astronomy-engine` computes real planetary positions for any date, in pure JS, no API calls.
- Child bodies (the Moon) sit at their parent's position moved a small fixed distance straight out from the Sun. - Run it in `packages/sim` so the engine and the screens agree on distances.
- The game clock still runs (1 real min = 1 game hour by default) for timers and the displayed in-game date. - Use a fixed **game epoch** mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default).
- `astronomy-engine` was used for real ephemeris positions until CR-015; it is gone. - Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits.
- For interstellar (late game), real star positions from a static dataset in `packages/content`. - For interstellar (late game), real star positions from a static dataset in `packages/content`.
## 6. Deployment ## 6. Deployment

View File

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

View File

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

View File

@ -28,9 +28,8 @@ export const shipModels: ShipModel[] = [
sprite: "ship-lighter", sprite: "ship-lighter",
name: "Lighter", name: "Lighter",
archetype: "lighter", archetype: "lighter",
// About a third of the way from Courier to Freighter; 36,000 (was 42,400) // About 40 % of the way from Courier to Freighter.
// keeps the first Market purchase in 45–75 minutes on the fixed map (Step 15.1). price: 42_400,
price: 36_000,
// Slower than the Freighter, faster than the Bulk Hauler. // Slower than the Freighter, faster than the Bulk Hauler.
cruiseSpeedKmPerGameHour: 1_600_000, cruiseSpeedKmPerGameHour: 1_600_000,
cargoCapacityTonnes: 80, cargoCapacityTonnes: 80,
@ -56,7 +55,7 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 6_500_000, cruiseSpeedKmPerGameHour: 6_500_000,
cargoCapacityTonnes: 30, cargoCapacityTonnes: 30,
// Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4). // Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4).
fuelRangeMillionKm: 250, fuelRangeMillionKm: 198,
hullRating: 1, hullRating: 1,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
@ -73,7 +72,7 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 2_000_000, cruiseSpeedKmPerGameHour: 2_000_000,
cargoCapacityTonnes: 100, cargoCapacityTonnes: 100,
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25). // Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
fuelRangeMillionKm: 460, fuelRangeMillionKm: 450,
hullRating: 2, hullRating: 2,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 4, 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 * Ship upgrades (CR-009 §2). Percentages are of the model's base stat and add
* up across levels. Overrides keep models distinct (decision 0.5.67) and give * up across levels. Overrides keep models distinct (decision 0.5.67) and give a
* only a maxed Courier the reach to Mars from Earth (decision 0.5.68, CR-015). * maxed Courier a reach to Mars near closest approach only (decision 0.5.68).
*/ */
export const upgrades: Upgrade[] = [ export const upgrades: Upgrade[] = [
{ {
@ -27,9 +27,9 @@ export const upgrades: Upgrade[] = [
percentPerLevel: 20, percentPerLevel: 20,
maxLevel: 3, maxLevel: 3,
modelOverrides: { modelOverrides: {
// 5 → 75 → 145 → 215 M km: Earth → Mars (≈ 201) only at level 3; level 2 can't reach Mars from the Moon either (145 × 1.3 < 200). // 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: 1400 }, courier: { percentPerLevel: 450 },
// 460 → 667 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth (≈ 792). // 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).
freighter: { percentPerLevel: 15 }, freighter: { percentPerLevel: 15 },
}, },
}, },

View File

@ -26,27 +26,16 @@ export const locationTypeSchema = z.enum([
]); ]);
export type LocationType = z.infer<typeof locationTypeSchema>; export type LocationType = z.infer<typeof locationTypeSchema>;
/** A fixed point around the Sun (decision 0.5.93): radius in million km, angle in degrees. */ /** Astronomical body a location's position is derived from. */
export const fixedPositionSchema = z.object({ export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]);
radiusMillionKm: z.number().nonnegative(), export type AstronomyBody = z.infer<typeof astronomyBodySchema>;
angleDegrees: z.number(),
});
export type FixedPosition = z.infer<typeof fixedPositionSchema>;
export const locationSchema = z.object({ export const locationSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), name: z.string().min(1),
type: locationTypeSchema, type: locationTypeSchema,
landingRequirement: landingClassSchema, landingRequirement: landingClassSchema,
/** Where it sits, for good (decision 0.5.93). */ body: astronomyBodySchema,
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. */ /** Multiplies fuel cost for launches from this location. */
launchCostMultiplier: z.number().positive(), launchCostMultiplier: z.number().positive(),
/** /**
@ -192,13 +181,10 @@ export const gameConfigSchema = z
.object({ .object({
/** Game hours per real hour (60 means 1 real minute = 1 game hour). */ /** Game hours per real hour (60 means 1 real minute = 1 game hour). */
timeCompression: z.number().positive(), 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({ epoch: z.object({
realStart: z.iso.datetime(), realStart: z.iso.datetime(),
gameStart: z.iso.datetime(), ephemerisDate: z.iso.datetime(),
}), }),
startingCredits: nonNegativeInt, startingCredits: nonNegativeInt,
/** Fraction of a normal payout the player receives for a tutorial run. */ /** Fraction of a normal payout the player receives for a tutorial run. */

View File

@ -1,58 +0,0 @@
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");
});
});

View File

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

View File

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

View File

@ -10,7 +10,8 @@
"balance": "tsx balance/run.ts" "balance": "tsx balance/run.ts"
}, },
"dependencies": { "dependencies": {
"@deadhead/content": "workspace:*" "@deadhead/content": "workspace:*",
"astronomy-engine": "^2.1.19"
}, },
"devDependencies": { "devDependencies": {
"tsx": "^4.23.15" "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 * 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 date, so * instant; a game time is the instant of the in-game (ephemeris) date, so
* `new Date(gameTime)` is the in-game date. * `new Date(gameTime)` is the in-game date.
*/ */
export type RealTime = number; 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. */ /** Converts a real instant to the in-game instant it maps to. */
export function realToGame(real: RealTime, config: GameConfig): GameTime { export function realToGame(real: RealTime, config: GameConfig): GameTime {
const realStart = Date.parse(config.epoch.realStart); const realStart = Date.parse(config.epoch.realStart);
const gameStart = Date.parse(config.epoch.gameStart); const gameStart = Date.parse(config.epoch.ephemerisDate);
return gameStart + (real - realStart) * config.timeCompression; return gameStart + (real - realStart) * config.timeCompression;
} }
/** Converts an in-game instant back to the real instant it maps to. */ /** Converts an in-game instant back to the real instant it maps to. */
export function gameToReal(game: GameTime, config: GameConfig): RealTime { export function gameToReal(game: GameTime, config: GameConfig): RealTime {
const realStart = Date.parse(config.epoch.realStart); const realStart = Date.parse(config.epoch.realStart);
const gameStart = Date.parse(config.epoch.gameStart); const gameStart = Date.parse(config.epoch.ephemerisDate);
return realStart + (game - gameStart) / config.timeCompression; return realStart + (game - gameStart) / config.timeCompression;
} }

View File

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

View File

@ -1,25 +1,60 @@
import type { FixedPosition, Location } from "@deadhead/content"; import type { AstronomyBody, Location } from "@deadhead/content";
import { Body, HelioVector, KM_PER_AU, RotateVector, Rotation_EQJ_ECL } from "astronomy-engine";
import type { GameTime } from "./clock"; import type { GameTime } from "./clock";
/** A heliocentric point in the plane of the map, in million km. */ /** A heliocentric point in the ecliptic plane, in million km. */
export interface Position { export interface Position {
x: number; x: number;
y: number; y: number;
} }
/** A fixed position as x/y (decision 0.5.93). */ const MILLION_KM_PER_AU = KM_PER_AU / 1e6;
export function fixedPoint(position: FixedPosition): Position { const MS_PER_DAY = 86_400_000;
const angle = (position.angleDegrees * Math.PI) / 180; const J2000 = Date.UTC(2000, 0, 1, 12);
return {
x: position.radiusMillionKm * Math.cos(angle), /** Fixed Earth→Moon offset (mean lunar distance); the Moon's orbit is ignored. */
y: position.radiusMillionKm * Math.sin(angle), const MOON_OFFSET_MILLION_KM = 0.384;
};
}
/** /**
* A location's position in million km from the Sun. The map is fixed * Ceres is not in astronomy-engine, so it follows a circular orbit with its
* (decision 0.5.93); the game time is ignored and goes away in Step 15.2. * real mean radius and period, starting from its approximate J2000 mean longitude.
*/ */
export function locationPosition(location: Location, _gameTime?: GameTime): Position { const CERES_RADIUS_AU = 2.7675;
return fixedPoint(location.position); 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);
} }

View File

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

View File

@ -1,13 +1,11 @@
import { content, upgradePercentPerLevel, type ShipModel } from "@deadhead/content"; import { content, upgradePercentPerLevel, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { checkRoute } from "../src/index"; import { checkRoute, realToGame } from "../src/index";
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
const tanks = content.upgrade("fuel-tanks")!; const tanks = content.upgrade("fuel-tanks")!;
const earth = content.location("earth")!; const earth = content.location("earth")!;
const moon = content.location("moon")!;
const mars = content.location("mars")!; 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). */ /** A Courier with fuel tanks at `level` (decision 0.5.63: linear % of base range). */
const withTanks = (level: number): ShipModel => ({ const withTanks = (level: number): ShipModel => ({
@ -16,16 +14,22 @@ const withTanks = (level: number): ShipModel => ({
courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100), courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100),
}); });
// Decision 0.5.68 as amended by CR-015: a plain yes or no on the fixed map. /** Monthly samples over 16 game years (CR-012). */
describe("Courier to Mars (decision 0.5.68)", () => { const MONTH = 30.44 * 24 * 3_600_000;
it("reaches Mars from Earth with maxed fuel tanks", () => { const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
expect(checkRoute(withTanks(3), earth, mars, now)).toEqual({ ok: true }); 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);
}); });
it("can't reach Mars from Earth or the Moon with fuel tanks at level 2 or less", () => { it("never reaches Mars with fuel tanks at level 2 or less", () => {
for (const level of [0, 1, 2]) { for (const level of [0, 1, 2]) expect(inRange(withTanks(level)).some(Boolean)).toBe(false);
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("gives the same distance at every time (decision 0.5.93)", () => { it("varies Earth→Mars by at least 200 million km over two years", () => {
const first = routeDistance(earth, mars, monthly[0]!); const distances = monthly.map((t) => routeDistance(earth, mars, t));
for (const t of monthly) expect(routeDistance(earth, mars, t)).toBe(first); expect(Math.max(...distances) - Math.min(...distances)).toBeGreaterThanOrEqual(200);
}); });
it("is symmetric", () => { it("is symmetric", () => {

View File

@ -1,6 +1,6 @@
import { content, type ShipModel } from "@deadhead/content"; import { content, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { checkRoute } from "../src/index"; import { checkRoute, routeDistance } from "../src/index";
const earth = content.location("earth")!; const earth = content.location("earth")!;
const moon = content.location("moon")!; const moon = content.location("moon")!;
@ -14,39 +14,70 @@ const bulkHauler = content.shipModel("bulk-hauler")!;
/** Test-only ship that cannot land on atmospheric worlds. */ /** Test-only ship that cannot land on atmospheric worlds. */
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" }; const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
// The map is fixed (decision 0.5.93), so any time gives the same answer. // Every 4 days across 12 years (one Jupiter orbit).
const now = Date.parse("2100-01-01T00:00:00Z"); const start = Date.parse("2100-01-01T00:00:00Z");
const outOfRange = { ok: false, reason: "out-of-range" }; 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;
}
// Origins are named in every test (decision 0.5.77: range is per leg, from its origin). // Origins are named in every test (decision 0.5.77: range is per leg, from its origin).
describe("route eligibility", () => { describe("route eligibility", () => {
it("lets a Courier fly Earth→Moon and Moon→Earth", () => { it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
expect(checkRoute(courier, earth, moon, now)).toEqual({ ok: true }); expect(checkRoute(courier, earth, moon, start)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth, now)).toEqual({ ok: true }); expect(checkRoute(courier, moon, earth, start)).toEqual({ ok: true });
}); });
it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter", () => { it("keeps a Courier from Earth out of range of Mars, Ceres and Jupiter even at their closest", () => {
for (const to of [mars, ceres, jupiter]) { for (const to of [mars, ceres, jupiter]) {
expect(checkRoute(courier, earth, to, now)).toEqual(outOfRange); expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({
ok: false,
reason: "out-of-range",
});
} }
}); });
it("rejects a vacuum-only ship at Earth because it cannot land", () => { it("rejects a vacuum-only ship at Earth because it cannot land", () => {
expect(checkRoute(vacuumShip, moon, earth, now)).toEqual({ expect(checkRoute(vacuumShip, moon, earth, start)).toEqual({
ok: false, ok: false,
reason: "cannot-land", reason: "cannot-land",
}); });
expect(checkRoute(vacuumShip, earth, moon, now)).toEqual({ ok: true }); expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true });
}); });
it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => { it("lets a Freighter from Earth reach Mars even at its farthest", () => {
expect(checkRoute(freighter, earth, mars, now)).toEqual({ ok: true }); expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).toEqual({ ok: true });
expect(checkRoute(freighter, earth, ceres, now)).toEqual({ ok: true });
}); });
it("lets only the Bulk Hauler reach Jupiter from Earth", () => { it("lets a Freighter from Earth reach Ceres at its closest but not its farthest (CR-012)", () => {
expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true }); expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "closest"))).toEqual({
expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange); ok: true,
expect(checkRoute(courier, earth, jupiter, now)).toEqual(outOfRange); });
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",
});
}); });
}); });

View File

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

View File

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