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-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": "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-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": "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": "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" },
|
{ "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,
|
"barren": 0.10,
|
||||||
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
"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": {
|
"types": {
|
||||||
"main": {
|
"main": {
|
||||||
"label": "Main Sequence",
|
"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 E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
|
||||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.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 { 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', []);
|
const goods = config.get('goods.goods', []);
|
||||||
assert.ok(goods.length >= 2, 'catalog has goods to trade');
|
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._scout.alpha, 0, '… hidden (alpha 0)');
|
||||||
assert.equal(view.sellGrid.alpha, 1, '… and the cargo grid is visible');
|
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.
|
// TAP THE ROW'S BOX → the scout opens on that good.
|
||||||
view._onRowTap(row);
|
view._onRowTap(row);
|
||||||
|
|
@ -941,7 +947,11 @@ console.log('\n== SET DESTINATION — tap a best-exit row on the scout: the scen
|
||||||
const calls = [];
|
const calls = [];
|
||||||
scene.askSetDestination = (exit) => { calls.push(exit); };
|
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);
|
view._onRowTap(row);
|
||||||
assert.equal(view._scoutOpen, row.id, 'scout open on the row');
|
assert.equal(view._scoutOpen, row.id, 'scout open on the row');
|
||||||
const d = view._scout.report;
|
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') {
|
} else if (e.cls === 'capital') {
|
||||||
const p = (content.planets ?? []).find((x) => typeof x?.class === 'string' && x.class.startsWith('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;
|
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 {
|
} else {
|
||||||
const p = (content.planets ?? []).find((x) => x?.class === e.cls);
|
const p = (content.planets ?? []).find((x) => x?.class === e.cls);
|
||||||
ok = !!p && e.objectId === p.name && e.objectName === p.name;
|
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
|
homeSystemId: g.homeSystemId, // neither fixture record is home
|
||||||
homeWorldFrame: g.homeWorldFrame,
|
homeWorldFrame: g.homeWorldFrame,
|
||||||
params: g.params,
|
params: g.params,
|
||||||
|
planetClasses: g.planetClasses, // the real deck stamp (classes must match contentOf)
|
||||||
planetFrames: new Map(),
|
planetFrames: new Map(),
|
||||||
stationFrames: new Map(),
|
stationFrames: new Map(),
|
||||||
jumpGatesFor: (id) => Array.from({ length: gateCounts[id] ?? 0 }, (_, i) => ({ id: `${id}-gate-${i}` })),
|
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,
|
homeSystemId: g.homeSystemId,
|
||||||
homeWorldFrame: g.homeWorldFrame,
|
homeWorldFrame: g.homeWorldFrame,
|
||||||
params: g.params,
|
params: g.params,
|
||||||
|
planetClasses: g.planetClasses,
|
||||||
planetFrames: new Map(),
|
planetFrames: new Map(),
|
||||||
stationFrames: new Map(),
|
stationFrames: new Map(),
|
||||||
jumpGatesFor: (id) => Array.from({ length: id === sA.id ? 3 : 1 }, (_, i) => ({ id: `${id}-gate-${i}` })),
|
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 spec = type.attributes?.settlements ?? {};
|
||||||
const attr = type.attributes ?? {};
|
const attr = type.attributes ?? {};
|
||||||
const density = settlementDensity(galaxy, record);
|
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);
|
const set = new Set(comp.classes);
|
||||||
if (isHome) set.add(homeCls); // the home world (a terran body by rule)
|
if (isHome) set.add(homeCls); // the home world (a terran body by rule)
|
||||||
if (comp.deepSpace) set.add('station');
|
if (comp.deepSpace) set.add('station');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Rng } from '../utils/Rng.js';
|
||||||
import { NameGenerator } from '../utils/NameGenerator.js';
|
import { NameGenerator } from '../utils/NameGenerator.js';
|
||||||
import { buildJumpNetwork } from './JumpNetwork.js';
|
import { buildJumpNetwork } from './JumpNetwork.js';
|
||||||
import { assignPlanetFrames } from './PlanetFrames.js';
|
import { assignPlanetFrames } from './PlanetFrames.js';
|
||||||
|
import { assignPlanetClasses } from './PlanetClasses.js';
|
||||||
import { assignStationFrames } from './StationFrames.js';
|
import { assignStationFrames } from './StationFrames.js';
|
||||||
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
|
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
|
||||||
import { buildEconomy } from '../economy/Economy.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.
|
// had already moved there) and deal it a home world of its own.
|
||||||
this.homeSystemId = null;
|
this.homeSystemId = null;
|
||||||
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
|
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.homeWorldFrame = null; // the starting system's home world frame
|
||||||
this.stationFrames = new Map(); // id → frame — the station-variant spread pass
|
this.stationFrames = new Map(); // id → frame — the station-variant spread pass
|
||||||
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
|
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).
|
// composition planet, so the frame pass never sees it).
|
||||||
this._assignFactions(barren);
|
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
|
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
|
||||||
// (class, frame) assignment — each planet's sheet frame avoids what
|
// (class, frame) assignment — each planet's sheet frame avoids what
|
||||||
// the NEAREST stars already wear for that class, so the same face is
|
// the NEAREST stars already wear for that class, so the same face is
|
||||||
|
|
@ -313,6 +332,7 @@ export class Galaxy {
|
||||||
homeId: this.currentSystemId,
|
homeId: this.currentSystemId,
|
||||||
params: this.params,
|
params: this.params,
|
||||||
neighborsOf: (id) => this.neighborsOf(id, pool8),
|
neighborsOf: (id) => this.neighborsOf(id, pool8),
|
||||||
|
planetClasses: this.planetClasses,
|
||||||
});
|
});
|
||||||
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
|
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
|
||||||
this.homeWorldFrame = homeFrame;
|
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
|
* faces a little better than roster order. Ties are broken by a
|
||||||
* derived, per-pick Rng (seeded).
|
* derived, per-pick Rng (seeded).
|
||||||
*
|
*
|
||||||
* Shared rolls: the pass re-derives each system's planet classes via the
|
* Shared classes: each system's planet classes come from the GALAXY-DECK
|
||||||
* same exported roll and forks as the content generator
|
* DEAL stamp (galaxy.planetClasses — js/galaxy/PlanetClasses.js, passed in
|
||||||
* (SystemGenerator.rollSystemComposition) — guaranteed to agree, since
|
* as `planetClasses`) — the same stamp the content generator uses, so the
|
||||||
* the forks are pure functions of (seed, id, type, density).
|
* (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
|
* Cost: one weighted draw + a few pool scans per planet — trivial at this
|
||||||
* galaxy size (data/galaxy.json → systemCount), computed once at galaxy
|
* galaxy size (data/galaxy.json → systemCount), computed once at galaxy
|
||||||
* build time (js/galaxy/Galaxy.js).
|
* 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 defs = typeDefs ?? config.get('systems.types', {});
|
||||||
const shim = { params: params ?? {} }; // settlementDensity's shape
|
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 isHome = rec.id === homeId;
|
||||||
const attr = defs[rec.type]?.attributes ?? {};
|
const attr = defs[rec.type]?.attributes ?? {};
|
||||||
const density = settlementDensity(shim, rec);
|
const density = settlementDensity(shim, rec);
|
||||||
const { classes } = rollSystemComposition(
|
const classes =
|
||||||
seed, rec, isHome, attr.settlements ?? {}, density, attr,
|
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) : []) ?? [];
|
const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? [];
|
||||||
|
|
||||||
// usage(class, frame) = [own, nb]: same-class planets already wearing
|
// 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,
|
* fully generated system: star, planets, moons, SETTLEMENTS, debris belt,
|
||||||
* hazard flag.
|
* 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:
|
* Deterministic contract:
|
||||||
* same galaxy seed + same record id ⇒ identical content, every time.
|
* same galaxy seed + same record id ⇒ identical content, every time.
|
||||||
* The draw stream is derived from (seed, 'system', id) — NOT from the
|
* 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 spec = attr.settlements ?? {};
|
||||||
const composition = rollSystemComposition(
|
const composition = rollSystemComposition(
|
||||||
galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr,
|
galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr,
|
||||||
|
galaxy?.planetClasses instanceof Map ? galaxy.planetClasses.get(record.id) : null,
|
||||||
);
|
);
|
||||||
const classes = composition.classes;
|
const classes = composition.classes;
|
||||||
const planetN = classes.length;
|
const planetN = classes.length;
|
||||||
|
|
@ -1320,16 +1330,21 @@ function mixTint(hex, strength) {
|
||||||
* 2) the free-space stations (0–2, per-type odds × the
|
* 2) the free-space stations (0–2, per-type odds × the
|
||||||
* density gradient; at most 2, which never exceeds the
|
* density gradient; at most 2, which never exceeds the
|
||||||
* minimum non-barren budget of 2);
|
* minimum non-barren budget of 2);
|
||||||
* 3) the planets — N − stations weighted class draws
|
* 3) the planets — N − stations classes, from the STAMPED
|
||||||
* (a 2-object system can be 0 planets + 2 stations).
|
* 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
|
* Both the content generator and the galaxy-wide frame pass
|
||||||
* (js/galaxy/PlanetFrames.js) call this — the forks
|
* (js/galaxy/PlanetFrames.js) call this — the forks
|
||||||
* (seed, 'system', id, 'planets' / 'settlements') are pure functions of
|
* (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 }
|
* → { 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) {
|
if (isHome) {
|
||||||
const rng = Rng.derive(seed, 'system', record.id, 'settlements');
|
const rng = Rng.derive(seed, 'system', record.id, 'settlements');
|
||||||
const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density);
|
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
|
// reach a planet and build out from it — a station-only budget
|
||||||
// (N = 2 stations) demotes one station to a planet.
|
// (N = 2 stations) demotes one station to a planet.
|
||||||
let stations = Number(deepSpace) + Number(waypoint);
|
let stations = Number(deepSpace) + Number(waypoint);
|
||||||
const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
|
|
||||||
let wp = waypoint;
|
let wp = waypoint;
|
||||||
let ds = deepSpace;
|
let ds = deepSpace;
|
||||||
while (stations >= objects && stations > 0) {
|
while (stations >= objects && stations > 0) {
|
||||||
|
|
@ -1372,7 +1386,17 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr)
|
||||||
else ds = false;
|
else ds = false;
|
||||||
stations = Number(ds) + Number(wp);
|
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 };
|
return { objects, deepSpace: ds, waypoint: wp, classes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ export function pickPreviewSystem(galaxy, spec, systemType = null) {
|
||||||
comp = rollSystemComposition(
|
comp = rollSystemComposition(
|
||||||
galaxy.seed, rec, isHome, attr.settlements ?? {},
|
galaxy.seed, rec, isHome, attr.settlements ?? {},
|
||||||
settlementDensity(galaxy, rec), attr,
|
settlementDensity(galaxy, rec), attr,
|
||||||
|
galaxy?.planetClasses instanceof Map ? galaxy.planetClasses.get(rec.id) : null,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue