orbit/dev/planet-balance.test.mjs

204 lines
9.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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