orbit/dev/system-effects.test.mjs

699 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/habitable are live;
* the redDwarf bundle has a flare + grade + particles; the binary
* bundle has a split grade + a two-light lift + a breath + a wanderer;
* the habitable bundle has a green grade + void + twinkling fireflies +
* a storm; the storm envelope (charge → strike → afterglow) is quiet
* mid-cycle, peaks at the strike, strobes and decays; 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))
);
})());
// --- The habitable storm envelope (charge → strike → afterglow) -----------
check('storm: quiet mid-cycle (no flash, charge settled)', (() => {
// 25% into a 12s cycle — before the charge window (last 45%) begins
const e = M.stormEnvelope(3000, 12, 0.55, 0, 0.45);
return e.flash === 0 && e.charge === 0;
})());
check('storm: the charge rises smoothly to 1 at the strike', (() => {
const at = (frac) => M.stormEnvelope(frac * 12 * 1000, 12, 0.55, 0, 0.45).charge;
const cEarly = at(0.5); // before the charge window (1-0.45=0.55)
const cMid = at(0.75); // mid-rise
const cLate = at(0.95); // near the strike
const cPeak = at(0.999); // just before the strike (the light is about to be released)
const cAfter = M.stormEnvelope(100, 12, 0.55, 0, 0.45).charge; // just after — spent
return cEarly === 0 && cMid > cEarly && cLate > cMid && cPeak > 0.99 && cAfter === 0;
})());
check('storm: the strike flashes ~1, then strobes (a rebound burst) and decays', (() => {
const W = 0.55;
const at = (dtSec) => M.stormEnvelope(12 * 1000 + dtSec * 1000, 12, W, 0, 0.45).flash;
return (
at(0) > 0.95 && // the primary burst
at(0.45 * W) > 0.4 && // the rebound burst is clearly present
at(2 * W) < 0.1 && // the afterglow has mostly faded
at(4 * W) < 0.03 && // …and is gone
M.stormEnvelope(12 * 1000 - 1000, 12, W, 0, 0.45).flash === 0 // nothing BEFORE the strike
);
})());
check('storm: bounded, deterministic, phase-shifted, no NaN across a sweep', (() => {
const sweep = () => {
for (let ms = 0; ms < 2 * 12 * 1000; ms += 7) {
const e = M.stormEnvelope(ms, 12, 0.55, 3.7, 0.45);
if (!Number.isFinite(e.charge) || !Number.isFinite(e.flash)) return false;
if (e.charge < 0 || e.charge > 1 || e.flash < 0 || e.flash > 1) return false;
}
return true;
};
const a = M.stormEnvelope(1000, 12, 0.55, 1.0, 0.45);
const b = M.stormEnvelope(1000, 12, 0.55, 1.0, 0.45);
const c = M.stormEnvelope(1000, 12, 0.55, 6.0, 0.45);
return sweep() && a.charge === b.charge && a.flash === b.flash && (a.charge !== c.charge || a.flash !== c.flash);
})());
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', 'habitable'];
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 four 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', '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 two-light void lift + a breath + a wandering star, and NO ripple', (() => {
const e = types.binary.effect;
const sp = e.grade?.split;
const lf = e.grade?.lift;
const br = e.breath;
const w = e.wanderer;
return (
e.ripple == null &&
!!sp && typeof sp.a === 'string' && typeof sp.b === 'string' && num_(sp.mix) && sp.mix > 0 &&
!!lf && num_(lf.amount) && lf.amount > 0 && typeof lf.a === 'string' && typeof lf.b === 'string' && lf.a !== lf.b &&
!!br && num_(br.period) && br.period > 0 && num_(br.depth) && br.depth > 0 &&
!!w && typeof w.color === 'string' && num_(w.parallax) && w.parallax > 0 &&
Array.isArray(w.coreSize) && Array.isArray(w.haloSize)
);
})());
check('habitable: a lush grade + a green void + twinkling fireflies + a purple storm, and NO ripple', (() => {
const e = types.habitable.effect;
return (
e.ripple == null &&
!!e.grade && typeof e.grade.tint === 'string' && e.grade.lift?.color === '#7ce8a4' && // a green-tinged well-lit wash
e.grade.split == null && e.breath == null && // the storm breathes instead
typeof e.void?.base === 'string' && // the forest-night dark
!!e.particles && typeof e.particles.color === 'string' && !!e.particles.twinkle && // the fireflies (they pulse)
!!e.storm && typeof e.storm.back === 'string' && typeof e.storm.front === 'string' && // the purple storm
typeof e.storm.flash === 'string' && e.storm.back !== e.storm.front
);
})());
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 two-light void glow + a slow breath + a companion star (no ripple)', (() => {
const e = types.binary.effect;
const sp = e.grade?.split ?? {};
const lf = e.grade?.lift ?? {};
const br = e.breath ?? {};
const w = e.wanderer ?? {};
const n = (v) => typeof v === 'number' && Number.isFinite(v);
return (
e.ripple == null && // the binary wears NO ripple
typeof sp.a === 'string' && typeof sp.b === 'string' && n(sp.mix) && sp.mix >= 0.2 && // pronounced art wash
n(lf.amount) && lf.amount > 0 && typeof lf.a === 'string' && typeof lf.b === 'string' && lf.a !== lf.b && // the two lights
n(br.period) && br.period >= 8 && n(br.depth) && br.depth > 0 && br.depth <= 0.5 && // a slow breath
typeof (e.void?.base) === 'string' && n(e.void?.alpha) && e.void.alpha > 0 && // the void the two lights paint on
typeof w.color === 'string' && n(w.parallax) && w.parallax > 0 &&
Array.isArray(w.coreSize) && w.coreSize[1] >= 80 && Array.isArray(w.haloSize) && w.haloSize[1] >= 250 // present, not a smudge
);
})());
check('habitable bundle: a gentle well-lit grade, a forest-night void, pulsing fireflies, and a storm that strikes', (() => {
const e = types.habitable.effect;
const g = e.grade ?? {};
const p = e.particles ?? {};
const st = e.storm ?? {};
const n = (v) => typeof v === 'number' && Number.isFinite(v);
return (
n(g.amount) && g.amount > 0 && g.amount < 0.2 && // a wash, not a wall
n(g.brightness) && g.brightness > 1 && n(g.saturation) && g.saturation > 1 && // the only type that gets more light
n(g.lift?.amount) && g.lift.amount > 0 && typeof g.lift.color === 'string' &&
typeof e.void?.base === 'string' && n(e.void?.grain) && e.void.grain >= 0 &&
n(p.count) && p.count >= 10 && typeof p.color === 'string' && p.blend === 'add' &&
Array.isArray(p.twinkle?.rate) && p.twinkle.rate[1] < 2 && // a slow blink, not a strobe
Array.isArray(p.twinkle?.depth) && p.twinkle.depth[1] < 1 &&
n(st.clusters) && st.clusters >= 2 &&
Array.isArray(st.size) && st.size[0] >= 300 && // real clouds, not mist
Array.isArray(st.cycle?.interval) && st.cycle.interval[0] >= 6 && // a patient storm
n(st.cycle?.flicker) && st.cycle.flicker > 0 &&
n(st.cycle?.charge) && st.cycle.charge > 0 && st.cycle.charge < 1 &&
n(st.flashStrength) && st.flashStrength > 0 && st.flashStrength <= 1
);
})());
// --- 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.uLift2R === grade.uLiftR && // single-hue lift (no distinct second light)
grade.breathPeriod === 0 && grade.uBreath === 0 && // no breath on a redDwarf
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 + two-light lift + breath (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) &&
grade.uLiftAmt > 0 && // the two-light void glow
(grade.uLift2R !== grade.uLiftR || grade.uLift2B !== grade.uLiftB) && // …with a distinct second light
grade.breathPeriod > 0 && grade.breathDepth > 0 // the slow breath
);
})());
check('facade: update() breathes the binary lights over time', (() => {
fakeCamMain.filters.internal.list.length = 0;
fx.apply('binary');
const grade = fakeCamMain.filters.internal.list.find((c) => c.renderNode === SE.GRADE_NODE);
fx.update(0);
const b0 = grade.uBreath;
// a quarter period later the breath has moved (sin is strictly monotone here)
fx.update((grade.breathPeriod * 1000) / 4);
return grade.uBreath !== b0 && Math.abs(grade.uBreath) <= grade.breathDepth;
})());
check('facade: apply(habitable) → a well-lit GREEN grade (single-hue lift, no split, no breath, no ripple)', (() => {
fakeCamMain.filters.internal.list.length = 0;
const active = fx.apply('habitable');
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 &&
grade && grade.uTintAmt > 0 && grade.uBright > 1 && grade.uSat > 1 && // well-lit + lush
grade.uLiftAmt > 0 && // the green shadow lift
grade.uLiftG > grade.uLiftR && grade.uLiftG > grade.uLiftB && // it is green
grade.uLift2R === grade.uLiftR && grade.uLift2G === grade.uLiftG && grade.uLift2B === grade.uLiftB && // single-hue (no second light)
(grade.uSplitMix === 0 || grade.uSplitMix === undefined) && grade.breathPeriod === 0 && grade.uBreath === 0
);
})());
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.28, uLiftR: 0.95, uLiftG: 0.69, uLiftB: 0.36,
uLift2R: 0.36, uLift2G: 0.61, uLift2B: 0.95, uBreath: 0.1,
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', 'uLift2R', 'uLift2G', 'uLift2B', 'uBreath',
'uGrainAmt', 'uGrainOffX', 'uGrainOffY',
];
const declared = names.every((u) => new RegExp(`uniform\\s+float\\s+${u}\\s*;`).test(gradeSrc));
return pushed.length === 30 && 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`);