/** * 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);