orbit/dev/system-effects.test.mjs

320 lines
14 KiB
JavaScript

/**
* 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`);