/** * 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 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('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 ✔');