/** * System effects test (dev tool, run with Node — no browser needed): * * node dev/system-effects.test.mjs * * Asserts: * - the RIPPLE / GRADE / FLARE MATH (js/visuals/SystemEffectsMath.js): * world->screen->UV under identity / translation / rotation / zoom; * the phase clock is monotonic and speed-scaled; the flare envelope is * a smooth periodic 0→1→0 bell; the binary orbit centers are * diametrically opposed on a seeded circle; the grade color chain * (brightness → saturation → tint → split → flash) is exact and * identity when the grade is empty; * - the DATA CONTRACT (data/systems.json): every type carries an * `effect` block; nebula/redDwarf/binary are live (kind "ripple"); * the redDwarf bundle has a flare + grade + particles; the binary * bundle has two centers + orbit + a split grade + a wanderer; padding * covers the worst-case displacement (amplitude × flare boost × * centers × width); * - 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, against the Phaser * stub): every uniform each filter's setupUniforms pushes is declared * in the fragment source, and both fragments keep the build's * filter-shader conventions (uMainSampler / outTexCoord / * boundedSampler); * - the FACADE (SystemEffects.apply/update/release) on a fake scene: * "none" touches nothing; the filters require WebGL (canvas degrades * to none); nebula → 1 filter (ripple); redDwarf → 2 (ripple + grade, * with a flare); binary → 2 (ripple with two orbiting centers + split * grade); the star anchor tracks world 0,0; release detaches 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 M = await import(pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href); const { worldToScreen, worldToUV, ripplePhase } = M; 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-6) => 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)', (() => { 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 flare / orbit / grade math --------------------------------------- check('flare: quiet outside the window, a smooth 0→1→0 bell inside', (() => { const f = (ms) => M.flareIntensity(ms, 18, 1.6, 0); const quiet = f(0) === 0 && f(5000) === 0 && f(10000) === 0; const peak = approx(f(800), 1, 1e-3); // mid of the 1.6 s flare const edges = f(0) === 0 && f(1600) === 0; const mid = f(500) > 0.5; return quiet && peak && edges && mid; })()); check('flare: per-system phase shifts the rhythm (deterministic)', (() => { const a = M.flareIntensity(1000, 18, 1.6, 0); const b = M.flareIntensity(1000, 18, 1.6, 9); return Number.isFinite(a) && Number.isFinite(b) && a !== b; })()); check('orbit: two centers, diametrically opposed on a seeded circle', (() => { const o = M.orbitCenters(0, 36, 0.16, 0); const mid0 = (o.cx0 + o.cx1) / 2; const midY = (o.cy0 + o.cy1) / 2; const dist = Math.hypot(o.cx0 - 0.5, o.cy0 - 0.5); return ( approx(mid0, 0.5) && approx(midY, 0.5) && // both orbit the view center approx(o.cx0 + o.cx1, 1) && approx(o.cy0 + o.cy1, 1) && // opposite approx(dist, 0.16) // on the radius ); })()); check('orbit: advancing time moves the pair (a full lap per period)', (() => { const t0 = M.orbitCenters(0, 36, 0.16, 0); const t9 = M.orbitCenters(9000, 36, 0.16, 0); // quarter lap const t36 = M.orbitCenters(36000, 36, 0.16, 0); // full lap → back return ( (t0.cx0 !== t9.cx0 || t0.cy0 !== t9.cy0) && (approx(t36.cx0, t0.cx0, 1e-6) && approx(t36.cy0, t0.cy0, 1e-6)) ); })()); check('hexToRgb01: parses #rrggbb and bad input falls to white', (() => { const w = M.hexToRgb01('#ffffff'); const b = M.hexToRgb01('#0000ff'); const bad = M.hexToRgb01('nope'); return ( approx(w[0], 1) && approx(w[1], 1) && approx(w[2], 1) && approx(b[0], 0) && approx(b[1], 0) && approx(b[2], 1) && bad[0] === 1 && bad[1] === 1 && bad[2] === 1 ); })()); check('tintMultiplier: a hue shift normalized to mean 1 (white → [1,1,1])', (() => { const w = M.tintMultiplier('#ffffff'); const orange = M.tintMultiplier('#ff9a5c'); return ( w.every((v) => approx(v, 1)) && approx((orange[0] + orange[1] + orange[2]) / 3, 1, 1e-6) && // normalized orange[0] > orange[2] // warm: red channel above blue ); })()); check('grade: empty grade is identity', (() => { const out = M.gradeColor([0.5, 0.5, 0.5], {}); return out.every((v, i) => approx(v, 0.5)); })()); check('grade: brightness scales, saturation pulls to luma, tint warms', (() => { const bright = M.gradeColor([0.5, 0.5, 0.5], { bright: 2 }); const sat = M.gradeColor([1, 0, 0], { sat: 0.5 }); // red → toward gray const tint = M.gradeColor([0.5, 0.5, 0.5], { tint: M.tintMultiplier('#ff9a5c'), tintAmt: 0.3 }); return ( approx(bright[0], 1) && (sat[0] < 1 && sat[0] > 0.5) && // red desaturated toward gray (tint[0] > tint[2]) // warmed: red above blue ); })()); check('grade: the split is a two-color directional wash (opposite sides differ)', (() => { const sp = { split: { a: M.hexToRgb01('#f2b05c'), b: M.hexToRgb01('#5b9bf2'), axis: [1, 0], mix: 0.15 } }; const left = M.gradeColor([0.5, 0.5, 0.5], { ...sp, pos: [0.1, 0.5] }); const right = M.gradeColor([0.5, 0.5, 0.5], { ...sp, pos: [0.9, 0.5] }); return ( (left[2] > left[0]) && // left leans cool (b = blue) (right[0] > right[2]) && // right leans warm (a = amber) (left[2] - left[0]) > (right[2] - right[0]) // the sides actually differ ); })()); check('grade: the flare flash is an additive warm spike', (() => { const base = M.gradeColor([0.5, 0.5, 0.5], {}); const flash = M.gradeColor([0.5, 0.5, 0.5], { flash: 0.35, flashColor: M.hexToRgb01('#ffd9a8') }); return flash.every((v) => v > base[0]) && flash[0] > flash[2]; })()); check('shadowLift: lifts the darkness toward red, leaves bright pixels alone', (() => { const lift = M.hexToRgb01('#c0392b'); const black = M.shadowLift([0, 0, 0], lift, 0.2); const white = M.shadowLift([0.9, 0.9, 0.9], lift, 0.2); const none = M.shadowLift([0, 0, 0], lift, 0); return ( black[0] > 0 && black[0] > black[2] && // black gains a red cast black[0] < lift[0] && // but stays subtle (a haze, not a flood) white.every((v) => v < 0.92) && // bright pixels barely move none.every((v) => v === 0) // amount 0 = identity ); })()); check('grade: the shadow lift integrates into gradeColor (red void)', (() => { const dark = M.gradeColor([0.02, 0.02, 0.02], { liftColor: M.hexToRgb01('#c0392b'), liftAmt: 0.2 }); const noLift = M.gradeColor([0.02, 0.02, 0.02], {}); return dark[0] > noLift[0] && dark[0] > dark[2]; // the dark space turns red })()); // --- The data contract (data/systems.json) --------------------------------- const types = config.section('systems.types', {}); const typeIds = Object.keys(types); const LIVE = ['nebula', 'redDwarf', 'binary']; const num_ = (v) => typeof v === 'number' && Number.isFinite(v); 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 === 'object')); check('the three live types wear sub-effect blocks; the rest render untouched', (() => { const live = LIVE.every((t) => Object.keys(types[t].effect).length > 0); const still = ['main', 'habitable', 'void'].every((t) => Object.keys(types[t].effect || {}).length === 0); return live && still; })()); check('nebula: a steady ripple, nothing else', (() => { const e = types.nebula.effect; return !!e.ripple && num_(e.ripple.amplitude) && e.ripple.amplitude > 0 && e.flare == null && e.grade == null && e.particles == null && e.wanderer == null; })()); check('redDwarf: a SUPER-subtle ripple that SURGES on a flare', (() => { const e = types.redDwarf.effect; const baseAmp = e.ripple?.amplitude; const boost = e.flare?.rippleBoost; return ( !!e.ripple && num_(baseAmp) && baseAmp > 0 && baseAmp <= 0.0015 && // super-subtle baseline num_(boost) && boost >= 4 && baseAmp * (1 + boost) > 0.008 // the surge is clearly visible ); })()); check('binary: a two-color split grade + a wandering star, and NO ripple', (() => { const e = types.binary.effect; const sp = e.grade?.split; const w = e.wanderer; return ( e.ripple == null && !!sp && typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 && !!w && typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 && Array.isArray(w.coreSize) && Array.isArray(w.haloSize) ); })()); check('every ripple bundle has sane numbers + padding covering the worst case', (() => { const maxDim = Math.max(1280, 720); return ['nebula', 'redDwarf'].every((t) => { const e = types[t].effect; const r = e.ripple; const boost = e.flare ? 1 + (e.flare.rippleBoost || 0) : 1; const centers = num_(r.centers) ? r.centers : 1; const need = r.amplitude * boost * centers * maxDim; return ( num_(r.strength) && r.strength > 0 && num_(r.amplitude) && r.amplitude > 0 && num_(r.speed) && r.speed > 0 && num_(r.padding) && r.padding >= need && (r.center === undefined || r.center === 'screen' || r.center === 'star') ); }); })()); check('redDwarf bundle: a flare (drives ripple + grade flash), a warm grade + red void, a red haze, ember particles', (() => { const e = types.redDwarf.effect; const f = e.flare ?? {}; const g = e.grade ?? {}; const h = e.haze ?? {}; const p = e.particles ?? {}; const n = (v) => typeof v === 'number' && Number.isFinite(v); return ( n(f.interval) && f.interval > 0 && n(f.duration) && f.duration > 0 && n(f.rippleBoost) && f.rippleBoost > 0 && n(f.flash) && f.flash > 0 && typeof f.flashColor === 'string' && typeof g.tint === 'string' && n(g.amount) && g.amount > 0 && n(g.saturation) && n(g.brightness) && n(g.lift?.amount) && g.lift.amount > 0 && typeof g.lift.color === 'string' && n(g.grain?.amount) && g.grain.amount > 0 && n(g.grain.parallax) && g.grain.parallax > 0 && typeof h.base === 'string' && typeof h.tint === 'string' && n(h.alpha) && h.alpha > 0 && n(h.grain) && h.grain > 0 && n(p.count) && p.count > 0 && typeof p.color === 'string' && p.blend === 'add' ); })()); check('binary bundle: a two-color split grade + a companion star (no ripple)', (() => { const e = types.binary.effect; const sp = e.grade?.split ?? {}; const w = e.wanderer ?? {}; return ( e.ripple == null && // the binary wears NO ripple typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 && typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 && Array.isArray(w.coreSize) && Array.isArray(w.haloSize) ); })()); // --- 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 camera split (js/visuals/UiCameras.js) + facade (fake scene) ------ 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, scrollX: 0, scrollY: 0, 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 }, systemContent: { fx: { phase: 0.5, angle: 0.3, drift: 0.7 } }, // per-system variation }; const split = ensureUiCameras(fakeScene); check('split: exactly two passes, main first, 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); assignUi({}, 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: the filters require WebGL (canvas degrades to none)', (() => { const canvasScene = { ...fakeScene, renderer: { gl: null, renderNodes: fakeRenderer.renderNodes } }; const f = new SE.SystemEffects(canvasScene); return f.apply('redDwarf') === false && f.active === false; })()); check('facade: apply(nebula) → one filter (ripple only)', (() => { 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' && list.length === 1 && list[0].renderNode === SE.RIPPLE_NODE && list[0].camera === fakeCamMain && list[0].centers === 1 && list[0].anchor === 'screen' ); })()); check('facade: nebula ripple reads the config (strength/amp/speed + padded)', (() => { const c = fakeCamMain.filters.internal.list[0]; const r = types.nebula.effect.ripple; return ( c.strength === r.strength && c.baseAmp === r.amplitude && c.speed === r.speed && c.paddingOverride && c.paddingOverride.x < 0 && c.paddingOverride.width > 0 ); })()); check('facade: apply(redDwarf) → ripple (star anchor, flare) + grade (warm tint + flash)', (() => { fakeCamMain.filters.internal.list.length = 0; const active = fx.apply('redDwarf'); const list = fakeCamMain.filters.internal.list; const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE); const grade = list.find((c) => c.renderNode === SE.GRADE_NODE); return ( active === true && list.length === 2 && ripple && ripple.centers === 1 && ripple.anchor === 'star' && ripple.flare?.rippleBoost > 0 && grade && grade.uTintAmt > 0 && grade.uSat < 1 && grade.uBright < 1 && grade.uLiftAmt > 0 && grade.uLiftR > grade.uLiftB && grade.uGrainAmt > 0 && grade.grainParallax > 0 && grade.uFlashR > 0 && grade.flareFlash > 0 ); })()); check('facade: update() parallaxes the grain by the camera scroll', (() => { fakeCamMain.filters.internal.list.length = 0; fx.apply('redDwarf'); const grade = fakeCamMain.filters.internal.list.find((c) => c.renderNode === SE.GRADE_NODE); const p = grade.grainParallax; fakeCamMain.scrollX = 500; fakeCamMain.scrollY = -250; fx.update(1000); const offX = grade.uGrainOffX; const offY = grade.uGrainOffY; // offset = scroll * parallax, and it changes as the camera moves fakeCamMain.scrollX = 700; fx.update(1100); return ( p > 0 && offX === 500 * p && offY === -250 * p && grade.uGrainOffX === 700 * p ); })()); check('facade: apply(binary) → a split grade ONLY (no ripple)', (() => { fakeCamMain.filters.internal.list.length = 0; const active = fx.apply('binary'); const list = fakeCamMain.filters.internal.list; const ripple = list.find((c) => c.renderNode === SE.RIPPLE_NODE); const grade = list.find((c) => c.renderNode === SE.GRADE_NODE); return ( active === true && fx.kind === 'grade' && list.length === 1 && !ripple && // no ripple on the binary grade && grade.uSplitMix > 0 && (grade.uSplitAR !== grade.uSplitBR || grade.uSplitAG !== grade.uSplitBG) ); })()); check('facade: apply() twice replaces (no duplicate filters)', (() => { fx.apply('redDwarf'); return fakeCamMain.filters.internal.list.length === 2; })()); check('facade: update() advances the phase and keeps the screen anchor pinned', (() => { fx.apply('nebula'); const c = fakeCamMain.filters.internal.list.find((x) => x.renderNode === SE.RIPPLE_NODE); fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; // camera moved fx.update(1500); return approx(c.cx0, 0.5) && approx(c.cy0, 0.5) && approx(c.time, 1.5 * (Number(types.nebula.effect.ripple.speed) || 1)); })()); check('facade: the "star" anchor tracks world 0,0 in screen UV', (() => { fx.apply('redDwarf'); const c = fakeCamMain.filters.internal.list.find((x) => x.renderNode === SE.RIPPLE_NODE); fakeCamMain.matrixCombined = { a: 1, b: 0, c: 0, d: 1, tx: 320, ty: 180 }; fx.update(1500); return approx(c.cx0, 320 / 1280) && approx(c.cy0, 180 / 720); })()); check('facade: the flare drives BOTH the ripple amplitude AND the grade flash', (() => { fx.apply('redDwarf'); const list = fakeCamMain.filters.internal.list; const ripple = list.find((x) => x.renderNode === SE.RIPPLE_NODE); const grade = list.find((x) => x.renderNode === SE.GRADE_NODE); // quiet moment → baseline amplitude, no flash const quietAmp = M.flareIntensity(0, 18, 1.6, 0.5 * 18) === 0; fx.update(0); const baseAmp = ripple.amp0; const baseFlash = grade.uFlash; // find a flare peak for this phase (scan the first interval) let peakMs = 0; for (let ms = 0; ms < 18000; ms += 10) { if (M.flareIntensity(ms, 18, 1.6, 0.5 * 18) > 0.999) { peakMs = ms; break; } } fx.update(peakMs); return ( quietAmp && baseAmp > 0 && baseFlash === 0 && ripple.amp0 > baseAmp && // the ripple surges during the flare grade.uFlash > 0 && // and the grade flashes ripple.amp0 < ripple.baseAmp * (1 + types.redDwarf.effect.flare.rippleBoost) + 1e-9 ); })()); 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 contract (through live node instances) ---------------------- const RippleClass = fakeRenderer.renderNodes._ctors[SE.RIPPLE_NODE]; const GradeClass = fakeRenderer.renderNodes._ctors[SE.GRADE_NODE]; check('nodes: both registered constructors exist and name themselves', typeof RippleClass === 'function' && typeof GradeClass === 'function' && SE.RIPPLE_NODE === 'FilterRippleEffect' && SE.GRADE_NODE === 'FilterGradeEffect'); const rippleNode = new RippleClass({ renderer: {} }); const rippleSrc = rippleNode.fragmentSource; check('ripple shader: declares every uniform setupUniforms pushes', (() => { rippleNode.setupUniforms({ time: 1, strength: 90, amp0: 0.01, amp1: 0, cx0: 0.5, cy0: 0.5, cx1: 0.5, cy1: 0.5 }, {}); const pushed = Object.keys(rippleNode.uniforms); const declared = ['time', 'strength', 'amp0', 'amp1', 'cx0', 'cy0', 'cx1', 'cy1'].every((u) => new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(rippleSrc)); return pushed.length === 8 && declared; })()); check('ripple shader: keeps the build\'s filter conventions', (() => { return ( rippleSrc.includes('uniform sampler2D uMainSampler;') && rippleSrc.includes('varying vec2 outTexCoord;') && rippleSrc.includes('boundedSampler(uMainSampler') && rippleSrc.includes('#pragma phaserTemplate(shaderName)') && rippleSrc.includes('#pragma phaserTemplate(fragmentHeader)') ); })()); check('ripple shader: radial displacement from each center, clean at the center, calm at the corners', (() => { return ( rippleSrc.includes('vec2 d0 = outTexCoord - vec2(cx0, cy0);') && rippleSrc.includes('smoothstep(0.0, 0.02, r0)') && rippleSrc.includes('0.8 + 0.2 * exp(-r0 * 0.25)') && rippleSrc.includes('w0 * amp0 * f0') && rippleSrc.includes('vec2 d1 = outTexCoord - vec2(cx1, cy1);') ); })()); const gradeNode = new GradeClass({ renderer: {} }); const gradeSrc = gradeNode.fragmentSource; check('grade shader: declares every uniform setupUniforms pushes', (() => { gradeNode.setupUniforms({ uBright: 1, uSat: 1, uTintAmt: 0.3, uTintR: 1, uTintG: 1, uTintB: 1, uSplitMix: 0.15, uSplitAR: 1, uSplitAG: 1, uSplitAB: 1, uSplitBR: 1, uSplitBG: 1, uSplitBB: 1, uAxisX: 1, uAxisY: 0, uFlash: 0.3, uFlashR: 1, uFlashG: 1, uFlashB: 1, uLiftAmt: 0.2, uLiftR: 0.75, uLiftG: 0.22, uLiftB: 0.17, uGrainAmt: 0.16, uGrainOffX: 12.3, uGrainOffY: -7.5, }, {}); const pushed = Object.keys(gradeNode.uniforms); const names = [ 'uBright', 'uSat', 'uTintAmt', 'uTintR', 'uTintG', 'uTintB', 'uSplitMix', 'uSplitAR', 'uSplitAG', 'uSplitAB', 'uSplitBR', 'uSplitBG', 'uSplitBB', 'uAxisX', 'uAxisY', 'uFlash', 'uFlashR', 'uFlashG', 'uFlashB', 'uLiftAmt', 'uLiftR', 'uLiftG', 'uLiftB', 'uGrainAmt', 'uGrainOffX', 'uGrainOffY', ]; const declared = names.every((u) => new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(gradeSrc)); return pushed.length === 26 && declared; })()); check('grade shader: keeps the build\'s filter conventions', (() => { return ( gradeSrc.includes('uniform sampler2D uMainSampler;') && gradeSrc.includes('varying vec2 outTexCoord;') && gradeSrc.includes('boundedSampler(uMainSampler') && gradeSrc.includes('#pragma phaserTemplate(shaderName)') && gradeSrc.includes('#pragma phaserTemplate(fragmentHeader)') ); })()); check('grade shader: brightness → saturation → tint → lift → split → flash, in order', (() => { const order = (a, b) => gradeSrc.indexOf(a) !== -1 && gradeSrc.indexOf(b) !== -1 && gradeSrc.indexOf(a) < gradeSrc.indexOf(b); return ( order('rgb *= uBright;', 'if (uSat != 1.0)') && order('if (uSat != 1.0)', 'if (uTintAmt > 0.0)') && order('if (uTintAmt > 0.0)', 'if (uLiftAmt > 0.0)') && order('if (uLiftAmt > 0.0)', 'if (uSplitMix > 0.0)') && order('if (uSplitMix > 0.0)', 'if (uFlash > 0.0)') ); })()); console.log(`\n✓ system effects: ${pass} checks passed`);