336 lines
17 KiB
JavaScript
336 lines
17 KiB
JavaScript
/**
|
||
* 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);
|
||
|
||
// 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);
|