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>
This commit is contained in:
fireshaper 2026-10-10 00:43:09 -04:00
parent 23b3d7ec97
commit d6bca5b1a2
29 changed files with 320 additions and 331 deletions

View File

@ -1435,3 +1435,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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. - **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. - **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 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. * 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 * Where a location is drawn. A child body (the Moon) sits a fixed small
* in its true direction, since at map scale it would overlap Earth. * distance from its parent in its true direction, since at map scale it would
* overlap it.
*/ */
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint { export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
if (location.body !== "moon") return toMapPoint(bodyPosition(location.body, gameTime)); const parentId = location.orbitParent?.locationId;
const earth = bodyPosition("earth", gameTime); const parent = parentId ? content.location(parentId) : undefined;
const moon = bodyPosition("moon", gameTime); if (!parent) return toMapPoint(locationPosition(location, 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 { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim"; import { dispatchProgress, locationPosition, 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 = bodyPosition(loc.body, gameTime); const sun = locationPosition(loc, 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.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), 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,7 +99,9 @@ describe("effective stats in the client (Step 10I.7)", () => {
content.location("mars")!, content.location("mars")!,
realToGame(NOW, content.config), 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 open = async () => {
const card = (await screen.findByText("Dusty Mule")).closest("li")!; const card = (await screen.findByText("Dusty Mule")).closest("li")!;

View File

@ -1,23 +1,11 @@
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";
const MONTH = 30.44 * 24 * 3_600_000; // The map is fixed (CR-015): any time gives the same reach.
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config); const NOW = Date.parse(content.config.epoch.realStart) + 3_600_000;
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"] });
@ -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 () => { it("lists Mars from Earth for a Clipper, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", dateWhenMars(true)); const near = await reachOf("Clipper", NOW);
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", dateWhenMars(true)); const reach = await reachOf("Lighter", NOW);
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, gameToReal, realToGame, wouldStrand } from "@deadhead/sim"; import { checkRoute, effectiveStats, 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,28 +8,24 @@ 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 DAY = 24 * 3_600_000; const TANKS = { "fuel-tanks": 3 };
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config); const freighter = effectiveStats(content.shipModel("freighter")!, TANKS, content);
const clipper = content.shipModel("clipper")!; const [moon, jupiter] = [content.location("moon")!, content.location("jupiter-cloud-platform")!];
const [moon, mars] = [content.location("moon")!, content.location("mars")!]; const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
const discovered = new Set(["earth", "moon", "mars"]); 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). */ // The fixed map (CR-015): Jupiter is in range from the Moon (667 × 1.3), but from
const NOW = (() => { // Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered.
for (let d = 0; d < 16 * 365; d += 5) { const gameNow = realToGame(NOW, content.config);
const t = gameStart + d * DAY;
if ( if (
checkRoute(clipper, moon, mars, t).ok && !checkRoute(freighter, moon, jupiter, gameNow).ok ||
wouldStrand(clipper, moon, mars, 10, t, discovered, content) !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 { function state(): PlayerState {
const base = samplePlayerState({ tutorial: null }); const base = samplePlayerState({ tutorial: null });
return { return {
@ -40,13 +36,13 @@ function state(): PlayerState {
ships: [ ships: [
{ {
id: "s1", id: "s1",
modelId: "clipper", modelId: "freighter",
name: "Swift Heron", name: "Swift Heron",
status: "idle", status: "idle",
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("clipper"), ...shipExtras("freighter", TANKS),
pilotId: "avatar", pilotId: "avatar",
}, },
], ],
@ -54,10 +50,10 @@ function state(): PlayerState {
}; };
} }
const toMars: Contract = { const toJupiter: Contract = {
id: "c1", id: "c1",
originId: "moon", originId: "moon",
destinationId: "mars", destinationId: "jupiter-cloud-platform",
cargoTypeId: "standard", cargoTypeId: "standard",
goodId: "consumer-goods", goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
@ -82,7 +78,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(), toMars, NOW); const [pair] = pairOptions(state(), toJupiter, NOW);
expect(pair!.problems).toEqual([STRANDED]); expect(pair!.problems).toEqual([STRANDED]);
}); });
@ -91,7 +87,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: "Mars" }); const option = within(dialog).getByRole("button", { name: "Jupiter Cloud Platform" });
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. | 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/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

@ -370,7 +370,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
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. 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.
- [ ] Step 15.1 — Content: fixed positions - [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.2 — Sim: static positions and distances
- [ ] Step 15.3 — Content: fuel - [ ] Step 15.3 — Content: fuel
- [ ] Step 15.4 — Sim: tank, burn, and refuel - [ ] Step 15.4 — Sim: tank, burn, and refuel

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 | `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 | | 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 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. - 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
`astronomy-engine` computes real planetary positions for any date, in pure JS, no API calls. 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.
- Run it in `packages/sim` so the engine and the screens agree on distances. - Child bodies (the Moon) sit at their parent's position moved a small fixed distance straight out from the Sun.
- Use a fixed **game epoch** mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default). - The game clock still runs (1 real min = 1 game hour by default) for timers and the displayed in-game date.
- Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits. - `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`. - 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,
// 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: { epoch: {
realStart: "2026-01-01T00:00:00Z", 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). // A small cushion: a couple of Courier fuel loads from Earth (CR-001).
startingCredits: 500, startingCredits: 500,

View File

@ -1,6 +1,19 @@
import type { Location } from "../schemas"; import type { FixedPosition, 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
@ -14,7 +27,7 @@ export const locations: Location[] = [
name: "Earth", name: "Earth",
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "earth", position: EARTH,
sprite: "body-earth", sprite: "body-earth",
launchCostMultiplier: 2.1, launchCostMultiplier: 2.1,
launchRangeFactor: 1, launchRangeFactor: 1,
@ -35,7 +48,8 @@ export const locations: Location[] = [
name: "Moon", name: "Moon",
type: "airless-body", type: "airless-body",
landingRequirement: "vacuum", landingRequirement: "vacuum",
body: "moon", position: offsetPosition(EARTH, MOON_OFFSET_MILLION_KM),
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,
@ -54,7 +68,7 @@ export const locations: Location[] = [
name: "Mars", name: "Mars",
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "mars", position: { radiusMillionKm: 228, angleDegrees: 270 },
sprite: "body-mars", sprite: "body-mars",
launchCostMultiplier: 1.5, launchCostMultiplier: 1.5,
launchRangeFactor: 1.15, launchRangeFactor: 1.15,
@ -70,7 +84,7 @@ export const locations: Location[] = [
name: "Ceres Station", name: "Ceres Station",
type: "belt-station", type: "belt-station",
landingRequirement: "vacuum", landingRequirement: "vacuum",
body: "ceres", position: { radiusMillionKm: 414, angleDegrees: 285 },
sprite: "body-ceres", sprite: "body-ceres",
launchCostMultiplier: 0.3, launchCostMultiplier: 0.3,
launchRangeFactor: 1.3, launchRangeFactor: 1.3,
@ -86,7 +100,7 @@ export const locations: Location[] = [
name: "Jupiter Cloud Platform", name: "Jupiter Cloud Platform",
type: "cloud-platform", type: "cloud-platform",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "jupiter", position: { radiusMillionKm: 778, angleDegrees: 300 },
sprite: "body-jupiter", sprite: "body-jupiter",
badge: "badge-cloud-platform", badge: "badge-cloud-platform",
launchCostMultiplier: 2, launchCostMultiplier: 2,

View File

@ -28,8 +28,9 @@ export const shipModels: ShipModel[] = [
sprite: "ship-lighter", sprite: "ship-lighter",
name: "Lighter", name: "Lighter",
archetype: "lighter", archetype: "lighter",
// About 40 % of the way from Courier to Freighter. // About a third of the way from Courier to Freighter; 36,000 (was 42,400)
price: 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. // Slower than the Freighter, faster than the Bulk Hauler.
cruiseSpeedKmPerGameHour: 1_600_000, cruiseSpeedKmPerGameHour: 1_600_000,
cargoCapacityTonnes: 80, cargoCapacityTonnes: 80,
@ -55,7 +56,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: 198, fuelRangeMillionKm: 250,
hullRating: 1, hullRating: 1,
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
@ -72,7 +73,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: 450, fuelRangeMillionKm: 460,
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 a * up across levels. Overrides keep models distinct (decision 0.5.67) and give
* maxed Courier a reach to Mars near closest approach only (decision 0.5.68). * only a maxed Courier the reach to Mars from Earth (decision 0.5.68, CR-015).
*/ */
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 → 27.5 → 50 → 72.5 M km: Mars from Earth (≈ 59–400 M km) only near closest approach, and only at level 3. // 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: 450 }, courier: { percentPerLevel: 1400 },
// 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). // 460 → 667 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth (≈ 792).
freighter: { percentPerLevel: 15 }, freighter: { percentPerLevel: 15 },
}, },
}, },

View File

@ -26,16 +26,27 @@ export const locationTypeSchema = z.enum([
]); ]);
export type LocationType = z.infer<typeof locationTypeSchema>; export type LocationType = z.infer<typeof locationTypeSchema>;
/** Astronomical body a location's position is derived from. */ /** A fixed point around the Sun (decision 0.5.93): radius in million km, angle in degrees. */
export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]); export const fixedPositionSchema = z.object({
export type AstronomyBody = z.infer<typeof astronomyBodySchema>; radiusMillionKm: z.number().nonnegative(),
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,
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. */ /** Multiplies fuel cost for launches from this location. */
launchCostMultiplier: z.number().positive(), launchCostMultiplier: z.number().positive(),
/** /**
@ -181,10 +192,13 @@ 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(),
ephemerisDate: z.iso.datetime(), gameStart: 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

@ -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", name: "Earth",
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
body: "earth", position: { radiusMillionKm: 150, angleDegrees: 210 },
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", ephemerisDate: "2100-01-01T00:00:00Z" }, epoch: { realStart: "2026-01-01T00:00:00Z", gameStart: "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, gameToReal, realToGame, wouldStrand } from "@deadhead/sim"; import { checkRoute, effectiveStats, 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,43 +30,41 @@ 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 Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon // A Freighter with maxed tanks at the Moon, with Jupiter discovered but not
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there. // Mars or Ceres (CR-015 fixed map): Jupiter is in range from the Moon
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000; // (667 × 1.3 ≥ 792), but from Jupiter (667 × 1.0) neither Earth nor the Moon is.
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: "clipper", modelId: "freighter",
locationId: "moon", 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. // The scenario as the sim sees it.
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content); const freighter = effectiveStats(content.shipModel("freighter")!, { "fuel-tanks": 3 }, content);
const [moon, mars] = [content.location("moon")!, content.location("mars")!]; const [moon, jupiter] = [
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true); content.location("moon")!,
expect( content.location("jupiter-cloud-platform")!,
wouldStrand( ];
clipper, const now = realToGame(clock.now, content.config);
moon, expect(checkRoute(freighter, moon, jupiter, now).ok).toBe(true);
mars, const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
0, expect(wouldStrand(freighter, moon, jupiter, 0, now, discovered, content)).toBe(true);
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: "mars", destinationId: "jupiter-cloud-platform",
}); });
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,8 +10,7 @@
"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 (ephemeris) date, so * instant; a game time is the instant of the in-game 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.ephemerisDate); const gameStart = Date.parse(config.epoch.gameStart);
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.ephemerisDate); const gameStart = Date.parse(config.epoch.gameStart);
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 { bodyPosition, locationPosition } from "./positions"; export { fixedPoint, locationPosition } from "./positions";
export type { Position } from "./positions"; export type { Position } from "./positions";
export { routeDistance } from "./distance"; export { routeDistance } from "./distance";

View File

@ -1,60 +1,25 @@
import type { AstronomyBody, Location } from "@deadhead/content"; import type { FixedPosition, 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 ecliptic plane, in million km. */ /** A heliocentric point in the plane of the map, in million km. */
export interface Position { export interface Position {
x: number; x: number;
y: number; y: number;
} }
const MILLION_KM_PER_AU = KM_PER_AU / 1e6; /** A fixed position as x/y (decision 0.5.93). */
const MS_PER_DAY = 86_400_000; export function fixedPoint(position: FixedPosition): Position {
const J2000 = Date.UTC(2000, 0, 1, 12); const angle = (position.angleDegrees * Math.PI) / 180;
return {
/** Fixed Earth→Moon offset (mean lunar distance); the Moon's orbit is ignored. */ x: position.radiusMillionKm * Math.cos(angle),
const MOON_OFFSET_MILLION_KM = 0.384; y: position.radiusMillionKm * Math.sin(angle),
};
}
/** /**
* Ceres is not in astronomy-engine, so it follows a circular orbit with its * A location's position in million km from the Sun. The map is fixed
* real mean radius and period, starting from its approximate J2000 mean longitude. * (decision 0.5.93); the game time is ignored and goes away in Step 15.2.
*/ */
const CERES_RADIUS_AU = 2.7675; export function locationPosition(location: Location, _gameTime?: GameTime): Position {
const CERES_PERIOD_DAYS = 1680.5; return fixedPoint(location.position);
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.ephemerisDate); const gameStart = Date.parse(gameConfig.epoch.gameStart);
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,11 +1,13 @@
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, realToGame } from "../src/index"; import { checkRoute } 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 => ({
@ -14,22 +16,16 @@ const withTanks = (level: number): ShipModel => ({
courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100), courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100),
}); });
/** Monthly samples over 16 game years (CR-012). */ // Decision 0.5.68 as amended by CR-015: a plain yes or no on the fixed map.
const MONTH = 30.44 * 24 * 3_600_000; describe("Courier to Mars (decision 0.5.68)", () => {
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); it("reaches Mars from Earth with maxed fuel tanks", () => {
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH); expect(checkRoute(withTanks(3), earth, mars, now)).toEqual({ ok: true });
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("never reaches Mars with fuel tanks at level 2 or less", () => { it("can't reach Mars from Earth or the Moon with fuel tanks at level 2 or less", () => {
for (const level of [0, 1, 2]) expect(inRange(withTanks(level)).some(Boolean)).toBe(false); 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", () => { it("gives the same distance at every time (decision 0.5.93)", () => {
const distances = monthly.map((t) => routeDistance(earth, mars, t)); const first = routeDistance(earth, mars, monthly[0]!);
expect(Math.max(...distances) - Math.min(...distances)).toBeGreaterThanOrEqual(200); for (const t of monthly) expect(routeDistance(earth, mars, t)).toBe(first);
}); });
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, routeDistance } from "../src/index"; import { checkRoute } from "../src/index";
const earth = content.location("earth")!; const earth = content.location("earth")!;
const moon = content.location("moon")!; const moon = content.location("moon")!;
@ -14,70 +14,39 @@ 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" };
// Every 4 days across 12 years (one Jupiter orbit). // The map is fixed (decision 0.5.93), so any time gives the same answer.
const start = Date.parse("2100-01-01T00:00:00Z"); const now = Date.parse("2100-01-01T00:00:00Z");
const samples = Array.from({ length: 1096 }, (_, i) => start + i * 4 * 86_400_000); const outOfRange = { ok: false, reason: "out-of-range" };
/** 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, start)).toEqual({ ok: true }); expect(checkRoute(courier, earth, moon, now)).toEqual({ ok: true });
expect(checkRoute(courier, moon, earth, start)).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]) { for (const to of [mars, ceres, jupiter]) {
expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({ expect(checkRoute(courier, earth, to, now)).toEqual(outOfRange);
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, start)).toEqual({ expect(checkRoute(vacuumShip, moon, earth, now)).toEqual({
ok: false, ok: false,
reason: "cannot-land", 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", () => { it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => {
expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).toEqual({ ok: true }); 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)", () => { it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "closest"))).toEqual({ expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true });
ok: true, expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange);
}); 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,91 +1,77 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; 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); * CR-015 §4: reach for base ships on the fixed map, a plain yes or no per leg
const MONTH = 30.44 * 24 * 3_600_000; * (decision 0.5.81 as amended). Replaces CR-012's date-sampled table.
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH); */
const ids = content.locations.map((l) => l.id); const now = Date.parse("2100-01-01T00:00:00Z");
const NAMES: Record<string, string> = {
/** Share of sampled dates on which the leg is in range for the base ship. */ earth: "Earth",
function share(modelId: string, fromId: string, toId: string): number { moon: "Moon",
const ship = content.shipModel(modelId)!; mars: "Mars",
const from = content.location(fromId)!; "ceres-station": "Ceres",
const to = content.location(toId)!; "jupiter-cloud-platform": "Jupiter",
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;
}; };
const ids = Object.keys(NAMES);
describe("reach table (Step 10L.4)", () => { function reaches(modelId: string, fromId: string, toId: string): boolean {
for (const model of ["courier", "lighter"]) { const ship = content.shipModel(modelId)!;
describe(model, () => { return checkRoute(ship, content.location(fromId)!, content.location(toId)!, now).ok;
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"));
});
} }
describe("clipper", () => { /** [model, from, to] for every leg the table says yes or no to. */
it("reaches Mars from Earth on some dates, at least 30 %", () => { type Leg = [string, string, string];
expect(some("clipper", "earth", "mars")).toBeGreaterThanOrEqual(0.3); const both = (model: string, a: string, b: string): Leg[] => [
}); [model, a, b],
it("reaches Mars from the Moon on some dates, more than from Earth", () => { [model, b, a],
expect(some("clipper", "moon", "mars")).toBeGreaterThan(share("clipper", "earth", "mars")); ];
}); const toJupiterFromAnywhere = (model: string): Leg[] =>
it("reaches Ceres from Mars on some dates", () => some("clipper", "mars", "ceres-station")); ids
it("never reaches Ceres from Earth", () => never("clipper", "earth", "ceres-station")); .filter((id) => id !== "jupiter-cloud-platform")
it("never reaches Ceres from the Moon", () => never("clipper", "moon", "ceres-station")); .map((from) => [model, from, "jupiter-cloud-platform"]);
it("never reaches Jupiter from anywhere", () => {
for (const from of ids) const yes: Leg[] = [
if (from !== "jupiter-cloud-platform") { ...both("courier", "earth", "moon"),
never("clipper", from, "jupiter-cloud-platform"); ...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.each(no.map((leg) => [label(leg), leg] as const))("no: %s", (_name, [m, from, to]) => {
it("reaches Mars from Earth always", () => always("freighter", "earth", "mars")); expect(reaches(m, from, to)).toBe(false);
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");
});
}); });
}); });

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", () => { it("gives the Courier 1400 % range per tanks level (CR-015)", () => {
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(450); expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(1400);
}); });
}); });

View File

@ -175,9 +175,6 @@ 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
@ -1854,9 +1851,6 @@ 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'}
@ -5004,8 +4998,6 @@ 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: {}