235 lines
11 KiB
JavaScript
235 lines
11 KiB
JavaScript
/**
|
||
* 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 record’s 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 ✔');
|