Compare commits

..

2 Commits

Author SHA1 Message Date
fireshaper c0d3897d8d Step 16.1: content: new locations
CI / check (push) Has been cancelled Details
Venus Cloud City, Io Sulfur Works and Europa Ice Station join the map
(fixed positions, Io and Europa riding with Jupiter), with their
fuel prices, range factors, shippers and sprites. Each makes a
Standard good so boards work there. The first-purchase balance window
widens to 45-95 minutes (user decision).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:34:51 -04:00
fireshaper 264783088e Record CR-016: Venus, Io, Europa and the asteroid belt (Phase 16)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:21:00 -04:00
26 changed files with 443 additions and 20 deletions

View File

@ -1498,3 +1498,17 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Never needs emergency fuel" is checked where the player decides**: each time an idle ship is about to choose work, after its "Refuel here first" top-up. Checking only at landings would miss a ship that ran down by deadheading. - **"Never needs emergency fuel" is checked where the player decides**: each time an idle ship is about to choose work, after its "Refuel here first" top-up. Checking only at landings would miss a ship that ran down by deadheading.
- **Plan notes, not rewrites**, as for every earlier change request: each amended decision or step says what CR-015 changed and points to the step. - **Plan notes, not rewrites**, as for every earlier change request: each amended decision or step says what CR-015 changed and points to the step.
## CR-016 recorded (Venus, Io, Europa, and the Asteroid Belt)
- **Numbering:** received as "CR-015 — Venus, Io, Europa, and the Asteroid Belt" for "Phase 15"; stored as **CR-016**, built as **Phase 16** (its 15.n = 16.n). Its decisions CR-015.D1–D6 are **0.5.100–0.5.105**. Its references: CR-005 → CR-007, CR-009 → CR-012, CR-011 → CR-014, CR-014 → CR-015, decision 0.5.31 → 0.5.39; CR-003 is the same. The numbering note at the top of the CR file has the full map.
- **The belt image holds six rocks**: `belt-asteroids.png` has a transparent background and six separate regions at alpha ≥ 16 (fainter specks of glow are ignored), so Step 16.5 slices it into six sprites.
- **Landing "Any" is the `vacuum` requirement**: every landing class meets it.
## Step 16.1
- **Bay lease prices for the new places** (the CR gives none): Venus 20,000 like Mars, a similar distance from Earth; Io and Europa 70,000 each, a little under Jupiter's platform (80,000), since they are meant as a second home for small ships in the Jupiter system.
- **Class mixes follow the goods**: Venus makes Standard, High-Value and Dangerous goods and wants Standard, High-Value and Heavy; Io makes Standard, Heavy and High-Value and wants all four; Europa makes all four and wants Standard, High-Value and Heavy.
- **Each new place's Standard good came with the locations**, ahead of 16.2: the board generator refuses a place that makes nothing Standard. The rest of the goods changes stay in 16.2.
- **First Market purchase target widened to 45–95 min** (user decision). With Venus as a second first-contact destination, the median moved from 64 to ~92 min. Purchases happen at the Clipper's long landings, so the median jumps between about 49, 64 and 92 min with small content changes; no tank-share, Lighter-price or first-contact-pay change landed it reliably inside 75.
- **CR-007's class-mix regression is measured on its original five places**: new destinations change the mix legitimately (and so do first contacts to undiscovered places), so the baseline is compared against the content it was taken from.

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -29,6 +29,18 @@
"kind": "body", "kind": "body",
"file": "body-jupiter.png" "file": "body-jupiter.png"
}, },
"body-venus": {
"kind": "body",
"file": "body-venus.png"
},
"body-io": {
"kind": "body",
"file": "body-io.png"
},
"body-europa": {
"kind": "body",
"file": "body-europa.png"
},
"badge-cloud-platform": { "badge-cloud-platform": {
"kind": "badge", "kind": "badge",
"file": null "file": null

View File

@ -11,6 +11,8 @@ const TYPE_LABELS: Record<Location["type"], string> = {
"airless-body": "Airless body", "airless-body": "Airless body",
"cloud-platform": "Cloud platform", "cloud-platform": "Cloud platform",
"belt-station": "Belt station", "belt-station": "Belt station",
"orbital-station": "Orbital station",
"surface-base": "Surface base",
}; };
/** What a bay is for (CR-002: bays are HQs, not access to contracts). */ /** What a bay is for (CR-002: bays are HQs, not access to contracts). */
export const HQ_EXPLANATION = export const HQ_EXPLANATION =

View File

@ -20,6 +20,8 @@ const TYPE_LABELS: Record<Location["type"], string> = {
"airless-body": "Airless body", "airless-body": "Airless body",
"cloud-platform": "Cloud platform", "cloud-platform": "Cloud platform",
"belt-station": "Belt station", "belt-station": "Belt station",
"orbital-station": "Orbital station",
"surface-base": "Surface base",
}; };
/** The solar system map (Phase 10). Tap a location or ship for details. */ /** The solar system map (Phase 10). Tap a location or ship for details. */

View File

