orbit/js/galaxy/PlanetClasses.js

174 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<systemId, string[]> — 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)}`;
}