/** * System effects test (dev tool, run with Node — no browser needed): * * node dev/system-effects.test.mjs * * Asserts: * - the RIPPLE MATH (js/visuals/SystemEffectsMath.js): world->screen->UV * under identity, translation, rotation and zoom transforms; the * phase clock is monotonic and speed-scaled; * - the DATA CONTRACT (data/systems.json): every system type carries an * `effect` block; `nebula` is the one live family (ripple) with * sane numeric parameters (and padding that covers the max * displacement); the other types render untouched (none); * - the DEMO PICKER (js/galaxy/FxSystems.js): richest system of the * requested type wins, ties fall to roster order, missing type => null; * - the SHADER CONTRACT (js/visuals/SystemEffects.js, exercised against * the Phaser stub): every uniform setupUniforms pushes is declared in * the fragment source, and the fragment keeps the build's filter-shader * conventions (uMainSampler / outTexCoord / boundedSampler); * - the FACADE (SystemEffects.apply/update/release) on a fake scene: * "none" touches nothing (no camera, no filter); "ripple" needs WebGL * and registers the node once, splits the cameras (UI roots off the * world pass, world roots off the UI pass), attaches a parameterized * controller to the world camera's internal filter list, advances the * phase in update() (screen anchor pinned, star anchor tracking * world 0,0), and releases cleanly. */ 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 { worldToScreen, worldToUV, ripplePhase } = await import( pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href ); const { pickFxSystem } = await import( pathToFileURL(join(__dirname, '../js/galaxy/FxSystems.js')).href ); const SE = await import(pathToFileURL(join(__dirname, '../js/visuals/SystemEffects.js')).href); const { ensureUiCameras, assignUi, assignWorld, isScreenPinned } = await import( pathToFileURL(join(__dirname, '../js/visuals/UiCameras.js')).href ); let pass = 0; function check(name, cond) { if (!cond) { console.error(`✗ ${name}`); process.exit(1); } pass++; console.log(`✓ ${name}`); } const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps; // --- The ripple math ------------------------------------------------------- const ID = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }; check('identity: world (0,0) -> screen (0,0)', worldToScreen(ID, 0, 0).x === 0 && worldToScreen(ID, 0, 0).y === 0); check('identity: UV of a screen point is itself / size', (() => { const uv = worldToUV(ID, 100, 50, 25, 10); return approx(uv.x, 0.25) && approx(uv.y, 0.2); })()); check('translation: world origin lands on the camera offset', (() => { const m = { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 }; const p = worldToScreen(m, 0, 0); return p.x === 100 && p.y === 40; })()); check('rotation: world->screen rotates (90° about the origin)', (() => { // 90° CCW in screen space (y-down): (1,0) -> (0,1). const m = { a: 0, b: 1, c: -1, d: 0, tx: 0, ty: 0 }; const p = worldToScreen(m, 1, 0); return approx(p.x, 0) && approx(p.y, 1); })()); check('zoom: world origin UV scales with the camera zoom', (() => { const m = { a: 2, b: 0, c: 0, d: 2, tx: 20, ty: 10 }; const uv = worldToUV(m, 100, 100, 0, 0); return approx(uv.x, 0.2) && approx(uv.y, 0.1); })()); check('phase: monotonic and speed-scaled (rad at t, from a ms clock)', (() => { return ( ripplePhase(0) === 0 && ripplePhase(1000) === 1 && ripplePhase(1000, 2) === 2 && ripplePhase(2000) > ripplePhase(1000) ); })()); // --- The data contract (data/systems.json) --------------------------------- const types = config.section('systems.types', {}); const typeIds = Object.keys(types); check('all six archetypes are present', typeIds.length === 6 && ['main', 'redDwarf', 'binary', 'habitable', 'nebula', 'void'].every((t) => typeIds.includes(t))); check('every type carries an effect block (data-driven by rule)', typeIds.every((t) => types[t].effect && typeof types[t].effect.kind === 'string')); check('nebula wears the ripple; the other five render untouched', (() => { const kinds = Object.fromEntries(typeIds.map((t) => [t, types[t].effect.kind])); return kinds.nebula === 'ripple' && typeIds.filter((t) => t !== 'nebula').every((t) => kinds[t] === 'none'); })()); check('ripple parameters are sane numbers', (() => { const e = types.nebula.effect; const n = (v) => typeof v === 'number' && Number.isFinite(v); return ( n(e.strength) && e.strength > 0 && n(e.amplitude) && e.amplitude > 0 && n(e.speed) && e.speed > 0 && n(e.padding) && e.padding > 0 && (e.center === undefined || e.center === 'screen' || e.center === 'star') ); })()); check('padding covers the max displacement (no out-of-range UV sampling)', (() => { const e = types.nebula.effect; const maxDispPx = Math.max(config.get('game.width', 1280), config.get('game.height', 720)) * e.amplitude; return e.padding >= maxDispPx; })()); // --- The demo picker (js/galaxy/FxSystems.js) ------------------------------- const fakeGalaxy = (records, contents = {}) => ({ records, contentCache: new Map(Object.entries(contents)), }); const rec = (id, type) => ({ id, type }); check('picker: richest system of the type wins', (() => { const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula'), rec('C', 'main')], { A: { planets: [1], settlements: [], asteroids: [] }, B: { planets: [1, 2, 3], settlements: [1], asteroids: [1, 2] }, C: { planets: [1, 2, 3, 4, 5], settlements: [1, 2], asteroids: [1, 2, 3] }, }); return pickFxSystem(g, 'nebula')?.id === 'B'; })()); check('picker: more objects beat more gates; ties fall to roster order', (() => { const g = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], { A: { planets: [1], jumps: [1, 2, 3, 4] }, B: { planets: [1, 2], jumps: [1] }, }); const first = pickFxSystem(g, 'nebula')?.id; const g2 = fakeGalaxy([rec('A', 'nebula'), rec('B', 'nebula')], { A: { planets: [1] }, B: { planets: [1] }, }); return first === 'B' && pickFxSystem(g2, 'nebula')?.id === 'A'; })()); check('picker: no system of the type => null', pickFxSystem(fakeGalaxy([rec('A', 'void')]), 'nebula') === null); check('picker: empty roster => null', pickFxSystem(fakeGalaxy([]), 'nebula') === null); // --- The shader contract ----------------------------------------------------- const fakeRenderer = { renderNodes: { _ctors: {}, hasNode(n) { return Object.prototype.hasOwnProperty.call(this._ctors, n); }, addNodeConstructor(n, C) { if (this._ctors[n]) throw new Error('node constructor ' + n + ' already exists'); this._ctors[n] = C; }, }, }; const makeCamera = (id) => ({ id, width: 1280, height: 720, matrixCombined: { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 40 }, filters: { internal: { list: [], add(f) { this.list.push(f); return f; }, remove(f) { const i = this.list.indexOf(f); if (i !== -1) this.list.splice(i, 1); return this; }, getActive() { return this.list.filter((f) => f.active); }, }, }, ignore(targets) { (Array.isArray(targets) ? targets : [targets]).forEach((t) => { t.cameraFilter |= this.id; }); return this; }, setForceComposite(v) { this.forceComposite = v; return this; }, }); const fakeCamMain = makeCamera(1); const mkObject = (id, scrollFactor) => ({ id, cameraFilter: 0, scrollFactorX: scrollFactor, scrollFactorY: scrollFactor }); const uiRoot = mkObject('ui-root', 0); const worldRoot = mkObject('world-root', 1); const fakeCameras = { main: fakeCamMain, cameras: [fakeCamMain], add(_x, _y, _w, _h, _isMain, name) { const c = makeCamera(2); c.name = name; this.cameras.push(c); return c; }, }; const fakeScene = { scale: { width: 1280, height: 720 }, cameras: fakeCameras, sys: { displayList: { getChildren: () => [uiRoot, worldRoot, mkObject('world-2', 1)] } }, renderer: { gl: {}, renderNodes: fakeRenderer.renderNodes }, }; // The real split: const split = ensureUiCameras(fakeScene); check('split: exactly two passes, main first (world under UI), UI pass force-composited', split && split.main === fakeCamMain && fakeCameras.cameras.length === 2 && fakeCameras.cameras[1] === split.ui && split.ui.forceComposite === true); check('split: screen-pinned roots are ignored by the world pass', (uiRoot.cameraFilter & fakeCamMain.id) !== 0); check('split: world roots are ignored by the UI pass', (worldRoot.cameraFilter & split.ui.id) !== 0); check('split: idempotent (same split object, no third camera)', ensureUiCameras(fakeScene) === split && fakeCameras.cameras.length === 2); check('isScreenPinned: scrollFactor-0 on either axis is UI', isScreenPinned({ scrollFactorX: 0, scrollFactorY: 1 }) && !isScreenPinned({ scrollFactorX: 1, scrollFactorY: 1 })); const lateUi = mkObject('late-ui', 0); assignUi(fakeScene, lateUi); check('assignUi: a late UI object joins the UI pass only', (lateUi.cameraFilter & fakeCamMain.id) !== 0 && (lateUi.cameraFilter & split.ui.id) === 0); const lateWorld = mkObject('late-world', 1); assignWorld(fakeScene, lateWorld); check('assignWorld: a late world object stays on the world pass only', (lateWorld.cameraFilter & split.ui.id) !== 0 && (lateWorld.cameraFilter & fakeCamMain.id) === 0); const noSplitScene = { }; assignUi(noSplitScene, lateUi); // must not throw check('assign*: no-op while the split does not exist (single-camera pipeline intact)', true); // The facade: const fx = new SE.SystemEffects(fakeScene); check('facade: "none" (main) attaches nothing — no filter, no new camera', (() => { const camsBefore = fakeCameras.cameras.length; const active = fx.apply('main'); return active === false && fx.active === false && fakeCamMain.filters.internal.list.length === 0 && fakeCameras.cameras.length === camsBefore; })()); check('facade: ripple requires WebGL (canvas degrades to none)', (() => { const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } }; const f = new SE.SystemEffects(canvasScene); return f.apply('nebula') === false && f.active === false; })()); check('facade: apply(nebula) registers the node once + splits + attaches', (() => { fakeCamMain.filters.internal.list.length = 0; const active = fx.apply('nebula'); const list = fakeCamMain.filters.internal.list; return ( active === true && fx.active === true && fx.kind === 'ripple' && fakeRenderer.renderNodes.hasNode(SE.RIPPLE_NODE) && list.length === 1 && list[0].renderNode === SE.RIPPLE_NODE && list[0].camera === fakeCamMain ); })()); check('facade: config parameters land on the controller', (() => { const c = fakeCamMain.filters.internal.list[0]; const e = types.nebula.effect; return ( c.strength === e.strength && c.amplitude === e.amplitude && c.speed === e.speed && c.paddingOverride && c.paddingOverride.x === -Math.max(4, Math.ceil(e.padding)) && c.paddingOverride.width === 2 * Math.max(4, Math.ceil(e.padding)) ); })()); check('facade: apply() twice replaces (no duplicate filters)', (() => { fx.apply('nebula'); return fakeCamMain.filters.internal.list.length === 1; })()); check('facade: update() advances the phase and keeps the screen anchor pinned', (() => { const c = fakeCamMain.filters.internal.list[0]; fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved fx.update(1500); return approx(c.centerX, 0.5) && approx(c.centerY, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.speed) || 1)); })()); check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => { const c = fakeCamMain.filters.internal.list[0]; c.center = 'star'; // exercise the tracking branch fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; fx.update(1500); return approx(c.centerX, 320 / 1280) && approx(c.centerY, 180 / 720); })()); check('facade: release() detaches cleanly (and twice)', (() => { fx.release(); const first = fakeCamMain.filters.internal.list.length === 0 && fx.active === false; fx.release(); return first; })()); // --- The shader source contract (through a live node instance) --------------- const NodeClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE]; check('node: the registered constructor exists and names itself', typeof NodeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect'); const liveNode = new NodeClass({ renderer: {} }); const fragSrc = liveNode.fragmentSource; check('shader: declares every uniform setupUniforms pushes', (() => { const ctrl = { time: 1, strength: 90, amplitude: 0.01, centerX: 0.5, centerY: 0.5 }; liveNode.setupUniforms(ctrl, {}); const pushed = Object.keys(liveNode.uniforms); const declared = ['time', 'strength', 'amplitude', 'centerX', 'centerY'].every((u) => new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(fragSrc)); return pushed.length === 5 && declared; })()); check('shader: keeps the build\'s filter conventions (uMainSampler, outTexCoord, boundedSampler)', (() => { return ( fragSrc.includes('uniform sampler2D uMainSampler;') && fragSrc.includes('varying vec2 outTexCoord;') && fragSrc.includes('boundedSampler(uMainSampler') && fragSrc.includes('#pragma phaserTemplate(shaderName)') && fragSrc.includes('#pragma phaserTemplate(fragmentHeader)') ); })()); check('shader: displacement is radial from the center, clean at the center, calm at the corners', (() => { return ( fragSrc.includes('length(delta)') && fragSrc.includes('smoothstep(0.0, 0.02, dist)') && fragSrc.includes('0.8 + 0.2 * exp(-dist * 0.25)') && fragSrc.includes('wave * amplitude * fade') ); })()); console.log(`\n✓ system effects: ${pass} checks passed`);