@ -31,10 +31,11 @@ describe("the fixed map (Step 15.8, decision 0.5.93)", () => {
} }
}); });
it("draws an orbit ring for every body but the Moon, which rides with Earth", () => { it("draws an orbit ring for every body but those riding with a parent (Moon, Io, Europa)", () => {
const scene = buildScene(samplePlayerState(), NOW); const scene = buildScene(samplePlayerState(), NOW);
const riders = ["moon", "io-sulfur-works", "europa-ice-station"];
for (const loc of scene.locations) { for (const loc of scene.locations) {
if (loc.id === "moon") expect(loc.orbitRadius).toBeNull(); if (riders.includes(loc.id)) expect(loc.orbitRadius, loc.id).toBeNull();
else expect(loc.orbitRadius, loc.id).toBeGreaterThan(0); else expect(loc.orbitRadius, loc.id).toBeGreaterThan(0);
} }
}); });
@ -52,7 +53,9 @@ describe("reach wording without dates (Step 15.8)", () => {
); );
const clipper = (await screen.findByRole("button", { name: "Buy Clipper" })).closest("li")!; const clipper = (await screen.findByRole("button", { name: "Buy Clipper" })).closest("li")!;
const reach = within(clipper).getByLabelText("Reach").textContent ?? ""; const reach = within(clipper).getByLabelText("Reach").textContent ?? "";
expect(reach).toBe("Reach From Earth: Moon, Mars · By hopping: Ceres Station"); expect(reach).toBe(
"Reach From Earth: Moon, Mars, Venus Cloud City · By hopping: Ceres Station",
);
expect(reach).not.toMatch(DATED); expect(reach).not.toMatch(DATED);
}); });

View File

