orbit/dev/asteroids.test.mjs

334 lines
18 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.

/**
* 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 12 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: 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
* 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 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.
// BARREN systems (0 planets + 0 free-space stations — the gate-only
// dead-end leaves) are the one exception: 12 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 targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters} (barren ⇒ ${A.barren?.clusters?.join('') ?? '12'})${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 12 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);