/** * Asteroid cluster test (dev tool, run with Node — no browser needed): * * node dev/asteroids.test.mjs * * Asserts the whole data/asteroids.json contract, generated by the real * SystemGenerator across a large seeded sample of the galaxy: * - COUNT vs PLANETS: clusterCount ≈ targetObjects − planetCount (± * jitter, clamped to [minClusters, maxClusters]) — so systems with * more planets get fewer clusters and vice versa (also checked as an * aggregate correlation across the non-barren sample); BARREN * systems (gate-only dead-end leaves) are the one exception — they * hold 1–2 clusters (their payload) INSIDE the arrival gate's * level-1 tether (data/asteroids.json → barren); * - the STARTING system always gets ≥ startingSystemMinClusters, and * those first ones sit INSIDE the initial tether (whole cluster, * minus placement.tetherMargin); * - GROUP SHAPE: 4–8 rocks, sizes in [64, 128], at least one full-size * rock, frames in [0, frameCount) with NO frame twice in a cluster, * every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration), * bound = max(offset + size/2); * - SPACING: no cluster within 1024 px (center-to-center) of ANY other * solid object — the home world (origin, starting system only), * planets, free-space stations, other clusters — and every non-barren * cluster sits in the [minRadius, maxRadius] annulus around the origin * (barren clusters orbit their gate instead); * - MOTION: each rock's spin and the group drift are within the * configured slow-spin ranges; * - NAMES: synthesised, and never repeating a name in the same system; * - DETERMINISM: same seed ⇒ identical clusters (deep equal), different * seed ⇒ different, and lazy (on-arrival) === eager (fresh galaxy). */ process.env.NODE_ENV = 'dev'; 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 { NameGenerator } = await import(pathToFileURL(join(__dirname, '../js/utils/NameGenerator.js')).href); const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href); const { formatSystemReport } = await import(pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href); let pass = 0; let failures = 0; function check(name, cond, detail = '') { if (!cond) { failures++; console.error(`✗ ${name}${detail ? ` — ${detail}` : ''}`); } else { pass++; console.log(`✓ ${name}`); } } function deepEq(a, b) { return JSON.stringify(a) === JSON.stringify(b); } // --- Config sanity --------------------------------------------------------- const A = config.section('asteroids', {}); check('asteroids.json is listed & loaded (enabled)', A.enabled !== false && typeof A.texture === 'string'); check('frame count configured', Number.isInteger(A.frameCount) && A.frameCount >= 8); const CL = A.cluster ?? {}; check('group size 4–8', (CL.groupSize?.min ?? 4) >= 4 && (CL.groupSize?.max ?? 8) <= 8 && CL.groupSize.min <= CL.groupSize.max); check('sizes 64–128 with a full-size minimum', (CL.sizes?.min ?? 64) === 64 && (CL.sizes?.max ?? 128) === 128 && (CL.minFullSize ?? 1) >= 1); const D = A.distribution ?? {}; check('distribution has target/min/max/jitter', Number.isFinite(D.targetObjects) && D.minClusters >= 1 && D.maxClusters >= D.minClusters && Number.isFinite(D.jitter)); check('starting system minimum ≥ 2', (D.startingSystemMinClusters ?? 2) >= 2); const P = A.placement ?? {}; check('placement rules present', P.minObjectSpacing === 1024 && P.minRadius > 0 && P.maxRadius >= P.minRadius); const TETHER = config.get('tether.level1Radius', 5120) * Math.pow(config.get('tether.radiusGrowth', 2.0), Math.max(1, Math.floor(config.get('tether.homeLevel', 1))) - 1); // --- Name synthesis --------------------------------------------------------- { const n1 = NameGenerator.asteroid(Rng.derive('x', 'names', 'S1', 'asteroids')); const n2 = NameGenerator.asteroid(Rng.derive('x', 'names', 'S1', 'asteroids')); check('asteroid names are "Name Suffix" strings', /^\S+ \S+$/.test(n1) && n1 === n2); } // --- The big sample --------------------------------------------------------- const SEED = 'asteroid-test-galaxy'; const g = Galaxy.create(SEED); const homeId = g.currentSystemId; const homeRec = g.records.find((r) => r.id === homeId); // The starting system MUST be in the sample (it carries the special rules). const sample = [homeRec, ...g.records.slice(0, 5000).filter((r) => r.id !== homeId)]; let shapeOk = true, shapeWhy = ''; let spacingOk = true, spacingWhy = ''; let annulusOk = true, annulusWhy = ''; let spinOk = true, spinWhy = ''; let namesOk = true, namesWhy = ''; let countOk = true, countWhy = ''; let homeOk = true, homeWhy = ''; const spinMin = (CL.spin?.minDegPerSec ?? 0.3) * (Math.PI / 180); const spinMax = (CL.spin?.maxDegPerSec ?? 1.6) * (Math.PI / 180); const gspinMin = (CL.groupSpin?.minDegPerSec ?? 0.12) * (Math.PI / 180); const gspinMax = (CL.groupSpin?.maxDegPerSec ?? 0.4) * (Math.PI / 180); for (const rec of sample) { const c = g.ensureContent(rec.id); const clusters = c.asteroids; const isHome = rec.id === homeId; if (!Array.isArray(clusters)) { countOk = false; countWhy = `${rec.id}: asteroids is not an array`; break; } // COUNT vs PLANETS (the inverse rule): count is the clamped, jittered // target — targetObjects − planetCount, ±jitter, clamped into [min, max] // (and [startingMin, max] for the home system). When the ideal band // clamps to empty, the clamped range itself is the contract. // BARREN systems (0 planets + 0 free-space stations — the gate-only // dead-end leaves) are the one exception: 1–2 clusters (barren.clusters) // as their payload, placed around the arrival gate. const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space'); if (barren) { const bc = A.barren ?? {}; const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1)); const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2)); if (clusters.length < lo || clusters.length > hi) { countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want ${lo}–${hi})`; break; } } else { const want = D.targetObjects - c.planets.length; let loW = Math.max(D.minClusters, isHome ? D.startingSystemMinClusters : -Infinity, Math.round(want - D.jitter)); let hiW = Math.min(D.maxClusters, Math.round(want + D.jitter)); if (loW > hiW) { loW = D.minClusters; hiW = D.maxClusters; } if (clusters.length < loW || clusters.length > hiW) { countOk = false; countWhy = `${rec.id}: ${c.planets.length} planets → ${clusters.length} clusters (want ${loW}–${hiW})`; break; } } // Names already used in this system (planets + stations + clusters). const used = new Set(); for (const p of c.planets) used.add(p.name); for (const s of c.settlements ?? []) if (s.name) used.add(s.name); // Spacing obstacles — solids only (the center holds the home world in // the starting system and is EMPTY in every other system). const objects = isHome ? [{ x: 0, y: 0 }] : []; for (const p of c.planets) if (typeof p.x === 'number') objects.push({ x: p.x, y: p.y }); for (const s of c.settlements ?? []) if (s.anchor?.type === 'space' && typeof s.x === 'number') objects.push({ x: s.x, y: s.y }); // Barren payload: the clusters orbit the arrival gate, inside its // level-1 tether (and at least barren.minRadius out from it). const gate = (c.jumps ?? [])[0]; const bMin = A.barren?.minRadius ?? 1024; const gTether = config.get('tether.level1Radius', 5120); const margin = P.tetherMargin ?? 96; for (let i = 0; i < clusters.length; i++) { const cl = clusters[i]; // --- Group shape ----------------------------------------------------- const rocks = cl.asteroids ?? []; if (rocks.length < CL.groupSize.min || rocks.length > CL.groupSize.max) { shapeOk = false; shapeWhy = `${rec.id}#${i}: ${rocks.length} rocks (want ${CL.groupSize.min}–${CL.groupSize.max})`; break; } if (!rocks.every((r) => r.size >= CL.sizes.min && r.size <= CL.sizes.max)) { shapeOk = false; shapeWhy = `${rec.id}#${i}: rock size out of [${CL.sizes.min}, ${CL.sizes.max}]`; break; } if (!rocks.some((r) => r.size === CL.sizes.max)) { shapeOk = false; shapeWhy = `${rec.id}#${i}: no full-size rock`; break; } const frames = rocks.map((r) => r.frame); if (!frames.every((f) => Number.isInteger(f) && f >= 0 && f < A.frameCount)) { shapeOk = false; shapeWhy = `${rec.id}#${i}: frame out of sheet [0, ${A.frameCount})`; break; } if (new Set(frames).size !== frames.length) { shapeOk = false; shapeWhy = `${rec.id}#${i}: a frame is used twice in one cluster`; break; } const bound = Math.max(...rocks.map((r) => Math.hypot(r.x, r.y) + r.size / 2)); if (Math.abs(bound - cl.bound) > 1e-9) { shapeOk = false; shapeWhy = `${rec.id}#${i}: bound ${cl.bound} ≠ max(offset + size/2) ${bound}`; break; } // Rocks keep a small gap from each other: no pair closer than // gapFactor × (r1 + r2) centers (the generator's relaxation contract). const gap = Math.max(1, CL.gapFactor ?? 1.12); outer: for (let ai = 0; ai < rocks.length; ai++) { for (let bi = ai + 1; bi < rocks.length; bi++) { const d = Math.hypot(rocks[ai].x - rocks[bi].x, rocks[ai].y - rocks[bi].y); const need = gap * (rocks[ai].size / 2 + rocks[bi].size / 2); if (d < need - 1e-6) { shapeOk = false; shapeWhy = `${rec.id}#${i}: rocks ${ai}/${bi} ${d.toFixed(1)} px apart (< ${need.toFixed(1)} gap)`; break outer; } } } // --- Motion ---------------------------------------------------------- if (!rocks.every((r) => Math.abs(r.spin) >= spinMin - 1e-12 && Math.abs(r.spin) <= spinMax + 1e-12)) { spinOk = false; spinWhy = `${rec.id}#${i}: rock spin out of range`; break; } if (Math.abs(cl.groupSpin) < gspinMin - 1e-12 || Math.abs(cl.groupSpin) > gspinMax + 1e-12) { spinOk = false; spinWhy = `${rec.id}#${i}: group spin out of range`; break; } const dspinMin = 0.2 * (Math.PI / 180); const dspinMax = 0.5 * (Math.PI / 180); if (typeof cl.debrisSpin !== 'number' || Math.abs(cl.debrisSpin) < dspinMin - 1e-12 || Math.abs(cl.debrisSpin) > dspinMax + 1e-12) { spinOk = false; spinWhy = `${rec.id}#${i}: debris spin out of range (${cl.debrisSpin})`; break; } // --- Names ----------------------------------------------------------- if (typeof cl.name !== 'string' || cl.name.length < 3) { namesOk = false; namesWhy = `${rec.id}#${i}: bad name "${cl.name}"`; break; } if (used.has(cl.name)) { namesOk = false; namesWhy = `${rec.id}#${i}: name "${cl.name}" repeats a name in the system`; break; } used.add(cl.name); // --- Spacing: ≥ 1024 px from every other SOLID ---------------------- for (const o of objects) { const d = Math.hypot(cl.x - o.x, cl.y - o.y); if (d < P.minObjectSpacing - 1e-6) { spacingOk = false; spacingWhy = `${rec.id}#${i}: ${Math.round(d)} px from an object (< ${P.minObjectSpacing})`; break; } } if (!spacingOk) break; // --- Barren payload: inside the gate's tether ------------------------ if (barren) { if (!gate || typeof gate.x !== 'number') { annulusOk = false; annulusWhy = `${rec.id}#${i}: barren cluster but no gate to anchor on`; break; } const dg = Math.hypot(cl.x - gate.x, cl.y - gate.y); if (dg < bMin - 1e-6 || dg + cl.bound + margin > gTether + 1e-6) { annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dg)} px from the gate (want ${bMin}–${gTether}, bound ${Math.round(cl.bound)} + margin ${margin})`; break; } } else { // --- Scatter annulus (non-barren: around the origin) --------------- const dist0 = Math.hypot(cl.x, cl.y); if (dist0 < P.minRadius - 1e-6 || dist0 > P.maxRadius + 1e-6) { annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dist0)} px from origin (want ${P.minRadius}–${P.maxRadius})`; break; } } // --- Starting-system tether guarantee --------------------------------- if (isHome && i < D.startingSystemMinClusters) { if (Math.hypot(cl.x, cl.y) + cl.bound + margin > TETHER + 1e-6) { homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(Math.hypot(cl.x, cl.y) + cl.bound)} + margin > ${TETHER})`; break; } } } if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break; } check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ ${A.barren?.clusters?.join('–') ?? '1–2'})${countOk ? '' : ' — ' + countWhy}`, countOk); check(`shape: groups of ${CL.groupSize.min}–${CL.groupSize.max}, sizes ${CL.sizes.min}–${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk); check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY solid (home world, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk); check(`scatter: non-barren clusters inside the ${P.minRadius}–${P.maxRadius} annulus; barren payload inside the gate's level-1 tether${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk); check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk); check('names: synthesised, never repeating a name in the same system', namesOk); const homeClusters = g.ensureContent(homeId).asteroids; check( `starting system: ≥ ${D.startingSystemMinClusters} clusters inside the initial tether (radius ${TETHER} px) — got ${homeClusters.length} clusters, first ${D.startingSystemMinClusters} inside`, homeClusters.length >= D.startingSystemMinClusters && homeOk, ); // The inverse rule, in aggregate: rockier systems (few planets) get more // clusters than planet-rich ones — over the NON-BARREN systems (barren // dead ends carry their fixed 1–2 payload clusters around the gate, a // separate rule). { const rows = sample.map((r) => { const c = g.ensureContent(r.id); const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space'); return { planets: c.planets.length, clusters: c.asteroids.length, barren }; }).filter((r) => !r.barren); const avg = (xs) => xs.reduce((s, x) => s + x, 0) / (xs.length || 1); const rich = rows.filter((r) => r.planets >= 4); const poor = rows.filter((r) => r.planets <= 2); check( `inverse rule (aggregate, non-barren): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥4 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`, rich.length > 0 && poor.length > 0 && avg(poor.map((r) => r.clusters)) > avg(rich.map((r) => r.clusters)), ); } // Clusters actually appear with variety (frame, size, spin direction mix). { const c = g.ensureContent(homeId); const all = c.asteroids.flatMap((cl) => cl.asteroids); check('home system clusters use multiple frames', new Set(all.map((r) => r.frame)).size >= 3); check('home system clusters mix spin directions', all.some((r) => r.spin > 0) && all.some((r) => r.spin < 0)); const report = formatSystemReport(c); check('report subtitle is the compact identity line (no cluster count)', /^[A-Za-z ]+ system · star [A-Z]$/.test(report.subtitle)); } // --- Determinism ------------------------------------------------------------- { const a = Galaxy.create(SEED).ensureContent(g.records[42].id); const b = Galaxy.create(SEED).ensureContent(g.records[42].id); check('same seed ⇒ identical clusters (deep equal)', deepEq(a.asteroids, b.asteroids)); const c = Galaxy.create('totally-different-seed').ensureContent(g.records[42].id); check('different seed ⇒ different clusters', !deepEq(a.asteroids, c.asteroids)); // Lazy === eager: a FRESH galaxy's on-arrival content matches the cached // one, including every cluster's position, rocks, spins and debris. const fresh = Galaxy.create(SEED); const ids = [homeId, g.records[Math.floor(g.records.length / 2)].id, g.records[g.records.length - 1].id]; const lazyEager = ids.every((id) => { const f = fresh.ensureContent(id); const cached = g.ensureContent(id); return deepEq(f.asteroids, cached.asteroids) && deepEq(f, cached); }); check('lazy (on-arrival) content === cached content (clusters included)', lazyEager); // And the starting-system tether guarantee holds on the fresh galaxy too. const fHome = fresh.ensureContent(homeId).asteroids; const inside = fHome.slice(0, D.startingSystemMinClusters).every( (cl) => Math.hypot(cl.x, cl.y) + cl.bound + (P.tetherMargin ?? 96) <= TETHER + 1e-6, ); check('fresh galaxy: starting-system tether clusters still inside', inside); } console.log(failures === 0 ? `\n${pass} asteroid cluster checks passed ✔` : `\n${failures} check(s) FAILED ✘ (${pass} passed)`); process.exit(failures === 0 ? 0 : 1);