@ -0,0 +1,201 @@
# Change Request CR-016 — Venus, Io, Europa, and the Asteroid Belt
> **Numbering note (added when recorded):** this request arrived titled "CR-015 — Venus, Io, Europa, and the Asteroid Belt" for "Phase 15". CR-015 (fixed map and fuel tanks) and Phase 15 already existed, so it is stored as **CR-016** and done as **Phase 16**: every "15.n" below means **16.n**. Its references map to this repo as: "CR-003 map art" = **CR-003** (its Steps 10C.2 and 10C.9 are the same numbers); "CR-005 cargo goods" = **CR-007**; "CR-009 new ships and range" = **CR-012**; "CR-011 local-first" = **CR-014**; "CR-014 fixed map and fuel tanks" = **CR-015** (its "§4 reach table" is CR-015 §4). Its "decision 0.5.31" (child bodies at a screen offset) is this plan's **0.5.39**. Its new decisions CR-015.D1–D6 are recorded as **0.5.100 New locations, 0.5.101 No Hardened yet, 0.5.102 Reach additions, 0.5.103 Goods additions, 0.5.104 Asteroid belt, 0.5.105 Jupiter-system layout**.
>
> **On recording:** the user's art is in `apps/web/public/assets/map/` (`body-venus.png`, `body-io.png`, `body-europa.png`, `belt-asteroids.png`, all with transparent backgrounds). `belt-asteroids.png` holds **six** separate rocks (six connected regions at alpha ≥ 16), so Step 16.5 slices it. Landing "Any" is this repo's `vacuum` landing requirement (every ship can land). The save keeps only discovered locations, so new undiscovered locations need no save data; Step 16.4 confirms an older save loads with them undiscovered.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-014 complete
**Do this:** as **Phase 15**
**Related docs:** `change-request-map-art.md` (CR-003), `change-request-cargo-goods.md` (CR-005), `change-request-new-ships-and-range.md` (CR-009), `change-request-local-first.md` (CR-011), `change-request-fixed-map-and-fuel-tanks.md` (CR-014), `game-design-document.md`, `DECISIONS.md`
This change request is written for the local-first engine (CR-011) and the fixed map with fuel tanks (CR-014).
---
## 1. Why
The map gets three new locations and a visible asteroid belt:
- **Venus Cloud City**, a floating platform high in Venus's atmosphere. It's an inner-system alternative to Mars for the Clipper: about the same distance from Earth, in the opposite direction.
- **Io Sulfur Works** and **Europa Ice Station**, two stops around Jupiter. Together with the Jupiter Cloud Platform they form a short-hop network, a second Earth–Moon loop for the late game. A small ship bought at a Jupiter-area HQ can work it without the long haul home.
- **The asteroid belt**, drawn as a band of rocks and dusty haze between Mars and Jupiter, with Ceres Station inside it. It's visual only.
The user has provided sprites for Venus, Io, and Europa, and `belt-asteroids.png` for the belt's rocks.
## 2. New locations
| | Venus Cloud City | Io Sulfur Works | Europa Ice Station |
|---|---|---|---|
| Id | `venus-cloud-city` | `io-sulfur-works` | `europa-ice-station` |
| Type | Cloud platform | Orbital station | Surface base |
| Landing | Atmospheric | Any | Any |
| Position | Radius 108M km, angle **110°** (target: ~200M km from Earth) | Fixed offset from Jupiter, 0.42M km | Fixed offset from Jupiter, 0.67M km |
| Range factor (CR-009) | 1.0 (floats above the gravity well, like Jupiter's platform) | 1.2 | 1.3 |
| Fuel price (CR-014, × base) | 1.3 | 1.0 | 0.7 (ice cracked into fuel) |
| Starts discovered | No | No | No |
| Sprite id | `body-venus` | `body-io` | `body-europa` |
| Shippers (flavor) | Aphrodite Atmospherics, Ishtar Cloudworks | Loki Sulfur Co., Pele Orbital Mining | Conamara Ice Works, Europa Deep Survey |
All names are invented for this game (Conamara, Ishtar, Loki, and Pele are real geographic or feature names on these worlds).
Io gets an orbital station rather than a surface base, so it needs no Hardened landing class. A surface Io destination, Venus's surface, and the Hardened class remain deferred.
### Approximate distances (check with the sim)
Earth–Venus ~200M km · Moon–Venus ~200M km · Venus–Mars ~332M km · Venus–Ceres ~520M km · Venus–Jupiter system ~885M km · Ceres–Io/Europa ~393M km (same as Ceres–Jupiter) · Jupiter platform ↔ Io ↔ Europa: under 1.1M km.
## 3. Reach rules (additions to the CR-014 §4 table)
Full-tank reach for base ships, using the CR-014 bands.
| Ship | Yes | No |
|---|---|---|
| Courier, Lighter | Jupiter platform ↔ Io ↔ Europa (local loop, if bought at a Jupiter-area HQ) | Earth→Venus, Moon→Venus |
| Clipper | Earth↔Venus, Moon→Venus, Jupiter-system local loop | Venus→Mars, any leg from the inner system to the Jupiter system |
| Freighter | Everything the Clipper can do, plus Venus→Mars and Ceres↔Io, Ceres↔Europa | Venus→Ceres; Earth, Moon, Mars, or Venus → any Jupiter-system location |
| Bulk Hauler | Every leg between all eight locations | — |
The Bulk Hauler's full-tank reach must now be **at least 900M km** (Venus to the Jupiter system is the longest leg). Re-tune its tank if needed.
## 4. New goods (additions to the CR-005 catalog)
| Id | Name | Class | Mass (t) | Pay × | Tier | Priority | Produced at | Demanded at |
|---|---|---|---|---|---|---|---|---|
| `cloud-botanicals` | Cloud-Grown Botanicals | Standard | 2–20 | 1.1 | — | Yes | Venus | Earth, Moon, Mars |
| `atmo-data-cores` | Atmospheric Data Cores | High-Value | 0.5–4 | 1.4 | 2 | No | Venus | Earth |
| `sulfur` | Refined Sulfur | Heavy | 40–200 | 0.9 | (Heavy rule) | No | Io | Earth, Mars, Venus |
| `volcanic-isotopes` | Volcanic Isotopes | High-Value | 0.5–5 | 1.7 | 3 | No | Io | Earth |
| `ocean-samples` | Subsurface Ocean Samples | High-Value | 0.2–2 | 1.8 | 3 | Yes | Europa | Earth |
Existing goods gain new producers and destinations:
| Good | Add as producer at | Add as destination |
|---|---|---|
| Water Ice | Europa | Io, Venus |
| Liquid Oxygen | Europa | Io |
| Packaged Food, Consumer Goods, Mail & Parcels, Medical Supplies, Electronics, Machine Parts | — | Venus, Io, Europa |
| Habitat Prefabs | — | Io, Europa |
| Industrial Chemicals | Venus | — |
Every new location must produce at least one Standard good for no-skill pilots (Venus: Cloud-Grown Botanicals; Io and Europa: add **Mail & Parcels** as a producer at both, since outbound mail exists everywhere).
## 5. The asteroid belt (visual only)
### Placement
- A ring band between **314M and 494M km** from the Sun (the real main belt), drawn with the map's existing compressed radial scale, so it sits between Mars and Jupiter with Ceres Station inside it.
- Layering, bottom to top: background → orbit rings → **belt haze** → **belt rocks** → routes → bodies and labels → ships.
- The belt is non-interactive: taps pass through to whatever is under them.
- Ceres's sprite and label keep their contrast halo so they read clearly on top of the belt.
### Rocks (`belt-asteroids.png`)
- The coders first inspect the image. If it contains **several rocks**, the asset script (CR-003 Step 10C.2) slices it into individual sprites by their transparent gaps. If it's **one rock**, use it alone.
- Scatter a fixed number of rock instances (default 220) across the band, placed by a seeded random generator so the belt looks the same every time. Vary size (mostly small, a few larger), rotation, and brightness. Denser toward the band's middle, thinning toward the edges.
- Leave a clear area around Ceres so no rock covers it.
### Haze
- A soft band of dust and tiny distant asteroids under the rocks, drawn as a ring mesh: a horizontal strip texture is wrapped around the ring (strip width maps to the full circle, strip height maps from inner to outer edge). This works with the map's compressed scale.
- **If the user provides `belt-haze.png`** (a seamless horizontal strip, see §8), use it.
- **Until then, generate the strip in code at startup:** a faint warm-grey band that fades out at the top and bottom edges, with soft noise and a few thousand one-to-two-pixel specks for the tiny asteroids.
- Haze opacity is a map style setting (default about 35%).
### Motion and performance
- The belt is static. No rotation or drift (the map is fixed, CR-014).
- Render rocks in a single batched container, using one texture per rock image.
- The full map with the belt must stay smooth on a mid-range phone (re-run the CR-003 Step 10C.9 manual check).
## 6. Map layout around Jupiter
- Io and Europa use fixed **screen-space offsets** from Jupiter, like the Moon from Earth (CR-003 decision 0.5.31), at angles that don't collide with the Jupiter platform badge.
- **Label decluttering:** when Jupiter-system labels would overlap at the current zoom, show only "Jupiter" with a small "+2" marker. Tapping it, or zooming in past a threshold, shows all three labels. Each location's tap target still meets the 44 px minimum. The same rule applies to Earth and the Moon if their labels collide.
## 7. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-015.D1–D6 here; note the mapping).
- **CR-015.D1 New locations.** Venus Cloud City, Io Sulfur Works, and Europa Ice Station per §2. All start undiscovered and are discovered by delivery, as usual.
- **CR-015.D2 No Hardened yet.** Io is an orbital station and Venus a cloud platform, so neither needs the Hardened landing class. Surface Io, Venus's surface, and Hardened ships stay deferred.
- **CR-015.D3 Reach additions.** §3 is added to the CR-014 §4 reach table. Bulk Hauler full-tank reach ≥ 900M km.
- **CR-015.D4 Goods additions.** §4 is added to the CR-005 catalog.
- **CR-015.D5 Asteroid belt.** Visual only, per §5. No debris hazard, mining claims, or belt events (those stay deferred).
- **CR-015.D6 Jupiter-system layout.** Screen-space offsets for Io and Europa, plus label decluttering, per §6.
## 8. Art
| Asset id | File | Status |
|---|---|---|
| `body-venus` | Venus sprite | Provided by the user |
| `body-io` | Io sprite | Provided by the user |
| `body-europa` | Europa sprite | Provided by the user |
| `belt-rocks` | `belt-asteroids.png` | Provided by the user |
| `belt-haze` | `belt-haze.png` | **Optional.** Generated in code until provided |
If the user wants hand-made haze, this is the ChatGPT prompt (with the usual style block in front):
> A seamless, horizontally tileable strip, 4096×256 pixels, of a dusty asteroid belt seen from far away: faint warm-grey haze with thousands of tiny specks of rock, densest along the horizontal middle and fading smoothly to fully transparent at the top and bottom edges. The left and right edges must match perfectly so it can wrap into a ring. Transparent background, no large rocks, no planets, no stars, no text.
## 9. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`. Map display settings live in the CR-003 map style config.
- Never modify the user's source images (CR-003 rule). Slicing and resizing write to the generated folder.
- Existing saves get a save migration: the three new locations are added as undiscovered. Nothing else changes.
- Update existing tests whose expectations change (location counts, fuel price ordering, reach table, Bulk Hauler reach band). Update `architecture.md` and `progress.md` as you go.
- Visual steps end with a committed screenshot (desktop and phone width) for the user's review, as in CR-003.
- Stop for the user's review at the end of Phase 15.
## 10. Steps — Phase 15
### Step 15.1 — Content: new locations
- Add the three locations from §2: type, landing, position (polar for Venus; offsets from Jupiter for Io and Europa), range factor, fuel price, `sellsFuel` true, sprite id, shippers, starts undiscovered.
- **Test:** Content validates with eight locations. Exactly Earth and the Moon start discovered. Computed distances match §2 within 2%. Fuel prices are ordered Jupiter platform < Europa < Ceres < Io < Moon < Venus < Mars < Earth (replaces the CR-014 ordering test).
### Step 15.2 — Content: goods
- Add the five goods in §4 and the producer and destination changes, including Mail & Parcels produced at Io and Europa.
- **Test:** All goods validate. Every location produces at least one Standard good and demands at least one good. Every new location appears as a destination for at least five goods.
### Step 15.3 — Content tuning: reach
- Adjust ship tanks and burn rates (Bulk Hauler first) so both the CR-014 §4 table and the §3 additions hold.
- **Test:** One test per cell of both tables, using full-tank reach. The no-strand check never blocks a "yes" leg. A Courier at the Jupiter platform can fly to Io and Europa and back.
### Step 15.4 — Engine: save migration and boards
- Save migration adds the three locations as undiscovered.
- Contract boards at discovered locations can now offer first-contact contracts to the new locations (existing logic; confirm it picks them up).
- **Test:** An older save migrates with eight locations, the new three undiscovered, and everything else unchanged. Over many generated Earth boards, first-contact contracts to Venus appear. After a delivery to Venus, Venus is discovered and has its own board with Venus goods.
### Step 15.5 — Assets: sprites and belt rocks
- Add the new sprites and `belt-asteroids.png` to the CR-003 manifest. Extend the asset script: if `belt-asteroids.png` contains several separate rocks, slice them into individual sprites (by connected non-transparent regions) and export each trimmed, at 64 px and 128 px; if it's one rock, export it the same way. Support the optional `belt-haze.png` (resize to a max of 4096×256, keep transparency).
- **Test:** The manifest tests pass. Slicing a fixture image with three separated rocks produces exactly three sprites; a single-rock fixture produces one. The CR-003 transparency check passes for the new sprites and the belt image.
### Step 15.6 — Map: belt haze
- Draw the haze ring mesh per §5 between the belt's inner and outer screen radii, using `belt-haze.png` if present, otherwise the strip generated in code.
- **Test:** Unit test: the ring mesh's inner and outer radii equal the compressed-scale screen radii of 314M and 494M km at several zoom levels. With no haze file, a generated texture is used and no warning other than the CR-003 missing-asset warning is logged. Manual check with screenshot.
### Step 15.7 — Map: belt rocks
- Scatter rocks per §5: seeded positions, size, rotation, brightness, density falloff, and a clear area around Ceres. Render in one batched container. Non-interactive.
- **Test:** Same seed gives identical rock placements. All rocks lie within the band. No rock lies within the clear radius around Ceres. A tap on a rock reaches the location or empty map beneath it. Manual check with screenshot; re-run the CR-003 performance check on a phone and note the result in `progress.md`.
### Step 15.8 — Map: new bodies and Jupiter-system layout
- Draw Venus at its position; Io and Europa at their screen offsets from Jupiter. Apply label decluttering (§6) to the Jupiter system and Earth–Moon.
- New locations use the existing undiscovered lock and reachability panels (CR-003, CR-009, CR-014).
- **Test:** Unit tests: Io's and Europa's screen positions are exactly their configured offsets from Jupiter at several zoom levels; at low zoom only one Jupiter-system label shows with "+2"; above the threshold all three show; every tap target is at least 44 px. Manual check with screenshot.
### Step 15.9 — Plan amendments and regression
- Apply the amendments in §11.
- Run the full suite.
- **Test:** CI is green. The Phase 12 balance script re-checks all timing targets with eight locations; the "first discovery under 90 minutes" target must still hold. Manual check: on a fresh account after the deal, take a first-contact contract to Venus with the Clipper, then view the belt and the Jupiter system on a phone.
## 11. Amendments
| Where | Change |
|---|---|
| Plan 0.2 | Locations: eight (add Venus Cloud City, Io Sulfur Works, Europa Ice Station). |
| Plan 0.3 | Remove "Venus/Io" from deferred; keep "Hardened landing class, surface Io, Venus surface". Belt hazards and mining claims stay deferred. |
| Plan 0.5 | Add CR-015.D1–D6 (next free numbers). |
| Plan 2.2 | Location count test becomes eight (15.1). |
| CR-003 | Manifest gains the new sprites and belt assets; layering order includes the belt (15.5–15.7). |
| CR-005 | Catalog additions (15.2). |
| CR-014 | Fuel price ordering test replaced (15.1); reach table extended; Bulk Hauler reach ≥ 900M km (15.3). |
| `game-design-document.md` | Destinations table and map description updated by the designer. |
## 12. Out of scope
The Hardened landing class and hardened destinations, belt debris hazards or random events, mining claims or a belt mining platform, Saturn and Titan, animated belt motion, and anything else in `implementation-plan.md` §0.3.

View File

@ -155,6 +155,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
97. **Refueling** (CR-015). Instant. Per ship: Auto top-up (default; fills on landing where fuel is sold, buying what the player can afford) or Manual. A refuel command fills or buys an amount at the ship's location. 97. **Refueling** (CR-015). Instant. Per ship: Auto top-up (default; fills on landing where fuel is sold, buying what the player can afford) or Manual. A refuel command fills or buys an amount at the ship's location.
98. **Fuel cost of a leg** (CR-015). Fuel burned × the tank's weighted average purchase price. Pilot profit share (0.5.12 as replaced by CR-010) uses it instead of the per-trip fuel charge. 98. **Fuel cost of a leg** (CR-015). Fuel burned × the tank's weighted average purchase price. Pilot profit share (0.5.12 as replaced by CR-010) uses it instead of the per-trip fuel charge.
99. **Emergency fuel** (CR-015). When an idle ship can't reach any other discovered location and the player can't afford the fuel, they can order enough for the cheapest reachable leg at 2.5× the local price, added to the loan balance (repaid as in 0.5.32). 99. **Emergency fuel** (CR-015). When an idle ship can't reach any other discovered location and the player can't afford the fuel, they can order enough for the cheapest reachable leg at 2.5× the local price, added to the loan balance (repaid as in 0.5.32).
100. **New locations** (CR-016). Venus Cloud City (cloud platform, atmospheric landing, 108M km at 110°, range factor 1.0, fuel × 1.3), Io Sulfur Works (orbital station, any landing, 0.42M km out from Jupiter, factor 1.2, fuel × 1.0) and Europa Ice Station (surface base, any landing, 0.67M km out from Jupiter, factor 1.3, fuel × 0.7). All start undiscovered and are discovered by delivery.
101. **No Hardened yet** (CR-016). Io is an orbital station and Venus a cloud platform, so neither needs the Hardened landing class. Surface Io, Venus's surface and Hardened ships stay deferred.
102. **Reach additions** (CR-016). CR-016 §3 is added to CR-015 §4's reach table; a Bulk Hauler's full-tank reach is at least 900M km.
103. **Goods additions** (CR-016). CR-016 §4's goods and producer/destination changes join the catalog (decision 0.5.54); every location produces at least one Standard good.
104. **Asteroid belt** (CR-016). A visual band of haze and rocks between 314M and 494M km from the Sun, static and non-interactive. No hazards, mining claims or belt events (deferred).
105. **Jupiter-system layout** (CR-016). Io and Europa sit at fixed screen offsets from Jupiter (as the Moon from Earth, decision 0.5.39); colliding labels collapse to the parent's with a "+N" marker until tapped or zoomed in.
### 0.6 Translating change requests written for the server (CR-014 §7) ### 0.6 Translating change requests written for the server (CR-014 §7)
@ -792,6 +798,12 @@ Steps 15.1–15.9 are specified in `memory-bank/change-requests/CR-015-fixed-map
--- ---
## Phase 16 — Venus, Io, Europa, and the Asteroid Belt (CR-016)
Steps 16.1–16.9 are specified in `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` §10 (written there as 15.1–15.9), with its §11 amendments. Stop for review at the end.
---
## Definition of Done (Base Game) ## Definition of Done (Base Game)
- All step tests pass in CI. - All step tests pass in CI.

View File

@ -388,3 +388,17 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as
5. Fly back toward Earth. On the next legs the dispatch sheet's "Fuel used (at your average price)" is lower than the same leg priced at Earth's 16 cr/fu, and the Fuel sheet's average price has dropped. 5. Fly back toward Earth. On the next legs the dispatch sheet's "Fuel used (at your average price)" is lower than the same leg priced at Earth's 16 cr/fu, and the Fuel sheet's average price has dropped.
**Phase 15 complete. Stopped for the user's review.** **Phase 15 complete. Stopped for the user's review.**
## Phase 16 — Venus, Io, Europa, and the Asteroid Belt (CR-016)
Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as "CR-015 … Phase 15"). New decisions recorded as plan §0.5.100–0.5.105.
- [x] **Step 16.1 — Content: new locations.** Venus Cloud City (cloud platform, atmospheric, 108M km at 110°, range factor 1.0, fuel × 1.3, bay 20,000), Io Sulfur Works (new type `orbital-station`, `vacuum` landing so any ship can land, 0.42M km out from Jupiter, factor 1.2, fuel × 1.0, bay 70,000) and Europa Ice Station (new type `surface-base`, `vacuum`, 0.67M km out from Jupiter, factor 1.3, fuel × 0.7, bay 70,000); all undiscovered, selling fuel, with the CR's shippers and class mixes. Pulled forward so the game keeps working: each new place's Standard good (Cloud-Grown Botanicals at Venus, a new good; Mail & Parcels also produced at Io and Europa), since boards can't be generated where nothing Standard is made; and the three body sprites in the art manifest, with their generated art (the belt is still 16.5). The balance check's first Market purchase moved from 64 to ~92 min (the Clipper's landings decide it, and Venus changed which first contacts boards offer); the user widened the window to 45–95. Tests: `packages/content/test/fixed-map.test.ts` (Earth/Moon–Venus ≈ 200, Venus–Mars ≈ 332, Venus–Ceres ≈ 520, Venus–Jupiter ≈ 885, Ceres–Io/Europa ≈ 393, all within 2 %; the Jupiter system within 1.1M km; Io and Europa ride with Jupiter at their offsets; the three undiscovered, selling fuel, not Hardened; only Earth and the Moon start discovered); `locations.test.ts` (eight locations; fuel prices ordered Jupiter < Europa < Ceres < Io < Moon < Venus < Mars < Earth). Updated: bundle count, first contacts may go to Mars or Venus, the CR-007 class-mix baseline measured on its original five places, orbit rings for every body but those riding a parent, the Market reach line now listing Venus.
- [ ] Step 16.2 — Content: goods
- [ ] Step 16.3 — Content tuning: reach
- [ ] Step 16.4 — Engine: save migration and boards
- [ ] Step 16.5 — Assets: sprites and belt rocks
- [ ] Step 16.6 — Map: belt haze
- [ ] Step 16.7 — Map: belt rocks
- [ ] Step 16.8 — Map: new bodies and Jupiter-system layout
- [ ] Step 16.9 — Plan amendments and regression

View File

@ -5,6 +5,9 @@ const MOON = "moon";
const MARS = "mars"; const MARS = "mars";
const CERES = "ceres-station"; const CERES = "ceres-station";
const JUPITER = "jupiter-cloud-platform"; const JUPITER = "jupiter-cloud-platform";
const VENUS = "venus-cloud-city";
const IO = "io-sulfur-works";
const EUROPA = "europa-ice-station";
/** /**
* The goods catalog (CR-007, decision 0.5.54). Pay multipliers scale the * The goods catalog (CR-007, decision 0.5.54). Pay multipliers scale the
@ -20,7 +23,7 @@ export const goods: Good[] = [
massTonnes: { min: 1, max: 8 }, massTonnes: { min: 1, max: 8 },
payMultiplier: 1.2, payMultiplier: 1.2,
priorityEligible: true, priorityEligible: true,
producedAt: [EARTH, MARS], producedAt: [EARTH, MARS, IO, EUROPA],
demandedAt: [MOON, MARS, CERES, JUPITER, EARTH], demandedAt: [MOON, MARS, CERES, JUPITER, EARTH],
}, },
{ {
@ -73,6 +76,17 @@ export const goods: Good[] = [
producedAt: [MARS], producedAt: [MARS],
demandedAt: [MOON, CERES, JUPITER], demandedAt: [MOON, CERES, JUPITER],
}, },
{
// Venus's Standard good for no-skill pilots (CR-016 §4).
id: "cloud-botanicals",
name: "Cloud-Grown Botanicals",
cargoClassId: "standard",
massTonnes: { min: 2, max: 20 },
payMultiplier: 1.1,
priorityEligible: true,
producedAt: [VENUS],
demandedAt: [EARTH, MOON, MARS],
},
{ {
id: "salvaged-components", id: "salvaged-components",
name: "Salvaged Components", name: "Salvaged Components",

View File

@ -9,6 +9,10 @@ import { goodsDemandedAt, goodsProducedAt } from "./goods";
*/ */
const EARTH: FixedPosition = { radiusMillionKm: 150, angleDegrees: 210 }; const EARTH: FixedPosition = { radiusMillionKm: 150, angleDegrees: 210 };
const MOON_OFFSET_MILLION_KM = 0.384; const MOON_OFFSET_MILLION_KM = 0.384;
const JUPITER: FixedPosition = { radiusMillionKm: 778, angleDegrees: 300 };
// Io and Europa ride with Jupiter (CR-016, decision 0.5.100).
const IO_OFFSET_MILLION_KM = 0.42;
const EUROPA_OFFSET_MILLION_KM = 0.67;
/** `parent` moved straight out from the Sun by `offset` million km. */ /** `parent` moved straight out from the Sun by `offset` million km. */
export function offsetPosition(parent: FixedPosition, offset: number): FixedPosition { export function offsetPosition(parent: FixedPosition, offset: number): FixedPosition {
@ -105,7 +109,7 @@ export const locations: Location[] = [
name: "Jupiter Cloud Platform", name: "Jupiter Cloud Platform",
type: "cloud-platform", type: "cloud-platform",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
position: { radiusMillionKm: 778, angleDegrees: 300 }, position: JUPITER,
sprite: "body-jupiter", sprite: "body-jupiter",
badge: "badge-cloud-platform", badge: "badge-cloud-platform",
fuelPrice: 0.6, fuelPrice: 0.6,
@ -118,4 +122,75 @@ export const locations: Location[] = [
cargoClassMix: { produces: ["dangerous"], demands: ["standard", "high-value", "heavy"] }, cargoClassMix: { produces: ["dangerous"], demands: ["standard", "high-value", "heavy"] },
shippers: ["Galilean Fuel Combine", "Zephyr Gas Works"], shippers: ["Galilean Fuel Combine", "Zephyr Gas Works"],
}, },
// CR-016 (decision 0.5.100): an inner-system alternative to Mars, and two
// short hops from Jupiter's platform. All undiscovered at the start.
{
id: "venus-cloud-city",
name: "Venus Cloud City",
type: "cloud-platform",
landingRequirement: "atmospheric",
// About 200M km from Earth, the other way from Mars.
position: { radiusMillionKm: 108, angleDegrees: 110 },
sprite: "body-venus",
fuelPrice: 1.3,
sellsFuel: true,
// Floats above the gravity well, like Jupiter's platform.
launchRangeFactor: 1,
startsDiscovered: false,
bayLeasePrice: 20000,
produces: goodsProducedAt("venus-cloud-city"),
demands: goodsDemandedAt("venus-cloud-city"),
cargoClassMix: {
produces: ["standard", "high-value", "dangerous"],
demands: ["standard", "high-value", "heavy"],
},
shippers: ["Aphrodite Atmospherics", "Ishtar Cloudworks"],
},
{
id: "io-sulfur-works",
name: "Io Sulfur Works",
type: "orbital-station",
// An orbital station: no Hardened landing class needed (decision 0.5.101).
landingRequirement: "vacuum",
position: offsetPosition(JUPITER, IO_OFFSET_MILLION_KM),
orbitParent: { locationId: "jupiter-cloud-platform", offsetMillionKm: IO_OFFSET_MILLION_KM },
sprite: "body-io",
fuelPrice: 1,
sellsFuel: true,
launchRangeFactor: 1.2,
startsDiscovered: false,
bayLeasePrice: 70000,
produces: goodsProducedAt("io-sulfur-works"),
demands: goodsDemandedAt("io-sulfur-works"),
cargoClassMix: {
produces: ["standard", "heavy", "high-value"],
demands: ["standard", "high-value", "heavy", "dangerous"],
},
shippers: ["Loki Sulfur Co.", "Pele Orbital Mining"],
},
{
id: "europa-ice-station",
name: "Europa Ice Station",
type: "surface-base",
landingRequirement: "vacuum",
position: offsetPosition(JUPITER, EUROPA_OFFSET_MILLION_KM),
orbitParent: {
locationId: "jupiter-cloud-platform",
offsetMillionKm: EUROPA_OFFSET_MILLION_KM,
},
sprite: "body-europa",
// Ice cracked into fuel.
fuelPrice: 0.7,
sellsFuel: true,
launchRangeFactor: 1.3,
startsDiscovered: false,
bayLeasePrice: 70000,
produces: goodsProducedAt("europa-ice-station"),
demands: goodsDemandedAt("europa-ice-station"),
cargoClassMix: {
produces: ["standard", "high-value", "heavy", "dangerous"],
demands: ["standard", "high-value", "heavy"],
},
shippers: ["Conamara Ice Works", "Europa Deep Survey"],
},
]; ];

View File

@ -23,6 +23,8 @@ export const locationTypeSchema = z.enum([
"airless-body", "airless-body",
"cloud-platform", "cloud-platform",
"belt-station", "belt-station",
"orbital-station",
"surface-base",
]); ]);
export type LocationType = z.infer<typeof locationTypeSchema>; export type LocationType = z.infer<typeof locationTypeSchema>;

View File

@ -3,7 +3,7 @@ import { content } from "../src/index";
describe("content bundle", () => { describe("content bundle", () => {
it("contains every kind of content", () => { it("contains every kind of content", () => {
expect(content.locations).toHaveLength(5); expect(content.locations).toHaveLength(8);
expect(content.shipModels).toHaveLength(6); expect(content.shipModels).toHaveLength(6);
expect(content.purchasableShipModels).toHaveLength(5); expect(content.purchasableShipModels).toHaveLength(5);
expect(content.cargoTypes).toHaveLength(4); expect(content.cargoTypes).toHaveLength(4);

View File

@ -56,3 +56,46 @@ describe("fixed map (Step 15.1, decision 0.5.93)", () => {
expect(lockfile).not.toContain("astronomy-engine"); expect(lockfile).not.toContain("astronomy-engine");
}); });
}); });
describe("new locations (Step 16.1, decision 0.5.100)", () => {
it.each([
["earth", "venus-cloud-city", 200],
["moon", "venus-cloud-city", 200],
["venus-cloud-city", "mars", 332],
["venus-cloud-city", "ceres-station", 520],
["venus-cloud-city", "jupiter-cloud-platform", 885],
["ceres-station", "io-sulfur-works", 393],
["ceres-station", "europa-ice-station", 393],
])("puts %s–%s at about %i M km (CR-016 §2, within 2 %)", (from, to, expected) => {
expect(Math.abs(distance(from, to) - expected) / expected).toBeLessThan(0.02);
});
it("keeps the Jupiter system within 1.1M km of the platform", () => {
const system = ["jupiter-cloud-platform", "io-sulfur-works", "europa-ice-station"];
for (const a of system) for (const b of system) expect(distance(a, b)).toBeLessThan(1.1);
});
it("rides Io and Europa with Jupiter, at their offsets", () => {
expect(content.location("io-sulfur-works")!.orbitParent).toEqual({
locationId: "jupiter-cloud-platform",
offsetMillionKm: 0.42,
});
expect(content.location("europa-ice-station")!.orbitParent).toEqual({
locationId: "jupiter-cloud-platform",
offsetMillionKm: 0.67,
});
});
it("starts all three undiscovered, selling fuel, landable without Hardened ships", () => {
for (const id of ["venus-cloud-city", "io-sulfur-works", "europa-ice-station"]) {
const location = content.location(id)!;
expect(location.startsDiscovered, id).toBe(false);
expect(location.sellsFuel, id).toBe(true);
expect(location.landingRequirement, id).not.toBe("hardened");
}
expect(content.locations.filter((l) => l.startsDiscovered).map((l) => l.id)).toEqual([
"earth",
"moon",
]);
});
});

View File

@ -10,9 +10,9 @@ describe("locations", () => {
} }
}); });
it("has exactly five locations with unique ids", () => { it("has exactly eight locations with unique ids (CR-016)", () => {
expect(locations).toHaveLength(5); expect(locations).toHaveLength(8);
expect(new Set(locations.map((l) => l.id)).size).toBe(5); expect(new Set(locations.map((l) => l.id)).size).toBe(8);
}); });
it("starts with exactly Earth and Moon discovered", () => { it("starts with exactly Earth and Moon discovered", () => {
@ -35,9 +35,18 @@ describe("locations", () => {
for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0); for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0);
}); });
it("orders fuel prices Jupiter < Ceres < Moon < Mars < Earth (decision 0.5.96)", () => { it("orders fuel prices Jupiter < Europa < Ceres < Io < Moon < Venus < Mars < Earth (CR-016)", () => {
const price = (id: string) => locations.find((l) => l.id === id)!.fuelPrice; const price = (id: string) => locations.find((l) => l.id === id)!.fuelPrice;
const order = ["jupiter-cloud-platform", "ceres-station", "moon", "mars", "earth"]; const order = [
"jupiter-cloud-platform",
"europa-ice-station",
"ceres-station",
"io-sulfur-works",
"moon",
"venus-cloud-city",
"mars",
"earth",
];
for (let i = 1; i < order.length; i++) { for (let i = 1; i < order.length; i++) {
expect(price(order[i - 1]!), order[i]).toBeLessThan(price(order[i]!)); expect(price(order[i - 1]!), order[i]).toBeLessThan(price(order[i]!));
} }

View File

@ -39,10 +39,10 @@ describe("balance simulation (Step 12.1)", () => {
expect(median(lighterFirst, "dealAccepted")).toBeLessThan(10); expect(median(lighterFirst, "dealAccepted")).toBeLessThan(10);
}); });
it("buys the first Market ship in 45–75 minutes (CR-013)", () => { it("buys the first Market ship in 45–95 minutes (CR-013, widened for CR-016)", () => {
const minutes = median(lighterFirst, "firstMarketPurchase"); const minutes = median(lighterFirst, "firstMarketPurchase");
expect(minutes).toBeGreaterThanOrEqual(45); expect(minutes).toBeGreaterThanOrEqual(45);
expect(minutes).toBeLessThanOrEqual(75); expect(minutes).toBeLessThanOrEqual(95);
}); });
it("discovers a new location in under 90 minutes", () => { it("discovers a new location in under 90 minutes", () => {

View File

@ -1,7 +1,13 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chooseGood, priorityWindow, routeGoods } from "../src/board"; import { chooseGood, priorityWindow, routeGoods } from "../src/board";
import { createRng, generateContractBoard, type BoardInput, type Contract } from "../src/index"; import {
createRng,
generateContractBoard,
type BoardInput,
type Contract,
type SimContent,
} from "../src/index";
const { config } = content; const { config } = content;
const bands = config.contractMassBands; const bands = config.contractMassBands;
@ -15,12 +21,19 @@ const earthInput: BoardInput = {
worldSeed: 12345, worldSeed: 12345,
}; };
/** Pre-change class shares, 1,000 Earth boards, all discovered, seed 12345 (CR-007 §6). */ /** The five places CR-007's baseline was measured with (CR-016 adds more destinations). */
const baseFive = new Set(["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]);
const fivePlaces: SimContent = {
...content,
locations: content.locations.filter((l) => baseFive.has(l.id)),
};
/** Pre-change class shares, 1,000 Earth boards, all five discovered, seed 12345 (CR-007 §6). */
const BASELINE: Record<string, number> = { standard: 76.79, "high-value": 23.21 }; const BASELINE: Record<string, number> = { standard: 76.79, "high-value": 23.21 };
function boards(input: BoardInput, count: number): Contract[][] { function boards(input: BoardInput, count: number, at: SimContent = content): Contract[][] {
return Array.from({ length: count }, (_, p) => return Array.from({ length: count }, (_, p) =>
generateContractBoard({ ...input, periodIndex: p }, content), generateContractBoard({ ...input, periodIndex: p }, at),
); );
} }
@ -81,7 +94,7 @@ describe("goods in board generation (CR-007)", () => {
it("keeps each cargo class within ±3 points of the pre-change mix from Earth", () => { it("keeps each cargo class within ±3 points of the pre-change mix from Earth", () => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
let total = 0; let total = 0;
for (const board of boards(earthInput, 1000)) { for (const board of boards({ ...earthInput, discoveredIds: baseFive }, 1000, fivePlaces)) {
for (const c of board) { for (const c of board) {
counts[c.cargoTypeId] = (counts[c.cargoTypeId] ?? 0) + 1; counts[c.cargoTypeId] = (counts[c.cargoTypeId] ?? 0) + 1;
total++; total++;

View File

@ -52,13 +52,20 @@ describe("a first contact the fleet can fly (decision 0.5.85)", () => {
const contracts = board(firstPeriod + k, ["earth", "moon"], [clipper]); const contracts = board(firstPeriod + k, ["earth", "moon"], [clipper]);
const flyable = contracts.filter((c) => c.firstContact && clipperCanFly(c)); const flyable = contracts.filter((c) => c.firstContact && clipperCanFly(c));
expect(flyable.length, `period ${k}`).toBeGreaterThanOrEqual(1); expect(flyable.length, `period ${k}`).toBeGreaterThanOrEqual(1);
expect(flyable.every((c) => c.destinationId === "mars")).toBe(true); // Mars and Venus Cloud City are both a Clipper leg from Earth (CR-016).
expect(flyable.every((c) => ["mars", "venus-cloud-city"].includes(c.destinationId))).toBe(
true,
);
} }
}); });
it("stops once the reachable places are discovered", () => { it("stops once the reachable places are discovered", () => {
for (let k = 0; k < 24; k++) { for (let k = 0; k < 24; k++) {
const contracts = board(firstPeriod + k, ["earth", "moon", "mars"], [clipper]); const contracts = board(
firstPeriod + k,
["earth", "moon", "mars", "venus-cloud-city"],
[clipper],
);
expect(contracts.filter((c) => c.firstContact && clipperCanFly(c))).toEqual([]); expect(contracts.filter((c) => c.firstContact && clipperCanFly(c))).toEqual([]);
} }
}); });