/** * 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); * - the FRONT ACCENT (js/utils/Color.js → shiftHue): the near layer's * tint is the base hue rotated by the configured hueShift, lightness * lifted (lightScale), saturation preserved (satScale 1); identity, * white/black preservation, and a valid distinct accent for all 8 * palette shades; * - the TWO-LAYER STRUCTURE (NebulaAtmosphere.create/update/destroy): * back clouds at depth 3 in the base tint with far parallax (<1), * front clouds at depth 11 (ABOVE the ship) in the accent tint with * near parallax (>1), sizes/alphas in the configured ranges, clouds * wrapped into the camera window on flight, slow self-swirl, clean * destroy, and front.enabled=false reverting to the single back layer. */ 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, NebulaAtmosphere } = await import( pathToFileURL(join(__dirname, '../js/visuals/NebulaAtmosphere.js')).href ); const { shiftHue, toColor, toCss } = await import( pathToFileURL(join(__dirname, '../js/utils/Color.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; } /** HSL of a color (number 0xRRGGBB or hex string) → { h: 0–360, s: 0–1, l: 0–1 } */ function hslOf(c) { let n; if (typeof c === 'number') n = c >>> 0; else { let h = String(c).trim().replace(/^#/, ''); if (h.length === 3) h = h.split('').map((ch) => ch + ch).join(''); 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; const l = (max + min) / 2; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); let h = 0; if (d !== 0) { if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h *= 60; if (h < 0) h += 360; } return { h, s, l }; } // 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); // ========================================================================= // 4. FRONT ACCENT (shiftHue — the near layer's derived tint) // ========================================================================= console.log('\n— Front accent (Color.shiftHue) —'); const accentCfg = nebulaCfg.front?.accent ?? {}; const HUE_SHIFT = accentCfg.hueShift ?? 42; const SAT_SCALE = accentCfg.satScale ?? 1; const LIGHT_SCALE = accentCfg.lightScale ?? 1.12; const baseColor = toColor('#f0884a'); const accent = shiftHue(baseColor, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE }); const hueDelta = (() => { // expected: hue(accent) − hue(base) ≡ +HUE_SHIFT (mod 360) const d = (hslOf(accent).h - hslOf(baseColor).h + 360) % 360; const want = HUE_SHIFT % 360; return Math.min(Math.abs(d - want), 360 - Math.abs(d - want)); })(); check(`hue rotated by the configured ${HUE_SHIFT}° (±3°)`, hueDelta <= 3); check(`lightness lifted by lightScale ${LIGHT_SCALE}`, hslOf(accent).l > hslOf(baseColor).l); check(`saturation preserved (satScale ${SAT_SCALE})`, Math.abs(hslOf(accent).s - hslOf(baseColor).s) <= 0.05); // Identity: no shift ⇒ same color (float round-trip, ±1 per channel). const identity = shiftHue(0x3f6fa0, {}); const chanDiff = (a, b, shift) => Math.abs(((a >> shift) & 255) - ((b >> shift) & 255)); check('identity round-trip keeps the color (±1/channel)', chanDiff(0x3f6fa0, identity, 16) <= 1 && chanDiff(0x3f6fa0, identity, 8) <= 1 && chanDiff(0x3f6fa0, identity, 0) <= 1); // Grays keep their gray: white stays white, black stays black. check('white and black are preserved', shiftHue(0xffffff, { hueShift: 42, lightScale: 1.12 }) === 0xffffff && shiftHue(0x000000, { hueShift: 42, lightScale: 1.12 }) === 0x000000); // Every palette shade yields a VALID accent that is DISTINCT from its base // (a 42° shift can't collide) and keeps saturation sane. let accentsOk = true; for (const shade of palette) { const b2 = toColor(shade); const a2 = shiftHue(b2, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE }); if (a2 < 0 || a2 > 0xffffff || a2 === b2) accentsOk = false; const hl = hslOf(a2); if (hl.l < 0 || hl.l > 1) accentsOk = false; } check('all 8 palette shades → valid, distinct accents', accentsOk); console.log(` base ${toCss(baseColor)} → accent ${toCss(accent)} (Δhue vs the expected +${HUE_SHIFT}°: ${hueDelta.toFixed(1)}°)`); // ========================================================================= // 5. TWO-LAYER STRUCTURE (create / update / destroy) // ========================================================================= console.log('\n— Two-layer structure (NebulaAtmosphere) —'); // A minimal fake scene: just enough surface for create()/update()/destroy() // (scale, cameras, add.image, textures, time). canvasTexture needs a // document.createElement canvas — stub it (the draw is a no-op here; the // TEXTURE math is already covered in section 3). function makeFakeScene() { const images = []; const fakeCtx = { createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }), putImageData: () => {}, }; globalThis.document = { createElement: () => ({ width: 0, height: 0, getContext: () => fakeCtx }), }; const makeImage = (x, y, key) => { const o = { x, y, key, scale: 1, alpha: 1, rotation: 0, depth: 0, tint: 0, destroyed: false }; o.setScale = (v) => ((o.scale = v), o); o.setAlpha = (v) => ((o.alpha = v), o); o.setRotation = (v) => ((o.rotation = v), o); o.setDepth = (v) => ((o.depth = v), o); o.setTint = (v) => ((o.tint = v), o); o.destroy = () => (o.destroyed = true); images.push(o); return o; }; return { images, scale: { width: 1280, height: 720 }, cameras: { main: { scrollX: 1000, scrollY: 500 } }, time: { now: 0 }, textures: { exists: () => false, addCanvas: () => {} }, add: { image: makeImage }, }; } const scene = makeFakeScene(); const neb = new NebulaAtmosphere(scene, '#f0884a', nebulaCfg); neb.create(); const M = 120; // = MARGIN in NebulaAtmosphere.js const back = scene.images.filter((o) => o.depth === 3); const front = scene.images.filter((o) => o.depth === 11); check(`back layer: cfg.count (${nebulaCfg.count}) clouds at depth 3`, back.length === nebulaCfg.count); check(`front layer: cfg.front.count (${nebulaCfg.front?.count}) clouds at depth 11 (above the ship)`, front.length === (nebulaCfg.front?.count ?? 0)); check('back tint = the base atmosphere color', back.every((o) => o.tint === baseColor)); const expectedAccent = shiftHue(baseColor, { hueShift: HUE_SHIFT, satScale: SAT_SCALE, lightScale: LIGHT_SCALE }); check('front tint = the derived accent (shiftHue of the base)', front.every((o) => o.tint === expectedAccent)); check('back parallax < 1 (far gas, slower than the world)', back.every((o) => o.parallax > 0 && o.parallax < 1)); check('front parallax > 1 (near gas, drifts past the world)', front.every((o) => o.parallax > 1) && front.every((o) => o.parallax <= (nebulaCfg.front?.parallax?.[1] ?? 1.4) + 1e-9)); check('front alpha within cfg.front.alpha', front.every((o) => o.alpha >= (nebulaCfg.front?.alpha?.[0] ?? 0.07) - 1e-9 && o.alpha <= (nebulaCfg.front?.alpha?.[1] ?? 0.15) + 1e-9)); check('front clouds are the BIG puffs (cfg.front.size range)', front.every((o) => o.scale * 256 >= (nebulaCfg.front?.size?.[0] ?? 700) - 1 && o.scale * 256 <= (nebulaCfg.front?.size?.[1] ?? 1400) + 1)); check('all clouds start inside the camera window (+margin)', scene.images.every((o) => o.x >= 1000 - M - 1e-6 && o.x < 1000 + 1280 + M + 1e-6 && o.y >= 500 - M - 1e-6 && o.y < 500 + 720 + M + 1e-6)); // drift wiring: rotSpeed = drift × [0.0012, 0.0036] rad/s (drift 2.5 ⇒ the // original 0.003–0.009; the front layer runs its own drift). const inRot = (o, drift) => Math.abs(o.rotSpeed) >= drift * 0.0012 - 1e-12 && Math.abs(o.rotSpeed) <= drift * 0.0036 + 1e-12; check('back swirl speed scaled by cfg.drift', back.every((o) => inRot(o, nebulaCfg.drift ?? 2.5))); check('front swirl speed scaled by cfg.front.drift', front.every((o) => inRot(o, nebulaCfg.front?.drift ?? nebulaCfg.drift ?? 2.5))); // Self-swirl over the scene clock (frame-safe, ms-based). scene.time.now = 500; neb.update(); check('clouds self-swirl over the scene clock', scene.images.some((o) => Math.abs(o.rotation - o.baseRotation) > 1e-6)); // Camera flight: the gas stays wrapped into the live view window. scene.cameras.main.scrollX += 3000; neb.update(); const newLeft = 1000 + 3000 - M; const spanX = 1280 + 2 * M; check('camera flight keeps the gas wrapped in view', scene.images.every((o) => o.x >= newLeft - 1e-6 && o.x < newLeft + spanX + 1e-6)); // Clean teardown (scene jump / system change). neb.destroy(); check('destroy() removes every cloud', scene.images.every((o) => o.destroyed) && neb.clouds.length === 0); // The REVERT path: front.enabled = false → the single back layer, as before. const scene2 = makeFakeScene(); const cfgNoFront = { ...nebulaCfg, front: { ...(nebulaCfg.front ?? {}), enabled: false } }; const neb2 = new NebulaAtmosphere(scene2, '#5b9bf2', cfgNoFront); neb2.create(); check('front.enabled=false reverts to the single back layer', scene2.images.length === nebulaCfg.count && scene2.images.every((o) => o.depth === 3)); // --------------------------------------------------------------------------- console.log(failures === 0 ? '\nAll nebula tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);