orbit/dev/jumpgate.test.mjs

235 lines
11 KiB
JavaScript
Raw Permalink 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.

/**
* JumpGate entity test (dev tool, run with Node — no browser needed):
*
* node dev/jumpgate.test.mjs
*
* Stubs just enough of Phaser to construct the REAL JumpGate from
* js/entities/JumpGate.js (the system's exit — content.jumps, laid out
* by SystemGenerator.layoutGates), then asserts the contract the rest
* of the game relies on:
* - discovery identity: discoveryId/discoveryName from the record,
* size from data/gates.json, bound = size (compass/toast scale),
* clearance = gates.shipClearance, rotation = the record's bearing;
* - the SPRITE renderer (data/gates.json → texture): frame 0 = the
* gate body (static, always full alpha — even dormant), frame 1 =
* the active swirl — hidden while dormant, revealed when active and
* then spinning clockwise with its alpha breathing EXACTLY between
* swirl.alphaMin and swirl.alphaMax; sprite scale =
* (size×2)/frameWidth (the ring's outer edge on the keepout disc);
* - the FALLBACK renderer (sheet missing): the old procedural gate —
* dormant dim (0.4), dormant field still, active field breathes;
* - the SOLID contract (same as Planet/Station): minCenterDistance,
* edgePoint exactly `gap` past the surface, aimPoint clamping, and
* constrainShip pushing the ship out of the keepout circle
* (through the shared Planet.resolve);
* - update() runs without throwing (both renderers);
* - destroy() tears the children down.
*/
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.children = [];
}
add(o) { this.children.push(o); return this; }
remove(o) { this.children = this.children.filter((c) => c !== o); return this; }
removeChildren() { this.children.length = 0; return this; }
removeAll(destroy = false) {
// Phaser v4 Container API (JumpGate.destroy relies on it): detach all
// children, destroying them when asked.
for (const c of this.children) if (destroy) c.destroy?.();
this.children.length = 0;
return this;
}
setOrigin() { return this; }
setDepth() { return this; }
setAlpha(a) { this.alpha = a; return this; }
destroy() { this.destroyed = true; return this; }
}
class Container extends GameObject {}
class Sprite extends GameObject {}
globalThis.window = {
Phaser: { GameObjects: { Container, Sprite } }, // js/vendor/phaser.js reads this
};
// --- 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++;
};
const { JumpGate } = await import(pathToFileURL(join(__dirname, '../js/entities/JumpGate.js')).href);
// A scene stub: textures.exists (flip per renderer pass), add.image
// (recording the sprite frames), add.graphics (recording no-op) and
// add.circle (the fallback field discs — setAlpha is driven by update()).
let texturesPresent = true;
const images = [];
const alphas = [];
const scene = {
textures: { exists: () => texturesPresent },
add: {
existing: (o) => o,
image: (x, y, key, frame) => {
const img = {
x, y, key, frame,
alpha: 1,
visible: true,
rotation: 0,
scale: 1,
setScale(s) { this.scale = s; return this; },
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
};
images.push(img);
return img;
},
graphics: () => ({
lineStyle() { return this; },
strokeCircle() { return this; },
fillStyle() { return this; },
fillCircle() { return this; },
lineBetween() { return this; },
}),
circle: (x, y, r, fill, alpha) => {
const c = { x, y, r, fill, alpha, setAlpha(a) { this.alpha = a; return this; } };
alphas.push(c);
return c;
},
},
};
// A real gate record from the real generator (the home system's first).
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const g = Galaxy.create('jumpgate-entity-test');
const rec = g.currentSystem();
const content = g.ensureContent(rec.id);
const gateRec = content.jumps[0];
const aMin = config.get('gates.swirl.alphaMin', 0.6);
const aMax = config.get('gates.swirl.alphaMax', 0.9);
const expectedScale = (config.get('gates.size', 96) * 2) / config.get('gates.frameWidth', 256);
// --- The SPRITE renderer (the default — the sheet is configured) --------
texturesPresent = true;
const gate = new JumpGate(scene, gateRec, { depth: 5 });
const [body, swirl] = images.slice(-2);
check('discoveryId / discoveryName come from the record', gate.discoveryId === gateRec.id && gate.discoveryName === gateRec.name);
check('position comes from the record', gate.x === gateRec.x && gate.y === gateRec.y);
check('size comes from data/gates.json', gate.size === config.get('gates.size', 96) && gate.bound === gate.size);
check('clearance comes from gates.shipClearance', gate.clearance === config.get('gates.shipClearance', 50));
check('rotation = the records bearing (facing the destination star)', gate.rotation === gateRec.rotation);
check('active mirrors the record (false by default — dormant)', gateRec.active === false && gate.active === false);
check('sprite path: both frames drawn (body = frame 0, swirl = frame 1)', body.frame === 0 && swirl.frame === 1);
check('sprite scale = (size×2)/frameWidth (the ring edge on the keepout disc)',
Math.abs(body.scale - expectedScale) < 1e-9 && Math.abs(swirl.scale - expectedScale) < 1e-9);
check('a dormant gate keeps the body at full alpha (1.0)', gate.alpha === 1);
check('a dormant gate hides the swirl', swirl.visible === false);
gate.update(1000);
gate.update(2000);
check('dormant gate: update() keeps the swirl hidden (no breathing)', swirl.visible === false);
// --- ACTIVITY: activation reveals the swirl; it spins + breaths ---------
const gateB = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
const swirlB = images[images.length - 1];
check('an active gate is not dimmed', gateB.alpha === 1);
check('an active gate reveals the swirl', swirlB.visible === true);
gateB.update(0);
const a0 = swirlB.alpha;
gateB.update(873); // t ≈ π/2 of the breath → near-maximum alpha
check('active gate: the swirl breathes (alpha changes over time)', Math.abs(swirlB.alpha - a0) > 1e-6);
let inRange = true;
for (let ms = 0; ms <= 4000; ms += 250) {
gateB.update(ms);
if (swirlB.alpha < aMin - 1e-9 || swirlB.alpha > aMax + 1e-9) inRange = false;
}
check('active gate: the swirl alpha stays within [alphaMin, alphaMax]', inRange);
gateB.update(1000);
const r1 = swirlB.rotation;
gateB.update(3000);
check('active gate: the swirl spins (rotation increases with time — clockwise)',
r1 > 0 && swirlB.rotation > r1);
// --- The solid contract (same rules as Planet / Station) ------------------
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
const minDist = gate.minCenterDistance(shipRadius);
check('minCenterDistance = radius + clearance + shipRadius', Math.abs(minDist - (gate.radius + gate.clearance + shipRadius)) < 1e-9);
{
const p = gate.edgePoint(1.2, 25, shipRadius);
check('edgePoint sits exactly radius + gap + shipRadius out, on the bearing',
Math.abs(Math.hypot(p.x - gate.x, p.y - gate.y) - (gate.radius + 25 + shipRadius)) < 1e-6 &&
Math.abs(Math.atan2(p.y - gate.y, p.x - gate.x) - 1.2) < 1e-9);
}
{
const inside = { x: gate.x + 10, y: gate.y + 10 }; // inside the keepout
const clamped = gate.aimPoint(inside.x, inside.y, shipRadius);
check('aimPoint clamps an inside target out to the keepout',
Math.abs(Math.hypot(clamped.x - gate.x, clamped.y - gate.y) - minDist) < 1e-6);
const outside = { x: gate.x + minDist * 2, y: gate.y };
const asIs = gate.aimPoint(outside.x, outside.y, shipRadius);
check('aimPoint leaves an outside target alone', asIs.x === outside.x && asIs.y === outside.y);
}
{
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
const s = ship(gate.x + 1, gate.y + 1); // jammed inside the keepout
gate.constrainShip(s, shipRadius);
const d = Math.hypot(s.x - gate.x, s.y - gate.y);
check('constrainShip pushes a ship inside the keepout back out',
Math.abs(d - minDist) < 1e-6);
const s2 = ship(gate.x + minDist * 2, gate.y, 10, 0); // outside: untouched
gate.constrainShip(s2, shipRadius);
check('constrainShip leaves a ship outside the keepout untouched', s2.x === gate.x + minDist * 2 && s2.body.velocity.x === 10);
}
// --- The FALLBACK renderer (sheet missing) — the procedural gate ---------
texturesPresent = false;
const gateC = new JumpGate(scene, gateRec, { depth: 5 });
const circlesC = alphas.slice(-2); // the fallback field discs
check('fallback: a dormant gate renders dim (alpha 0.4)', Math.abs(gateC.alpha - 0.4) < 1e-9);
check('fallback: the procedural field is built (two discs)', circlesC.length === 2);
const before = circlesC.map((c) => c.alpha);
gateC.update(1000);
gateC.update(2000);
check('fallback: dormant gate — update() leaves the field still', circlesC.every((c, i) => c.alpha === before[i]));
const gateD = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
const circlesD = alphas.slice(-2);
check('fallback: an active gate is not dimmed', gateD.alpha === 1);
const d0 = circlesD.map((c) => c.alpha);
gateD.update(873); // t ≈ π/2 of the pulse → maximum breathing
check('fallback: an active gate breathes its field', circlesD.some((c, i) => Math.abs(c.alpha - d0[i]) > 1e-6));
// --- Teardown -------------------------------------------------------------
gate.update(1000); // must not throw (sprite path, dormant)
check('sprite gate: update() runs without throwing', true);
const childCount = gate.children.length;
gate.destroy();
check('destroy() clears the children', gate.children.length === 0 && childCount > 0);
if (failures > 0) {
console.error(`\n${failures} jumpgate test(s) FAILED`);
process.exit(1);
}
console.log('\nAll JumpGate entity checks passed ✔');