orbit/dev/storm.test.mjs

214 lines
8.6 KiB
JavaScript

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