202 lines
9.4 KiB
JavaScript
202 lines
9.4 KiB
JavaScript
/**
|
||
* 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);
|