From 0b92cb7b98ba6d636b0094b74a14bbea4cc39723 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 6 Sep 2026 13:06:29 -0600 Subject: [PATCH] Galaxy refactor: 200 systems, object-composition, dormant gates, frame diversity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Galaxy shrunk to 200 systems (data/galaxy.json) — always fully known, generation ~15 ms; roster/content two-level design unchanged. - Object composition replaces planetCount (data/systems.json → objectCount): 10% barren (jump-gate-only), else 2/3/4/5 objects (planets + free-space stations) at 15/30/30/15. One unified roll (rollSystemComposition) shared by the content generator and the frame pass. - Barren systems: no planets, no stations, no asteroid clusters; their single gate sits on the star→destination ray at barrenDistance (8192 px). Strong connectivity keeps them reachable. - Jump gates are DORMANT by default: every gate record carries active:false, the entity renders dim with a still field. Activation is the seam for the tether mechanic (an activated gate anchors a level-1 tether). data/gates.json gains barrenDistance. - Frame diversity (js/galaxy/PlanetFrames.js): one galaxy-wide pass spreads (class, frame) across the galaxy — fixed spatial order, least-used face among already-assigned nearest stars, seeded tie-breaks. Stamps planet.frame + content.homeFrame, read by GameScene. - Tests: dev/frames.test.mjs added; jumps/galaxy/discovery/jumpgate/asteroids suites updated to the new rules (composition buckets, barren gates, active flags, frame determinism/spread, 5-object max, index clamps). - Docs (README, PROJECT_NOTES) and stale 40k references updated. --- README.md | 22 +- data/galaxy.json | 2 +- data/gates.json | 3 +- data/naming.json | 2 +- data/systems.json | 7 +- dev/asteroids.test.mjs | 40 ++-- dev/discovery.test.mjs | 27 ++- dev/frames.test.mjs | 184 ++++++++++++++++ dev/galaxy.test.mjs | 69 ++++-- dev/jumpgate.test.mjs | 28 ++- dev/jumps.test.mjs | 127 +++++++---- docs/PROJECT_NOTES.md | 58 +++-- js/entities/JumpGate.js | 16 +- js/galaxy/Galaxy.js | 27 ++- js/galaxy/JumpNetwork.js | 2 +- js/galaxy/PlanetFrames.js | 113 ++++++++++ js/galaxy/SystemGenerator.js | 410 ++++++++++++++++++++++++----------- js/scenes/GameScene.js | 20 +- js/utils/NameGenerator.js | 2 +- 19 files changed, 905 insertions(+), 254 deletions(-) create mode 100644 dev/frames.test.mjs create mode 100644 js/galaxy/PlanetFrames.js diff --git a/README.md b/README.md index ce33213..e8381f8 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,12 @@ node dev/server.mjs 8080 (click it and type), and rerollable — and the menu shows what that seed builds (the galaxy's name, system count, archetype count) **before** you commit. Same seed ⇒ same galaxy. -- **Procedural galaxy**: 40,000 star systems in a seeded disk + core + +- **Procedural galaxy**: 200 star systems in a seeded disk + core + spiral arms (`data/galaxy.json`), typed into six themed archetypes (`data/systems.json`) with per-type distribution weights and radial bands — the first "how does the galaxy lay itself out" rules. - **Two-level generation**: the whole galaxy roster is generated at New - Game (~70 ms); each system's planets/moons/belts/settlements are + Game (~15 ms); each system's planets/moons/belts/settlements are generated lazily on arrival, deterministically (seed + system id), so lazy and eager give identical results. - **A lived-in galaxy**: the galaxy was settled long before you arrive. @@ -49,9 +49,11 @@ node dev/server.mjs 8080 stations over the rest, cloud bases riding gas giants — plus stations adrift in open space and beacons, whose odds are per-archetype (`data/systems.json`) and thin out from the settled core to the wilder - rim (`data/galaxy.json`). Barren systems with nothing adrift report - *charted · unclaimed*. Systems hold 2–4 planets, or none (~20% are - barren); the starting system always holds the home world, a gas giant, + rim (`data/galaxy.json`). **Barren systems** (~10%, the + `objectCount` → 0 stops) hold nothing but their jump gate and report + *charted · unclaimed*. Non-home systems hold 0/2/3/4/5 objects + (planets + free-space stations, `data/systems.json → objectCount`); + the starting system always holds the home world, a gas giant, and a rocky world. Each settlement has a name, population, and an `owner` seam reserved for the factions/pirates to come. The current system's dossier (name, identity, what's there) shows @@ -69,7 +71,10 @@ node dev/server.mjs 8080 `assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there** in the current system's open space. Every system also holds 1–3 JUMP GATES — solid, discoverable exits that face their destination star — - wired into a strongly-connected gate network (data/gates.json); + wired into a strongly-connected gate network (data/gates.json); every + gate is `active: false` for now — DORMANT (dimmed, field still) — and + activation is the seam for the tether mechanic (an activated gate + anchors a level-1 tether); the jump drive itself comes next. Discovered worlds get a screen-edge arrow + a name tag showing the world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the @@ -238,9 +243,10 @@ runtime data — loaders and tests ignore them. ```sh node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node node dev/starfield.test.mjs # runs the real Starfield.create() in Node -node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager, gate anchors + report +node dev/galaxy.test.mjs # galaxy determinism, composition, lazy vs eager, gradient + report +node dev/frames.test.mjs # the galaxy-wide (class, frame) pass: in-pool, spread, determinism node dev/jumps.test.mjs # the gate network: 1–3 gates, locality, strong connectivity + placement invariants -node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields + the solid contract (Node) +node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields, dormant/active, the solid contract (Node) node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps) node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest diff --git a/data/galaxy.json b/data/galaxy.json index e0ae517..eb09307 100644 --- a/data/galaxy.json +++ b/data/galaxy.json @@ -1,5 +1,5 @@ { - "systemCount": 40000, + "systemCount": 200, "radius": 20000, "layout": { "coreFraction": 0.25, diff --git a/data/gates.json b/data/gates.json index f321782..9169c8a 100644 --- a/data/gates.json +++ b/data/gates.json @@ -1,5 +1,5 @@ { - "_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets), every jump is to a nearby star, and no system ever holds more than maxGates gates. PLACEMENT: each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color. typeLabel = the discovery/compass label.", + "_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets, no unreachable stars), every jump is to a nearby star, and no system ever holds more than maxGates gates. Single-gate systems (e.g. the barren ones) read as dead-end corridors — enter from a neighbor, exit through the gate — which is fine as long as they are connected to the main network, which strong connectivity guarantees. PLACEMENT (anchored systems): each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the gate sits ON the ray from the star toward its destination, `barrenDistance` px from the star (stepped outward within the minRadius..maxRadius band if two gates would otherwise violate the gap), facing its destination. ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label.", "enabled": true, "minGates": 1, "maxGates": 3, @@ -12,6 +12,7 @@ "minRadius": 2048, "maxRadius": 20480, "anchorTetherLevel": 1, + "barrenDistance": 8192, "typeLabel": "Jump Gate", "theme": { "color": "#5fd4ff" } } diff --git a/data/naming.json b/data/naming.json index 23d6147..2b973a6 100644 --- a/data/naming.json +++ b/data/naming.json @@ -1,5 +1,5 @@ { - "_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 40,000-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).", + "_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 200-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).", "star": { "syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"], "minParts": 2, diff --git a/data/systems.json b/data/systems.json index 7de86c4..ac8b696 100644 --- a/data/systems.json +++ b/data/systems.json @@ -1,6 +1,9 @@ { - "_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). planetCount is a GLOBAL rule, not per-type: noneChance of all systems are barren (no planets), the rest hold a uniform whole number in [min, max]; the STARTING system always holds exactly two generated planets (gas giant + rocky) beside the home world. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.", - "planetCount": { "noneChance": 0.2, "min": 2, "max": 4 }, + "_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a jump-gate-only system — star and gates, nothing else), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.", + "objectCount": { + "barren": 0.10, + "objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 } + }, "types": { "main": { "label": "Main Sequence", diff --git a/dev/asteroids.test.mjs b/dev/asteroids.test.mjs index 164adca..39040ba 100644 --- a/dev/asteroids.test.mjs +++ b/dev/asteroids.test.mjs @@ -8,7 +8,8 @@ * - COUNT vs PLANETS: clusterCount ≈ targetObjects − planetCount (± * jitter, clamped to [minClusters, maxClusters]) — so systems with * more planets get fewer clusters and vice versa (also checked as an - * aggregate correlation across the sample); + * aggregate correlation across the non-barren sample); barren systems + * (jump-gate-only) hold NO clusters; * - the STARTING system always gets ≥ startingSystemMinClusters, and * those first ones sit INSIDE the initial tether (whole cluster, * minus placement.tetherMargin); @@ -121,12 +122,21 @@ for (const rec of sample) { // target — targetObjects − planetCount, ±jitter, clamped into [min, max] // (and [startingMin, max] for the home system). When the ideal band // clamps to empty, the clamped range itself is the contract. - const want = D.targetObjects - c.planets.length; - let loW = Math.max(D.minClusters, isHome ? D.startingSystemMinClusters : -Infinity, Math.round(want - D.jitter)); - let hiW = Math.min(D.maxClusters, Math.round(want + D.jitter)); - if (loW > hiW) { loW = D.minClusters; hiW = D.maxClusters; } - if (clusters.length < loW || clusters.length > hiW) { - countOk = false; countWhy = `${rec.id}: ${c.planets.length} planets → ${clusters.length} clusters (want ${loW}–${hiW})`; break; + // BARREN systems (0 planets + 0 free-space stations — the jump-gate-only + // stops) are the one exception: no clusters by design. + const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space'); + if (barren) { + if (clusters.length !== 0) { + countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want 0)`; break; + } + } else { + const want = D.targetObjects - c.planets.length; + let loW = Math.max(D.minClusters, isHome ? D.startingSystemMinClusters : -Infinity, Math.round(want - D.jitter)); + let hiW = Math.min(D.maxClusters, Math.round(want + D.jitter)); + if (loW > hiW) { loW = D.minClusters; hiW = D.maxClusters; } + if (clusters.length < loW || clusters.length > hiW) { + countOk = false; countWhy = `${rec.id}: ${c.planets.length} planets → ${clusters.length} clusters (want ${loW}–${hiW})`; break; + } } // Names already used in this system (planets + stations + clusters). @@ -224,7 +234,7 @@ for (const rec of sample) { if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break; } -check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk); +check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ 0)${countOk ? '' : ' — ' + countWhy}`, countOk); check(`shape: groups of ${CL.groupSize.min}–${CL.groupSize.max}, sizes ${CL.sizes.min}–${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk); check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk); check(`scatter: every cluster inside the ${P.minRadius}–${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk); @@ -237,17 +247,19 @@ check( ); // The inverse rule, in aggregate: rockier systems (few planets) get more -// clusters than planet-rich ones. +// clusters than planet-rich ones — over the NON-BARREN systems (barren are +// jump-gate-only and hold 0 clusters by design, a separate rule). { const rows = sample.map((r) => { const c = g.ensureContent(r.id); - return { planets: c.planets.length, clusters: c.asteroids.length }; - }); - const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length; + const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space'); + return { planets: c.planets.length, clusters: c.asteroids.length, barren }; + }).filter((r) => !r.barren); + const avg = (xs) => xs.reduce((s, x) => s + x, 0) / (xs.length || 1); const rich = rows.filter((r) => r.planets >= 4); const poor = rows.filter((r) => r.planets <= 2); check( - `inverse rule (aggregate): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥4 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`, + `inverse rule (aggregate, non-barren): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥4 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`, rich.length > 0 && poor.length > 0 && avg(poor.map((r) => r.clusters)) > avg(rich.map((r) => r.clusters)), ); } @@ -273,7 +285,7 @@ check( // Lazy === eager: a FRESH galaxy's on-arrival content matches the cached // one, including every cluster's position, rocks, spins and debris. const fresh = Galaxy.create(SEED); - const ids = [homeId, g.records[999].id, g.records[g.records.length - 1].id]; + const ids = [homeId, g.records[Math.floor(g.records.length / 2)].id, g.records[g.records.length - 1].id]; const lazyEager = ids.every((id) => { const f = fresh.ensureContent(id); const cached = g.ensureContent(id); diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index 4687036..7216c1b 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -13,8 +13,10 @@ * elsewhere) — sits in the band [minSpacing, maxSpacing] px * center-to-center (the home system's band tightens to * [minSpacing, homeMaxSpacing] and it holds ≤ 3 objects — 5 points - * cannot sit 6400..10240 px apart), planets scaled by class — - * deterministically (same seed ⇒ same layout, different seed ⇒ + * cannot sit 6400..10240 px apart), planets scaled by class, the + * OBJECT COMPOSITION holding (data/systems.json → objectCount: every + * non-home system 0/2/3/4/5 objects, in the configured proportions) + * — deterministically (same seed ⇒ same layout, different seed ⇒ * different); * - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on * the screen-edge rect (edges AND corners), circleInView is exact, @@ -117,7 +119,7 @@ const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum const g = Galaxy.create('discovery-layout-test'); let layoutOk = true; let layoutWhy = ''; -let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations) +let sawMaxObjects = false; // the N = 5 maximum (3 planets + 2 stations) let maxObjectsSeen = 0; let homeOk = true; const probe = (systems) => { @@ -140,7 +142,9 @@ const probe = (systems) => { // Layout objects: the central body (origin — the home world in the // starting system, the star elsewhere) + planets + free-space // stations (planet-bound settlements sit ON their planet — not - // layout objects of their own). + // layout objects of their own). A BARREN system (objectCount → 0) + // holds none — its only objects are the jump gates (not layout + // objects here; the band check degenerates to nothing). const objs = [{ x: 0, y: 0 }]; for (const p of c.planets) objs.push({ x: p.x, y: p.y }); for (const s of c.settlements ?? []) { @@ -154,7 +158,11 @@ const probe = (systems) => { } const nObjects = objs.length - 1; if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects; - if (nObjects === 6) sawMaxObjects = true; + if (nObjects === 5) sawMaxObjects = true; + if (!isHome && !(nObjects === 0 || (nObjects >= 2 && nObjects <= 5))) { + layoutOk = false; + if (!layoutWhy) layoutWhy = `${rec.id}: holds ${nObjects} objects (composition wants 0/2/3/4/5)`; + } // The home system holds at most 3 objects (its 2 fixed planets + at // most one free-space station) — 5 points cannot sit 6400..10240 px // apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6). @@ -178,13 +186,14 @@ const probe = (systems) => { } } }; -probe(g.records.slice(0, 5000)); +probe(g.records); +const nProbe = g.records.length; check( - `layout: 5000 systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`, + `layout: ${nProbe} systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`, layoutOk && homeOk, ); -check(`layout: no system exceeds the 6-object maximum (4 planets + 2 stations) — max in sample: ${maxObjectsSeen}`, maxObjectsSeen <= 6); -check('layout: the 6-object maximum (4 planets + 2 stations) occurs in the sample', sawMaxObjects); +check(`layout: no system exceeds the 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5); +check('layout: the 5-object maximum occurs in the galaxy', sawMaxObjects); // Determinism on a system that has BOTH planet and station objects. const rec0 = g.records.find((r) => diff --git a/dev/frames.test.mjs b/dev/frames.test.mjs new file mode 100644 index 0000000..5313a0d --- /dev/null +++ b/dev/frames.test.mjs @@ -0,0 +1,184 @@ +/** + * Frame-diversity test (dev tool, run with Node — no browser needed): + * + * node dev/frames.test.mjs + * + * Asserts the galaxy-wide (class, frame) pass (js/galaxy/PlanetFrames.js, + * wired in js/galaxy/Galaxy.js, data/planets.json → frames): + * - every planet carries a sheet frame inside its class's pool, and the + * starting system's home world carries a terran-pool frame + * (content.homeFrame); + * - two planets of the SAME class in one system never wear the same + * face (intra-system diversity); + * - the pass beats a naive random pick: the (class, frame) collision + * rate among each planet's 8-nearest stars is LOWER than the same + * metric for an independent random-per-planet assignment; + * - determinism: same seed ⇒ identical frames (galaxy and content), + * different seed ⇒ different assignment (spot check); + * - lazy === eager: the stamped frames equal a fresh galaxy's. + */ + +process.env.NODE_ENV = 'dev'; + +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); +const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href); + +let failures = 0; +const check = (label, cond, extra = '') => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`); + if (!cond) failures++; +}; + +const SEED = 'frames-test-seed'; +const g = Galaxy.create(SEED); +const POOL = Math.max(1, Math.floor(g.params.neighbors ?? 8)); +const poolFor = (cls) => { + const p = config.get(`planets.frames.${cls}`); + return Array.isArray(p) && p.length > 0 ? p : [0]; +}; + +// ---------------------------------------------------------------------- +// 1. Frames are present and in-pool +// ---------------------------------------------------------------------- +{ + let inPool = true; + let why = ''; + for (const rec of g.records) { + const c = g.ensureContent(rec.id); + for (const p of c.planets) { + if (typeof p.frame !== 'number' || !Number.isInteger(p.frame) || !poolFor(p.class).includes(p.frame)) { + inPool = false; + if (!why) why = `${rec.id}: ${p.class} planet frame ${p.frame} ∉ ${poolFor(p.class)}`; + } + } + } + const home = g.ensureContent(g.currentSystemId); + if (typeof home.homeFrame !== 'number' || !poolFor('terran').includes(home.homeFrame)) { + inPool = false; + if (!why) why = `home world frame ${home.homeFrame} ∉ terran pool`; + } + check('every planet frame is an integer in its class pool (home world: terran pool)', inPool, why); + + // Intra-system diversity: same-class worlds wear different faces UNLESS + // the system holds more of that class than the class pool has faces + // (pigeonhole — then every face is used, and the lexicographic pass + // guarantees exactly pool-size distinct faces). + let distinct = true; + let whyD = ''; + for (const rec of g.records) { + const c = g.ensureContent(rec.id); + const byClass = new Map(); + for (const p of c.planets) { + if (!byClass.has(p.class)) byClass.set(p.class, []); + byClass.get(p.class).push(p.frame); + } + for (const [cls, fs2] of byClass) { + const m = fs2.length; + const poolN = poolFor(cls).length; + const used = new Set(fs2).size; + if (used !== Math.min(m, poolN)) { + distinct = false; + if (!whyD) whyD = `${rec.id}: ${m} ${cls} worlds use ${used} faces (want ${Math.min(m, poolN)})`; + } + } + } + check('same-class worlds in one system wear distinct faces (pigeonhole-permitting)', distinct, whyD); +} + +// ---------------------------------------------------------------------- +// 2. The pass beats a naive random assignment +// ---------------------------------------------------------------------- +{ + // The pass's (class, frame) collision rate: for each planet, count its + // 8-nearest stars' planets wearing the SAME class AND frame (directed). + const collisionRate = (frameOf) => { + let coll = 0; + let checks = 0; + for (const rec of g.records) { + const c = g.ensureContent(rec.id); + c.planets.forEach((p, i) => { + for (const nb of g.neighborsOf(rec.id, POOL)) { + checks++; + const nbC = g.ensureContent(nb.id); + if (nbC.planets.some((q, j) => q.class === p.class && frameOf(nb.id, q, j) === p.frame)) coll++; + } + }); + } + return { coll, checks, rate: coll / checks }; + }; + + const passed = collisionRate((id, p, i) => g.planetFrames.get(id)[i]); + + // Naive: independent random pool picks (seeded, per planet). + const naive = new Map(); + for (const rec of g.records) { + const c = g.ensureContent(rec.id); + naive.set(rec.id, c.planets.map((p, i) => { + const pool = poolFor(p.class); + return pool.length === 1 ? pool[0] : pool[Math.floor(Rng.derive(SEED, 'naive', rec.id, String(i), p.class).next() * pool.length)]; + })); + } + const na = collisionRate((id, p, i) => naive.get(id)[i]); + + check( + `(class, frame) collisions vs the ${POOL}-nearest pool: pass ${(passed.rate * 100).toFixed(1)}% < naive ${(na.rate * 100).toFixed(1)}%`, + passed.rate < na.rate, + `${passed.coll}/${passed.checks} vs ${na.coll}/${na.checks} (density sets the floor — same-class neighbors dominate)`, + ); +} + +// ---------------------------------------------------------------------- +// 3. Determinism + lazy === eager +// ---------------------------------------------------------------------- +{ + const g2 = Galaxy.create(SEED); + let same = true; + for (const rec of g.records) { + if (JSON.stringify(g.planetFrames.get(rec.id)) !== JSON.stringify(g2.planetFrames.get(rec.id))) { + same = false; + break; + } + } + check('same seed ⇒ same (class, frame) assignment galaxy-wide', same); + check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame); + + // Content frames === galaxy frames (the stamping contract). + let stamped = true; + for (const rec of g.records) { + const c = g.ensureContent(rec.id); + if (JSON.stringify(c.planets.map((p) => p.frame)) !== JSON.stringify(g.planetFrames.get(rec.id))) { + stamped = false; + break; + } + } + check('content planet frames === the galaxy pass (lazy === eager stamping)', stamped); + + const g3 = Galaxy.create('frames-test-OTHER'); + let diff = false; + for (const rec of g.records) { + if (JSON.stringify(g.planetFrames.get(rec.id)) !== JSON.stringify(g3.planetFrames.get(rec.id))) { + diff = true; + break; + } + } + check('different seed ⇒ different assignment (spot check)', diff || g3.records[0].id !== g.records[0].id); +} + +console.log(failures === 0 ? '\nAll frame-diversity tests passed ✔' : `\n${failures} test(s) FAILED ✘`); +process.exit(failures === 0 ? 0 : 1); diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index 9db634b..ba7626d 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -11,10 +11,13 @@ * - type distribution matches the weights in data/systems.json; * - type radius bands (the first proximity rule) are respected; * - every generated system obeys its type's attribute bounds; + * - the OBJECT COMPOSITION (data/systems.json → objectCount): every + * non-home system holds 0, 2, 3, 4, or 5 objects (planets + free-space + * stations), ≈ 10% barren — jump-gate-only stops; * - lazy (on-arrival) content === eager (generateAll) content; * - spatial-hash neighbor queries agree with brute force; * - starting system policy works. - * Also reports generation timing for the default 40,000-system galaxy. + * Also reports generation timing for the 200-system galaxy. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; @@ -146,33 +149,36 @@ let big; } check('radius bands (proximity rule) respected by every system', bandOk); - // Lazy contents: the global planet-count rule across the WHOLE galaxy. + // Lazy contents: the global OBJECT-COMPOSITION rule across the WHOLE + // galaxy (data/systems.json → objectCount: 0/2/3/4/5 objects, ≈ 10% + // barren — jump-gate-only stops). const t1 = performance.now(); - const pc = config.get('systems.planetCount', { noneChance: 0, min: 0, max: 99 }); + const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } }); let boundsOk = true; let emptyCount = 0; for (const r of big.records) { const content = big.ensureContent(r.id); - const n = content.planets.length; + const n = content.planets.length + (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length; if (n === 0) emptyCount++; - if (!(n === 0 || (n >= pc.min && n <= pc.max))) { + if (r.id !== big.currentSystemId && !(n === 0 || (n >= 2 && n <= 5))) { boundsOk = false; break; } if (!content.star || typeof content.star.class !== 'string') boundsOk = false; } const tGen = performance.now() - t1; - check(`every system has 0 or [${pc.min}, ${pc.max}] planets (the global planetCount rule)`, boundsOk); + check('every non-home system holds 0/2/3/4/5 objects (the global objectCount rule)', boundsOk); const emptyShare = emptyCount / nSys; + const barrenExpect = OC.barren ?? 0.1; check( - `≈ ${Math.round((pc.noneChance ?? 0) * 100)}% of systems are barren (observed ${(emptyShare * 100).toFixed(1)}%)`, - Math.abs(emptyShare - (pc.noneChance ?? 0.2)) < 0.01, + `≈ ${Math.round(barrenExpect * 100)}% of systems are barren — jump-gate-only (observed ${(emptyShare * 100).toFixed(1)}%)`, + Math.abs(emptyShare - barrenExpect) < 0.08, ); - check('lazy content generation over all 40k systems', big.generatedCount === nSys); + check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys); // Lazy === eager: fresh galaxy (unopened) vs fully generated one. const fresh = Galaxy.create(SEED); - const sample = [big.records[0].id, big.records[999].id, big.records[nSys - 1].id]; + const sample = [big.records[0].id, big.records[Math.floor(nSys / 2)].id, big.records[nSys - 1].id]; const lazyEager = sample.every((id) => deepEq(fresh.ensureContent(id), big.ensureContent(id))); check('lazy (on-arrival) content === content already generated', lazyEager); const eager = Galaxy.create(SEED).generateAll(); @@ -308,25 +314,43 @@ let big; } check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk); - // JUMP-GATE ANCHOR GUARANTEE: every system holds at least one planet or - // free-space station — the jump gates sit within level-1 tether of an - // anchor, so a system with neither would be closed (unreachable). - let anchored = 0; + // OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only + // unsettled ones — jump-gate-only stops, deliberately (strong + // connectivity keeps them reachable); every non-barren system is settled + // (all its planets + its free-space stations). + let barren = 0; for (const r of sample) { const c = big2.ensureContent(r.id); - if (c.planets.length > 0 || c.settlements.some((s) => s.anchor?.type === 'space')) anchored++; + if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++; } - check(`every system holds a gate anchor — a planet or space station (${anchored}/${sample.length} in sample)`, anchored === sample.length); - check('no system is left unclaimed (the anchor guarantee settled the barren ones)', sample.every((r) => big2.ensureContent(r.id).settlements.length > 0)); + const barrenShare = barren / sample.length; + check( + `barren systems are jump-gate-only: ≈ ${Math.round((config.get('systems.objectCount.barren', 0.1) * 100))}% (observed ${(barrenShare * 100).toFixed(1)}%)`, + Math.abs(barrenShare - (config.get('systems.objectCount.barren', 0.1))) < 0.08, + ); + check( + 'every non-barren system is settled (the home system is exempt)', + sample.every((r) => { + const c = big2.ensureContent(r.id); + const barren2 = c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space'); + return barren2 || r.id === big2.currentSystemId || c.settlements.length > 0; + }), + ); - // Core→rim gradient: the settled heart is denser than the wilder rim. - const withCount = sample.map((r) => ({ rNorm: r.rNorm, n: big2.ensureContent(r.id).settlements.length })); + // Core→rim gradient: the free-space STATION odds scale with the core→rim + // density (data/galaxy.json → settlements.gradient) — the planets are + // settled regardless, so the gradient lives in how many of the objects + // are stations: the settled heart is denser in stations than the rim. + const withCount = sample.map((r) => ({ + rNorm: r.rNorm, + n: (big2.ensureContent(r.id).settlements ?? []).filter((s) => s.anchor?.type === 'space').length, + })); withCount.sort((a, b) => a.rNorm - b.rNorm); const third = Math.floor(withCount.length / 3); const inner = withCount.slice(0, third); const outer = withCount.slice(-third); const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length; - check(`core→rim settlement gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer)); + check(`core→rim station gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer)); // Station + planet naming now draws from the curated BANKS (no repeats // within a system until the pool is exhausted). The deck is a seeded @@ -349,8 +373,9 @@ let big; check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0)); check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12'); - // The anchor guarantee settled every system, so the "unclaimed" report - // branch is a defensive fallback (content with zero settlements). + // The BARREN systems (objectCount → 0) are genuinely unclaimed — that + // is the "unclaimed · N jump gates" report branch in the wild now (not + // just a defensive fallback). // No-duplicate guarantee: within a system, planet names, station names // and jump-gate names never repeat (drawn without replacement from the diff --git a/dev/jumpgate.test.mjs b/dev/jumpgate.test.mjs index 55dc77c..c3dcc31 100644 --- a/dev/jumpgate.test.mjs +++ b/dev/jumpgate.test.mjs @@ -10,11 +10,15 @@ * - discovery identity: discoveryId/discoveryName from the record, * size from data/gates.json, bound = size (compass/toast scale), * clearance = gates.shipClearance, rotation = the record's bearing; + * - ACTIVITY (data/gates.json → ACTIVITY): gate.active mirrors the + * record (false by default — the gate is dormant: dimmed, field + * still), an active:true gate breathes its field; * - the SOLID contract (same as Planet/Station): minCenterDistance, * edgePoint exactly `gap` past the surface, aimPoint clamping, and * constrainShip pushing the ship out of the keepout circle * (through the shared Planet.resolve); - * - update() breathes the field without throwing; + * - update() breathes the field (active) or keeps it still (dormant) + * without throwing; * - destroy() tears the children down. */ import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -100,6 +104,24 @@ check('position comes from the record', gate.x === gateRec.x && gate.y === gateR check('size comes from data/gates.json', gate.size === config.get('gates.size', 96) && gate.bound === gate.size); check('clearance comes from gates.shipClearance', gate.clearance === config.get('gates.shipClearance', 50)); check('rotation = the record’s bearing (facing the destination star)', gate.rotation === gateRec.rotation); +check('active mirrors the record (false by default — dormant)', gateRec.active === false && gate.active === false); +check('a dormant gate renders dim (alpha 0.4)', Math.abs(gate.alpha - 0.4) < 1e-9); + +// --- ACTIVITY: a dormant gate's field stays still; an active one breathes +{ + const before = alphas.map((c) => c.alpha); + gate.update(1000); + gate.update(2000); + check('dormant gate: update() leaves the field still (no breathing)', alphas.every((c, i) => c.alpha === before[i])); + const alphasB = alphas.length; + const gateB = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 }); + const alphas2 = alphas.slice(alphasB); + gateB.update(0); + const a0 = alphas2.map((c) => c.alpha); + gateB.update(873); // t ≈ π/2 of the pulse → maximum breathing + check('active gate: the field breathes (alpha changes over time)', alphas2.some((c, i) => Math.abs(c.alpha - a0[i]) > 1e-6)); + check('an active gate is not dimmed', gateB.alpha === 1); +} // --- The solid contract (same rules as Planet / Station) ------------------ const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2; @@ -134,8 +156,8 @@ check('minCenterDistance = radius + clearance + shipRadius', Math.abs(minDist - } // --- Animation + teardown -------------------------------------------------- -gate.update(1000); // must not throw; the field breathes -check('update() breathes the field discs (alpha set)', alphas.length === 2 && alphas.every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5)); +gate.update(1000); // must not throw; a dormant gate keeps the field still +check('update() runs without throwing (dormant gate)', alphas.slice(0, 2).every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5)); const childCount = gate.children.length; gate.destroy(); check('destroy() clears the children', gate.children.length === 0 && childCount > 0); diff --git a/dev/jumps.test.mjs b/dev/jumps.test.mjs index eee0668..c1b5a67 100644 --- a/dev/jumps.test.mjs +++ b/dev/jumps.test.mjs @@ -14,16 +14,22 @@ * * the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates): * - content.jumps matches the network (count + destinations); - * - every gate is within level-1 tether (tether.level1Radius) of an - * ANCHOR — a planet, a free-space station, or the home world in the - * starting system; - * - every gate FACES its destination star on the 2-D map: the bearing - * from the anchor to the gate is within 90° of the system→star - * bearing (soft rule — same side, never opposite); + * - ANCHORED systems (a planet, a free-space station, or the home world): + * every gate is within level-1 tether (tether.level1Radius) of an + * anchor and FACES its destination star on the 2-D map (soft rule — + * within 90° of the system→star bearing from the anchoring object); + * - BARREN systems (objectCount → 0 — a jump-gate-only stop): the gate + * sits ON the ray toward its destination, ≥ gates.barrenDistance from + * the star, within the radius band, facing it; * - gates stay gates.minRadius..gates.maxRadius from the star; * - gates keep size+clearance from anchor discs and 2·size+gateGap * from each other; * - gate ids/names are unique per system; + * - every gate record carries active: false (data/gates.json → ACTIVITY); + * - the OBJECT COMPOSITION (data/systems.json → objectCount): every + * non-home system holds 0, 2, 3, 4, or 5 objects (planets + + * free-space stations) in the configured proportions; a barren + * system holds nothing else — no asteroid clusters either; * * and DETERMINISM: same seed ⇒ same network, same gates, same layout; * different seed ⇒ different network (spot check). @@ -65,6 +71,7 @@ const CLEAR = GATES.clearance ?? 256; const GAP = GATES.gateGap ?? 192; const MIN_R = GATES.minRadius ?? 2048; const MAX_R = GATES.maxRadius ?? 20480; +const BARN_D = GATES.barrenDistance ?? 8192; const SEED = 'jumps-test-seed'; const g = Galaxy.create(SEED); @@ -137,12 +144,14 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P // ---------------------------------------------------------------------- { const sample = [recOf.get(HOME), ...g.records.slice(0, 3000).filter((r) => r.id !== HOME)]; - let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0; - let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = ''; + let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0, barrenBad = 0, active = 0; + let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '', bWhy = ''; let netMismatch = false; for (const sys of sample) { const c = g.ensureContent(sys.id); const isHome = sys.id === HOME; + const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length; + const isBarren = c.planets.length === 0 && spaceCount === 0; // content.jumps matches the network (count + destinations, in order). const net = g.jumpGatesFor(sys.id); if ( @@ -152,10 +161,15 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P netMismatch = true; if (!mWhy) mWhy = `${sys.id}: content.jumps ≠ jumpGatesFor`; } + // ACTIVITY (data/gates.json → ACTIVITY): every gate starts inert. + if (c.jumps.some((j) => j.active !== false)) { + active++; + if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`; + } // Anchors: planets, free-space stations, and (home) the home world. const anchors = [ ...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })), - ...(c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })), + ...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []), ]; if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' }); @@ -167,28 +181,39 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P return; } const th = Math.atan2(t.y - sys.y, t.x - sys.x); - // TETHER (hard): within level-1 range of some anchor. - let bestD = Infinity; - for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y)); - if (bestD > TETHER + 1e-6) { - tether++; - if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`; - } - // FACING (soft): from the anchoring object, the gate is on the - // target side — within 90° of the system→star bearing (some anchor - // must satisfy BOTH the tether and the facing). - let onSide = false; - for (const a of anchors) { - const d = Math.hypot(j.x - a.x, j.y - a.y); - if (d > TETHER + 1e-6) continue; - if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true; - } - if (!onSide) { - facing++; - if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`; + const d0 = Math.hypot(j.x, j.y); + if (isBarren) { + // BARREN: on the ray toward the destination, ≥ barrenDistance from + // the star (stepped outward within the band if the gap forced it), + // facing it (the bearing nudge, if any, stays within 90°). + const dev = Math.abs(norm(Math.atan2(j.y, j.x) - th)); + if (d0 < BARN_D - 1e-6 || d0 > MAX_R + 1e-6 || dev >= Math.PI / 2) { + barrenBad++; + if (!bWhy) bWhy = `${sys.id}: barren gate ${j.id} at ${Math.round(d0)} px, ${(dev * 57.3).toFixed(1)}° off the target ray`; + } + } else { + // TETHER (hard): within level-1 range of some anchor. + let bestD = Infinity; + for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y)); + if (bestD > TETHER + 1e-6) { + tether++; + if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`; + } + // FACING (soft): from the anchoring object, the gate is on the + // target side — within 90° of the system→star bearing (some anchor + // must satisfy BOTH the tether and the facing). + let onSide = false; + for (const a of anchors) { + const d = Math.hypot(j.x - a.x, j.y - a.y); + if (d > TETHER + 1e-6) continue; + if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true; + } + if (!onSide) { + facing++; + if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`; + } } // RADIUS band from the star. - const d0 = Math.hypot(j.x, j.y); if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) { radius++; if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the star (band ${MIN_R}..${MAX_R})`; @@ -222,21 +247,48 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P } } check('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy); - check(`tether (hard): every gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy); - check('facing (soft): every gate is on the target side of its anchor (< 90°)', facing === 0, fWhy); + check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy); + check('facing (soft): every anchored-system gate is on the target side of its anchor (< 90°)', facing === 0, fWhy); + check(`barren gates sit on the target ray, ≥ ${BARN_D} px from the star, facing it`, barrenBad === 0, bWhy); check(`radius band: every gate ${MIN_R}..${MAX_R} px from the star`, radius === 0, rWhy); check(`clearance: every gate keeps ${SIZE} + 100 px from anchor discs`, clearance === 0, cWhy); check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy); check('gate ids are -j and names are unique per system', unique === 0); + check('every gate record is active:false (inert until activation)', active === 0, bWhy); - // The anchor guarantee: every system holds a planet or space station - // (a gate must be tether-reachable from one). - let noAnchor = 0; + // The OBJECT COMPOSITION (data/systems.json → objectCount): every + // non-home system holds 0, 2, 3, 4, or 5 objects (planets + + // free-space stations) in the configured proportions — 0 = a barren, + // jump-gate-only stop (no asteroid clusters either). + const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } }); + const expected = { 0: OC.barren ?? 0.1 }; + for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w; + const counts = {}; + let shapeBad = 0, whyShape = '', barrenClusters = 0; for (const r of g.records) { + if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets) const c = g.ensureContent(r.id); - if (c.planets.length === 0 && !(c.settlements ?? []).some((s) => s.anchor?.type === 'space')) noAnchor++; + const n = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length; + counts[n] = (counts[n] ?? 0) + 1; + if (!(n in expected)) { + shapeBad++; + if (!whyShape) whyShape = `${r.id}: ${n} objects`; + } + if (n === 0 && (c.asteroids ?? []).length > 0) barrenClusters++; } - check(`anchor guarantee: every one of the ${g.records.length} systems holds a planet or space station`, noAnchor === 0, `${noAnchor} without`); + const nN = g.records.length - 1; + let distOk = true; + for (const [n, p] of Object.entries(expected)) { + const obs = (counts[Number(n)] ?? 0) / nN; + const sd = Math.sqrt(p * (1 - p) / nN); + if (Math.abs(obs - p) > 4 * sd + 0.004) { + distOk = false; + console.log(` ${n} objects: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`); + } + } + check(`composition: every non-home system holds 0/2/3/4/5 objects — ${g.records.length} systems`, shapeBad === 0, whyShape); + check('composition: the object counts match the configured proportions (±4σ)', distOk); + check('barren systems are truly barren — no asteroid clusters', barrenClusters === 0, `${barrenClusters} with clusters`); } // ---------------------------------------------------------------------- @@ -254,7 +306,8 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P const b = g2.ensureContent(g2.records[i].id); if (JSON.stringify(a.jumps) !== JSON.stringify(b.jumps)) placement = false; if ( - a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y) || + a.planets.length !== b.planets.length || + a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y || p.frame !== b.planets[k].frame) || JSON.stringify((a.settlements ?? []).map((s) => [s.x, s.y])) !== JSON.stringify((b.settlements ?? []).map((s) => [s.x, s.y])) ) placement = false; diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index ca99c7c..21d3430 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -81,7 +81,7 @@ The galaxy is **seeded and two-level**. The seed is chosen on the main menu (displayed, editable, rerollable; same seed ⇒ same galaxy). 1. **Roster** — `Galaxy.create(seed)` builds every system's *identity* - (id, name, type, x, y) up front. 40,000 systems is ~70 ms, so the whole + (id, name, type, x, y) up front. 200 systems is ~15 ms, so the whole galaxy is always known: the player can never "discover" a layout that wasn't already implied by the seed. 2. **Contents** — planets/moons/belts/**settlements**/hazards are @@ -107,13 +107,26 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy). classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, free-space settlement odds). New attribute key = JSON + a few lines in `SystemGenerator.js`; -- **planet count is global, not per-type** — `data/systems.json → - planetCount`: noneChance (20%) of all systems are barren (no planets - at all), the rest hold a uniform whole number in [min, max] (2–4). - Exception: the **starting system** always holds exactly two generated - planets — a gas giant and a rocky world — which with the home world - (the origin, the player's homestead, not a generated planet) makes its - three planets, always. +- **object count is global, not per-type** — `data/systems.json → + objectCount`: `barren` (10%) of all systems are jump-gate-only (no + planets, no stations — `barren` ⇒ `0`), the rest hold a weighted whole + number of OBJECTS (planets + free-space stations) from the `objects` + table (2/3/4/5). Stations roll first (0–2, per-type odds × the core→rim + density), then planets fill the remaining budget (N − stations). The + **starting system** is the exception: it always holds exactly two + generated planets — a gas giant and a rocky world — which with the home + world (the origin, the player's homestead, not a generated planet) makes + its three planets, always. +- **planet frames are spread galaxy-wide** — `data/planets.json → frames` + maps each class to its spritesheet face pool. A random per-system pick + would let neighboring stars wear the same face, so `Galaxy._generate()` + runs one galaxy-wide pass (`js/galaxy/PlanetFrames.js → + assignPlanetFrames`): systems in a FIXED spatial order (x, y — a pure + function of the seeded roster, so it never depends on visit order) + each pick the least-used (class, frame) among their already-assigned + nearest stars, tie-broken by a derived seeded Rng. The stamps land on + each planet record (`planet.frame`) and the home world's face on + `content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`. - **distributed** — `distribution.weight` (how common) and `distribution.radiusBand` (first proximity rule: e.g. `void` systems live in the outer rim). Richer galaxy-level rules (clustering, @@ -135,11 +148,11 @@ rocky world, `miningStation` over every other rocky/ice/lava world, `cloudBase` riding every gas giant. Plus the free-space kinds: `deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon — the faint trace of a crossed galaxy), still rolled per archetype and -thinned core→rim. The **anchor guarantee** (jump gates must be -reachable on a level-1 tether) settles the rest: a system that rolls no -planet and no free-space station still gets a `deepSpaceStation`, so no -system is left unclaimed (the "charted · unclaimed" report branch is a -defensive fallback). Model & seams: +thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are +the one exception to "lived in": they hold no planets and no stations — +just the jump gate — and are reachable only by jumping in (strong +connectivity keeps them on the network). Their "charted · unclaimed" report +branch is now the normal case, not a defensive fallback. Model & seams: - **Kinds vocabulary** — `data/settlements.json` (label, description, theme color, population range, anchor type). Add a kind = JSON + naming pool; the generator picks it up by name. @@ -262,7 +275,13 @@ exit toward a NEARBY star on the 2-D map. Two layers: px) from the star, keep `size` + `clearance` from anchor discs and 2·`size` + `gateGap` from each other, and get unique names ("Avidy Gate", "Avidy Gate II" — star names are syllable-generated and -collide). A gate's `rotation` is the bearing from the gate to its +collide). **Barren systems** (no anchors — the `objectCount` → 0 stops) + get their single gate on the star→destination ray at `barrenDistance` + (8192 px) instead. Every gate record carries `active: false` — gates + are DORMANT until activated (the entity renders dim, field still); + activation is the seam for the tether mechanic: an activated gate + anchors a level-1 tether so the player can leave. A gate's `rotation` + is the bearing from the gate to its destination star — the art (twin-pylon portal, `js/entities/JumpGate.js` — procedural, Station.js-style) faces where it jumps. - **In the scene** — GameScene builds the gates as solid world objects @@ -273,10 +292,11 @@ collide). A gate's `rotation` is the bearing from the gate to its — standing at a gate is where the jump happens; the jump mechanic itself is the follow-up. - **Determinism** — same seed ⇒ same network, same gates, same - placements. Verified: `dev/jumps.test.mjs` (network invariants, - placement invariants, determinism), `dev/jumpgate.test.mjs` (the - entity's solid contract), and the layout band in - `dev/discovery.test.mjs`. + placements, same `active` flags. Verified: `dev/jumps.test.mjs` + (network invariants, placement invariants, barren gates on-ray, + `active: false` everywhere, composition buckets, determinism), + `dev/jumpgate.test.mjs` (the entity's solid contract + dormant/active + rendering), and the layout band in `dev/discovery.test.mjs`. ## The tether — the player's range (important) @@ -680,7 +700,7 @@ The player holds a REPUTATION (standing) on each planet and space station: - [x] v0.1 foundation — menu → New Game → click-to-fly ship - [x] Galaxy seed on the main menu (displayed, editable, rerollable; same seed → same galaxy, shown before you commit) -- [x] Two-level worldgen: seeded galaxy roster (40k systems) + lazy, +- [x] Two-level worldgen: seeded galaxy roster (200 systems) + lazy, order-independent system contents; system archetypes in JSON (theme + attributes + distribution weight/radius band) - [x] The lived-in layer: settlements (colonies, mining stations, cloud diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index 6d4f171..7cd6067 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -17,6 +17,13 @@ import { Planet } from './Planet.js'; * standing at a gate is where the jump happens; that arrives with the * jump mechanic. * + * ACTIVITY (data/gates.json → ACTIVITY): `gate.active` defaults to false + * — the gate is inert until the player activates it (future mechanic), + * and an activated gate anchors a level-1 tether at its own position — + * the room to move in a barren system. Until then the gate reads as + * DORMANT: dimmed overall (alpha 0.4), the field still and faint (the + * pulse is the "this one is live" tell). + * * The container itself carries `rotation` (the art is drawn facing * +x), so the whole gate — pylons, mouth, chevrons — faces its * destination. The collision API is circular and rotation-blind. @@ -35,6 +42,8 @@ export class JumpGate extends Phaser.GameObjects.Container { super(scene, gate.x, gate.y); this.scene.add.existing(this); // v4: new'd containers are not on the display list this.gate = gate; + // Inert until activated (data/gates.json → ACTIVITY) — see the header. + this.active = gate?.active === true; this.discoveryId = gate.id; this.discoveryName = gate.name; @@ -135,12 +144,17 @@ export class JumpGate extends Phaser.GameObjects.Container { chev(S * 0.24, S * 0.2, S * 0.05, 0.95); chev(S * 0.48, S * 0.2, S * 0.04, 0.6); this.add(top); + + // DORMANT look — inactive gates are dim (the pulse below is the live tell). + if (!this.active) this.setAlpha(0.4); } /** The ring drifts; the field breathes. (GameScene.update drives this.) */ update(time) { const t = time / 1000; - if (this.ringBody) this.ringBody.rotation = t * 0.12; + if (this.ringBody) this.ringBody.rotation = t * (this.active ? 0.12 : 0.06); + // A dormant gate keeps a still, faint field — no breathing. + if (!this.active) return; const pulse = 0.5 + 0.5 * Math.sin(t * 1.8); if (this.field) this.field.setAlpha(0.1 + 0.14 * pulse); if (this.fieldCore) this.fieldCore.setAlpha(0.14 + 0.2 * pulse); diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index e04acaa..ab79828 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -2,6 +2,7 @@ import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; import { buildJumpNetwork } from './JumpNetwork.js'; +import { assignPlanetFrames } from './PlanetFrames.js'; import { generateSystemContent } from './SystemGenerator.js'; const TAU = Math.PI * 2; @@ -19,8 +20,8 @@ const wrapPI = (a) => { * * 1. GALAXY ROSTER — generated once, up front, when New Game is pressed: * `systemCount` lightweight records ({ id, name, type, x, y }). - * This is cheap: ~40,000 systems is a fraction of a second and a few - * MB. It fixes the shape of the galaxy, where every system sits, and + * This is cheap: 200 systems is a few ms and a few hundred KB. + * It fixes the shape of the galaxy, where every system sits, and * what KIND each one is — for the entire galaxy, from the seed alone. * * 2. SYSTEM CONTENTS — planets, moons, belts, hazards… generated LAZILY, @@ -50,6 +51,8 @@ export class Galaxy { this.byId = new Map(); this.contentCache = new Map(); this.currentSystemId = null; + this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass + this.homeWorldFrame = null; // the starting system's home world frame this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name')); } @@ -67,7 +70,7 @@ export class Galaxy { if (Object.keys(typeDefs).length === 0) { throw new Error('No system types found — is data/systems.json listed in data/manifest.json?'); } - const count = Math.floor(params.systemCount ?? 40000); + const count = Math.floor(params.systemCount ?? 200); if (!(count >= 1)) { throw new Error(`galaxy.systemCount must be a whole number >= 1 (got ${params.systemCount})`); } @@ -184,6 +187,24 @@ export class Galaxy { if (this.jumpNetworkRepaired > 0) { console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`); } + + // FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide + // (class, frame) assignment — each planet's sheet frame avoids what + // the NEAREST stars already wear for that class, so the same face is + // spread across the galaxy instead of clustering in one region. Fixed + // roster order ⇒ visit-order independent; read back by the content + // generator (planet.frame / content.homeFrame) — the lazy === eager + // contract is preserved. + const pool8 = Math.max(1, Math.floor(this.params.neighbors ?? 8)); + const { frames, homeFrame } = assignPlanetFrames({ + seed: this.seed, + records, + homeId: this.currentSystemId, + params: this.params, + neighborsOf: (id) => this.neighborsOf(id, pool8), + }); + this.planetFrames = frames; // Map id → [frame, ...] (ordinal order) + this.homeWorldFrame = homeFrame; } /** Center-weighted radius sample in [0,1]: core bulge + disk. */ diff --git a/js/galaxy/JumpNetwork.js b/js/galaxy/JumpNetwork.js index cf64d6a..3326a48 100644 --- a/js/galaxy/JumpNetwork.js +++ b/js/galaxy/JumpNetwork.js @@ -35,7 +35,7 @@ * connectivity survives. * * The repair pass (below) is defensive: it fires only if the neighbor - * graph is disconnected (effectively impossible at 40k points), and it + * graph is disconnected (effectively impossible at this scale), and it * still respects the degree budget. */ diff --git a/js/galaxy/PlanetFrames.js b/js/galaxy/PlanetFrames.js new file mode 100644 index 0000000..24115c1 --- /dev/null +++ b/js/galaxy/PlanetFrames.js @@ -0,0 +1,113 @@ +import { config } from '../config/Config.js'; +import { Rng } from '../utils/Rng.js'; +import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; + +/** + * FRAME DIVERSITY — the galaxy-wide (class, frame) assignment. + * + * data/planets.json → frames maps each planet class to a pool of + * spritesheet frames (terran/ice/lava share 0–2, gas 3–5, rocky 6–8 — + * the same face can be worn by different classes; that's fine). Picking + * a random frame per system at render time would let neighboring stars + * wear the same face; this pass spreads each (class, frame) across the + * galaxy instead: when a system's class-C planet needs a frame, it + * AVOIDS frames class C already wears among the NEAREST stars (the + * galaxy's neighbor pool, data/galaxy.json → neighbors), so the same + * face reappears only far away. + * + * Why the pass is order-independent (determinism): systems are assigned + * in a FIXED order — sorted by (x, y), a pure function of the seeded + * roster — and each system only counts frames ALREADY assigned to its + * neighbors (plus its own earlier planets). Nothing depends on generation + * timing or visit order, so lazy (on-arrival) content generation and + * eager generateAll stamp the same frames. The spatial order also makes + * the "already assigned" set spatially consistent, which spreads the + * faces a little better than roster order. Ties are broken by a + * derived, per-pick Rng (seeded). + * + * Shared rolls: the pass re-derives each system's planet classes via the + * same exported roll and forks as the content generator + * (SystemGenerator.rollSystemComposition) — guaranteed to agree, since + * the forks are pure functions of (seed, id, type, density). + * + * Cost: one weighted draw + a few pool scans per planet — trivial at this + * galaxy size (data/galaxy.json → systemCount), computed once at galaxy + * build time (js/galaxy/Galaxy.js). + */ +export function assignPlanetFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) { + const defs = typeDefs ?? config.get('systems.types', {}); + const shim = { params: params ?? {} }; // settlementDensity's shape + + const poolFor = (cls) => { + const pool = config.get(`planets.frames.${cls}`); + return Array.isArray(pool) && pool.length > 0 ? pool : [0]; + }; + + const assigned = new Map(); // id → [[class, frame], ...] (assigned so far) + const frames = new Map(); // id → [frame, ...] (one per planet, ordinal order) + let homeFrame = null; + + // FIXED spatial order (x, then y): a pure function of the seeded roster, + // so the assignment never depends on generation timing or visit order. + const ordered = records.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y)); + + for (const rec of ordered) { + const isHome = rec.id === homeId; + const attr = defs[rec.type]?.attributes ?? {}; + const density = settlementDensity(shim, rec); + const { classes } = rollSystemComposition( + seed, rec, isHome, attr.settlements ?? {}, density, attr, + ); + const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? []; + + // usage(class, frame) = [own, nb]: same-class planets already wearing + // that frame in THIS system (own) and among the ALREADY-ASSIGNED + // neighbor systems (nb — the spread objective). + const list = []; + const usage = (cls, f) => { + let own = 0; + let nb = 0; + for (const [c2, f2] of list) if (c2 === cls && f2 === f) own++; + for (const n2 of neighbors) { + for (const [c2, f2] of assigned.get(n2.id) ?? []) if (c2 === cls && f2 === f) nb++; + } + return [own, nb]; + }; + const less = (a, b) => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]); + + // Pick the frame with the lowest (own, nb) usage — lexicographic: a + // free face in this system always beats a used-but-neighborly one — + // ties broken by a deterministic derived pick (seed, id, ordinal, class). + const pick = (cls, idx) => { + const pool = poolFor(cls); + if (pool.length === 1) return pool[0]; + let best = null; + for (const f of pool) { + const u = usage(cls, f); + if (best === null || less(u, best)) best = u; + } + const tied = pool.filter((f) => { + const u = usage(cls, f); + return u[0] === best[0] && u[1] === best[1]; + }); + if (tied.length === 1) return tied[0]; + return Rng.derive(seed, 'frames', rec.id, String(idx), cls).pick(tied); + }; + + classes.forEach((cls, i) => { + const f = pick(cls, i); + list.push([cls, f]); + }); + frames.set(rec.id, list.map(([, f]) => f)); + if (isHome) { + // The home world (the origin) is a class-terran body — it takes a + // frame from the same pass so its face is spread too. + const homeCls = config.get('planets.homePlanet', 'terran'); + homeFrame = pick(homeCls, 99); + list.push([homeCls, homeFrame]); + } + assigned.set(rec.id, list); + } + + return { frames, homeFrame }; +} diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 2422201..b88571d 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -22,21 +22,29 @@ const DEG = Math.PI / 180; * data/systems.json (`types..attributes`): star classes, binary * chance, planet class weights, moon/belt chances, habitability, hazard, * and — the lived-in layer — `settlements` (per-type odds for the - * free-space kinds). Planet COUNT is a global rule (data/systems.json → - * `planetCount`): noneChance of systems are barren, the rest hold 2–4 - * worlds. Settlement kinds and their population ranges live in - * data/settlements.json; the core→rim density gradient in data/galaxy.json - * (`settlements.gradient`). + * free-space kinds). The system's OBJECT COUNT is a global rule + * (data/systems.json → `objectCount`): the starting system is exempt + * (fixed two planets beside the home world + at most one station); + * every other system rolls its TOTAL object count — planets + + * free-space stations together — from the table (default: 10% barren — + * a jump-gate-only system — then 2/3/4/5 objects at 15/30/30/15%). + * Stations roll first (per-type odds × the core→rim gradient), planets + * fill the rest of the budget. Settlement kinds and their population + * ranges live in data/settlements.json; the core→rim density gradient in + * data/galaxy.json (`settlements.gradient`). * * The galaxy is ALREADY LIVED IN: it was settled long before the player. * EVERY planet hosts a settlement (for now — data/settlements.json → * allPlanetsSettled + settledKindByClass): colonies on habitable worlds, - * mining stations over the rest, cloud bases riding gas giants. Every - * system holds at least one planet or free-space station — a barren - * system that rolls no station gets a gate station — because the jump - * gates (below) must be tether-reachable from an anchor. Nothing here is - * hostile yet: `owner` on every settlement is a reserved seam for the - * factions and pirates we'll introduce later. + * mining stations over the rest, cloud bases riding gas giants. The + * BARREN systems (objectCount → 0) are the deliberate exception: star and + * jump gates, nothing else — dead-end stops on the network (strong + * connectivity keeps them reachable and escapable), where the player's + * room to move is the activated gate's own level-1 tether (data/gates.json + * → ACTIVITY: every gate carries `active`, default false; the activation + * mechanic is future work). Nothing here is hostile yet: `owner` on every + * settlement is a reserved seam for the factions and pirates we'll + * introduce later. * * The STARTING system is special: the player's home world sits at the * origin (not a generated planet, fixed key 'home'), and the system always @@ -60,9 +68,22 @@ const DEG = Math.PI / 180; * JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js): * 1–3 gates per system — each placed on the side of the system facing its * destination star on the 2-D map (an upper-right gate jumps to a star in - * the upper right), within level-1 tether (5120 px) of a planet or space - * station. The galaxy-wide network is strongly connected: no closed - * systems, no trapped sets. + * the upper right). An ANCHORED system (a planet, a free-space station, + * or the home world) hosts its gates within level-1 tether (5120 px) of + * an anchor; a BARREN system (no anchors) hosts its gate(s) on the ray + * toward the destination, `barrenDistance` from the star. Every gate + * record carries `active` (default false — the activation mechanic is + * future work; an activated gate anchors a level-1 tether). The + * galaxy-wide network is strongly connected: no closed systems, no + * trapped sets, the whole galaxy is reachable. + * + * FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet + * frame is assigned by a galaxy-wide pass — the system's (class, frame) + * avoids what the NEAREST stars already wear for that class — so the same + * face (e.g. terran frame 0) is spread across the galaxy instead of + * clustering in one region. The pass runs once at galaxy build (fixed + * roster order ⇒ visit-order independent) and stamps `planet.frame` / + * `content.homeFrame` here; the renderer prefers those over a random pick. * * Place identity (the reputation/trading/faction keys): every planet and * settlement carries a stable `id`, seed-deterministic because it is @@ -101,19 +122,26 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { }; } - // --- Planets ---------------------------------------------------------- - // How many worlds the system holds (data/systems.json → planetCount): - // noneChance of systems are barren (ZERO planets); the rest get a - // uniform whole number in [min, max]. The STARTING system is the one - // exception: it always holds exactly TWO generated planets — a gas - // giant and a rocky world — which with the home world (the origin, the - // player's homestead, not a generated planet) makes its three planets. + // --- Composition: the system's object budget -------------------------- + // data/systems.json → objectCount. The STARTING system is exempt: it + // always holds exactly TWO generated planets — a gas giant and a rocky + // world — beside the home world (the origin, the player's homestead, + // not a generated planet), plus at most one free-space station. + // Every other system rolls its TOTAL object count N — planets + + // free-space stations together — from the configured table (default: + // 10% barren — a jump-gate-only stop — then 2/3/4/5 objects at + // 15/30/30/15%). Stations roll next (per-type odds × the core→rim + // density gradient, ≤ 2), the planets fill the rest: N − stations. + // The roll lives on dedicated forks (rollSystemComposition), so the + // galaxy-wide frame pass can reproduce it exactly. const isHome = record.id === galaxy?.currentSystemId; - const pc = attr.planetCount ?? config.get('systems.planetCount', { noneChance: 0.2, min: 2, max: 4 }); - const count = isHome - ? 2 - : rng.chance(pc.noneChance ?? 0.2) ? 0 : rng.int(pc.min ?? 2, pc.max ?? 4); - const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; + const spec = attr.settlements ?? {}; + const composition = rollSystemComposition( + galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr, + ); + const classes = composition.classes; + const planetN = classes.length; + // --- Planets ---------------------------------------------------------- // Planet names come from a curated bank (data/naming.json → banks.planet), // dealt out per-system without repeats (see NameGenerator.planetDeck). // A dedicated derived stream keeps this order-independent (lazy === eager). @@ -126,10 +154,8 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { // from the rest of the deck (all distinct within the system). const homeName = isHome ? planetDeck[0] : null; const planets = []; - for (let i = 1; i <= count; i++) { - // The starting system's two worlds are fixed (gas giant, then rocky); - // everywhere else the class rolls from the type's weights. - const pclass = isHome ? (i === 1 ? 'gas' : 'rocky') : rng.weighted(classWeights, 'rocky'); + for (let i = 1; i <= planetN; i++) { + const pclass = classes[i - 1]; let moons = 0; if (rng.chance(attr.moonChance ?? 0.3)) { // Jovian/ice worlds drag moon systems; terrestrials mostly don't. @@ -144,17 +170,27 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1), }); } + // FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide + // (class, frame) pass): stamp the assigned sheet frame on each world so + // the same class+face is spread across the galaxy (the renderer + // prefers planet.frame over a random pick). + const frames = galaxy?.planetFrames?.get(record.id); + if (Array.isArray(frames)) { + for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i]; + } + // --- Settlements (the lived-in layer) --------------------------------- const settlements = generateSettlements({ rng, systemId: record.id, kindDefs: config.get('settlements.kinds', {}), - spec: attr.settlements ?? {}, + spec, planets, stationDeck: NameGenerator.stationDeck( Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations') ), density: settlementDensity(galaxy, record), + stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint }, isHome, }); @@ -207,7 +243,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { hazard, jumps, // the system's jump gates (1–3; [] for a one-system galaxy) }; - if (isHome) content.homeName = homeName; // the player's home world (starting system only) + if (isHome) { + content.homeName = homeName; // the player's home world (starting system only) + // The home world's sheet frame (the galaxy-wide frame pass stamped it + // on the galaxy — js/galaxy/PlanetFrames.js). + if (typeof galaxy?.homeWorldFrame === 'number') content.homeFrame = galaxy.homeWorldFrame; + } return content; } @@ -396,15 +437,20 @@ function bestRotation(place, targetAngles) { * - sits on the system's side of its DESTINATION star — the bearing is * computed in the 2-D map plane, so "an upper-right gate" means "a * star to the upper right on the map"; - * - is within level-`anchorTetherLevel` tether (5120 px for level 1) of - * an ANCHOR — a planet or space station (the home world counts) — on - * the anchor's tether circle, chosen in order of facing quality: - * (1) the far ray-circle intersection (the gate exactly on the target - * ray — system, gate, and star collinear), (2) the point of the - * circle aimed exactly at the target star, (3) a forward-hemisphere + * - ANCHORED systems (a planet, a free-space station, or the home world): + * within level-`anchorTetherLevel` tether (5120 px for level 1) of an + * anchor — on the anchor's tether circle, chosen in order of facing + * quality: (1) the far ray-circle intersection (the gate exactly on + * the target ray — system, gate, and star collinear), (2) the point of + * the circle aimed exactly at the target star, (3) a forward-hemisphere * scan of the circle (±75°). Every candidate is exactly `range` from * its anchor, so tether-reachability holds by construction and the * facing deviation never exceeds 90° (in practice a few degrees); + * - BARREN systems (no anchors — objectCount → 0): on the ray toward the + * destination, `barrenDistance` from the star (stepped outward within + * the radius band only if the gate gap forces it). The activation + * mechanic (future work) then turns the gate itself into the system's + * level-1 tether anchor — see data/gates.json → ACTIVITY; * - stays `minRadius..maxRadius` from the star, `size` + `clearance` * clear of every anchor disc, and 2·`size` + `gateGap` from every * other gate. @@ -415,7 +461,10 @@ function bestRotation(place, targetAngles) { * * Record shape (one per gate): * { id: `-j`, name: ` Gate`, to, toName, - * x, y, size, rotation } + * x, y, size, rotation, active: false } + * `active` defaults to false — the gate is inert until the player + * activates it (data/gates.json → ACTIVITY); an activated gate anchors a + * level-1 tether at its own position. */ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { const g = config.section('gates', {}); @@ -431,7 +480,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { // Anchors: the planets, the free-space stations, and (home only) the // home world at the origin — the bodies a gate's tether may hang from. - // (Every system holds at least one — generateSettlements guarantees it.) + // A BARREN system holds none — its gates use the on-ray rule below. const anchors = [ ...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })), ...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })), @@ -446,73 +495,110 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { const ux = Math.cos(th); const uy = Math.sin(th); - // Candidate points, best first: - // tier 1 — for every anchor, the FAR ray-circle intersection: the - // gate exactly ON the target ray (system center, gate, and - // star collinear), outside the anchor. Always faces the - // target (≤ 90° from the anchor's point of view). - // tier 2 — for every anchor, the point on the anchor's tether circle - // that faces the target: a + range·u — exactly `range` from - // the anchor (tether-reachable) and aimed exactly at the - // star (zero deviation from the anchor's point of view). - // tier 3 — a forward-hemisphere scan around each anchor's circle - // (32 points, ± up to 75° from the target direction) — the - // clearance search for tight systems. - // Every candidate is exactly `range` from an anchor, so the level-N - // tether rule (hard) is met by construction; the direction rule (soft) - // is honored by the tier order: on-ray → aimed → near-aimed. - const cands = []; - anchors.forEach((a, ai) => { - const proj = a.x * ux + a.y * uy; // signed distance along the ray - const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance - if (h <= range) { - const off = Math.sqrt(Math.max(0, range * range - h * h)); - cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy }); - } - cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy }); - for (let k = 0; k < 32; k++) { - const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction - const dx = ux * Math.cos(phi) - uy * Math.sin(phi); - const dy = ux * Math.sin(phi) + uy * Math.cos(phi); - cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy }); - } - }); - cands.sort((p, q) => p.tier - q.tier || p.order - q.order); - - const ok = (c) => { - const d2c = c.px * c.px + c.py * c.py; - if (d2c < minR * minR || d2c > maxR * maxR) return false; - for (const b of anchors) { - const need = b.r + size + clearance; - const dx = c.px - b.x; - const dy = c.py - b.y; - if (dx * dx + dy * dy < need * need) return false; - } - for (const j of jumps) { - const need = 2 * size + gap; - const dx = c.px - j.x; - const dy = c.py - j.y; - if (dx * dx + dy * dy < need * need) return false; - } - return true; - }; - let chosen = null; - for (const c of cands) { - if (ok(c)) { - chosen = c; - break; + if (anchors.length === 0) { + // BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate + // sits ON the ray toward its destination, `barrenDistance` from the + // star (data/gates.json), stepped outward in 1024 px steps within the + // radius band if the gate gap forces it (two close targets). When the + // player activates it (future mechanic), the gate itself becomes the + // system's level-1 tether anchor (data/gates.json → ACTIVITY). + const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192))); + const okB = (px, py) => { + const d2c = px * px + py * py; + if (d2c < minR * minR || d2c > maxR * maxR) return false; + for (const j of jumps) { + const need = 2 * size + gap; + const dx = px - j.x; + const dy = py - j.y; + if (dx * dx + dy * dy < need * need) return false; + } + return true; + }; + for (let D = base; D <= maxR + 1e-6 && !chosen; D += 1024) { + if (okB(D * ux, D * uy)) chosen = { px: D * ux, py: D * uy }; + } + for (const eps of [0.05, -0.05, 0.1, -0.1, 0.2, -0.2]) { + // Last resort — a 1024 px step over the band should always clear a + // 384 px gap; angle-nudge if not (the facing rule is soft). + if (chosen) break; + const a = th + eps; + if (okB(base * Math.cos(a), base * Math.sin(a))) { + chosen = { px: base * Math.cos(a), py: base * Math.sin(a) }; + } + } + if (!chosen) { + chosen = { px: base * ux, py: base * uy }; + console.warn(`[orbit] ${record.id}: gate ${i + 1} could not clear the gate gap (barren)`); + } + } else { + // Candidate points, best first: + // tier 1 — for every anchor, the FAR ray-circle intersection: the + // gate exactly ON the target ray (system center, gate, and + // star collinear), outside the anchor. Always faces the + // target (≤ 90° from the anchor's point of view). + // tier 2 — for every anchor, the point on the anchor's tether circle + // that faces the target: a + range·u — exactly `range` from + // the anchor (tether-reachable) and aimed exactly at the + // star (zero deviation from the anchor's point of view). + // tier 3 — a forward-hemisphere scan around each anchor's circle + // (32 points, ± up to 75° from the target direction) — the + // clearance search for tight systems. + // Every candidate is exactly `range` from an anchor, so the level-N + // tether rule (hard) is met by construction; the direction rule (soft) + // is honored by the tier order: on-ray → aimed → near-aimed. + const cands = []; + anchors.forEach((a, ai) => { + const proj = a.x * ux + a.y * uy; // signed distance along the ray + const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance + if (h <= range) { + const off = Math.sqrt(Math.max(0, range * range - h * h)); + cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy }); + } + cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy }); + for (let k = 0; k < 32; k++) { + const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction + const dx = ux * Math.cos(phi) - uy * Math.sin(phi); + const dy = ux * Math.sin(phi) + uy * Math.cos(phi); + cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy }); + } + }); + cands.sort((p, q) => p.tier - q.tier || p.order - q.order); + + const ok = (c) => { + const d2c = c.px * c.px + c.py * c.py; + if (d2c < minR * minR || d2c > maxR * maxR) return false; + for (const b of anchors) { + const need = b.r + size + clearance; + const dx = c.px - b.x; + const dy = c.py - b.y; + if (dx * dx + dy * dy < need * need) return false; + } + for (const j of jumps) { + const need = 2 * size + gap; + const dx = c.px - j.x; + const dy = c.py - j.y; + if (dx * dx + dy * dy < need * need) return false; + } + return true; + }; + + for (const c of cands) { + if (ok(c)) { + chosen = c; + break; + } + } + if (!chosen) { + // Every candidate failed clearance (nearly impossible — an anchor's + // tether circle is 5120 px across, the discs under a thousand): take + // the first anchor's aimed point anyway — the tether and facing rules + // outrank cosmetics. + chosen = cands.find((c) => c.tier === 2) ?? cands[0]; + console.warn( + `[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`, + ); } - } - if (!chosen) { - // Every candidate failed clearance (nearly impossible — an anchor's - // tether circle is 5120 px across, the discs under a thousand): take - // the first anchor's aimed point anyway — the tether and facing rules - // outrank cosmetics. - chosen = cands.find((c) => c.tier === 2) ?? cands[0]; - console.warn( - `[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`, - ); } // Unique gate name (two targets can share a star name — star names are @@ -530,6 +616,11 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { x: chosen.px, y: chosen.py, size, + // Inert until the player activates it (data/gates.json → ACTIVITY): + // an activated gate anchors a level-1 tether at its own position — + // the room to move in a barren system. The activation mechanic is + // future work; the renderer dims inactive gates. + active: false, // The gate's visual bearing — from the gate's own position to the // destination star, so it always points exactly at where it jumps. rotation: Math.atan2(t.y - chosen.py, t.x - chosen.px), @@ -618,6 +709,9 @@ function homeWorldRadius() { function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) { const cfg = config.section('asteroids', {}); if (cfg.enabled === false) return []; + // A BARREN system (objectCount → 0) is a jump-gate-only stop — star and + // gates, nothing else — so no clusters. + if (planets.length === 0 && freeSpace.length === 0) return []; // Without the solar-system layout there are no placed objects to space // against — no clusters either (the scene renders nothing else anyway). if (config.get('planets.solarSystem.enabled', true) === false) return []; @@ -858,12 +952,87 @@ function mixTint(hex, strength) { return (mix((n >> 16) & 255) << 16) | (mix((n >> 8) & 255) << 8) | mix(n & 255); } +/** + * The system's OBJECT COMPOSITION (data/systems.json → objectCount + + * attributes.settlements + the core→rim density gradient — + * data/galaxy.json → settlements.gradient), in one deterministic roll: + * + * home system → fixed: 2 planets (gas giant + rocky) beside the home + * world, + at most one free-space station (deepSpace only — + * a waypoint would be a 5th point the home band can't hold); + * every other → 1) the FINAL object count N from the configured table + * (0 = barren, then 2/3/4/5 at the configured weights — the + * distribution is on the FINAL count, by design); + * 2) the free-space stations (0–2, per-type odds × the + * density gradient; at most 2, which never exceeds the + * minimum non-barren budget of 2); + * 3) the planets — N − stations weighted class draws + * (a 2-object system can be 0 planets + 2 stations). + * + * Both the content generator and the galaxy-wide frame pass + * (js/galaxy/PlanetFrames.js) call this — the forks + * (seed, 'system', id, 'planets' / 'settlements') are pure functions of + * their inputs, so they always agree. + * → { objects, deepSpace, waypoint, classes } + */ +export function rollSystemComposition(seed, record, isHome, spec, density, attr) { + if (isHome) { + const rng = Rng.derive(seed, 'system', record.id, 'settlements'); + const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); + return { objects: 2, deepSpace, waypoint: false, classes: ['gas', 'rocky'] }; + } + // 1) The final object count — the configured composition table. + const oc = config.get('systems.objectCount', { + barren: 0.1, + objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 }, + }); + const table = { 0: Math.max(0, Number(oc.barren) || 0) }; + for (const [k, w] of Object.entries(oc.objects ?? {})) { + const n = Number(k); + if (Number.isInteger(n) && n > 0) table[n] = Math.max(0, Number(w) || 0); + } + const rngP = Rng.derive(seed, 'system', record.id, 'planets'); + const objects = Math.max(0, Number(rngP.weighted(table, 2))); + + // BARREN (N = 0): a jump-gate-only system — no stations, no planets. + if (objects === 0) { + return { objects, deepSpace: false, waypoint: false, classes: [] }; + } + + // 2) The free-space stations (≤ 2 — never more than the min budget of 2). + const rngS = Rng.derive(seed, 'system', record.id, 'settlements'); + const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); + const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density); + const stations = Number(deepSpace) + Number(waypoint); + + // 3) The planets fill the rest of the budget (N − stations ≥ 0). + const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; + const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky')); + return { objects, deepSpace, waypoint, classes }; +} + +/** + * The system's PLANET CLASSES — a thin wrapper over rollSystemComposition + * for callers that only need the worlds (the frame pass uses the full roll). + */ +export function rollPlanetClasses(seed, record, isHome, attr, spec, density) { + return rollSystemComposition(seed, record, isHome, spec, density, attr).classes; +} + +/** Backwards-compatible roll (tests/tools) — the stations of a system. */ +export function rollStationCount(seed, record, isHome, spec, density) { + const c = rollSystemComposition(seed, record, isHome, spec, density, {}); + return { deepSpace: c.deepSpace, waypoint: c.waypoint, count: Number(c.deepSpace) + Number(c.waypoint) }; +} + /** * Core→rim density: the settled heart of the galaxy has more activity per * system; the rim is thinner, lonelier. `factor` scales every settlement * chance (clamped to a floor so the rim isn't dead). 0 = no gradient. + * Exported: the frame pass (js/galaxy/PlanetFrames.js) re-derives the same + * values from the same inputs. */ -function settlementDensity(galaxy, record) { +export function settlementDensity(galaxy, record) { const g = galaxy?.params?.settlements?.gradient ?? {}; const falloff = Math.max(0, g.falloff ?? 0.7); const floor = clamp(g.floor ?? 0.22, 0, 1); @@ -886,7 +1055,7 @@ function settlementDensity(galaxy, record) { * the old per-type odds (spec.chance + needs) take over again. The * free-space kinds still roll per type (core→rim scaled). */ -function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, isHome = false }) { +function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false }) { const out = []; let nameIndex = 0; // next station name from the system's deck (no repeats) @@ -927,26 +1096,13 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe } } - // Free-floating, out in the dark (per-type odds, core→rim scaled). - // The HOME system is capped at ONE free-space station: with the home - // world and its two fixed planets it must stay a ≤ 4-object - // configuration — the home band (6400..10240 px) cannot hold 5 points - // (see layoutSystem). - if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) { - make('deepSpaceStation', { type: 'space' }); - } - if (!isHome && roll(rng, spec.waypoint?.chance ?? 0.2, density)) { - make('waypoint', { type: 'space' }); - } - - // JUMP-GATE ANCHOR GUARANTEE — every system must hold at least one - // planet or space station: the jump gates sit within level-1 tether of - // an anchor, so a system with neither would be unreachable (a closed - // system). A barren system that rolled no free-space station gets a - // gate station. - if (planets.length === 0 && out.length === 0) { - make('deepSpaceStation', { type: 'space' }); - } + // Free-floating, out in the dark — the PRE-ROLLED flags (rollStationCount: + // per-type odds × the core→rim density gradient; the home system never + // rolls a waypoint — its band can't hold 5 points). A BARREN system + // (objectCount → 0) rolled no stations — it is a jump-gate-only stop, + // deliberately. + if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }); + if (stations?.waypoint) make('waypoint', { type: 'space' }); return out; } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index f90c661..7b8dfc5 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -218,11 +218,17 @@ export class GameScene extends Phaser.Scene { // The home planet — the player's Terran world, always present in the // system they start in. It sits at the world origin. Which Terran face - // it shows is a seed-deterministic pick from the terran pool, so the - // same galaxy always yields the same home world. + // it shows comes from the galaxy-wide frame pass (content.homeFrame — + // the same (class, face) spreading as the planets), falling back to a + // seed-deterministic pool pick, so the same galaxy always yields the + // same home world. const homeName = config.get('planets.homePlanet', 'terran'); const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home'); - this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName); + const homeFrame = + typeof this.systemContent.homeFrame === 'number' + ? this.systemContent.homeFrame + : Planet.frameFor(homeName, homeRng); + this.planet = new Planet(this, 0, 0, homeFrame, homeName); this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10) // The rest of the solar system — the generated worlds, placed by the @@ -233,7 +239,13 @@ export class GameScene extends Phaser.Scene { for (const rec of this.systemContent.planets ?? []) { if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue; const kind = rec.class || 'rocky'; - const frame = Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name)); + // The galaxy-wide frame pass (js/galaxy/PlanetFrames.js) stamped + // `rec.frame` to spread each (class, face) across the galaxy; fall + // back to a random pool pick for content that predates the pass. + const frame = + typeof rec.frame === 'number' + ? rec.frame + : Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name)); const tint = config.get(`planets.classTint.${kind}`); const p = new Planet(this, rec.x, rec.y, frame, kind, { scale: rec.scale ?? 1, diff --git a/js/utils/NameGenerator.js b/js/utils/NameGenerator.js index aa19539..76c0f49 100644 --- a/js/utils/NameGenerator.js +++ b/js/utils/NameGenerator.js @@ -7,7 +7,7 @@ import { config } from '../config/Config.js'; * * STARS & THE GALAXY — synthesised from syllable pools (data/naming.json → * star / galaxy). Short, consistent, and effectively unlimited: there are - * 40,000 systems and no reason to run out of star names. + * 200 systems and no reason to run out of star names. * * PLANETS & STATIONS — drawn from finite, curated NAME BANKS (data/ * naming.json → banks). A deep pool of hand-picked names — colonial