Step 3.3: add location positions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:41:39 -04:00
parent 79c63c13b7
commit 301c0934e1
7 changed files with 121 additions and 2 deletions

View File

@ -87,3 +87,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Seeds are 32-bit unsigned integers.** The PRNG is mulberry32; child seeds are an FNV-1a hash of the label, started from the parent seed, plus a final mixing step.
- **`createRng` returns an object with internal state.** It is still deterministic: the same seed always gives the same sequence. Each use (a board, a name) creates its own generator from a derived seed, so draws never depend on call order elsewhere.
## Step 3.3
- **Positions are heliocentric, in the J2000 mean ecliptic plane** (`HelioVector` rotated by `Rotation_EQJ_ECL`), keeping x and y and dropping the small out-of-plane z.
- **The Moon is Earth plus 0.384M km on the +x axis**, as the plan specifies; its real orbit is ignored.
- **Ceres uses a circular orbit** (astronomy-engine has no Ceres): radius 2.7675 AU (~414M km), period 1,680.5 days, mean longitude ~160° at J2000. The angle is approximate, which is fine for gameplay.
- **Test date is 2100-03-01**, close to the placeholder game epoch.

View File

@ -21,7 +21,7 @@
- [x] **Step 3.1 — Game clock conversion.** `src/clock.ts`: `realToGame` / `gameToReal` using the config epoch and compression; times are Unix-ms numbers.
- [x] **Step 3.2 — Seeded RNG.** `src/rng.ts`: `createRng(seed)` (mulberry32) with `next`, `int`, `pick`; `deriveSeed(parent, label)` for child seeds.
- [ ] Step 3.3 — Location positions
- [x] **Step 3.3 — Location positions.** `src/positions.ts`: heliocentric ecliptic positions (million km) from `astronomy-engine` for Earth, Mars and Jupiter; Moon = Earth + fixed offset; Ceres on a circular orbit.
- [ ] Step 3.4 — Route distance
- [ ] Step 3.5 — Travel time
- [ ] Step 3.6 — Fuel cost

View File

@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@deadhead/content": "workspace:*"
"@deadhead/content": "workspace:*",
"astronomy-engine": "^2.1.19"
}
}

View File

@ -1,2 +1,3 @@
export * from "./clock";
export * from "./rng";
export * from "./positions";

View File

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

View File

@ -0,0 +1,42 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { locationPosition } from "../src/index";
const when = Date.parse("2100-03-01T00:00:00Z");
const fromSun = (id: string) => {
const { x, y } = locationPosition(content.location(id)!, when);
return Math.hypot(x, y);
};
describe("location positions", () => {
it("puts Earth about 150 million km from the Sun", () => {
expect(fromSun("earth")).toBeGreaterThan(147);
expect(fromSun("earth")).toBeLessThan(153);
});
it("puts Mars 206–250 million km from the Sun", () => {
expect(fromSun("mars")).toBeGreaterThan(206);
expect(fromSun("mars")).toBeLessThan(250);
});
it("puts Jupiter Cloud Platform 740–820 million km from the Sun", () => {
expect(fromSun("jupiter-cloud-platform")).toBeGreaterThan(740);
expect(fromSun("jupiter-cloud-platform")).toBeLessThan(820);
});
it("puts Ceres Station on its ~414 million km orbit", () => {
expect(fromSun("ceres-station")).toBeCloseTo(414, 0);
});
it("keeps the Moon within 1 million km of Earth", () => {
const earth = locationPosition(content.location("earth")!, when);
const moon = locationPosition(content.location("moon")!, when);
expect(Math.hypot(moon.x - earth.x, moon.y - earth.y)).toBeLessThan(1);
});
it("returns identical values for the same time", () => {
for (const location of content.locations) {
expect(locationPosition(location, when)).toEqual(locationPosition(location, when));
}
});
});

View File

@ -53,6 +53,9 @@ importers:
'@deadhead/content':
specifier: workspace:*
version: link:../content
astronomy-engine:
specifier: ^2.1.19
version: 2.1.19
packages:
@ -350,6 +353,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
astronomy-engine@2.1.19:
resolution: {integrity: sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@ -1119,6 +1125,8 @@ snapshots:
assertion-error@2.0.1: {}
astronomy-engine@2.1.19: {}
balanced-match@4.0.4: {}
brace-expansion@5.0.12: