orbit/dev/route-pulse.test.mjs

202 lines
9.4 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.

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