523 lines
26 KiB
JavaScript
523 lines
26 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 × per-zone mix (zoneMix, ±4σ);
|
||
* - field bounds + even field (Poisson disk), every record carries d + zone;
|
||
* - starting system = the star nearest the configured home corner;
|
||
* - every generated system obeys its type's attribute bounds;
|
||
* - the OBJECT COMPOSITION (data/systems.json → objectCount): every
|
||
* non-home system holds 0, 2, 3, 4, or 5 objects (planets + free-space
|
||
* stations), ≈ 10% barren — the gate-only dead-end leaves of the
|
||
* jump network (the maze's dead ends);
|
||
* - 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 90-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);
|
||
const nSys = big.records.length;
|
||
check(`roster size = galaxy.systemCount (${n})`, nSys === 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 — PER ZONE (the region layer:
|
||
// distribution.zoneMix in data/galaxy.json multiplies each type's global
|
||
// distribution.weight in data/systems.json; the old per-type radiusBand
|
||
// is gone — type flavor is regional now).
|
||
const zoneCfg = big.params.distribution?.zones ?? [];
|
||
const zoneMix = big.params.distribution?.zoneMix ?? {};
|
||
const baseW = Object.fromEntries(typeIds.map((id) => [id, Math.max(0, types[id].distribution?.weight ?? 1)]));
|
||
const zoneRecs = {};
|
||
for (const r of big.records) (zoneRecs[r.zone] ??= []).push(r);
|
||
let distOk = true;
|
||
for (const z of zoneCfg) {
|
||
const zrecs = zoneRecs[z.name] ?? [];
|
||
const nZ = zrecs.length;
|
||
if (nZ < 3) continue; // too few to be meaningful
|
||
const zw = {};
|
||
let tot = 0;
|
||
for (const id of typeIds) {
|
||
const mul = zoneMix[z.name]?.[id];
|
||
zw[id] = baseW[id] * (mul === undefined ? 1 : Math.max(0, mul));
|
||
tot += zw[id];
|
||
}
|
||
const counts = {};
|
||
for (const r of zrecs) counts[r.type] = (counts[r.type] ?? 0) + 1;
|
||
for (const id of typeIds) {
|
||
const p = zw[id] / tot;
|
||
if (p <= 0) continue;
|
||
const obs = (counts[id] ?? 0) / nZ;
|
||
const sd = Math.sqrt(p * (1 - p) / nZ);
|
||
if (Math.abs(obs - p) > 4 * sd + 0.004) {
|
||
distOk = false;
|
||
console.log(` zone ${z.name} type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`);
|
||
}
|
||
}
|
||
}
|
||
check('type distribution matches configured weights × zoneMix per zone (±4σ)', distOk);
|
||
|
||
// FIELD BOUNDS + EVEN FIELD + DIFFICULTY COORDINATE (the layout contract).
|
||
const fieldW = Math.max(2, Math.floor(Number(big.params.layout?.field?.width) || 32000));
|
||
const fieldH = Math.max(2, Math.floor(Number(big.params.layout?.field?.height) || 16000));
|
||
check(
|
||
'every system sits inside the 2:1 field (±width/2 × ±height/2)',
|
||
big.records.every((r) => Math.abs(r.x) <= fieldW / 2 + 1e-9 && Math.abs(r.y) <= fieldH / 2 + 1e-9),
|
||
);
|
||
let minPair = Infinity;
|
||
for (let i = 0; i < big.records.length; i++)
|
||
for (let j = i + 1; j < big.records.length; j++)
|
||
minPair = Math.min(minPair, Math.hypot(big.records[i].x - big.records[j].x, big.records[i].y - big.records[j].y));
|
||
const spacingFloor = 0.7 * Math.sqrt((fieldW * fieldH) / big.records.length);
|
||
check(
|
||
`even field (Poisson disk): min pair distance ${Math.round(minPair)} px ≥ ${Math.round(spacingFloor)} px — no clumps, no voids`,
|
||
minPair >= spacingFloor - 1e-6,
|
||
);
|
||
check(
|
||
'every record carries d in [0,1] + a zone name',
|
||
big.records.every((r) => Number.isFinite(r.d) && r.d >= 0 && r.d <= 1 && typeof r.zone === 'string'),
|
||
);
|
||
check(
|
||
'record.zone matches its d against the configured zones',
|
||
big.records.every((r) => {
|
||
const z = zoneCfg.find((zz) => r.d >= zz.d[0] && r.d < zz.d[1]) ?? zoneCfg[zoneCfg.length - 1];
|
||
return z?.name === r.zone;
|
||
}),
|
||
);
|
||
// CORNER HOME (startingSystem.policy 'corner', corner SE — lower right,
|
||
// screen y-down): the starting system is the star NEAREST the home corner.
|
||
const homeCorner = { x: fieldW / 2, y: fieldH / 2 };
|
||
const nearestToCorner = big.records
|
||
.slice()
|
||
.sort((a, b) => (a.x - homeCorner.x) ** 2 + (a.y - homeCorner.y) ** 2 - ((b.x - homeCorner.x) ** 2 + (b.y - homeCorner.y) ** 2))[0].id;
|
||
check('corner policy: the starting system is the star nearest the home (SE) corner', big.currentSystemId === nearestToCorner);
|
||
check(
|
||
'corner policy: the home system sits in the NEAR zone (d ≈ 0 at the home corner)',
|
||
big.currentSystem().zone === zoneCfg[0]?.name,
|
||
);
|
||
|
||
// Lazy contents: the global OBJECT-COMPOSITION rule across the WHOLE
|
||
// galaxy (data/systems.json → objectCount: 0/2/3/4/5 objects, ≈ 10%
|
||
// barren — jump-gate-only stops).
|
||
const t1 = performance.now();
|
||
const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } });
|
||
let boundsOk = true;
|
||
let emptyCount = 0;
|
||
for (const r of big.records) {
|
||
const content = big.ensureContent(r.id);
|
||
const n = content.planets.length + (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||
if (n === 0) emptyCount++;
|
||
if (r.id !== big.currentSystemId && !(n === 0 || (n >= 2 && n <= 5))) {
|
||
boundsOk = false;
|
||
break;
|
||
}
|
||
if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
|
||
}
|
||
const tGen = performance.now() - t1;
|
||
check('every non-home system holds 0/2/3/4/5 objects (the global objectCount rule)', boundsOk);
|
||
const emptyShare = emptyCount / nSys;
|
||
const barrenExpect = OC.barren ?? 0.1;
|
||
const barrenSd = Math.sqrt(barrenExpect * (1 - barrenExpect) / nSys);
|
||
check(
|
||
`≈ ${Math.round(barrenExpect * 100)}% of systems are barren — the gate-only dead-end leaves (observed ${(emptyShare * 100).toFixed(1)}%)`,
|
||
Math.abs(emptyShare - barrenExpect) < 4 * barrenSd + 0.004,
|
||
);
|
||
check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys);
|
||
// (The barren-share check below uses the same ±4σ band as the other
|
||
// composition checks — at 90 systems the count is small, so an absolute
|
||
// 0.08 band was tighter than the sampling noise.)
|
||
|
||
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
|
||
const fresh = Galaxy.create(SEED);
|
||
const sample = [big.records[0].id, big.records[Math.floor(nSys / 2)].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 randomPolicy = Galaxy.create('random-policy', { systemCount: 250, startingSystem: { policy: 'random' } });
|
||
check('random starting policy picks a roster member', randomPolicy.byId.has(randomPolicy.currentSystemId));
|
||
|
||
const center = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'center' } });
|
||
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);
|
||
|
||
// The CORNER policy honors whichever corner is configured (here: NW —
|
||
// upper left, screen y-down), not just the default SE.
|
||
const nw = Galaxy.create('nw-policy', { systemCount: 250, startingSystem: { policy: 'corner', corner: 'NW' } });
|
||
const halfNWx = Math.max(2, Math.floor(Number(nw.params.layout?.field?.width) || 32000)) / 2;
|
||
const halfNWy = Math.max(2, Math.floor(Number(nw.params.layout?.field?.height) || 16000)) / 2;
|
||
const trueNW = nw.records
|
||
.slice()
|
||
.sort((a, b) => (a.x + halfNWx) ** 2 + (a.y + halfNWy) ** 2 - ((b.x + halfNWx) ** 2 + (b.y + halfNWy) ** 2))[0].id;
|
||
check('corner policy honors the configured corner (NW)', nw.currentSystemId === trueNW);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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/jumps/belt/hazard', !!c.star && Array.isArray(c.planets) && Array.isArray(c.settlements) && Array.isArray(c.jumps) && !!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);
|
||
|
||
// The all-planets-settled rule (for now): EVERY world hosts a settlement,
|
||
// the kind that fits its class (data/settlements.json → settledKindByClass
|
||
// — habitable rocky ⇒ colony, other rocky/ice/lava ⇒ mining station,
|
||
// gas ⇒ cloud base).
|
||
let allSettledOk = true;
|
||
for (const r of sample) {
|
||
const c = big2.ensureContent(r.id);
|
||
for (const p of c.planets) {
|
||
const anchored = c.settlements.some(
|
||
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
|
||
);
|
||
const kindOk = c.settlements.filter(
|
||
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
|
||
).every((s) =>
|
||
(p.class === 'gas' && s.kind === 'cloudBase') ||
|
||
(p.class !== 'gas' && (s.kind === 'colony' || s.kind === 'miningStation'))
|
||
);
|
||
if (!anchored || !kindOk) { allSettledOk = false; break; }
|
||
}
|
||
if (!allSettledOk) break;
|
||
}
|
||
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
|
||
|
||
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
|
||
// unsettled ones — the gate-only dead-end leaves of the jump network
|
||
// (the spanning tree keeps them reachable: the maze's dead ends, in and
|
||
// out the same way); every non-barren system is settled (all its
|
||
// planets + its free-space stations).
|
||
let barren = 0;
|
||
for (const r of sample) {
|
||
const c = big2.ensureContent(r.id);
|
||
if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++;
|
||
}
|
||
const barrenShare = barren / sample.length;
|
||
const barrenP = config.get('systems.objectCount.barren', 0.1);
|
||
check(
|
||
`barren systems are the dead-end leaves: ≈ ${Math.round(barrenP * 100)}% (observed ${(barrenShare * 100).toFixed(1)}%)`,
|
||
Math.abs(barrenShare - barrenP) < 4 * Math.sqrt(barrenP * (1 - barrenP) / sample.length) + 0.004,
|
||
);
|
||
check(
|
||
'every non-barren system is settled (the home system is exempt)',
|
||
sample.every((r) => {
|
||
const c = big2.ensureContent(r.id);
|
||
const barren2 = c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space');
|
||
return barren2 || r.id === big2.currentSystemId || c.settlements.length > 0;
|
||
}),
|
||
);
|
||
|
||
// Home→far gradient: the free-space STATION odds scale with the home→far
|
||
// density (data/galaxy.json → settlements.gradient, keyed on d: 0 at the
|
||
// home corner, 1 at the far corner) — the planets are settled regardless,
|
||
// so the gradient lives in how many of the objects are stations: the
|
||
// settled heart is denser in stations than the deep corner.
|
||
const withCount = sample.map((r) => ({
|
||
d: r.d,
|
||
n: (big2.ensureContent(r.id).settlements ?? []).filter((s) => s.anchor?.type === 'space').length,
|
||
}));
|
||
withCount.sort((a, b) => a.d - b.d);
|
||
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(`home→far station gradient (near ${avg(inner).toFixed(2)}/system > far ${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 anyContent = big2.ensureContent(anySys.id);
|
||
const report = formatSystemReport(anyContent);
|
||
check('report has title/subtitle/status', !!report.title && !!report.subtitle && typeof report.status === 'string');
|
||
check('report subtitle is the compact identity line ("<Type> system · star <class>")',
|
||
/^[A-Za-z ]+ system · star [A-Z]$/.test(report.subtitle));
|
||
check('report status shows faction control + population (or unclaimed)',
|
||
/Faction: Neutral · (Pop ~[0-9.]+[kM]?|Unclaimed)$/.test(report.status));
|
||
check('report population sums match', report.population === (anyContent.settlements ?? []).reduce((s, x) => s + (x.population ?? 0), 0));
|
||
check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12');
|
||
|
||
// The BARREN systems (objectCount → 0) are genuinely unclaimed — that
|
||
// is the "Faction: Neutral · Unclaimed" report branch in the wild now
|
||
// (not just a defensive fallback).
|
||
|
||
// No-duplicate guarantee: within a system, planet names, station names
|
||
// and jump-gate names never repeat (drawn without replacement from the
|
||
// banks; gates get a " Gate" suffix, disambiguated with II/III if two
|
||
// targets share a star name).
|
||
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);
|
||
const gnames = (c.jumps ?? []).map((j) => j.name);
|
||
return (
|
||
new Set(pnames).size === pnames.length &&
|
||
new Set(snames).size === snames.length &&
|
||
new Set(gnames).size === gnames.length &&
|
||
new Set([...pnames, ...snames, ...gnames]).size === pnames.length + snames.length + gnames.length
|
||
);
|
||
});
|
||
check('within a system: planet, station & gate 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)));
|
||
|
||
// REGRESSION (the "Home World in a non-home system" bug): the player's
|
||
// position moves (jumps + saves write galaxy.currentSystemId = dest),
|
||
// and content is generated LAZILY on arrival — so the isHome rule must
|
||
// key off the frozen starting system (galaxy.homeSystemId), not the
|
||
// movable currentSystemId. Move the pointer to a non-home system and
|
||
// generate its content there: it must still have no home world, while
|
||
// the starting system keeps its home world.
|
||
const visited = big2.records.find((r) => r.id !== homeRec.id);
|
||
big2.currentSystemId = visited.id; // what prepareLoad()/a jump does
|
||
check(
|
||
'homeSystemId froze the starting system before the pointer moved',
|
||
big2.homeSystemId === homeRec.id && big2.currentSystemId !== big2.homeSystemId,
|
||
);
|
||
check(
|
||
'a non-home system visited after the pointer moved still has no home world',
|
||
!('homeName' in big2.ensureContent(visited.id)) && !('homeFrame' in big2.ensureContent(visited.id)),
|
||
);
|
||
check(
|
||
'the starting system keeps its home world regardless of the pointer',
|
||
typeof big2.ensureContent(homeRec.id).homeName === 'string' &&
|
||
big2.isHomeSystem(homeRec.id) === true &&
|
||
big2.isHomeSystem(visited.id) === false,
|
||
);
|
||
|
||
// The starting system always holds exactly three planets: the home world
|
||
// (the origin, not a generated planet) + a gas giant + a rocky world —
|
||
// both generated worlds settled (gas ⇒ cloud base, rocky ⇒ colony or
|
||
// mining station).
|
||
check(
|
||
'starting system = home world + gas giant + rocky world (exactly 2 generated: gas, then rocky)',
|
||
homeC.planets.length === 2 && homeC.planets[0].class === 'gas' && homeC.planets[1].class === 'rocky',
|
||
);
|
||
check(
|
||
'starting system: gas giant rides a cloud base; rocky world hosts a colony or mining station',
|
||
homeC.settlements.some((s) => s.kind === 'cloudBase' && s.anchor?.ordinal === 1) &&
|
||
homeC.settlements.some((s) => (s.kind === 'colony' || s.kind === 'miningStation') && s.anchor?.ordinal === 2),
|
||
);
|
||
|
||
// 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);
|