334 lines
18 KiB
JavaScript
334 lines
18 KiB
JavaScript
/**
|
||
* 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);
|