174 lines
8.2 KiB
JavaScript
174 lines
8.2 KiB
JavaScript
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)}`;
|
||
}
|