166 lines
6.5 KiB
JavaScript
166 lines
6.5 KiB
JavaScript
/**
|
||
* dev/star-art.test.mjs — the GALAXY tab's zoom-bloom star art (pure
|
||
* geometry in js/galaxy/GalaxyChart.js). Bare Node, no Phaser:
|
||
*
|
||
* node dev/star-art.test.mjs
|
||
*/
|
||
import { strict as assert } from 'node:assert';
|
||
import { pathToFileURL } from 'node:url';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
|
||
|
||
// --- Load the real config (data/*.json) into the config singleton --------
|
||
const { config } = await import(file('config/Config.js'));
|
||
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 {
|
||
mixHex,
|
||
starDotPx,
|
||
layerFade,
|
||
starArtSpec,
|
||
} = await import(file('galaxy/GalaxyChart.js'));
|
||
|
||
const ART = config.get('map.galaxy.stars.art', {});
|
||
const TYPES = ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'];
|
||
|
||
/* — dot size — */
|
||
{
|
||
const d1 = starDotPx(1, ART);
|
||
const d2 = starDotPx(2, ART);
|
||
const d10 = starDotPx(10, ART);
|
||
assert.ok(d1 >= 3, `dot at 1× should be a real dot (got ${d1})`);
|
||
assert.ok(d2 > d1, 'dot grows with zoom');
|
||
assert.ok(d10 > d2, 'dot keeps growing to max zoom');
|
||
assert.equal(starDotPx(0.5, ART), starDotPx(1, ART), 'below 1× the dot holds its floor');
|
||
console.log(' ok dot size: floor + zoom growth');
|
||
}
|
||
|
||
/* — layer fade — */
|
||
{
|
||
assert.equal(layerFade(1.0, 1.6, 0.45), 0, 'below the threshold: 0');
|
||
assert.ok(layerFade(1.61, 1.6, 0.45) > 0, 'just past the threshold: rising');
|
||
assert.equal(layerFade(3.0, 1.6, 0.45), 1, 'past the fade span: 1');
|
||
const a = layerFade(1.7, 1.6, 0.45);
|
||
const b = layerFade(1.8, 1.6, 0.45);
|
||
assert.ok(a < b, 'fade is monotonic');
|
||
console.log(' ok layer fade: 0 → 1 across the fade span');
|
||
}
|
||
|
||
/* — mixHex — */
|
||
{
|
||
assert.equal(mixHex('#000000', '#ffffff', 0), '#000000');
|
||
assert.equal(mixHex('#000000', '#ffffff', 1), '#ffffff');
|
||
const mid = mixHex('#000000', '#ffffff', 0.5);
|
||
assert.match(mid, /^#[0-9a-f]{6}$/);
|
||
assert.ok(mid !== '#000000' && mid !== '#ffffff', 'mid mix is between the ends');
|
||
console.log(' ok mixHex: endpoints + midpoint');
|
||
}
|
||
|
||
/* — below the flare threshold: a plain dot, no art — */
|
||
{
|
||
for (const t of TYPES) {
|
||
const spec = starArtSpec(t, { z: 1, time: 0, dot: 4, pulse: 0.5, cfg: ART });
|
||
assert.equal(spec.prims.length, 0, `${t}: no art below flareAt`);
|
||
}
|
||
console.log(' ok all types: plain dot below flareAt');
|
||
}
|
||
|
||
/* — every type blooms: spikes (all but the void) + a crown — */
|
||
{
|
||
const atCrown = { z: 4, time: 0, dot: 8, pulse: 0.5, cfg: ART };
|
||
for (const t of TYPES) {
|
||
const spec = starArtSpec(t, atCrown);
|
||
assert.ok(spec.prims.length > 0, `${t}: crown present at z=4`);
|
||
const tc = ART[t] ?? {};
|
||
const hasSpikes = (tc.spikes ?? 4) > 0;
|
||
assert.equal(spec.prims.some((p) => p.k === 'tri'), hasSpikes, `${t}: spike presence follows config`);
|
||
if (t === 'void') assert.ok(spec.prims.some((p) => p.k === 'darkdot'), 'void: dark horizon present');
|
||
if (t === 'binary') assert.ok(spec.prims.some((p) => p.k === 'ellipse'), 'binary: orbit ellipse present');
|
||
if (t === 'nebula') assert.equal(spec.prims.filter((p) => p.k === 'ellipse').length, 3, 'nebula: 3 disc layers');
|
||
if (t === 'habitable') assert.ok(spec.prims.filter((p) => p.k === 'dot' && p.x !== 0 || (p.k === 'dot' && p.y !== 0)).length >= 1, 'habitable: orbiting world(s) present');
|
||
}
|
||
console.log(' ok crowns: spikes + the per-type signature');
|
||
}
|
||
|
||
/* — binary: the companion rides its ellipse (and moves over time) — */
|
||
{
|
||
const base = { z: 5, dot: 8, pulse: 0.5, cfg: ART };
|
||
const s1 = starArtSpec('binary', { ...base, time: 0, seed: { compPhase: 0.3 } });
|
||
const s2 = starArtSpec('binary', { ...base, time: 7000, seed: { compPhase: 0.3 } });
|
||
const comp = (spec) => spec.prims.filter((p) => p.k === 'dot' && p.a > 0.9);
|
||
const c1 = comp(s1);
|
||
const c2 = comp(s2);
|
||
assert.ok(c1.length >= 1 && c2.length >= 1, 'companion dot present at both times');
|
||
const [a] = c1;
|
||
const [b] = c2;
|
||
assert.ok(
|
||
Math.hypot(a.x - b.x, a.y - b.y) > 1,
|
||
'the companion moves over time',
|
||
);
|
||
// on the ellipse: (x')²/rx² + (y')²/ry² ≈ 1 after undoing the rotation
|
||
const tc = ART.binary.companion;
|
||
const dot = 8;
|
||
const rx = tc.dist * dot;
|
||
const ry = rx * tc.tilt;
|
||
const onEllipse = (pt) => {
|
||
// rotation defaults: seed.orbitRot missing → 0.5 in the fallback
|
||
const rot = 0.5;
|
||
const cs = Math.cos(rot);
|
||
const sn = Math.sin(rot);
|
||
const lx = pt.x * cs + pt.y * sn;
|
||
const ly = -pt.x * sn + pt.y * cs;
|
||
const v = (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry);
|
||
return Math.abs(v - 1) < 0.01;
|
||
};
|
||
assert.ok(onEllipse(a), 'companion sits on the orbit ellipse');
|
||
assert.ok(onEllipse(b), 'companion stays on the orbit ellipse over time');
|
||
console.log(' ok binary: companion orbits on its ellipse');
|
||
}
|
||
|
||
/* — habitable: the world(s) sit on their orbit radii — */
|
||
{
|
||
const spec = starArtSpec('habitable', { z: 5, time: 1000, dot: 8, pulse: 0.5, cfg: ART });
|
||
const dots = spec.prims.filter((p) => p.k === 'dot' && p.a > 0.9 && p.r >= 1.3 && p.r < 8);
|
||
assert.ok(dots.length >= 1, 'orbiting world present');
|
||
for (const d of dots) {
|
||
const rr = Math.hypot(d.x, d.y);
|
||
assert.ok(Math.abs(rr - 8 * 2.2) < 0.01 || Math.abs(rr - 8 * 1.7) < 0.01, `world on an orbit ring (r=${rr.toFixed(2)})`);
|
||
}
|
||
console.log(' ok habitable: worlds ride their zone rings');
|
||
}
|
||
|
||
/* — determinism: same input → same spec — */
|
||
{
|
||
const o = { z: 5, time: 4200, dot: 9, pulse: 0.6, cfg: ART, seed: { spikeAngle: 1.1, compPhase: 2.2 } };
|
||
const a = JSON.stringify(starArtSpec('binary', o));
|
||
const b = JSON.stringify(starArtSpec('binary', { ...o }));
|
||
assert.equal(a, b, 'specs are deterministic');
|
||
console.log(' ok deterministic specs');
|
||
}
|
||
|
||
/* — every primitive is finite + sane — */
|
||
{
|
||
for (const t of TYPES) {
|
||
const spec = starArtSpec(t, { z: 8, time: 12345, dot: 12, pulse: 0.7, cfg: ART });
|
||
for (const p of spec.prims) {
|
||
for (const v of Object.values(p)) {
|
||
if (typeof v === 'number') assert.ok(Number.isFinite(v), `${t}: finite ${JSON.stringify(p)}`);
|
||
}
|
||
if (p.a !== undefined) assert.ok(p.a >= 0 && p.a <= 1.01, `${t}: alpha in range`);
|
||
}
|
||
}
|
||
console.log(' ok all primitives finite, alphas in range');
|
||
}
|
||
|
||
console.log('star-art: all tests passed ✓');
|