orbit/dev/galaxy.test.mjs

523 lines
26 KiB
JavaScript
Raw Permalink 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 × 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);