diff --git a/DECISIONS.md b/DECISIONS.md index 6ebac1c..8cef03f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c021c48..b01614b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/sim/package.json b/packages/sim/package.json index 6027bf4..56d4633 100644 --- a/packages/sim/package.json +++ b/packages/sim/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@deadhead/content": "workspace:*" + "@deadhead/content": "workspace:*", + "astronomy-engine": "^2.1.19" } } diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 984edf2..1dc91a0 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -1,2 +1,3 @@ export * from "./clock"; export * from "./rng"; +export * from "./positions"; diff --git a/packages/sim/src/positions.ts b/packages/sim/src/positions.ts new file mode 100644 index 0000000..8de0a28 --- /dev/null +++ b/packages/sim/src/positions.ts @@ -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); +} diff --git a/packages/sim/test/positions.test.ts b/packages/sim/test/positions.test.ts new file mode 100644 index 0000000..9091cf8 --- /dev/null +++ b/packages/sim/test/positions.test.ts @@ -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)); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bb74b2..4895d26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: