185 lines
7.1 KiB
JavaScript
185 lines
7.1 KiB
JavaScript
/**
|
|
* Frame-diversity test (dev tool, run with Node — no browser needed):
|
|
*
|
|
* node dev/frames.test.mjs
|
|
*
|
|
* Asserts the galaxy-wide (class, frame) pass (js/galaxy/PlanetFrames.js,
|
|
* wired in js/galaxy/Galaxy.js, data/planets.json → frames):
|
|
* - every planet carries a sheet frame inside its class's pool, and the
|
|
* starting system's home world carries a terran-pool frame
|
|
* (content.homeFrame);
|
|
* - two planets of the SAME class in one system never wear the same
|
|
* face (intra-system diversity);
|
|
* - the pass beats a naive random pick: the (class, frame) collision
|
|
* rate among each planet's 8-nearest stars is LOWER than the same
|
|
* metric for an independent random-per-planet assignment;
|
|
* - determinism: same seed ⇒ identical frames (galaxy and content),
|
|
* different seed ⇒ different assignment (spot check);
|
|
* - lazy === eager: the stamped frames equal a fresh galaxy's.
|
|
*/
|
|
|
|
process.env.NODE_ENV = 'dev';
|
|
|
|
import { pathToFileURL } from 'node:url';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
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 { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
|
|
|
|
let failures = 0;
|
|
const check = (label, cond, extra = '') => {
|
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
const SEED = 'frames-test-seed';
|
|
const g = Galaxy.create(SEED);
|
|
const POOL = Math.max(1, Math.floor(g.params.neighbors ?? 8));
|
|
const poolFor = (cls) => {
|
|
const p = config.get(`planets.frames.${cls}`);
|
|
return Array.isArray(p) && p.length > 0 ? p : [0];
|
|
};
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 1. Frames are present and in-pool
|
|
// ----------------------------------------------------------------------
|
|
{
|
|
let inPool = true;
|
|
let why = '';
|
|
for (const rec of g.records) {
|
|
const c = g.ensureContent(rec.id);
|
|
for (const p of c.planets) {
|
|
if (typeof p.frame !== 'number' || !Number.isInteger(p.frame) || !poolFor(p.class).includes(p.frame)) {
|
|
inPool = false;
|
|
if (!why) why = `${rec.id}: ${p.class} planet frame ${p.frame} ∉ ${poolFor(p.class)}`;
|
|
}
|
|
}
|
|
}
|
|
const home = g.ensureContent(g.currentSystemId);
|
|
if (typeof home.homeFrame !== 'number' || !poolFor('terran').includes(home.homeFrame)) {
|
|
inPool = false;
|
|
if (!why) why = `home world frame ${home.homeFrame} ∉ terran pool`;
|
|
}
|
|
check('every planet frame is an integer in its class pool (home world: terran pool)', inPool, why);
|
|
|
|
// Intra-system diversity: same-class worlds wear different faces UNLESS
|
|
// the system holds more of that class than the class pool has faces
|
|
// (pigeonhole — then every face is used, and the lexicographic pass
|
|
// guarantees exactly pool-size distinct faces).
|
|
let distinct = true;
|
|
let whyD = '';
|
|
for (const rec of g.records) {
|
|
const c = g.ensureContent(rec.id);
|
|
const byClass = new Map();
|
|
for (const p of c.planets) {
|
|
if (!byClass.has(p.class)) byClass.set(p.class, []);
|
|
byClass.get(p.class).push(p.frame);
|
|
}
|
|
for (const [cls, fs2] of byClass) {
|
|
const m = fs2.length;
|
|
const poolN = poolFor(cls).length;
|
|
const used = new Set(fs2).size;
|
|
if (used !== Math.min(m, poolN)) {
|
|
distinct = false;
|
|
if (!whyD) whyD = `${rec.id}: ${m} ${cls} worlds use ${used} faces (want ${Math.min(m, poolN)})`;
|
|
}
|
|
}
|
|
}
|
|
check('same-class worlds in one system wear distinct faces (pigeonhole-permitting)', distinct, whyD);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 2. The pass beats a naive random assignment
|
|
// ----------------------------------------------------------------------
|
|
{
|
|
// The pass's (class, frame) collision rate: for each planet, count its
|
|
// 8-nearest stars' planets wearing the SAME class AND frame (directed).
|
|
const collisionRate = (frameOf) => {
|
|
let coll = 0;
|
|
let checks = 0;
|
|
for (const rec of g.records) {
|
|
const c = g.ensureContent(rec.id);
|
|
c.planets.forEach((p, i) => {
|
|
for (const nb of g.neighborsOf(rec.id, POOL)) {
|
|
checks++;
|
|
const nbC = g.ensureContent(nb.id);
|
|
if (nbC.planets.some((q, j) => q.class === p.class && frameOf(nb.id, q, j) === p.frame)) coll++;
|
|
}
|
|
});
|
|
}
|
|
return { coll, checks, rate: coll / checks };
|
|
};
|
|
|
|
const passed = collisionRate((id, p, i) => g.planetFrames.get(id)[i]);
|
|
|
|
// Naive: independent random pool picks (seeded, per planet).
|
|
const naive = new Map();
|
|
for (const rec of g.records) {
|
|
const c = g.ensureContent(rec.id);
|
|
naive.set(rec.id, c.planets.map((p, i) => {
|
|
const pool = poolFor(p.class);
|
|
return pool.length === 1 ? pool[0] : pool[Math.floor(Rng.derive(SEED, 'naive', rec.id, String(i), p.class).next() * pool.length)];
|
|
}));
|
|
}
|
|
const na = collisionRate((id, p, i) => naive.get(id)[i]);
|
|
|
|
check(
|
|
`(class, frame) collisions vs the ${POOL}-nearest pool: pass ${(passed.rate * 100).toFixed(1)}% < naive ${(na.rate * 100).toFixed(1)}%`,
|
|
passed.rate < na.rate,
|
|
`${passed.coll}/${passed.checks} vs ${na.coll}/${na.checks} (density sets the floor — same-class neighbors dominate)`,
|
|
);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 3. Determinism + lazy === eager
|
|
// ----------------------------------------------------------------------
|
|
{
|
|
const g2 = Galaxy.create(SEED);
|
|
let same = true;
|
|
for (const rec of g.records) {
|
|
if (JSON.stringify(g.planetFrames.get(rec.id)) !== JSON.stringify(g2.planetFrames.get(rec.id))) {
|
|
same = false;
|
|
break;
|
|
}
|
|
}
|
|
check('same seed ⇒ same (class, frame) assignment galaxy-wide', same);
|
|
check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame);
|
|
|
|
// Content frames === galaxy frames (the stamping contract).
|
|
let stamped = true;
|
|
for (const rec of g.records) {
|
|
const c = g.ensureContent(rec.id);
|
|
if (JSON.stringify(c.planets.map((p) => p.frame)) !== JSON.stringify(g.planetFrames.get(rec.id))) {
|
|
stamped = false;
|
|
break;
|
|
}
|
|
}
|
|
check('content planet frames === the galaxy pass (lazy === eager stamping)', stamped);
|
|
|
|
const g3 = Galaxy.create('frames-test-OTHER');
|
|
let diff = false;
|
|
for (const rec of g.records) {
|
|
if (JSON.stringify(g.planetFrames.get(rec.id)) !== JSON.stringify(g3.planetFrames.get(rec.id))) {
|
|
diff = true;
|
|
break;
|
|
}
|
|
}
|
|
check('different seed ⇒ different assignment (spot check)', diff || g3.records[0].id !== g.records[0].id);
|
|
}
|
|
|
|
console.log(failures === 0 ? '\nAll frame-diversity tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
|
process.exit(failures === 0 ? 0 : 1);
|