From cedee9f58e03e919d4a4921e6050c2644d5ee94d Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 13 Sep 2026 16:23:31 -0600 Subject: [PATCH] Add galaxy-wide planet-class balance via a shared deck deal - New `js/galaxy/PlanetClasses.js` deals non-home planets from one shared deck so every class lands at ~1/N of the galaxy while star type still flavors local systems - Config knob `systems.planetClassBalance` (enabled + bias) blends an even split with the star-mix preference; bias 0 = exactly even, 1 = old free preference - Stamp `galaxy.planetClasses` is read back by content generator, frame pass, economy, and type preview so lazy/eager generation agree - `rollSystemComposition` accepts optional stamped classes; falls back to per-type weighted draws when balance is off or stamp absent - Fix arid-surface video path in landing.json (was pointing at ice surface) - Update shop-ui and trade-scout tests to pick rows the current system actually wants, and handle home-world exit resolution --- data/landing.json | 2 +- data/systems.json | 5 + dev/planet-balance.test.mjs | 203 +++++++++++++++++++++++++++++++++++ dev/shop-ui.test.mjs | 14 ++- dev/trade-scout.test.mjs | 5 + dev/type-preview.test.mjs | 2 + js/economy/Economy.js | 7 +- js/galaxy/Galaxy.js | 20 ++++ js/galaxy/PlanetClasses.js | 173 +++++++++++++++++++++++++++++ js/galaxy/PlanetFrames.js | 20 ++-- js/galaxy/SystemGenerator.js | 36 +++++-- js/galaxy/TypeSystems.js | 1 + 12 files changed, 470 insertions(+), 18 deletions(-) create mode 100644 dev/planet-balance.test.mjs create mode 100644 js/galaxy/PlanetClasses.js diff --git a/data/landing.json b/data/landing.json index 3ccb7c1..9b0a450 100644 --- a/data/landing.json +++ b/data/landing.json @@ -74,7 +74,7 @@ { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "lava-shop-02.mp4" }, { "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" }, - { "land": "arid-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, + { "land": "arid-land-02.mp4", "surface": "arid-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, { "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" }, { "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" }, { "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" }, diff --git a/data/systems.json b/data/systems.json index c40e9db..1147cb0 100644 --- a/data/systems.json +++ b/data/systems.json @@ -5,6 +5,11 @@ "barren": 0.10, "objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 } }, + "planetClassBalance": { + "_comment": "GALAXY-WIDE PLANET CLASS BALANCE (js/galaxy/PlanetClasses.js): the galaxy deals its non-home planets from ONE SHARED DECK so no class dominates the mix. The deck's per-class quotas blend a perfectly even split (1/N of all worlds per class) with the galaxy's own star-mix preference (each system type's planetClasses table, slot-weighted over the real roster) — `bias` is the blend: 0 = an EXACTLY even deck, 1 = the star mix's free preference (≈ the old per-system sampling in expectation). Each system then DRAWS its worlds from the remaining deck using its OWN star type's table as the draw weights — the star still flavors local systems (nebulae run a bit lava-heavy, voids a bit ice-heavy, binaries a bit gas-heavy), but the galaxy-wide mix stays even: every class lands at 1/N ± the bias tilt. The STARTING system is exempt (its fixed gas giant + rocky world beside the home world), and each CAPITAL world reserves its outer slot (a capital* world is not one of the balanced classes). Flip `enabled` off to return to plain per-system weighted draws (the pre-balance behavior).", + "enabled": true, + "bias": 0.2 + }, "types": { "main": { "label": "Main Sequence", diff --git a/dev/planet-balance.test.mjs b/dev/planet-balance.test.mjs new file mode 100644 index 0000000..40b5416 --- /dev/null +++ b/dev/planet-balance.test.mjs @@ -0,0 +1,203 @@ +/** + * Planet-class balance test (dev tool, run with Node — no browser needed): + * + * node dev/planet-balance.test.mjs + * + * The galaxy-wide even-mix deal (data/systems.json → planetClassBalance, + * js/galaxy/PlanetClasses.js): the non-home planets are dealt from ONE + * shared deck so every class lands at ~1/N of the galaxy while the star + * type still flavors local systems. Asserts: + * + * - STAMP: galaxy.planetClasses covers every non-home, non-barren + * system; the content generator, the frame pass and the economy + * all see the same classes (stamp === content, lazy === eager). + * - EVEN: every class's share of the galaxy's balanced worlds is + * close to 1/N (the old per-system sampling swung ~3%–50%); the + * deck sums exactly; no class missing. + * - FLAVOR: the star type still steers local systems (per-type mixes + * differ from a flat 1/N, and are spread across classes). + * - CAPITALS: each designated capital's outermost world is its + * capital* world, and capitals are NOT part of the balanced pool. + * - HOME: the starting system keeps its fixed gas + rocky pair. + * - DETERMINISM: same seed ⇒ identical stamp; different seed ⇒ + * a different one. + * - OFF SWITCH: planetClassBalance.enabled = false ⇒ the old + * per-system weighted draws (stamp null, generator self-consistent). + */ +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Load the real config (data/*.json) into the config singleton ------ +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 { capitalClassFor } = await import(pathToFileURL(join(__dirname, '../js/galaxy/PlanetClasses.js')).href); + +let failures = 0; +const check = (label, cond, extra = '') => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`); + if (!cond) failures++; +}; +const deepEq = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +const SEED = 'QB4UP8J5'; +const g = Galaxy.create(SEED); +const stamp = g.planetClasses; +check('galaxy.planetClasses stamp exists (Map)', stamp instanceof Map); +if (!stamp) { + console.log('\nplanet-balance: aborted — no stamp'); + process.exit(1); +} + +// --- STAMP === CONTENT (every system, lazy === eager) ------------------- +const types = config.get('systems.types', {}); +const balancedClasses = [ + ...new Set(Object.values(types).flatMap((t) => Object.keys(t.attributes?.planetClasses ?? {}))), +].sort(); +const contentOf = (id) => g.ensureContent(id); + +let stampContentOk = true; +let stampReason = ''; +for (const rec of g.records) { + const content = contentOf(rec.id); + const classes = content.planets.map((p) => p.class); + if (rec.id === g.homeSystemId) { + if (stamp.has(rec.id)) { stampContentOk = false; stampReason = 'home system is in the deck'; break; } + if (classes[0] !== 'gas' || classes[1] !== 'rocky') { + stampContentOk = false; stampReason = `home system planets = ${classes}`; break; + } + continue; + } + const st = stamp.get(rec.id); + if (!Array.isArray(st)) { + if (classes.length > 0) { stampContentOk = false; stampReason = `${rec.id}: content has ${classes.length} planets, no stamp`; break; } + continue; // barren — no stamp, no planets + } + if (!deepEq(st, classes)) { stampContentOk = false; stampReason = `${rec.id}: stamp ${st} ≠ content ${classes}`; break; } +} +check('stamp === content classes for every system (home exempt, barren empty)', stampContentOk, stampReason); + +// --- THE ECONOMY SEES THE SAME CLASSES ---------------------------------- +let econOk = true; +for (const [id, set] of [...g.economy.classBySystem.entries()].slice(0, 25)) { + const rec = g.byId.get(id); + const content = g.ensureContent(id); + const expect = new Set(content.planets.map((p) => p.class)); + if (id === g.homeSystemId) expect.add(config.get('planets.homePlanet', 'terran')); + if (rec.capital) expect.add('capital'); + if (content.settlements.some((s) => s.kind === 'deepSpaceStation')) expect.add('station'); + // 'station'/'capital' markers are added by the economy; planets must match. + const planetPart = [...set].filter((c) => balancedClasses.includes(c) || String(c).startsWith('capital')); + const contentPart = [...expect].filter((c) => balancedClasses.includes(c) || String(c).startsWith('capital')); + if (!deepEq(planetPart.slice().sort(), contentPart.slice().sort())) { econOk = false; break; } +} +check('economy classBySystem carries the same planet classes (sample of 25)', econOk); + +// --- EVEN: the galaxy-wide mix ------------------------------------------ +const counts = Object.fromEntries(balancedClasses.map((c) => [c, 0])); +let P = 0; +let capitals = 0; +for (const [id, cls] of stamp) { + for (const c of cls) { + if (String(c).startsWith('capital')) { capitals += 1; continue; } + if (!counts[c]) { counts[c] = 0; } + counts[c] += 1; + P += 1; + } +} +check('deck is non-trivial (P ≥ 100 worlds)', P >= 100, `P=${P}`); +const shares = Object.fromEntries(balancedClasses.map((c) => [c, counts[c] / P])); +let evenOk = true; +let evenBad = ''; +for (const c of balancedClasses) { + if (counts[c] === 0) { evenOk = false; evenBad = `${c} missing`; break; } + if (shares[c] < 0.12 || shares[c] > 0.35) { evenOk = false; evenBad = `${c} at ${(shares[c] * 100).toFixed(1)}%`; break; } +} +check('every class present, each within 12–35% of the galaxy (≈ even)', evenOk, evenBad); +const spread = Math.max(...Object.values(shares)) - Math.min(...Object.values(shares)); +check('max−min class spread ≤ 20 points (old sampling swung 30+)', spread <= 0.2, `spread ${(spread * 100).toFixed(1)} pts`); + +// --- CAPITALS: reserved slots, outer position ---------------------------- +const capRecs = g.records.filter((r) => r.capital && r.id !== g.homeSystemId); +let capOk = true; +let capReason = ''; +for (const rec of capRecs) { + const cls = stamp.get(rec.id) ?? []; + const want = capitalClassFor(rec.capital); + const outer = contentOf(rec.id).planets.at(-1)?.class; + if (cls.length === 0 || cls[cls.length - 1] !== want || outer !== want) { + capOk = false; capReason = `${rec.id}: stamp=${cls} outer=${outer} want=${want}`; break; + } +} +check(`every capital's outermost world is its ${capitals > 0 ? 'capital*' : 'faction'} seat (${capitals} reserved)`, capOk, capReason); + +// --- FLAVOR: the star type still steers local systems -------------------- +const byType = {}; +for (const rec of g.records) { + if (rec.id === g.homeSystemId) continue; + const cls = (stamp.get(rec.id) ?? []).filter((c) => !String(c).startsWith('capital')); + if (cls.length === 0) continue; + const t = (byType[rec.type] ??= { n: 0, c: {} }); + t.n += cls.length; + for (const c of cls) t.c[c] = (t.c[c] ?? 0) + 1; +} +let flavorOk = true; +let flavorReason = ''; +for (const [type, t] of Object.entries(byType)) { + const local = balancedClasses.map((c) => t.c[c] / t.n); + const dev = Math.max(...local.map((s) => Math.abs(s - 1 / balancedClasses.length))); + if (dev < 0.02) { flavorOk = false; flavorReason = `${type} is flat (max deviation ${(dev * 100).toFixed(1)} pts)`; break; } +} +check('star types still flavor local mixes (no type sits exactly flat)', flavorOk, flavorReason); +const flavorDetail = balancedClasses + .map((c) => { + const perType = Object.entries(byType).map(([t, x]) => `${t}:${(((x.c[c] ?? 0) / x.n) * 100).toFixed(0)}`); + return `${c}(${perType.join(' ')})`; + }); +console.log(' flavor: ' + flavorDetail.join(' ')); +console.log(' mix: ' + balancedClasses.map((c) => `${c} ${(shares[c] * 100).toFixed(1)}%`).join(' ')); + +// --- DETERMINISM ---------------------------------------------------------- +const g2 = Galaxy.create(SEED); +const stampEq = + stamp.size === g2.planetClasses.size && + [...stamp.entries()].every(([id, v]) => deepEq(g2.planetClasses.get(id), v)); +check('same seed ⇒ identical stamp', stampEq); +const other = Galaxy.create('ORBIT-TEST-1').planetClasses; +let diff = false; +for (const [id, v] of stamp) { + if (!deepEq(other?.get(id), v)) { diff = true; break; } +} +check('different seed ⇒ different stamp (spot check)', diff); + +// --- OFF SWITCH: the old per-system weighted draws ------------------------ +config.init({ + ...configData, + systems: { ...configData.systems, planetClassBalance: { enabled: false, bias: 0.2 } }, +}); +const gOff = Galaxy.create(SEED); +check('balance off ⇒ stamp is null', gOff.planetClasses === null); +let offOk = true; +let offReason = ''; +for (const rec of gOff.records) { + if (rec.id === gOff.homeSystemId) continue; + const planets = gOff.ensureContent(rec.id).planets.map((p) => p.class); + if (planets.length > 0 && !planets.every((c) => balancedClasses.includes(c) || String(c).startsWith('capital'))) { + offOk = false; offReason = `${rec.id}: ${planets}`; break; + } +} +check('balance off ⇒ content still self-consistent (valid classes)', offOk, offReason); +config.init(configData); // restore + +console.log(failures === 0 ? '\nAll planet-balance tests passed ✔' : `\n${failures} test(s) FAILED ✘`); +process.exit(failures === 0 ? 0 : 1); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 532e2eb..ab74cc9 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -61,6 +61,7 @@ const { CargoHold } = await import(pathToFileURL(join(__dirname, '../js/economy/ const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href); const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); const { ShopView } = await import(pathToFileURL(join(__dirname, '../js/ui/shop/ShopView.js')).href); +const { sellsHere } = await import(pathToFileURL(join(__dirname, '../js/economy/TradeScout.js')).href); const goods = config.get('goods.goods', []); assert.ok(goods.length >= 2, 'catalog has goods to trade'); @@ -884,7 +885,12 @@ console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the re assert.equal(view._scout.alpha, 0, '… hidden (alpha 0)'); assert.equal(view.sellGrid.alpha, 1, '… and the cargo grid is visible'); - const row = view.buyList.rows[0]; + // A stock row THIS system wants — so the current (charted) system is a + // valid exit in the report (any stock row would do for the UI + // assertions; rows[0] is not guaranteed to be bought locally). + const row = + view.buyList.rows.find((r) => classesOf(sysId).some((c) => sellsHere(r.id, c))) ?? + view.buyList.rows[0]; // TAP THE ROW'S BOX → the scout opens on that good. view._onRowTap(row); @@ -941,7 +947,11 @@ console.log('\n== SET DESTINATION — tap a best-exit row on the scout: the scen const calls = []; scene.askSetDestination = (exit) => { calls.push(exit); }; - const row = view.buyList.rows[0]; + // A row whose report has exits to tap (one the current system wants — + // rows[0] is not guaranteed to be bought locally). + const row = + view.buyList.rows.find((r) => classesOf(sysId).some((c) => sellsHere(r.id, c))) ?? + view.buyList.rows[0]; view._onRowTap(row); assert.equal(view._scoutOpen, row.id, 'scout open on the row'); const d = view._scout.report; diff --git a/dev/trade-scout.test.mjs b/dev/trade-scout.test.mjs index f62c0bb..827590b 100644 --- a/dev/trade-scout.test.mjs +++ b/dev/trade-scout.test.mjs @@ -140,6 +140,11 @@ const row = stock.find((s) => sellsHere(s.id, classOf(sysId).find((c) => c !== ' } else if (e.cls === 'capital') { const p = (content.planets ?? []).find((x) => typeof x?.class === 'string' && x.class.startsWith('capital')); ok = !!p && e.objectId === p.name && e.objectName === p.name; + } else if (e.systemId === g.homeSystemId && e.cls === config.get('planets.homePlanet', 'terran')) { + // The HOME WORLD — the central body, NOT in content.planets + // (discovery id 'home' — objectForClass's special case): + // the home system's terran exit resolves to it. + ok = e.objectId === 'home' && e.objectName === (content.homeName ?? 'home'); } else { const p = (content.planets ?? []).find((x) => x?.class === e.cls); ok = !!p && e.objectId === p.name && e.objectName === p.name; diff --git a/dev/type-preview.test.mjs b/dev/type-preview.test.mjs index 471ef23..9c37277 100644 --- a/dev/type-preview.test.mjs +++ b/dev/type-preview.test.mjs @@ -356,6 +356,7 @@ function verifyPick(spec, pick, why) { homeSystemId: g.homeSystemId, // neither fixture record is home homeWorldFrame: g.homeWorldFrame, params: g.params, + planetClasses: g.planetClasses, // the real deck stamp (classes must match contentOf) planetFrames: new Map(), stationFrames: new Map(), jumpGatesFor: (id) => Array.from({ length: gateCounts[id] ?? 0 }, (_, i) => ({ id: `${id}-gate-${i}` })), @@ -412,6 +413,7 @@ function verifyPick(spec, pick, why) { homeSystemId: g.homeSystemId, homeWorldFrame: g.homeWorldFrame, params: g.params, + planetClasses: g.planetClasses, planetFrames: new Map(), stationFrames: new Map(), jumpGatesFor: (id) => Array.from({ length: id === sA.id ? 3 : 1 }, (_, i) => ({ id: `${id}-gate-${i}` })), diff --git a/js/economy/Economy.js b/js/economy/Economy.js index 61c97e4..edf9087 100644 --- a/js/economy/Economy.js +++ b/js/economy/Economy.js @@ -485,7 +485,12 @@ export function buildEconomy(galaxy) { const spec = type.attributes?.settlements ?? {}; const attr = type.attributes ?? {}; const density = settlementDensity(galaxy, record); - const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr); + // The SAME planet classes the content generator draws (the galaxy-deck + // deal stamp, data/systems.json → planetClassBalance — null when the + // balance is off, in which case the per-system weighted draws apply). + const stamped = + galaxy.planetClasses instanceof Map ? galaxy.planetClasses.get(id) : null; + const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr, stamped); const set = new Set(comp.classes); if (isHome) set.add(homeCls); // the home world (a terran body by rule) if (comp.deepSpace) set.add('station'); diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index 85cf56e..54d2996 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -3,6 +3,7 @@ import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; import { buildJumpNetwork } from './JumpNetwork.js'; import { assignPlanetFrames } from './PlanetFrames.js'; +import { assignPlanetClasses } from './PlanetClasses.js'; import { assignStationFrames } from './StationFrames.js'; import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js'; import { buildEconomy } from '../economy/Economy.js'; @@ -86,6 +87,7 @@ export class Galaxy { // had already moved there) and deal it a home world of its own. this.homeSystemId = null; this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass + this.planetClasses = null; // id → [class, ...] — the even-mix deal (or null, balance off) this.homeWorldFrame = null; // the starting system's home world frame this.stationFrames = new Map(); // id → frame — the station-variant spread pass this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name')); @@ -299,6 +301,23 @@ export class Galaxy { // composition planet, so the frame pass never sees it). this._assignFactions(barren); + // PLANET CLASS BALANCE (js/galaxy/PlanetClasses.js): the galaxy-wide + // even-mix pass — the non-home planets are dealt from ONE shared deck + // (even per-class quotas ± the star-mix tilt; each system draws its + // worlds weighted by its star type's table), so every class lands at + // ~1/N of the galaxy while the star type still flavors local systems. + // Stamped before the frame pass (the frames index the classes) and + // read back by the content generator + the economy pass — lazy === + // eager, every subsystem sees the same worlds. NULL when + // planetClassBalance.enabled === false (callers keep the old + // per-system weighted draws). + this.planetClasses = assignPlanetClasses({ + seed: this.seed, + records, + homeId: this.currentSystemId, + params: this.params, + }); + // 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 @@ -313,6 +332,7 @@ export class Galaxy { homeId: this.currentSystemId, params: this.params, neighborsOf: (id) => this.neighborsOf(id, pool8), + planetClasses: this.planetClasses, }); this.planetFrames = frames; // Map id → [frame, ...] (ordinal order) this.homeWorldFrame = homeFrame; diff --git a/js/galaxy/PlanetClasses.js b/js/galaxy/PlanetClasses.js new file mode 100644 index 0000000..bf7516e --- /dev/null +++ b/js/galaxy/PlanetClasses.js @@ -0,0 +1,173 @@ +import { config } from '../config/Config.js'; +import { Rng } from '../utils/Rng.js'; +import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; + +/** + * GALAXY-WIDE PLANET CLASS BALANCE (data/systems.json → planetClassBalance). + * + * Before this pass, every planet was an independent weighted draw from its + * system type's table (data/systems.json → attributes.planetClasses), so the + * galaxy-wide mix inherited the type/zone mix on top of the tables: rocky + * ended up at ~35-40%, arid at ~4-6%, and any given galaxy could swing a + * lot. The design call (see the config block): the galaxy should read as + * EVEN — every planet class present in roughly equal measure — while the + * STAR TYPE still flavors each local system a little. + * + * THE MECHANISM — a shared deck, dealt in two stages: + * 1. QUOTA — count every planet slot in the galaxy (the SAME + * composition roll the content generator uses: objectCount minus the + * free-space stations; barren ⇒ 0, home ⇒ exempt, non-barren ⇒ ≥ 1), + * minus the reserved CAPITAL slots (a capital* world is not one of + * the balanced classes). The per-class quotas blend a perfectly even + * split (P/N each) with the galaxy's own star-mix preference — the + * slot-weighted average of the real types' tables — by + * `planetClassBalance.bias` (0 = exactly even, 1 = the star mix's + * free preference). Largest-remainder rounding makes the quotas + * integers summing to exactly P. + * 2. DEAL — the systems draw their worlds from the remaining deck, in a + * FIXED spatial order (x, then y — the frame pass's convention; a + * pure function of the seeded roster). Each draw weights the + * system's OWN star-type table (the preference) over the classes the + * deck still holds (the availability — a depleted class simply can't + * be drawn). The star type still steers: a nebula system + * preferentially consumes the deck's lava before the habitable ones + * do. First-come dealing is deliberate — the leftover tail lands on + * the far systems, and the galaxy keeps an organic, non-uniform + * texture while the totals stay even. + * + * DETERMINISM: pure function of (seed, roster, config) — no hidden + * state. The stamp `galaxy.planetClasses` (Map id → [class, ...], + * ordinal order) is read back by the content generator + * (SystemGenerator.generateSystemContent), the frame pass + * (PlanetFrames.assignPlanetFrames), the economy pass + * (Economy.buildEconomy) and the dev preview (TypeSystems) — so lazy + * (on-arrival) === eager (generateAll) and every subsystem sees the same + * worlds. Standalone rollSystemComposition() callers (dev tools, tests + * without a galaxy object) keep the old per-system weighted draws. + * + * RETURN: Map — every non-home, non-barren system; + * - ordinary systems: `planetsN` balanced classes (orbital order); + * - capital systems: `planetsN − 1` balanced classes + the `capital*` + * class LAST (the outermost slot — generateSystemContent's capital + * block then re-stamps it, frame/habitable/moons included); + * - home system: absent (its fixed gas + rocky pair is exempt); + * - null, when planetClassBalance.enabled === false (callers fall back + * to the per-system weighted draws). + */ +export function assignPlanetClasses({ seed, records, homeId, params, typeDefs = null }) { + const defs = typeDefs ?? config.get('systems.types', {}); + const bal = config.section('systems.planetClassBalance', {}); + if (bal.enabled === false) return null; + const bias = Math.min(1, Math.max(0, Number(bal.bias) || 0)); + + // The class vocabulary — the union of every type's planetClasses table + // (sorted: a stable order for the quota + deal logic; 'terran' is + // excluded by construction — it is the home world's class, never a + // generated one). + const classes = [ + ...new Set( + Object.values(defs).flatMap((t) => + Object.keys(t?.attributes?.planetClasses ?? {})), + ), + ].sort(); + if (classes.length === 0) return null; + + // --- 1) The galaxy's total planet slots (the balanced part) ---------- + // The SAME composition roll the content generator uses (dedicated + // per-system forks — pure functions of the inputs), so the slot counts + // agree with the content: barren ⇒ 0, non-barren ⇒ objects − stations + // (≥ 1 by the demotion rule). The home system keeps its fixed + // gas + rocky pair — it is exempt from the deck. A CAPITAL system + // reserves its outermost slot (the capital world replaces it — + // SystemGenerator's capital block), so the balanced classes only ever + // fill the worlds that stay "ordinary". + const shim = { params: params ?? {} }; + const demand = []; // { id, x, y, n, capital } + for (const rec of records) { + if (rec.id === homeId) continue; + const attr = defs[rec.type]?.attributes ?? {}; + const comp = rollSystemComposition( + seed, rec, false, attr.settlements ?? {}, settlementDensity(shim, rec), attr, + ); + const slots = Math.max(0, Number(comp.objects) - Number(comp.deepSpace) - Number(comp.waypoint)); + if (slots === 0) continue; + demand.push({ + id: rec.id, + x: rec.x, + y: rec.y, + n: slots - (rec.capital ? 1 : 0), + capital: rec.capital ? String(rec.capital) : null, + }); + } + const P = demand.reduce((a, d) => a + Math.max(0, d.n), 0); + if (P <= 0) return null; + + // --- 2) The deck quotas (even ± the star-mix tilt) ------------------- + // Galaxy-level preference: how much each class the galaxy's star mix + // WANTS — the slot-weighted average of the real per-type tables. + const byId = new Map(records.map((r) => [r.id, r])); + const pref = Object.fromEntries(classes.map((c) => [c, 0])); + for (const d of demand) { + const t = defs[byId.get(d.id).type]?.attributes?.planetClasses ?? {}; + for (const c of classes) pref[c] += Math.max(0, d.n) * (Math.max(0, Number(t[c]) || 0)); + } + const prefSum = classes.reduce((a, c) => a + pref[c], 0) || 1; + + // Blended shares (sum to 1 by construction: (1−bias)/N each + bias × a + // normalized preference) → expected slots per class → quotas by + // largest remainder (integers summing to exactly P; each quota within + // 1 slot of its expectation). + const N = classes.length; + const expected = {}; + for (const c of classes) expected[c] = P * ((1 - bias) / N + (bias * pref[c]) / prefSum); + const quota = {}; + for (const c of classes) quota[c] = Math.floor(expected[c]); + let leftover = P - classes.reduce((a, c) => a + quota[c], 0); + const order = classes.slice().sort((a, b) => { + const fa = expected[a] - Math.floor(expected[a]); + const fb = expected[b] - Math.floor(expected[b]); + return fb - fa || pref[b] - pref[a] || a.localeCompare(b); + }); + for (let i = 0; leftover > 0; i = (i + 1) % N) { + quota[order[i]] += 1; + leftover -= 1; + } + + // --- 3) Deal the deck (fixed spatial order, type-weighted draws) ----- + const deck = { ...quota }; + const out = new Map(); + const ordered = demand.slice().sort((a, b) => a.x - b.x || a.y - b.y); + for (const d of ordered) { + const rec = byId.get(d.id); + const table = defs[rec.type]?.attributes?.planetClasses ?? {}; + const cls = []; + const dr = Rng.derive(seed, 'planets', d.id, 'deal'); // per-system fork + for (let i = 0; i < Math.max(0, d.n); i++) { + // Draw weights: the system's OWN star-type table (the preference) + // restricted to classes the deck still holds (the availability). A + // depleted class simply can't be drawn — the evenness guarantee. + const pairs = {}; + let w = 0; + for (const c of classes) { + const pw = deck[c] > 0 ? (Math.max(0, Number(table[c])) || 0) : 0; + pairs[c] = pw; + w += pw; + } + const c = w > 0 + ? dr.weighted(pairs) + : dr.weighted(Object.fromEntries(classes.map((k) => [k, deck[k]])), classes[0]); + deck[c] = Math.max(0, deck[c] - 1); + cls.push(c); + } + if (d.capital) cls.push(capitalClassFor(d.capital)); // the outer slot + out.set(d.id, cls); + } + return out; +} + +/** The capital world's class (SystemGenerator's capital block uses the + * same formula: `capital` + the faction id, capitalized). */ +export function capitalClassFor(faction) { + const cap = String(faction); + return `capital${cap.charAt(0).toUpperCase()}${cap.slice(1)}`; +} diff --git a/js/galaxy/PlanetFrames.js b/js/galaxy/PlanetFrames.js index 24115c1..40379c0 100644 --- a/js/galaxy/PlanetFrames.js +++ b/js/galaxy/PlanetFrames.js @@ -25,16 +25,19 @@ import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; * 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). + * Shared classes: each system's planet classes come from the GALAXY-DECK + * DEAL stamp (galaxy.planetClasses — js/galaxy/PlanetClasses.js, passed in + * as `planetClasses`) — the same stamp the content generator uses, so the + * (class, frame) pairs line up exactly. When the stamp is absent (balance + * disabled, standalone callers) the pass re-derives them via the same + * exported roll (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 }) { +export function assignPlanetFrames({ seed, records, homeId, params, neighborsOf, planetClasses = null, typeDefs = null }) { const defs = typeDefs ?? config.get('systems.types', {}); const shim = { params: params ?? {} }; // settlementDensity's shape @@ -55,9 +58,10 @@ export function assignPlanetFrames({ seed, records, homeId, params, neighborsOf, 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 classes = + planetClasses instanceof Map && Array.isArray(planetClasses.get(rec.id)) + ? planetClasses.get(rec.id) + : rollSystemComposition(seed, rec, isHome, attr.settlements ?? {}, density, attr).classes; const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? []; // usage(class, frame) = [own, nb]: same-class planets already wearing diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index c6ff6ac..ab4c421 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -11,6 +11,15 @@ const DEG = Math.PI / 180; * fully generated system: star, planets, moons, SETTLEMENTS, debris belt, * hazard flag. * + * PLANET CLASS BALANCE (data/systems.json → planetClassBalance, pass in + * js/galaxy/PlanetClasses.js): the galaxy's non-home planets are dealt + * from ONE SHARED DECK — per-class quotas blend a perfectly even split + * with the star mix's own preference (`bias`), and each system draws its + * worlds from the remaining deck weighted by its star type's table. The + * mix stays galaxy-wide even while the star type still flavors local + * systems; the stamp (`galaxy.planetClasses`) feeds rollSystemComposition + * below (stampedClasses). + * * Deterministic contract: * same galaxy seed + same record id ⇒ identical content, every time. * The draw stream is derived from (seed, 'system', id) — NOT from the @@ -174,6 +183,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { const spec = attr.settlements ?? {}; const composition = rollSystemComposition( galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr, + galaxy?.planetClasses instanceof Map ? galaxy.planetClasses.get(record.id) : null, ); const classes = composition.classes; const planetN = classes.length; @@ -1320,16 +1330,21 @@ function mixTint(hex, strength) { * 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). + * 3) the planets — N − stations classes, from the STAMPED + * GALAXY-DECK DEAL when `stampedClasses` is given + * (data/systems.json → planetClassBalance — the even + * galaxy-wide mix, js/galaxy/PlanetClasses.js), else + * the per-type weighted draws (standalone callers). * * 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. + * their inputs, so they always agree. The stamped classes are themselves + * a pure function of (seed, roster, config) (stamped once at galaxy + * build), so lazy (on-arrival) === eager (generateAll) holds for them. * → { objects, deepSpace, waypoint, classes } */ -export function rollSystemComposition(seed, record, isHome, spec, density, attr) { +export function rollSystemComposition(seed, record, isHome, spec, density, attr, stampedClasses = null) { if (isHome) { const rng = Rng.derive(seed, 'system', record.id, 'settlements'); const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); @@ -1364,7 +1379,6 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr) // reach a planet and build out from it — a station-only budget // (N = 2 stations) demotes one station to a planet. let stations = Number(deepSpace) + Number(waypoint); - const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; let wp = waypoint; let ds = deepSpace; while (stations >= objects && stations > 0) { @@ -1372,7 +1386,17 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr) else ds = false; stations = Number(ds) + Number(wp); } - const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky')); + // The classes: the GALAXY-DECK DEAL when stamped (the even galaxy-wide + // mix — PlanetClasses.js), else the per-type weighted draws (standalone + // callers: dev tools, tests without a galaxy object). A stamped list of + // the wrong length is treated as unstamped (defensive — the deck pass + // and this roll always agree, so this should never fire). + const count = objects - stations; + const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; + const classes = + Array.isArray(stampedClasses) && stampedClasses.length === count + ? stampedClasses.slice() + : Array.from({ length: count }, () => rngP.weighted(classWeights, 'rocky')); return { objects, deepSpace: ds, waypoint: wp, classes }; } diff --git a/js/galaxy/TypeSystems.js b/js/galaxy/TypeSystems.js index 65e0b1e..6afc80e 100644 --- a/js/galaxy/TypeSystems.js +++ b/js/galaxy/TypeSystems.js @@ -162,6 +162,7 @@ export function pickPreviewSystem(galaxy, spec, systemType = null) { comp = rollSystemComposition( galaxy.seed, rec, isHome, attr.settlements ?? {}, settlementDensity(galaxy, rec), attr, + galaxy?.planetClasses instanceof Map ? galaxy.planetClasses.get(rec.id) : null, ); } catch { continue;