Step 15.1: content: fixed positions
CI / check (push) Successful in 7m40s
Details
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:
parent
23b3d7ec97
commit
d6bca5b1a2
10
DECISIONS.md
10
DECISIONS.md
|
|
@ -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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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.
|
||||
|
|
@ -35,13 +36,16 @@ export function toMapPoint(position: Position): MapPoint {
|
|||
}
|
||||
|
||||
/**
|
||||
* Where a location is drawn. The Moon sits a fixed small distance from Earth
|
||||
* in its true direction, since at map scale it would overlap Earth.
|
||||
* Where a location is drawn. A child body (the Moon) sits a fixed small
|
||||
* distance from its parent in its true direction, since at map scale it would
|
||||
* overlap it.
|
||||
*/
|
||||
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
|
||||
if (location.body !== "moon") return toMapPoint(bodyPosition(location.body, gameTime));
|
||||
const earth = bodyPosition("earth", gameTime);
|
||||
const moon = bodyPosition("moon", gameTime);
|
||||
const parentId = location.orbitParent?.locationId;
|
||||
const parent = parentId ? content.location(parentId) : undefined;
|
||||
if (!parent) return toMapPoint(locationPosition(location, gameTime));
|
||||
const earth = locationPosition(parent, gameTime);
|
||||
const moon = locationPosition(location, gameTime);
|
||||
const dx = moon.x - earth.x;
|
||||
const dy = -(moon.y - earth.y);
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
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 { mapStyle } from "./style";
|
||||
|
||||
|
|
@ -98,13 +98,13 @@ export function buildScene(state: PlayerState, now: number): Scene {
|
|||
|
||||
return {
|
||||
locations: content.locations.map((loc) => {
|
||||
const sun = bodyPosition(loc.body, gameTime);
|
||||
const sun = locationPosition(loc, gameTime);
|
||||
return {
|
||||
id: loc.id,
|
||||
name: loc.name,
|
||||
point: locationMapPoint(anchorLocation(loc.id), gameTime),
|
||||
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),
|
||||
hasBay: state.bays.some((b) => b.locationId === loc.id),
|
||||
idleShips: idleAt(loc.id),
|
||||
|
|
|
|||
|
|
@ -99,7 +99,9 @@ describe("effective stats in the client (Step 10I.7)", () => {
|
|||
content.location("mars")!,
|
||||
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 card = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,11 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const MONTH = 30.44 * 24 * 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");
|
||||
}
|
||||
// The map is fixed (CR-015): any time gives the same reach.
|
||||
const NOW = Date.parse(content.config.epoch.realStart) + 3_600_000;
|
||||
|
||||
async function reachOf(modelName: string, now: number) {
|
||||
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 () => {
|
||||
const near = await reachOf("Clipper", dateWhenMars(true));
|
||||
it("lists Mars from Earth for a Clipper, and Ceres by hopping", async () => {
|
||||
const near = await reachOf("Clipper", NOW);
|
||||
expect(near).toContain("From Earth now: Moon, Mars");
|
||||
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 () => {
|
||||
const reach = await reachOf("Lighter", dateWhenMars(true));
|
||||
const reach = await reachOf("Lighter", NOW);
|
||||
expect(reach).toBe("Reach From Earth now: Moon");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
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 { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
|
||||
const clipper = content.shipModel("clipper")!;
|
||||
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
|
||||
const discovered = new Set(["earth", "moon", "mars"]);
|
||||
const TANKS = { "fuel-tanks": 3 };
|
||||
const freighter = effectiveStats(content.shipModel("freighter")!, TANKS, content);
|
||||
const [moon, jupiter] = [content.location("moon")!, content.location("jupiter-cloud-platform")!];
|
||||
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
|
||||
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). */
|
||||
const NOW = (() => {
|
||||
for (let d = 0; d < 16 * 365; d += 5) {
|
||||
const t = gameStart + d * DAY;
|
||||
if (
|
||||
checkRoute(clipper, moon, mars, t).ok &&
|
||||
wouldStrand(clipper, moon, mars, 10, t, discovered, content)
|
||||
) {
|
||||
return gameToReal(t, content.config);
|
||||
}
|
||||
}
|
||||
throw new Error("no stranding date");
|
||||
})();
|
||||
const STRANDED = "Would be stranded — can't fly out of Mars from there";
|
||||
// The fixed map (CR-015): Jupiter is in range from the Moon (667 × 1.3), but from
|
||||
// Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered.
|
||||
const gameNow = realToGame(NOW, content.config);
|
||||
if (
|
||||
!checkRoute(freighter, moon, jupiter, gameNow).ok ||
|
||||
!wouldStrand(freighter, moon, jupiter, 10, gameNow, discovered, content)
|
||||
) {
|
||||
throw new Error("the scenario no longer strands");
|
||||
}
|
||||
|
||||
/** A crewed Clipper at the Moon, with Mars discovered. */
|
||||
/** A crewed Freighter with maxed tanks at the Moon, with Jupiter discovered. */
|
||||
function state(): PlayerState {
|
||||
const base = samplePlayerState({ tutorial: null });
|
||||
return {
|
||||
|
|
@ -40,13 +36,13 @@ function state(): PlayerState {
|
|||
ships: [
|
||||
{
|
||||
id: "s1",
|
||||
modelId: "clipper",
|
||||
modelId: "freighter",
|
||||
name: "Swift Heron",
|
||||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("clipper"),
|
||||
...shipExtras("freighter", TANKS),
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
@ -54,10 +50,10 @@ function state(): PlayerState {
|
|||
};
|
||||
}
|
||||
|
||||
const toMars: Contract = {
|
||||
const toJupiter: Contract = {
|
||||
id: "c1",
|
||||
originId: "moon",
|
||||
destinationId: "mars",
|
||||
destinationId: "jupiter-cloud-platform",
|
||||
cargoTypeId: "standard",
|
||||
goodId: "consumer-goods",
|
||||
shipper: "Pacific Orbital Logistics",
|
||||
|
|
@ -82,7 +78,7 @@ describe("stranding reason (Step 10L.9)", () => {
|
|||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +87,7 @@ describe("stranding reason (Step 10L.9)", () => {
|
|||
const card = (await screen.findByText("Swift Heron")).closest("li")!;
|
||||
await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ }));
|
||||
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).toHaveTextContent(STRANDED);
|
||||
expect(within(dialog).getByRole("button", { name: "Earth" })).toBeEnabled();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format
|
|||
| 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/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/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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
- [ ] 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.3 — Content: fuel
|
||||
- [ ] Step 15.4 — Sim: tank, burn, and refuel
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| PWA | `vite-plugin-pwa` (Workbox) |
|
||||
| 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 |
|
||||
| Testing | Vitest (sim, engine, components), Playwright (end to end) |
|
||||
| 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
|
||||
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.
|
||||
- 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
|
||||
|
||||
`astronomy-engine` computes real planetary positions for any date, in pure JS, no API calls.
|
||||
- Run it in `packages/sim` so the engine and the screens agree on distances.
|
||||
- Use a fixed **game epoch** mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default).
|
||||
- Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits.
|
||||
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.
|
||||
- Child bodies (the Moon) sit at their parent's position moved a small fixed distance straight out from the Sun.
|
||||
- The game clock still runs (1 real min = 1 game hour by default) for timers and the displayed in-game date.
|
||||
- `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`.
|
||||
|
||||
## 6. Deployment
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import type { GameConfig } from "../schemas";
|
|||
export const gameConfig: GameConfig = {
|
||||
// 1 real minute = 1 game hour.
|
||||
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: {
|
||||
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).
|
||||
startingCredits: 500,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import type { Location } from "../schemas";
|
||||
import type { FixedPosition, Location } from "../schemas";
|
||||
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
|
||||
* Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote
|
||||
|
|
@ -14,7 +27,7 @@ export const locations: Location[] = [
|
|||
name: "Earth",
|
||||
type: "atmospheric-world",
|
||||
landingRequirement: "atmospheric",
|
||||
body: "earth",
|
||||
position: EARTH,
|
||||
sprite: "body-earth",
|
||||
launchCostMultiplier: 2.1,
|
||||
launchRangeFactor: 1,
|
||||
|
|
@ -35,7 +48,8 @@ export const locations: Location[] = [
|
|||
name: "Moon",
|
||||
type: "airless-body",
|
||||
landingRequirement: "vacuum",
|
||||
body: "moon",
|
||||
position: offsetPosition(EARTH, MOON_OFFSET_MILLION_KM),
|
||||
orbitParent: { locationId: "earth", offsetMillionKm: MOON_OFFSET_MILLION_KM },
|
||||
sprite: "body-moon",
|
||||
launchCostMultiplier: 0.5,
|
||||
launchRangeFactor: 1.3,
|
||||
|
|
@ -54,7 +68,7 @@ export const locations: Location[] = [
|
|||
name: "Mars",
|
||||
type: "atmospheric-world",
|
||||
landingRequirement: "atmospheric",
|
||||
body: "mars",
|
||||
position: { radiusMillionKm: 228, angleDegrees: 270 },
|
||||
sprite: "body-mars",
|
||||
launchCostMultiplier: 1.5,
|
||||
launchRangeFactor: 1.15,
|
||||
|
|
@ -70,7 +84,7 @@ export const locations: Location[] = [
|
|||
name: "Ceres Station",
|
||||
type: "belt-station",
|
||||
landingRequirement: "vacuum",
|
||||
body: "ceres",
|
||||
position: { radiusMillionKm: 414, angleDegrees: 285 },
|
||||
sprite: "body-ceres",
|
||||
launchCostMultiplier: 0.3,
|
||||
launchRangeFactor: 1.3,
|
||||
|
|
@ -86,7 +100,7 @@ export const locations: Location[] = [
|
|||
name: "Jupiter Cloud Platform",
|
||||
type: "cloud-platform",
|
||||
landingRequirement: "atmospheric",
|
||||
body: "jupiter",
|
||||
position: { radiusMillionKm: 778, angleDegrees: 300 },
|
||||
sprite: "body-jupiter",
|
||||
badge: "badge-cloud-platform",
|
||||
launchCostMultiplier: 2,
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ export const shipModels: ShipModel[] = [
|
|||
sprite: "ship-lighter",
|
||||
name: "Lighter",
|
||||
archetype: "lighter",
|
||||
// About 40 % of the way from Courier to Freighter.
|
||||
price: 42_400,
|
||||
// About a third of the way from Courier to Freighter; 36,000 (was 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.
|
||||
cruiseSpeedKmPerGameHour: 1_600_000,
|
||||
cargoCapacityTonnes: 80,
|
||||
|
|
@ -55,7 +56,7 @@ export const shipModels: ShipModel[] = [
|
|||
cruiseSpeedKmPerGameHour: 6_500_000,
|
||||
cargoCapacityTonnes: 30,
|
||||
// Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4).
|
||||
fuelRangeMillionKm: 198,
|
||||
fuelRangeMillionKm: 250,
|
||||
hullRating: 1,
|
||||
landingClass: "atmospheric",
|
||||
overheadGameHours: 2,
|
||||
|
|
@ -72,7 +73,7 @@ export const shipModels: ShipModel[] = [
|
|||
cruiseSpeedKmPerGameHour: 2_000_000,
|
||||
cargoCapacityTonnes: 100,
|
||||
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
|
||||
fuelRangeMillionKm: 450,
|
||||
fuelRangeMillionKm: 460,
|
||||
hullRating: 2,
|
||||
landingClass: "atmospheric",
|
||||
overheadGameHours: 4,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import type { Upgrade } from "../schemas";
|
|||
|
||||
/**
|
||||
* 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
|
||||
* maxed Courier a reach to Mars near closest approach only (decision 0.5.68).
|
||||
* up across levels. Overrides keep models distinct (decision 0.5.67) and give
|
||||
* only a maxed Courier the reach to Mars from Earth (decision 0.5.68, CR-015).
|
||||
*/
|
||||
export const upgrades: Upgrade[] = [
|
||||
{
|
||||
|
|
@ -27,9 +27,9 @@ export const upgrades: Upgrade[] = [
|
|||
percentPerLevel: 20,
|
||||
maxLevel: 3,
|
||||
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.
|
||||
courier: { percentPerLevel: 450 },
|
||||
// 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).
|
||||
// 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: 1400 },
|
||||
// 460 → 667 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth (≈ 792).
|
||||
freighter: { percentPerLevel: 15 },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,16 +26,27 @@ export const locationTypeSchema = z.enum([
|
|||
]);
|
||||
export type LocationType = z.infer<typeof locationTypeSchema>;
|
||||
|
||||
/** Astronomical body a location's position is derived from. */
|
||||
export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]);
|
||||
export type AstronomyBody = z.infer<typeof astronomyBodySchema>;
|
||||
/** A fixed point around the Sun (decision 0.5.93): radius in million km, angle in degrees. */
|
||||
export const fixedPositionSchema = z.object({
|
||||
radiusMillionKm: z.number().nonnegative(),
|
||||
angleDegrees: z.number(),
|
||||
});
|
||||
export type FixedPosition = z.infer<typeof fixedPositionSchema>;
|
||||
|
||||
export const locationSchema = z.object({
|
||||
id: idSchema,
|
||||
name: z.string().min(1),
|
||||
type: locationTypeSchema,
|
||||
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. */
|
||||
launchCostMultiplier: z.number().positive(),
|
||||
/**
|
||||
|
|
@ -181,10 +192,13 @@ export const gameConfigSchema = z
|
|||
.object({
|
||||
/** Game hours per real hour (60 means 1 real minute = 1 game hour). */
|
||||
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({
|
||||
realStart: z.iso.datetime(),
|
||||
ephemerisDate: z.iso.datetime(),
|
||||
gameStart: z.iso.datetime(),
|
||||
}),
|
||||
startingCredits: nonNegativeInt,
|
||||
/** Fraction of a normal payout the player receives for a tutorial run. */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/** Vite's `?raw` imports: a file's text. */
|
||||
declare module "*?raw" {
|
||||
const text: string;
|
||||
export default text;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ const validLocation = {
|
|||
name: "Earth",
|
||||
type: "atmospheric-world",
|
||||
landingRequirement: "atmospheric",
|
||||
body: "earth",
|
||||
position: { radiusMillionKm: 150, angleDegrees: 210 },
|
||||
launchCostMultiplier: 3,
|
||||
launchRangeFactor: 1,
|
||||
startsDiscovered: true,
|
||||
|
|
@ -74,7 +74,7 @@ const validSkill = {
|
|||
|
||||
const validGameConfig = {
|
||||
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,
|
||||
tutorialPayoutShare: 0.2,
|
||||
contractBoardSize: 8,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
|
||||
import { content } from "@deadhead/content";
|
||||
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 { 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 owner = await signUpOwner(app);
|
||||
await setCredits(app, 100_000);
|
||||
// A Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon
|
||||
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there.
|
||||
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000;
|
||||
clock.now = gameToReal(departure, content.config);
|
||||
// A Freighter with maxed tanks at the Moon, with Jupiter discovered but not
|
||||
// Mars or Ceres (CR-015 fixed map): Jupiter is in range from the Moon
|
||||
// (667 × 1.3 ≥ 792), but from Jupiter (667 × 1.0) neither Earth nor the Moon is.
|
||||
await edit(app, (save) => {
|
||||
Object.assign(
|
||||
save.ships.find((s) => s.id === owner.shipId)!,
|
||||
{
|
||||
modelId: "clipper",
|
||||
modelId: "freighter",
|
||||
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.
|
||||
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
|
||||
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
|
||||
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true);
|
||||
expect(
|
||||
wouldStrand(
|
||||
clipper,
|
||||
moon,
|
||||
mars,
|
||||
0,
|
||||
realToGame(clock.now, content.config),
|
||||
undefined,
|
||||
content,
|
||||
),
|
||||
).toBe(true);
|
||||
const freighter = effectiveStats(content.shipModel("freighter")!, { "fuel-tanks": 3 }, content);
|
||||
const [moon, jupiter] = [
|
||||
content.location("moon")!,
|
||||
content.location("jupiter-cloud-platform")!,
|
||||
];
|
||||
const now = realToGame(clock.now, content.config);
|
||||
expect(checkRoute(freighter, moon, jupiter, now).ok).toBe(true);
|
||||
const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]);
|
||||
expect(wouldStrand(freighter, moon, jupiter, 0, now, discovered, content)).toBe(true);
|
||||
|
||||
const before = await getState(app, owner);
|
||||
const response = await call(app, owner, "POST", "/api/dispatches", {
|
||||
kind: "reposition",
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
destinationId: "mars",
|
||||
destinationId: "jupiter-cloud-platform",
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("would-strand");
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
"balance": "tsx balance/run.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deadhead/content": "workspace:*",
|
||||
"astronomy-engine": "^2.1.19"
|
||||
"@deadhead/content": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.23.15"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { GameConfig } from "@deadhead/content";
|
|||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
export function realToGame(real: RealTime, config: GameConfig): GameTime {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Converts an in-game instant back to the real instant it maps to. */
|
||||
export function gameToReal(game: GameTime, config: GameConfig): RealTime {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export type { GameTime, RealTime } from "./clock";
|
|||
export { createRng, deriveSeed } from "./rng";
|
||||
export type { Rng, Seed } from "./rng";
|
||||
|
||||
export { bodyPosition, locationPosition } from "./positions";
|
||||
export { fixedPoint, locationPosition } from "./positions";
|
||||
export type { Position } from "./positions";
|
||||
|
||||
export { routeDistance } from "./distance";
|
||||
|
|
|
|||
|
|
@ -1,60 +1,25 @@
|
|||
import type { AstronomyBody, Location } from "@deadhead/content";
|
||||
import { Body, HelioVector, KM_PER_AU, RotateVector, Rotation_EQJ_ECL } from "astronomy-engine";
|
||||
import type { FixedPosition, Location } from "@deadhead/content";
|
||||
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 {
|
||||
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;
|
||||
/** A fixed position as x/y (decision 0.5.93). */
|
||||
export function fixedPoint(position: FixedPosition): Position {
|
||||
const angle = (position.angleDegrees * Math.PI) / 180;
|
||||
return {
|
||||
x: position.radiusMillionKm * Math.cos(angle),
|
||||
y: position.radiusMillionKm * Math.sin(angle),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A location's position in million km from the Sun. The map is fixed
|
||||
* (decision 0.5.93); the game time is ignored and goes away in Step 15.2.
|
||||
*/
|
||||
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);
|
||||
export function locationPosition(location: Location, _gameTime?: GameTime): Position {
|
||||
return fixedPoint(location.position);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { MS_PER_HOUR, MS_PER_MINUTE, gameToReal, realToGame } from "../src/index";
|
||||
|
||||
const realStart = Date.parse(gameConfig.epoch.realStart);
|
||||
const gameStart = Date.parse(gameConfig.epoch.ephemerisDate);
|
||||
const gameStart = Date.parse(gameConfig.epoch.gameStart);
|
||||
|
||||
describe("game clock", () => {
|
||||
it("maps the real epoch to the game epoch", () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { content, upgradePercentPerLevel, type ShipModel } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkRoute, realToGame } from "../src/index";
|
||||
import { checkRoute } from "../src/index";
|
||||
|
||||
const courier = content.shipModel("courier")!;
|
||||
const tanks = content.upgrade("fuel-tanks")!;
|
||||
const earth = content.location("earth")!;
|
||||
const moon = content.location("moon")!;
|
||||
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). */
|
||||
const withTanks = (level: number): ShipModel => ({
|
||||
|
|
@ -14,22 +16,16 @@ const withTanks = (level: number): ShipModel => ({
|
|||
courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100),
|
||||
});
|
||||
|
||||
/** Monthly samples over 16 game years (CR-012). */
|
||||
const MONTH = 30.44 * 24 * 3_600_000;
|
||||
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
|
||||
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);
|
||||
// Decision 0.5.68 as amended by CR-015: a plain yes or no on the fixed map.
|
||||
describe("Courier to Mars (decision 0.5.68)", () => {
|
||||
it("reaches Mars from Earth with maxed fuel tanks", () => {
|
||||
expect(checkRoute(withTanks(3), earth, mars, now)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("never reaches Mars with fuel tanks at level 2 or less", () => {
|
||||
for (const level of [0, 1, 2]) expect(inRange(withTanks(level)).some(Boolean)).toBe(false);
|
||||
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(checkRoute(withTanks(level), earth, mars, now).ok, `level ${level}`).toBe(false);
|
||||
expect(checkRoute(withTanks(level), moon, mars, now).ok, `level ${level}`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ describe("route distance", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("varies Earth→Mars by at least 200 million km over two years", () => {
|
||||
const distances = monthly.map((t) => routeDistance(earth, mars, t));
|
||||
expect(Math.max(...distances) - Math.min(...distances)).toBeGreaterThanOrEqual(200);
|
||||
it("gives the same distance at every time (decision 0.5.93)", () => {
|
||||
const first = routeDistance(earth, mars, monthly[0]!);
|
||||
for (const t of monthly) expect(routeDistance(earth, mars, t)).toBe(first);
|
||||
});
|
||||
|
||||
it("is symmetric", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { content, type ShipModel } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkRoute, routeDistance } from "../src/index";
|
||||
import { checkRoute } from "../src/index";
|
||||
|
||||
const earth = content.location("earth")!;
|
||||
const moon = content.location("moon")!;
|
||||
|
|
@ -14,70 +14,39 @@ const bulkHauler = content.shipModel("bulk-hauler")!;
|
|||
/** Test-only ship that cannot land on atmospheric worlds. */
|
||||
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
|
||||
|
||||
// Every 4 days across 12 years (one Jupiter orbit).
|
||||
const start = Date.parse("2100-01-01T00:00:00Z");
|
||||
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;
|
||||
}
|
||||
// The map is fixed (decision 0.5.93), so any time gives the same answer.
|
||||
const now = Date.parse("2100-01-01T00:00:00Z");
|
||||
const outOfRange = { ok: false, reason: "out-of-range" };
|
||||
|
||||
// Origins are named in every test (decision 0.5.77: range is per leg, from its origin).
|
||||
describe("route eligibility", () => {
|
||||
it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
|
||||
expect(checkRoute(courier, earth, moon, start)).toEqual({ ok: true });
|
||||
expect(checkRoute(courier, moon, earth, start)).toEqual({ ok: true });
|
||||
expect(checkRoute(courier, earth, moon, now)).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]) {
|
||||
expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({
|
||||
ok: false,
|
||||
reason: "out-of-range",
|
||||
});
|
||||
expect(checkRoute(courier, earth, to, now)).toEqual(outOfRange);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
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", () => {
|
||||
expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).toEqual({ ok: true });
|
||||
it("lets a Freighter from Earth reach Mars and Ceres (CR-015 §4)", () => {
|
||||
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)", () => {
|
||||
expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "closest"))).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
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",
|
||||
});
|
||||
it("lets only the Bulk Hauler reach Jupiter from Earth", () => {
|
||||
expect(checkRoute(bulkHauler, earth, jupiter, now)).toEqual({ ok: true });
|
||||
expect(checkRoute(freighter, earth, jupiter, now)).toEqual(outOfRange);
|
||||
expect(checkRoute(courier, earth, jupiter, now)).toEqual(outOfRange);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,91 +1,77 @@
|
|||
import { content } from "@deadhead/content";
|
||||
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);
|
||||
const MONTH = 30.44 * 24 * 3_600_000;
|
||||
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
|
||||
const ids = content.locations.map((l) => l.id);
|
||||
|
||||
/** 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 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;
|
||||
/**
|
||||
* CR-015 §4: reach for base ships on the fixed map, a plain yes or no per leg
|
||||
* (decision 0.5.81 as amended). Replaces CR-012's date-sampled table.
|
||||
*/
|
||||
const now = Date.parse("2100-01-01T00:00:00Z");
|
||||
const NAMES: Record<string, string> = {
|
||||
earth: "Earth",
|
||||
moon: "Moon",
|
||||
mars: "Mars",
|
||||
"ceres-station": "Ceres",
|
||||
"jupiter-cloud-platform": "Jupiter",
|
||||
};
|
||||
const ids = Object.keys(NAMES);
|
||||
|
||||
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"));
|
||||
});
|
||||
}
|
||||
function reaches(modelId: string, fromId: string, toId: string): boolean {
|
||||
const ship = content.shipModel(modelId)!;
|
||||
return checkRoute(ship, content.location(fromId)!, content.location(toId)!, now).ok;
|
||||
}
|
||||
|
||||
describe("clipper", () => {
|
||||
it("reaches Mars from Earth on some dates, at least 30 %", () => {
|
||||
expect(some("clipper", "earth", "mars")).toBeGreaterThanOrEqual(0.3);
|
||||
});
|
||||
it("reaches Mars from the Moon on some dates, more than from Earth", () => {
|
||||
expect(some("clipper", "moon", "mars")).toBeGreaterThan(share("clipper", "earth", "mars"));
|
||||
});
|
||||
it("reaches Ceres from Mars on some dates", () => some("clipper", "mars", "ceres-station"));
|
||||
it("never reaches Ceres from Earth", () => never("clipper", "earth", "ceres-station"));
|
||||
it("never reaches Ceres from the Moon", () => never("clipper", "moon", "ceres-station"));
|
||||
it("never reaches Jupiter from anywhere", () => {
|
||||
for (const from of ids)
|
||||
if (from !== "jupiter-cloud-platform") {
|
||||
never("clipper", from, "jupiter-cloud-platform");
|
||||
}
|
||||
});
|
||||
/** [model, from, to] for every leg the table says yes or no to. */
|
||||
type Leg = [string, string, string];
|
||||
const both = (model: string, a: string, b: string): Leg[] => [
|
||||
[model, a, b],
|
||||
[model, b, a],
|
||||
];
|
||||
const toJupiterFromAnywhere = (model: string): Leg[] =>
|
||||
ids
|
||||
.filter((id) => id !== "jupiter-cloud-platform")
|
||||
.map((from) => [model, from, "jupiter-cloud-platform"]);
|
||||
|
||||
const yes: Leg[] = [
|
||||
...both("courier", "earth", "moon"),
|
||||
...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("reaches Mars from Earth always", () => always("freighter", "earth", "mars"));
|
||||
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");
|
||||
});
|
||||
it.each(no.map((leg) => [label(leg), leg] as const))("no: %s", (_name, [m, from, to]) => {
|
||||
expect(reaches(m, from, to)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(450);
|
||||
it("gives the Courier 1400 % range per tanks level (CR-015)", () => {
|
||||
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(1400);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -175,9 +175,6 @@ importers:
|
|||
'@deadhead/content':
|
||||
specifier: workspace:*
|
||||
version: link:../content
|
||||
astronomy-engine:
|
||||
specifier: ^2.1.19
|
||||
version: 2.1.19
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: ^4.23.15
|
||||
|
|
@ -1854,9 +1851,6 @@ packages:
|
|||
ast-v8-to-istanbul@1.0.7:
|
||||
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
|
||||
|
||||
astronomy-engine@2.1.19:
|
||||
resolution: {integrity: sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==}
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -5004,8 +4998,6 @@ snapshots:
|
|||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
astronomy-engine@2.1.19: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async@3.2.6: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue