/** * Route-pulse test (dev tool, run with Node — no browser needed): * * node dev/route-pulse.test.mjs * * The shared route-target halo (js/entities/RoutePulse.js) — the in-world * "your destination is HERE" mark worn by BOTH the route gate (full * pulse, data/gates.json → route.pulse) and the destination OBJECT * (planet / space station — the subdued pulse, route.objectPulse): * - RoutePulse itself: show/hide, the steady ring + two ripples, * geometry scaled by the object's keepout radius, ripple animation, * idempotent toggling, cfg + color + position plumbing; * - the data contract: the destination-object pulse is genuinely * SUBDUED relative to the route-gate pulse (lower alphas); * - the Station integration: setRouteTarget()/update() drive the same * halo (the gate and planet integrations are covered by * dev/jumpgate.test.mjs and dev/planet.test.mjs). */ import { pathToFileURL, fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Phaser stub: the Container base + a recording graphics -------------- class GameObject { constructor(scene, x = 0, y = 0) { this.scene = scene; this.x = x; this.y = y; this.rotation = 0; this.alpha = 1; this.visible = true; this.children = []; } add(o) { this.children.push(o); return this; } removeAll(destroy = false) { for (const c of this.children) if (destroy) c.destroy?.(); this.children.length = 0; return this; } setDepth() { return this; } setAlpha(a) { this.alpha = a; return this; } setScale(s) { this.scale = s; return this; } destroy() { this.destroyed = true; return this; } } class Container extends GameObject {} class Sprite extends GameObject {} // toColor (js/utils/Color.js) — a real value→int conversion, like the browser's const hexToRgbInt = (v) => { const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : 0xffffff; }; globalThis.window = { Phaser: { GameObjects: { Container, Sprite }, // js/vendor/phaser.js reads this Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } }, }, }; // --- 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); let failures = 0; const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; // A fresh recording scene (one graphics, its strokes tracked). const makeScene = (texturesPresent) => { const strokes = []; // { r, style, color, alpha } per strokeCircle const positions = []; return { strokes, positions, textures: { exists: () => texturesPresent }, add: { existing: (o) => o, graphics: () => ({ x: 0, y: 0, visible: true, setPosition(x, y) { this.x = x; this.y = y; positions.push([x, y]); return this; }, setDepth() { return this; }, lineStyle(style, color, alpha) { this._ls = { style, color, alpha }; return this; }, strokeCircle(_cx, _cy, r) { strokes.push({ r, ...(this._ls ?? {}) }); return this; }, strokeRect() { return this; }, fillStyle() { return this; }, fillCircle() { return this; }, fillRect() { return this; }, lineBetween() { return this; }, clear() { strokes.length = 0; return this; }, destroy() { this.destroyed = true; }, }), circle: (x, y, r, fill, alpha) => ({ x, y, r, fill, alpha, setAlpha(a) { this.alpha = a; return this; }, setScale() { return this; } }), }, }; }; // --- 1. RoutePulse itself ------------------------------------------------- const { RoutePulse } = await import(pathToFileURL(join(__dirname, '../js/entities/RoutePulse.js')).href); { const scene = makeScene(true); const p = new RoutePulse(scene, { size: 100, cfg: {}, color: 0x112233, x: 30, y: -20 }); check('off by default, ring hidden', p.on === false && p.ring.visible === false); check('the ring sits where the host put it (planet-style placement)', p.ring.x === 30 && p.ring.y === -20 && scene.positions.length === 1); p.set(true); check('set(true): on + visible', p.on === true && p.ring.visible === true); p.draw(1.0); check('draw: exactly the steady ring + two ripples (3 circles)', scene.strokes.length === 3); const steady = 100 * 1.18; // the built-in baseRadius (cfg omitted) check(`default cfg: steady ring at baseRadius × size (${scene.strokes[0].r})`, Math.abs(scene.strokes[0].r - steady) < 1e-9); check('all circles sit outside the keepout disc (size = 100)', scene.strokes.every((s) => s.r >= 100 - 1e-9)); check('the color is passed through (0x112233)', scene.strokes.every((s) => s.color === 0x112233)); check('the steady ring keeps the cfg alpha', scene.strokes[0].alpha === 0.5); const snap = scene.strokes.slice(1); p.draw(1.0 + 0.4); // a quarter of the 1.6 s period later — different phases check('the ripples animate over time (radii + alpha move)', scene.strokes.length === 3 && (scene.strokes[1].r !== snap[0].r || scene.strokes[1].alpha !== snap[0].alpha)); check('ripple alpha fades with phase (later phase → lower alpha)', scene.strokes.every((s, i) => i === 0 || s.alpha <= 0.85 + 1e-9)); p.set(true); // no-op when already on check('set(true) twice is a no-op', p.on === true && p.ring.visible === true); p.set(false); check('set(false): off, cleared + hidden', p.on === false && p.ring.visible === false && scene.strokes.length === 0); p.draw(5.0); // drawing while off is a no-op check('draw() while off: nothing drawn', scene.strokes.length === 0); p.destroy(); check('destroy() tears the ring down', p.ring.destroyed === true); } { // A custom cfg (the subdued profile) is respected end-to-end. const scene = makeScene(true); const cfg = config.section('gates.route.objectPulse', {}); const p = new RoutePulse(scene, { size: 46, cfg, color: 0xff8c1a }); p.set(true); p.draw(0); const steady = 46 * cfg.baseRadius; check(`custom cfg: steady ring at objectPulse.baseRadius × size (${scene.strokes[0].r.toFixed(2)} = 46 × ${cfg.baseRadius})`, scene.strokes.length === 3 && Math.abs(scene.strokes[0].r - steady) < 1e-9); check('custom cfg: the subdued alphas are used', scene.strokes[0].alpha === cfg.ringAlpha && scene.strokes.some((s) => s.alpha === cfg.rippleAlpha)); } // --- 2. The data contract: the object pulse is SUBDUED -------------------- { const full = config.section('gates.route.pulse', {}); const sub = config.section('gates.route.objectPulse', {}); for (const k of ['baseRadius', 'expand', 'period', 'ringAlpha', 'rippleAlpha']) { check(`both pulses define ${k}`, typeof full[k] === 'number' && typeof sub[k] === 'number'); } check(`the object pulse is less intense (ringAlpha ${sub.ringAlpha} < ${full.ringAlpha}; rippleAlpha ${sub.rippleAlpha} < ${full.rippleAlpha})`, sub.ringAlpha < full.ringAlpha && sub.rippleAlpha < full.rippleAlpha); check(`...with a smaller reach (baseRadius ${sub.baseRadius} ≤ ${full.baseRadius}, expand ${sub.expand} ≤ ${full.expand})`, sub.baseRadius <= full.baseRadius && sub.expand <= full.expand); check('the pulse color is the route orange (compassColor)', String(config.get('gates.route.compassColor', '')).toLowerCase() === '#ff8c1a'); } // --- 3. Station integration (the destination OBJECT wears it) ------------- const { Station } = await import(pathToFileURL(join(__dirname, '../js/entities/Station.js')).href); const scene = makeScene(false); // sheet missing → the procedural build const st = new Station(scene, { id: 'set-station-test', kind: 'deepSpaceStation', name: 'Station One', anchor: { type: 'space' }, x: 40, y: 70, }); check('fresh station: the route pulse is off + hidden by default', st.routePulse.on === false && st.routePulse.ring.visible === false); st.setRouteTarget(true); check('setRouteTarget(true): on + visible', st.routePulse.on === true && st.routePulse.ring.visible === true); st.update(1000); const sub = config.section('gates.route.objectPulse', {}); const steady = st.size * sub.baseRadius; const stStrokes = scene.strokes.slice(-3); // the station's ring is the last graphics check(`station pulse: steady ring + two ripples, outside the keepout disc (radii ${stStrokes.map((s) => s.r.toFixed(1)).join(', ')})`, stStrokes.length === 3 && stStrokes.every((s) => s.r >= st.size - 1e-9) && Math.abs(stStrokes[0].r - steady) < 1e-9); check('the station pulse uses the SUBDUED cfg (not the gate full pulse)', st.routePulse.cfg === sub && st.routePulse.cfg.baseRadius === sub.baseRadius); st.setRouteTarget(false); check('setRouteTarget(false): cleared + hidden', st.routePulse.on === false && st.routePulse.ring.visible === false && scene.strokes.length === 0); st.update(2000); // the rest of the procedural update still runs check('update() keeps running after the pulse drops (beacon/ring untouched)', true); console.log(failures === 0 ? '\nAll route-pulse tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1);