orbit/dev/galaxy.test.mjs

348 lines
18 KiB
JavaScript
Raw 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.

/**
* Galaxy & system generation test (dev tool, run with Node — no browser):
*
* node dev/galaxy.test.mjs
*
* Runs the REAL Rng, NameGenerator, SystemGenerator, and Galaxy from js/
* against the real data/*.json config, then asserts the determinism
* contract the whole game rests on:
* - same seed ⇒ identical roster (ids, names, types, positions);
* - different seed ⇒ different galaxy;
* - type distribution matches the weights in data/systems.json;
* - type radius bands (the first proximity rule) are respected;
* - every generated system obeys its type's attribute bounds;
* - lazy (on-arrival) content === eager (generateAll) content;
* - spatial-hash neighbor queries agree with brute force;
* - starting system policy works.
* Also reports generation timing for the default 40,000-system galaxy.
*/
import { pathToFileURL } from 'node:url';
import { 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 { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { NameGenerator } = await import(pathToFileURL(join(__dirname, '../js/utils/NameGenerator.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const deepEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const SEED = 'orbit-determinism-test';
const types = config.section('systems.types', {});
const typeIds = Object.keys(types);
const weights = typeIds.map((id) => Math.max(0, types[id].distribution?.weight ?? 1));
const totalW = weights.reduce((s, w) => s + w, 0);
const expected = Object.fromEntries(typeIds.map((id, i) => [id, weights[i] / totalW]));
// ----------------------------------------------------------------------
// 1. Rng: determinism, streams, weighted
// ----------------------------------------------------------------------
{
const a = new Rng('same-seed');
const b = new Rng('same-seed');
const seqA = Array.from({ length: 5 }, () => a.next());
const seqB = Array.from({ length: 5 }, () => b.next());
check('same seed ⇒ same sequence', deepEq(seqA, seqB));
check('different seed ⇒ different sequence', !deepEq(seqA, Array.from({ length: 5 }, () => new Rng('other').next())));
const f1 = Rng.derive('seed', 'system', 'S001');
const f2 = Rng.derive('seed', 'system', 'S001');
check('derive() is stable for same labels', deepEq([f1.next(), f1.next()], [f2.next(), f2.next()]));
check('derive() streams differ for different labels', Rng.derive('seed', 'system', 'S001').next() !== Rng.derive('seed', 'system', 'S002').next());
const c = new Rng('parent');
const fork = c.fork('child');
c.next(); // parent use must not disturb the fork
const fresh = Rng.derive('parent', 'child');
check('fork independent of parent state', deepEq([fork.next(), fork.next()], [fresh.next(), fresh.next()]));
const w = new Rng('w');
const draws = Array.from({ length: 10000 }, () => w.weighted({ a: 30, b: 70 }));
const shareA = draws.filter((x) => x === 'a').length / draws.length;
check(`weighted() respects weights (a=${(shareA * 100).toFixed(1)}% ≈ 30%)`, Math.abs(shareA - 0.3) < 0.03);
const ints = Array.from({ length: 1000 }, () => new Rng('i').int(3, 7));
check('int() stays in [min,max]', ints.every((n) => Number.isInteger(n) && n >= 3 && n <= 7));
const sh = new Rng('s').shuffle([1, 2, 3, 4, 5]);
check('shuffle() is a permutation', [...sh].sort((x, y) => x - y).join() === '1,2,3,4,5');
const n = new Rng('n');
const norms = Array.from({ length: 4000 }, () => n.normal(0, 1));
const mean = norms.reduce((s, v) => s + v, 0) / norms.length;
const sd = Math.sqrt(norms.reduce((s, v) => s + v * v, 0) / norms.length);
check(`normal() ≈ N(0,1) (mean ${mean.toFixed(2)}, sd ${sd.toFixed(2)})`, Math.abs(mean) < 0.08 && Math.abs(sd - 1) < 0.08);
const s1 = Rng.randomSeedString(8);
check('randomSeedString() length & charset', /^[a-z2-9]{8}$/.test(s1));
const star = NameGenerator.star(Rng.derive('x', 'name', 'S1'));
check('star name synthesis', typeof star === 'string' && star.length >= 3 && star === star.charAt(0).toUpperCase() + star.slice(1));
}
// ----------------------------------------------------------------------
// 2. Galaxy: determinism & seed sensitivity (full default size)
// ----------------------------------------------------------------------
let big;
{
const t0 = performance.now();
big = Galaxy.create(SEED);
const tCreate = performance.now() - t0;
const n = config.get('galaxy.systemCount', 0);
check(`roster size = galaxy.systemCount (${n})`, big.records.length === n);
check('ids are stable & unique', new Set(big.records.map((r) => r.id)).size === n);
check('current system is part of the roster', big.byId.has(big.currentSystemId));
const again = Galaxy.create(SEED);
check('same seed ⇒ identical roster (names, types, x, y)', deepEq(big.records, again.records));
check('same seed ⇒ same starting system', big.currentSystemId === again.currentSystemId);
check('same seed ⇒ same galaxy name', big.name === again.name);
const other = Galaxy.create('totally-different');
check('different seed ⇒ different galaxy', !deepEq(big.records, other.records));
// Type distribution vs configured weights.
const counts = {};
for (const r of big.records) counts[r.type] = (counts[r.type] ?? 0) + 1;
const nSys = big.records.length;
let distOk = true;
for (const id of typeIds) {
const obs = (counts[id] ?? 0) / nSys;
const sd = Math.sqrt((expected[id] * (1 - expected[id])) / nSys);
if (Math.abs(obs - expected[id]) > 4 * sd + 0.004) {
distOk = false;
console.log(` type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(expected[id] * 100).toFixed(1)}%`);
}
}
check('type distribution matches configured weights (±4σ)', distOk);
// Radius bands (the first "proximity" rule).
const R = Math.max(1, big.params.radius ?? 20000);
const flatten = big.params.layout?.flatten ?? 0.62;
let bandOk = true;
for (const r of big.records) {
const band = types[r.type].distribution?.radiusBand;
if (!Array.isArray(band) || band.length !== 2) continue;
const rNorm = Math.sqrt(r.x * r.x + (r.y / flatten) ** 2) / R;
if (rNorm < band[0] - 1e-9 || rNorm > band[1] + 1e-9) {
bandOk = false;
break;
}
}
check('radius bands (proximity rule) respected by every system', bandOk);
// Lazy contents: attribute bounds across the WHOLE galaxy.
const t1 = performance.now();
let boundsOk = true;
for (const r of big.records) {
const content = big.ensureContent(r.id);
const attr = types[r.type].attributes ?? {};
const pc = attr.planetCount ?? { min: 0, max: 99 };
if (content.planets.length < pc.min || content.planets.length > pc.max) {
boundsOk = false;
break;
}
if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
}
const tGen = performance.now() - t1;
check('every system obeys its type attribute bounds (planetCount etc.)', boundsOk);
check('lazy content generation over all 40k systems', big.generatedCount === nSys);
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
const fresh = Galaxy.create(SEED);
const sample = [big.records[0].id, big.records[999].id, big.records[nSys - 1].id];
const lazyEager = sample.every((id) => deepEq(fresh.ensureContent(id), big.ensureContent(id)));
check('lazy (on-arrival) content === content already generated', lazyEager);
const eager = Galaxy.create(SEED).generateAll();
check('generateAll() (eager mode) identical to lazy', sample.every((id) => deepEq(eager.contentOf(id), fresh.contentOf(id))));
console.log(` timing: roster(${nSys}) ${tCreate.toFixed(0)} ms · all contents ${tGen.toFixed(0)} ms`);
}
// ----------------------------------------------------------------------
// 3. Small galaxy: neighbors & starting system vs brute force
// ----------------------------------------------------------------------
{
const small = Galaxy.create('small', { systemCount: 300 });
const recs = small.records;
const bruteNearest = (x, y, k) =>
recs
.map((r) => ({ d2: (r.x - x) ** 2 + (r.y - y) ** 2, record: r }))
.sort((a, b) => a.d2 - b.d2)
.slice(0, k)
.map((e) => e.record.id);
let nnOk = true;
for (let i = 0; i < 20; i++) {
const probe = recs[(i * 17) % recs.length];
const k = 5;
const got = small
.neighborsOf(probe.id, k)
.map((r) => r.id)
.sort();
const want = bruteNearest(probe.x, probe.y, k + 1)
.filter((id) => id !== probe.id)
.slice(0, k)
.sort();
if (!deepEq(got, want)) {
nnOk = false;
break;
}
}
check('neighborsOf() matches brute-force k-nearest (300-system galaxy)', nnOk);
const point = { x: 1234.5, y: -777.25 };
const gotP = small.nearest(point.x, point.y, 3).map((r) => r.id).sort();
const wantP = bruteNearest(point.x, point.y, 3).sort();
check('nearest(point, k) matches brute force', deepEq(gotP, wantP));
const centerPolicy = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'random' } });
check('random starting policy picks a roster member', centerPolicy.byId.has(centerPolicy.currentSystemId));
const center = Galaxy.create('center-policy', { systemCount: 250 });
const centerRecs = center.records;
const trueCenter = centerRecs.slice().sort((a, b) => (a.x ** 2 + a.y ** 2) - (b.x ** 2 + b.y ** 2))[0].id;
const gridNearest = center.nearest(0, 0, 1)[0].id;
check('center starting policy picks the record nearest the origin', center.currentSystem().id === trueCenter && gridNearest === trueCenter);
}
// ----------------------------------------------------------------------
// 4. Sanity: content shape & theming hooks
// ----------------------------------------------------------------------
{
const g = Galaxy.create('content-shape');
const sys = g.currentSystem();
const c = g.ensureContent(sys.id);
check('content has star/planets/settlements/belt/hazard', !!c.star && Array.isArray(c.planets) && Array.isArray(c.settlements) && !!c.belt && typeof c.hazard === 'boolean');
check('planets have name/ordinal/class/moons/habitable', c.planets.every((p) => p.name && p.ordinal >= 1 && p.class && Number.isInteger(p.moons) && typeof p.habitable === 'boolean'));
check('type themes are defined (UI hook)', typeIds.every((id) => typeof types[id].theme?.color === 'string'));
check('galaxy name is deterministic per seed', Galaxy.create('content-shape').name === g.name);
}
// ----------------------------------------------------------------------
// 5. The lived-in layer: settlements, gradient, report
// ----------------------------------------------------------------------
{
const kinds = config.get('settlements.kinds', {});
check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min));
// Structure across a big sample of the full galaxy.
const big2 = Galaxy.create(SEED);
const sample = big2.records.slice(0, 3000);
let structOk = true;
for (const r of sample) {
const c = big2.ensureContent(r.id);
for (const s of c.settlements) {
if (!kinds[s.kind]) { structOk = false; break; }
if (s.anchor.type === 'planet') {
const planet = c.planets.find((p) => p.ordinal === s.anchor.ordinal);
if (!planet) structOk = false;
} else if (s.anchor.type !== 'space') structOk = false;
const pop = kinds[s.kind].population;
if (s.population < pop.min || s.population > pop.max) structOk = false;
if (s.owner !== null) structOk = false; // reserved seam, unused for now
if (!s.name) structOk = false;
}
if (!structOk) break;
}
check('settlement structure: known kinds, valid anchors, population in range, owner=null (faction seam)', structOk);
// The lived-in mix actually shows up across the sample.
const seen = new Set();
let anchorRulesOk = true;
for (const r of sample) {
const c = big2.ensureContent(r.id);
for (const s of c.settlements) {
seen.add(s.kind);
const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal);
if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false;
if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false;
}
}
check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k)));
check('colonies sit on habitable worlds; cloud bases ride gas giants', anchorRulesOk);
// Not everything is inhabited — some systems stay unclaimed.
let unclaimed = 0;
for (const r of sample) if ((big2.ensureContent(r.id).settlements).length === 0) unclaimed++;
check(`some systems are charted-but-unclaimed (${unclaimed}/${sample.length} in sample)`, unclaimed > 0);
// Core→rim gradient: the settled heart is denser than the wilder rim.
const withCount = sample.map((r) => ({ rNorm: r.rNorm, n: big2.ensureContent(r.id).settlements.length }));
withCount.sort((a, b) => a.rNorm - b.rNorm);
const third = Math.floor(withCount.length / 3);
const inner = withCount.slice(0, third);
const outer = withCount.slice(-third);
const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length;
check(`core→rim settlement gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
// Station + planet naming now draws from the curated BANKS (no repeats
// within a system until the pool is exhausted). The deck is a seeded
// shuffle of the whole bank ⇒ the first N draws are N distinct names.
const stDeck = NameGenerator.stationDeck(Rng.derive('x', 'names', 'S1', 'stations'));
check('station deck is a non-empty list of names', Array.isArray(stDeck) && stDeck.length >= 10 && stDeck.every((n) => typeof n === 'string' && n.length > 0));
check('station deck has no duplicates', new Set(stDeck).size === stDeck.length);
const plDeck = NameGenerator.planetDeck(Rng.derive('x', 'names', 'S1', 'planets'));
check('planet deck is a non-empty list of names', Array.isArray(plDeck) && plDeck.length >= 10 && plDeck.every((n) => typeof n === 'string' && n.length > 0));
check('planet deck has no duplicates', new Set(plDeck).size === plDeck.length);
const { formatSystemReport, formatPop } = await import(
pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href
);
const anySys = big2.currentSystem();
const report = formatSystemReport(big2.ensureContent(anySys.id));
check('report has title/subtitle/settlements/summary', !!report.title && !!report.subtitle && Array.isArray(report.settlements) && typeof report.summary === 'string');
check('report lines name their anchor world or open space', report.settlements.every((s) => /on .+|in open space/.test(s.text)));
check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0));
check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12');
// Unclaimed systems read as "charted · unclaimed" in the report.
const unclaimedRec = sample.map((r) => big2.ensureContent(r.id)).find((c) => c.settlements.length === 0);
check('unclaimed systems report "charted · unclaimed"', unclaimedRec && formatSystemReport(unclaimedRec).summary === 'charted · unclaimed');
// No-duplicate guarantee: within a system, planet names and station names
// never repeat (drawn without replacement from the bank).
const noDupOk = sample.every((r) => {
const c = big2.ensureContent(r.id);
const pnames = c.planets.map((p) => p.name);
const snames = c.settlements.map((s) => s.name);
return new Set(pnames).size === pnames.length && new Set(snames).size === snames.length;
});
check('within a system: planet names & station names are all distinct', noDupOk);
// The starting system's HOME world gets a bank name distinct from its
// planets; only the starting system has one.
const homeRec = big2.currentSystem();
const homeC = big2.ensureContent(homeRec.id);
check(
'starting system has a home-world bank name, distinct from its planets',
typeof homeC.homeName === 'string' && homeC.homeName.length > 0 &&
!homeC.planets.some((p) => p.name === homeC.homeName),
);
const nonHomeRec = big2.records.find((r) => r.id !== homeRec.id);
check('other systems have no home world', !('homeName' in big2.ensureContent(nonHomeRec.id)));
// Lazy === eager still holds with the lived-in layer (spot check).
const fresh2 = Galaxy.create(SEED);
check('settlements: lazy content === content from a fresh galaxy', deepEq(fresh2.ensureContent(big2.records[0].id).settlements, big2.ensureContent(big2.records[0].id).settlements));
}
console.log(failures === 0 ? '\nAll galaxy tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);