/** * 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: the global planet-count rule across the WHOLE galaxy. const t1 = performance.now(); const pc = config.get('systems.planetCount', { noneChance: 0, min: 0, max: 99 }); let boundsOk = true; let emptyCount = 0; for (const r of big.records) { const content = big.ensureContent(r.id); const n = content.planets.length; if (n === 0) emptyCount++; if (!(n === 0 || (n >= pc.min && n <= pc.max))) { boundsOk = false; break; } if (!content.star || typeof content.star.class !== 'string') boundsOk = false; } const tGen = performance.now() - t1; check(`every system has 0 or [${pc.min}, ${pc.max}] planets (the global planetCount rule)`, boundsOk); const emptyShare = emptyCount / nSys; check( `≈ ${Math.round((pc.noneChance ?? 0) * 100)}% of systems are barren (observed ${(emptyShare * 100).toFixed(1)}%)`, Math.abs(emptyShare - (pc.noneChance ?? 0.2)) < 0.01, ); 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/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); // JUMP-GATE ANCHOR GUARANTEE: every system holds at least one planet or // free-space station — the jump gates sit within level-1 tether of an // anchor, so a system with neither would be closed (unreachable). let anchored = 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')) anchored++; } check(`every system holds a gate anchor — a planet or space station (${anchored}/${sample.length} in sample)`, anchored === sample.length); check('no system is left unclaimed (the anchor guarantee settled the barren ones)', sample.every((r) => big2.ensureContent(r.id).settlements.length > 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/gates/summary', !!report.title && !!report.subtitle && Array.isArray(report.settlements) && Array.isArray(report.gates) && 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 gate lines name their destination star', report.gates.every((gt) => /jump to .+/.test(gt.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'); // The anchor guarantee settled every system, so the "unclaimed" report // branch is a defensive fallback (content with zero settlements). // 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))); // 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);