204 lines
9.4 KiB
JavaScript
204 lines
9.4 KiB
JavaScript
/**
|
||
* 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);
|