/** * Storm layer test (dev tool, run with Node — no browser needed): * * node dev/storm.test.mjs * * Asserts the HABITABLE storm's VOICE (js/visuals/StormLayer.js + * data/sfx.json → thunder_01…04): * - a cluster STRIKES (its flash envelope opens, env.flash 0 → >0) * exactly once per strike — one thunder clap, a random member of * the four thunder_XX keys, at the configured volume; * - the first update() frame only ARMS (a cluster already mid-strike * at scene entry stays silent); * - the throttle: a second strike inside ~0.7 s makes no second clap; * - the master switch (sfx.enabled=false) silences it; * - the JUMP teardown: a destroyed storm can't strike anymore (jumping * out of a habitable system must not leave its thunder ringing on). */ 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 SFX = configData.sfx; const { stormEnvelope } = await import( pathToFileURL(join(__dirname, '../js/visuals/SystemEffectsMath.js')).href ); const { StormLayer } = await import( pathToFileURL(join(__dirname, '../js/visuals/StormLayer.js')).href ); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // --- A headless scene just enough for StormLayer.create()/update() -------- // textures.exists() → true (the cloud textures are "already there", so the // canvasTexture() draw never runs — no document/canvas needed), and the // sprites are plain chainable records. function makeScene(o = {}) { const calls = []; const image = (x, y) => ({ x, y, rotation: 0, setDepth() { return this; }, setTint() { return this; }, setScale() { return this; }, setAlpha() { return this; }, setBlendMode() { return this; }, setRotation(r) { this.rotation = r; return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; }, destroy() { this.active = false; }, }); return { calls, scale: { width: 1280, height: 720 }, cameras: { main: { scrollX: 0, scrollY: 0 } }, add: { image: (x, y, _key) => image(x, y) }, textures: { exists: () => true, addCanvas: () => {} }, sound: o.sound === false ? null : { play: (key, opts) => { calls.push({ key, opts }); } }, }; } const FX = { phase: 0.37, angle: 0.5, drift: 0.6 }; // per-system variation (seeded) const STORM_CFG = configData.systems.types.habitable.effect.storm; const THUNDER_KEYS = ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04']; /** The first moment (ms) at which ANY cluster's flash envelope opens. */ function firstStrikeMs(storm, horizonMs = 120000) { for (let ms = 16; ms < horizonMs; ms += 16) { const now = storm.clusters.some((c) => stormEnvelope(ms, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0); const before = storm.clusters.some((c) => stormEnvelope(ms - 16, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0); if (now && !before) return ms; } return null; } const isThunder = (call) => call && THUNDER_KEYS.includes(call.key.replace(/^sfx_/, '')); // 1) A strike plays exactly one thunder clap — a random member of the // four keys — at the configured volume. { const scene = makeScene(); const storm = new StormLayer(scene, STORM_CFG, FX); storm.create(); check('create() builds 3 clusters (back/flash/front each)', storm.clusters.length === 3); const strikeMs = firstStrikeMs(storm); check('the storm has a strike within the horizon', strikeMs !== null); scene.calls.length = 0; storm.update(strikeMs - 16, 16); // just before: quiet (and this arms the detector) const quiet = scene.calls.length === 0; storm.update(strikeMs, 16); // the strike: the flash window opens const played = scene.calls.length === 1 && isThunder(scene.calls[0]) && scene.calls[0].opts.volume === SFX.volume; check('quiet before the strike', quiet); check('the strike plays exactly one thunder clap (sfx_thunder_XX, sfx.volume)', played); // Mid-strobe: the envelope is still > 0 → NO repeat (once per strike). storm.update(strikeMs + 400, 16); check('no repeat mid-strobe (once per strike)', scene.calls.length === 1); } // 2) First frame only ARMS: a cluster already mid-strike at entry is silent. { const scene = makeScene(); const storm = new StormLayer(scene, STORM_CFG, FX); storm.create(); const strikeMs = firstStrikeMs(storm); scene.calls.length = 0; storm.update(strikeMs + 400, 16); // first frame, mid-strike (flash > 0) const armed = scene.calls.length === 0; // …and the NEXT strike does clap. let next = null; for (let ms = strikeMs + 416; ms < strikeMs + 120000; ms += 16) { const now = storm.clusters.some((c) => stormEnvelope(ms, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0); const before = storm.clusters.some((c) => stormEnvelope(ms - 16, c.interval, c.strikeDur, c.phase, c.chargeWindow).flash > 0); if (now && !before) { next = ms; break; } } check('a cluster mid-strike at entry stays silent (arming frame)', armed && next !== null); if (next !== null) { scene.calls.length = 0; storm.update(next - 16, 16); storm.update(next, 16); check('…and the following strike still claps', scene.calls.length === 1 && isThunder(scene.calls[0])); } } // 3) The throttle: strikes closer than ~0.7 s make one clap, not two. { const scene = makeScene(); const storm = new StormLayer(scene, STORM_CFG, FX); storm.create(); const strikeMs = firstStrikeMs(storm); scene.calls.length = 0; storm.update(strikeMs - 16, 16); // quiet (arms the detector) storm.update(strikeMs, 16); // the strike → the first clap const first = scene.calls.length === 1 && isThunder(scene.calls[0]); storm.thunderStrike(strikeMs + 100); // a near-simultaneous strike (another cluster) const throttled = scene.calls.length === 1; storm.thunderStrike(strikeMs + 800); // …but a strike after the window claps again const resumes = scene.calls.length === 2 && isThunder(scene.calls[1]); check('the strike claps (setup)', first); check('near-simultaneous strikes make one clap (throttled)', throttled); check('a strike after the throttle window claps again', resumes); } // 4) The master switch silences the strike. { config.init({ ...configData, sfx: { ...SFX, enabled: false } }); const scene = makeScene(); const storm = new StormLayer(scene, STORM_CFG, FX); storm.create(); const strikeMs = firstStrikeMs(storm); scene.calls.length = 0; storm.update(strikeMs, 16); check('sfx.enabled=false → the strike is silent', scene.calls.length === 0); config.init(configData); } // 5) No sound manager (headless) → no throw. { const scene = makeScene({ sound: false }); const storm = new StormLayer(scene, STORM_CFG, FX); storm.create(); let threw = false; try { storm.update(firstStrikeMs(storm), 16); storm.thunderStrike(999999); } catch (e) { threw = true; } check('no sound manager → the strike is silent, no throw', !threw); } // 6) The JUMP teardown contract (GameScene._teardownEffectLayers): a // DESTROYED storm can't strike anymore — the player jumps out of a // habitable system and its thunder must not ring on in the next one. // (The scene side of the contract — create() dropping the ref — is // the GameScene change; this is the half the storm owes.) { const scene = makeScene(); const storm = new StormLayer(scene, STORM_CFG, FX); storm.create(); const strikeMs = firstStrikeMs(storm); scene.calls.length = 0; storm.update(strikeMs - 16, 16); // quiet (arms the detector) storm.destroy(); // the jump's teardown storm.update(strikeMs, 16); // the strike that used to ring on… storm.update(strikeMs + 400, 16); storm.update(strikeMs + 20000, 16); // …and one a cycle later check('a destroyed storm stays silent (the jump teardown)', scene.calls.length === 0); } console.log(failures === 0 ? '\nAll storm voice tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);