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.stations)); 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)}`; }