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
This commit is contained in:
Brian Fertig 2026-09-13 16:23:31 -06:00
parent 8ff7f0ee12
commit cedee9f58e
12 changed files with 470 additions and 18 deletions

View File

@ -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" },

View File

@ -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",

203
dev/planet-balance.test.mjs Normal file
View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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}` })),

View File

@ -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');

View File

@ -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;

173
js/galaxy/PlanetClasses.js Normal file
View File

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

View File

@ -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

View File

@ -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 };
}

View File

@ -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;