orbit/dev/jumpgate.test.mjs

177 lines
7.8 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.

/**
* 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;
* - ACTIVITY (data/gates.json → ACTIVITY): gate.active mirrors the
* record (false by default — the gate is dormant: dimmed, field
* still), an active:true gate breathes its field;
* - 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() breathes the field (active) or keeps it still (dormant)
* without throwing;
* - 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: add.existing (v4 quirk), add.graphics (recording no-op),
// add.circle (the field discs — setAlpha is driven by update()).
const alphas = [];
const scene = {
add: {
existing: (o) => o,
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 gate = new JumpGate(scene, gateRec, { depth: 5 });
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('a dormant gate renders dim (alpha 0.4)', Math.abs(gate.alpha - 0.4) < 1e-9);
// --- ACTIVITY: a dormant gate's field stays still; an active one breathes
{
const before = alphas.map((c) => c.alpha);
gate.update(1000);
gate.update(2000);
check('dormant gate: update() leaves the field still (no breathing)', alphas.every((c, i) => c.alpha === before[i]));
const alphasB = alphas.length;
const gateB = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
const alphas2 = alphas.slice(alphasB);
gateB.update(0);
const a0 = alphas2.map((c) => c.alpha);
gateB.update(873); // t ≈ π/2 of the pulse → maximum breathing
check('active gate: the field breathes (alpha changes over time)', alphas2.some((c, i) => Math.abs(c.alpha - a0[i]) > 1e-6));
check('an active gate is not dimmed', gateB.alpha === 1);
}
// --- 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);
}
// --- Animation + teardown --------------------------------------------------
gate.update(1000); // must not throw; a dormant gate keeps the field still
check('update() runs without throwing (dormant gate)', alphas.slice(0, 2).every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5));
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 ✔');