Step 3.3: add location positions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
79c63c13b7
commit
301c0934e1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deadhead/content": "workspace:*"
|
||||
"@deadhead/content": "workspace:*",
|
||||
"astronomy-engine": "^2.1.19"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./clock";
|
||||
export * from "./rng";
|
||||
export * from "./positions";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue