148 lines
6.4 KiB
JavaScript
148 lines
6.4 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 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 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; }
|
||
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 record’s bearing (facing the destination star)', gate.rotation === gateRec.rotation);
|
||
|
||
// --- 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; the field breathes
|
||
check('update() breathes the field discs (alpha set)', alphas.length === 2 && alphas.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 ✔');
|