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