orbit/dev/nebula.test.mjs

236 lines
9.5 KiB
JavaScript

/**
* Nebula atmosphere test (dev tool, run with Node — no browser needed):
*
* node dev/nebula.test.mjs
*
* Asserts:
* - the PALETTE contract (data/game.json → nebula.palette): exactly 8
* distinct, valid hex shades — the gas is tinted per-system from this;
* - the PER-SYSTEM COLOR (js/galaxy/SystemGenerator.js): every generated
* system stamps content.atmosphere.color, always a member of the
* palette; same seed ⇒ the same color for a system (deterministic), and
* it is stable across lazy vs. eager generation;
* - the CLOUD TEXTURE (js/visuals/NebulaAtmosphere.js → drawCloud): a
* valid alpha channel (some opaque gas, not empty), faded at the edges
* (center denser than the rim), white RGB (tint sets the color), and
* deterministic (two renders are byte-identical).
*/
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
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 { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { drawCloud } = await import(
pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// --- Helpers -------------------------------------------------------------
const isHex = (s) =>
typeof s === 'string' && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.trim());
function hueOf(hex) {
let h = String(hex).trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
if (d === 0) return 0;
let hue;
if (max === r) hue = ((g - b) / d) % 6;
else if (max === g) hue = (b - r) / d + 2;
else hue = (r - g) / d + 4;
hue *= 60;
return hue < 0 ? hue + 360 : hue;
}
// A headless 2-D context just enough for drawCloud (it only uses
// createImageData / putImageData). drawCloud fills the buffer we hand it.
function renderCloud(w, h, tex) {
const data = new Uint8ClampedArray(w * h * 4);
const ctx = {
createImageData: (ww, hh) => ({ width: ww, height: hh, data }),
putImageData: () => {}, // the buffer is already filled by drawCloud
};
drawCloud(ctx, w, h, tex);
return data;
}
// =========================================================================
// 1. PALETTE contract
// =========================================================================
console.log('\n— Palette (data/game.json → nebula.palette) —');
const nebulaCfg = config.section('game.nebula', {});
const palette = nebulaCfg.palette ?? [];
const distinct = new Set(palette.map((c) => String(c).toLowerCase().replace(/^#/, '')));
check('palette has exactly 8 shades', palette.length === 8);
check('all 8 shades are distinct', distinct.size === 8);
check('every shade is a valid hex color', palette.every(isHex));
if (palette.length === 8) {
const hues = palette.map(hueOf);
const minSep = (() => {
const sorted = [...hues].sort((a, b) => a - b);
let m = Infinity;
for (let i = 0; i < sorted.length; i++) {
const next = (i + 1) % sorted.length;
let gap = Math.abs(sorted[next] - sorted[i]);
gap = Math.min(gap, 360 - gap);
if (i < sorted.length - 1) m = Math.min(m, gap);
}
return m;
})();
console.log(
` hues: ${hues.map((h) => h.toFixed(0) + '°').join(' ')} (min neighbour separation ${minSep.toFixed(0)}°)`,
);
check('colours are well spread (min neighbour separation ≥ 25°)', minSep >= 25);
}
// =========================================================================
// 2. PER-SYSTEM COLOR (deterministic, palette-member)
// =========================================================================
console.log('\n— Per-system colour (SystemGenerator) —');
const SEED = 'orbit-nebula-test';
const gal = Galaxy.create(SEED);
const gal2 = Galaxy.create(SEED); // fresh, same seed → must match
let allStamped = true;
let allInPalette = true;
let sameSeedStable = true;
let nebulaSystems = 0;
const seenColors = new Set();
for (const rec of gal.records) {
const c1 = gal.ensureContent(rec.id);
const c2 = gal2.ensureContent(rec.id);
if (!c1?.atmosphere || typeof c1.atmosphere.color !== 'string') allStamped = false;
if (typeof c1?.atmosphere?.color === 'string' && !isHex(c1.atmosphere.color)) allInPalette = false;
const palKey = (x) => String(x ?? '').toLowerCase().replace(/^#/, '');
if (c1?.atmosphere?.color && !palette.some((p) => palKey(p) === palKey(c1.atmosphere.color)))
allInPalette = false;
if (c1?.atmosphere?.color !== c2?.atmosphere?.color) sameSeedStable = false;
if (rec.type === 'nebula') {
nebulaSystems++;
if (c1?.atmosphere?.color) seenColors.add(palKey(c1.atmosphere.color));
}
}
check('every system stamps content.atmosphere.color (a string)', allStamped);
check('every stamped colour is a valid hex in the palette', allInPalette);
check('same seed ⇒ same colour for every system (deterministic)', sameSeedStable);
check('the galaxy actually contains nebula systems', nebulaSystems > 0);
// With 8 colours and ~90 systems, a real random assignment should hit many
// distinct shades across the nebulae (not a degenerate single colour).
check('nebulae draw on more than one shade (assignment is really random)', seenColors.size >= 3);
console.log(
` ${gal.records.length} systems · ${nebulaSystems} nebulae · ${seenColors.size} distinct nebula shades used`,
);
// Lazy === eager: a third galaxy generated on arrival matches.
const spot = gal.records.find((r) => r.type === 'nebula') ?? gal.records[0];
const fresh = Galaxy.create(SEED);
const lazyEq =
fresh.ensureContent(spot.id).atmosphere.color === gal.ensureContent(spot.id).atmosphere.color;
check('lazy (on-arrival) colour === eager (up-front) colour', lazyEq);
// =========================================================================
// 3. CLOUD TEXTURE (drawCloud)
// =========================================================================
console.log('\n— Cloud texture (NebulaAtmosphere.drawCloud) —');
const SIZE = 128; // smaller than the 256 default → fast in Node
const texCfg = nebulaCfg.texture ?? {};
const buf = renderCloud(SIZE, SIZE, texCfg);
const alphaAt = (x, y) => buf[(y * SIZE + x) * 4 + 3];
const rgbWhite = (x, y) => {
const i = (y * SIZE + x) * 4;
return buf[i] === 255 && buf[i + 1] === 255 && buf[i + 2] === 255;
};
// Some gas exists (not an all-transparent sprite).
let opaqueCount = 0;
let maxAlpha = 0;
for (let y = 0; y < SIZE; y++)
for (let x = 0; x < SIZE; x++) {
const a = alphaAt(x, y);
if (a > 0) opaqueCount++;
if (a > maxAlpha) maxAlpha = a;
}
check('the cloud has visible gas (some opaque pixels)', opaqueCount > SIZE * SIZE * 0.01);
check('the brightest filament reaches real density (max alpha ≥ 40)', maxAlpha >= 40);
// White RGB everywhere (the tint sets the color; the texture carries alpha).
let allWhite = true;
for (let y = 0; y < SIZE; y += 7)
for (let x = 0; x < SIZE; x += 7) if (alphaAt(x, y) > 0 && !rgbWhite(x, y)) allWhite = false;
check('opaque pixels are white RGB (tint-able)', allWhite);
// Edge fade: the centre band is denser than the outer ring.
const ring = (x0, x1, y0, y1, fn) => {
let s = 0;
let n = 0;
// Integer coords only — the alpha buffer is indexed by whole pixels.
const ax0 = Math.round(x0), ax1 = Math.round(x1);
const ay0 = Math.round(y0), ay1 = Math.round(y1);
for (let y = ay0; y < ay1; y += 3)
for (let x = ax0; x < ax1; x += 3) {
s += fn(x, y);
n++;
}
return n ? s / n : 0;
};
const centre = ring(SIZE * 0.35, SIZE * 0.65, SIZE * 0.35, SIZE * 0.65, alphaAt);
// The rim: average alpha over ONLY the outer 12% border (apples to apples
// with the centre — not diluted by the interior pixels).
let rimSum = 0;
let rimN = 0;
for (let y = 0; y < SIZE; y += 2)
for (let x = 0; x < SIZE; x += 2)
if (x < SIZE * 0.12 || x > SIZE * 0.88 || y < SIZE * 0.12 || y > SIZE * 0.88) {
rimSum += alphaAt(x, y);
rimN++;
}
const rim = rimN ? rimSum / rimN : 0;
check('edges fade to transparent (centre denser than the rim)', centre > rim);
console.log(` centre avg alpha ${centre.toFixed(1)} · rim avg alpha ${rim.toFixed(1)}`);
// Determinism: two renders are byte-identical.
const buf2 = renderCloud(SIZE, SIZE, texCfg);
let identical = buf.length === buf2.length;
if (identical) for (let i = 0; i < buf.length; i++) if (buf[i] !== buf2[i]) { identical = false; break; }
check('texture is deterministic (two renders byte-identical)', identical);
// ---------------------------------------------------------------------------
console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);