orbit/dev/asteroids.test.mjs

294 lines
15 KiB
JavaScript
Raw 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.

/**
* 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 sample);
* - the STARTING system always gets ≥ startingSystemMinClusters, and
* those first ones sit INSIDE the initial tether (whole cluster,
* minus placement.tetherMargin);
* - GROUP SHAPE: 48 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
* object — home world (origin), planets, free-space stations, other
* clusters — and every cluster sits in the [minRadius, maxRadius]
* annulus around the origin;
* - 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 48', (CL.groupSize?.min ?? 4) >= 4 && (CL.groupSize?.max ?? 8) <= 8 && CL.groupSize.min <= CL.groupSize.max);
check('sizes 64128 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.
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.
const objects = [{ 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 });
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 object ----------------------
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;
// --- Scatter annulus ---------------------------------------------------
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 (dist0 + cl.bound + (P.tetherMargin ?? 96) > TETHER + 1e-6) {
homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(dist0 + cl.bound)} + margin > ${TETHER})`; break;
}
}
}
if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break;
}
check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters}${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 object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${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.
{
const rows = sample.map((r) => {
const c = g.ensureContent(r.id);
return { planets: c.planets.length, clusters: c.asteroids.length };
});
const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
const rich = rows.filter((r) => r.planets >= 6);
const poor = rows.filter((r) => r.planets <= 2);
check(
`inverse rule (aggregate): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥6 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 names the clusters (${c.asteroids.length})`, report.subtitle.includes(`${c.asteroids.length} asteroid cluster`));
}
// --- 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[999].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);