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:
parent
8ff7f0ee12
commit
cedee9f58e
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}` })),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue