/** * Signal-compass test (dev tool, run with Node — no browser needed): * * node dev/signal-compass.test.mjs * * Asserts: * - the SIGNAL ENVELOPE (js/ui/SignalCompass.js → signalAlpha): full * strength for the first `fullMs`, a LINEAR fade to 0 over `fadeMs`, * then gone — including the edges (negative age, the full/fade * boundary, fully expired) and the `fadeMs = 0` step case; * - the BEARING (bearingAngle): a signal sits at the object's direction * from the ship (screen/world y-down), for the four cardinal cases; * - the CONFIG (data/signalCompass.json): sane radius + lifetime, valid * hex colors, positive geometry, a boolean enable gate — and that it is * registered in the manifest (so it actually loads). */ process.env.NODE_ENV = 'dev'; 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); // Minimal Phaser stub — enough to import the UI module below. globalThis.window = { Phaser: { GameObjects: { Graphics: class {}, }, BlendModes: { ADD: 'ADD' }, Display: { Color: { ValueToColor: () => ({ color: 0x00e5ff }) } }, }, }; const { signalAlpha, bearingAngle, SignalCompass } = await import( pathToFileURL(join(__dirname, '../js/ui/SignalCompass.js')).href ); let pass = 0; function check(name, cond) { if (!cond) { console.error(`✗ ${name}`); process.exit(1); } pass++; console.log(`✓ ${name}`); } const TAU = Math.PI * 2; const FULL = 20000; const FADE = 5000; // ---------------------------------------------------------------------- // 1. Signal envelope — full → linear fade → gone // ---------------------------------------------------------------------- check('signalAlpha: before full ⇒ 1', signalAlpha(0, FULL, FADE) === 1 && signalAlpha(19999, FULL, FADE) === 1); check('signalAlpha: exactly at full ⇒ 1', signalAlpha(FULL, FULL, FADE) === 1); check('signalAlpha: mid-fade ⇒ linear (½ way ⇒ ½)', Math.abs(signalAlpha(FULL + FADE / 2, FULL, FADE) - 0.5) < 1e-9); check('signalAlpha: a known fade point (¼ into the fade ⇒ ¾)', Math.abs(signalAlpha(FULL + FADE * 0.25, FULL, FADE) - 0.75) < 1e-9); check('signalAlpha: fully expired ⇒ 0', signalAlpha(FULL + FADE, FULL, FADE) === 0); check('signalAlpha: past expiry stays 0', signalAlpha(FULL + FADE + 5000, FULL, FADE) === 0); check('signalAlpha: negative age clamps to full', signalAlpha(-500, FULL, FADE) === 1); check('signalAlpha: monotonic non-increasing across the life', (() => { let prev = 2; for (let t = -100; t <= FULL + FADE + 100; t += 500) { const a = signalAlpha(t, FULL, FADE); if (a > prev + 1e-9) return false; prev = a; } return true; })()); check('signalAlpha: fadeMs=0 is a hard cutoff', signalAlpha(999, 1000, 0) === 1 && signalAlpha(1000, 1000, 0) === 0 && signalAlpha(1999, 1000, 0) === 0); check('signalAlpha: stays in [0,1]', (() => { for (let t = -1000; t <= 40000; t += 700) { const a = signalAlpha(t, FULL, FADE); if (a < 0 || a > 1) return false; } return true; })()); // ---------------------------------------------------------------------- // 2. Bearing — the signal sits at the object's direction from the ship // ---------------------------------------------------------------------- const EPS = 1e-9; check('bearingAngle: object to the +x ⇒ 0', Math.abs(bearingAngle(0, 0, 100, 0)) < EPS); check('bearingAngle: object below (+y, y-down) ⇒ +π/2', Math.abs(bearingAngle(0, 0, 0, 100) - Math.PI / 2) < EPS); check('bearingAngle: object above (−y) ⇒ −π/2', Math.abs(bearingAngle(0, 0, 0, -100) + Math.PI / 2) < EPS); check('bearingAngle: object to the −x ⇒ ±π', Math.abs(Math.abs(bearingAngle(0, 0, -100, 0)) - Math.PI) < EPS); check('bearingAngle: diagonal (1,1) ⇒ π/4', Math.abs(bearingAngle(0, 0, 100, 100) - Math.PI / 4) < EPS); check('bearingAngle: relative to a non-origin ship', (() => { // ship at (10,10), object at (110,10) → due +x from the ship ⇒ 0 return Math.abs(bearingAngle(10, 10, 110, 10)) < EPS; })()); check('bearingAngle: both sides of the −x axis land on ±π (same line)', (() => { const a = bearingAngle(0, 0, -100, 0.001); // just below the −x axis (y-down) const b = bearingAngle(0, 0, -100, -0.001); // just above it const nearPi = (v) => Math.abs(Math.abs(v) - Math.PI) < 0.02; return nearPi(a) && nearPi(b); })()); // ---------------------------------------------------------------------- // 3. Config — sane, valid, and actually registered // ---------------------------------------------------------------------- const sc = config.section('signalCompass', {}); check('config: enabled is a boolean', typeof sc.enabled === 'boolean'); check('config: radius is a sensible px count', Number.isFinite(sc.radius) && sc.radius >= 40 && sc.radius <= 480); check('config: lifetime.fullMs > 0', Number.isFinite(sc.lifetime?.fullMs) && sc.lifetime.fullMs > 0); check('config: lifetime.fadeMs >= 0', Number.isFinite(sc.lifetime?.fadeMs) && sc.lifetime.fadeMs >= 0); const hex = /^#[0-9a-fA-F]{6}$/; check('config: colors are valid hex', ['signal', 'glow', 'ring'].every((k) => hex.test(sc.colors?.[k] ?? ''))); const sig = sc.signal ?? {}; check('config: signal arc + dot are positive', (sig.arcHalfDeg ?? 0) > 0 && (sig.coreWidth ?? 0) > 0 && (sig.dotRadius ?? 0) > 0); check('config: signal alphas in [0,1]', [sig.coreAlpha, sig.glowAlpha, sig.dotAlpha].every((a) => a >= 0 && a <= 1)); const rp = sig.ripples ?? {}; check('config: ripples sane', (rp.count ?? 0) >= 0 && (rp.spacingPx ?? 0) > 0 && rp.alpha >= 0 && rp.alpha <= 1); const pu = sig.pulse ?? {}; check('config: pulse gentle (amp < 0.5 so it reads as a signal, not a strobe)', pu.amp >= 0 && pu.amp < 0.5 && pu.hz > 0 && pu.hz < 8); const rg = sc.ring ?? {}; check('config: ring alphas in [0,1]', rg.alpha >= 0 && rg.alpha <= 1 && rg.glowAlpha >= 0 && rg.glowAlpha <= 1); check('config: the 20 s full + 5 s fade defaults match the brief', sc.lifetime.fullMs === 20000 && sc.lifetime.fadeMs === 5000); // Registered in the manifest (so it loads) + the class exposes its API. const manifest = JSON.parse(fs.readFileSync(join(dataDir, 'manifest.json'), 'utf8')); check('manifest: signalCompass.json is listed', Array.isArray(manifest.files) && manifest.files.includes('signalCompass.json')); check('api: SignalCompass has refresh + destroy', typeof SignalCompass === 'function' && typeof SignalCompass.prototype.refresh === 'function' && typeof SignalCompass.prototype.destroy === 'function'); // A constructed instance picks up the config (radius from data/signalCompass.json). const fakeScene = { add: { graphics: () => ({ setDepth: () => ({ setBlendMode: () => ({ clear() {}, destroy() {} }) }) }) } }; const inst = new SignalCompass(fakeScene); check('instance: radius came from config (128)', inst.radius === 128); check('instance: enabled gate read', inst.enabled === true); console.log(`\nsignal-compass: ${pass} checks passed`);