/** * Dev: real-input test for the GATE comm window (v0.5): * * clicking a gate NO LONGER jumps (or fly-heres) — it opens the * shared comms panel in its gate variant (js/ui/CommsPanel.js): * * 1. click a DORMANT gate → window opens: LINK … DORMANT, * REQUEST JUMP grayed (disabled), CANCEL; * the ship does NOT fly to the gate * 2. click CANCEL → closes, no jump seam fires * 3. click an ACTIVE gate → LINK … ACTIVE, REQUEST JUMP enabled * 4. click REQUEST JUMP → the jump seam fires with the gate * entity (jumpThroughGate) + closes * 5. ESC → closes * 6. gate window open, click ANOTHER gate → the window re-anchors * on that gate (name + position) * * Real DOM mouse events hit the canvas (Phaser's input manager), so * this exercises the full path: scene pointerdown → gateAt → * openGateCommsPanel, and the panel's own button dispatch * (commsAction). * * node dev/server.mjs 8099 * node dev/cdp-firefox.mjs http://localhost:8099/dev/gate-comms.html \ * 'return window.__GATE_COMMS__; ' 90000 */ import Phaser from '../js/vendor/phaser.js'; import { config } from '../js/config/Config.js'; import { ConfigLoader } from '../js/config/ConfigLoader.js'; import { createGameConfig } from '../js/config/GameConfig.js'; import { GameScene } from '../js/scenes/GameScene.js'; const data = await ConfigLoader.load(); config.init(data); const __fonts = globalThis.document?.fonts; if (__fonts && typeof __fonts.load === 'function') { const fams = ['header', 'body'] .map((k) => config.get(`theme.fonts.${k}.family`)) .filter((f) => typeof f === 'string' && f.length > 0); if (fams.length > 0) { await Promise.race([ Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))), new Promise((r) => setTimeout(r, 2000)), ]); } } // 'VESTRA's starting system holds three (dormant) gates. globalThis.__ORBIT_DEV_SEED = 'VESTRA'; const gameConfig = createGameConfig(); gameConfig.scene = [GameScene]; // GameScene boots first if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; const game = new Phaser.Game(gameConfig); window.game = game; /* ---- helpers ---------------------------------------------------------- */ const scene = () => { const g = window.game; if (!g) return null; const s = g.scene.getScene('GameScene'); return s && s.sys?.isActive && s.commsPanel ? s : null; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Pump the (rAF-frozen) loop until `ok()` or the budget elapses. */ async function pump(s, ok, ms = 4000) { const t0 = Date.now(); while (!ok() && Date.now() - t0 < ms) { s.game.loop.step(performance.now()); await sleep(0); } return ok(); } async function ready() { const t0 = Date.now(); for (;;) { const s = scene(); if (s && s.ship && (s.systemGates?.length ?? 0) >= 2) return s; if (Date.now() - t0 > 45000) throw new Error('game never became ready'); await sleep(50); } } /** A real mouse click at viewport (client) coordinates (v4's input * manager listens for classic mouse events on the canvas). A leading * mousemove refreshes the pointer so the mousedown's world coords are * current (Phaser caches them per frame). * NOTE: camera placement must use cam.setScroll() — plain .scrollX * assignment does not stick — and the scene's follow must be off first. */ function clickAtClient(clientX, clientY) { const canvas = document.querySelector('canvas'); const mk = (type, buttons) => new MouseEvent(type, { bubbles: true, composed: true, cancelable: true, view: window, clientX, clientY, button: 0, buttons, }); canvas.dispatchEvent(mk('mousemove', 0)); window.game.loop.step(performance.now()); canvas.dispatchEvent(mk('mousedown', 1)); canvas.dispatchEvent(mk('mouseup', 0)); } /** ESC (Phaser's keyboard plugin reads the legacy keyCode). */ function pressEscape() { const ev = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, composed: true, cancelable: true, }); Object.defineProperty(ev, 'keyCode', { get: () => 27 }); Object.defineProperty(ev, 'which', { get: () => 27 }); window.dispatchEvent(ev); } /** World → client coords through the camera + the CSS-scaled canvas rect. */ function worldToClient(s, wx, wy) { const cam = s.cameras.main; const rect = document.querySelector('canvas').getBoundingClientRect(); const scaleX = rect.width / s.scale.width; const scaleY = rect.height / s.scale.height; return { clientX: rect.left + (wx - cam.scrollX) * scaleX, clientY: rect.top + (wy - cam.scrollY) * scaleY, }; } /* ---- the test --------------------------------------------------------- */ window.__GATE_COMMS_TEST__ = async () => { const out = { steps: [] }; const s = await ready(); const step = (name, pass, extra = {}) => out.steps.push({ name, pass, ...extra }); s.cameraFollowShip = false; s.hideHint?.(); s.ship.stop(); const [gateA, gateB] = s.systemGates; // both dormant by default // Keep the ship OUT of both gates' keepouts (solid — the ship can't // overlap them), well off-screen from the framings below. (A click on // a gate must not set the ship's target — that's the fly-here tell.) s.ship.x = gateA.x + 2500; s.ship.y = gateA.y + 2000; const frame = (wx, wy, sx, sy) => { s.cameras.main.setScroll(wx - sx, wy - sy); s.cameraFollowShip = false; }; const center = (wx, wy) => ({ sx: 640, sy: 300, wx, wy }); // The jump seam — stubbed (this harness registers ONLY GameScene; // the real jumpThroughGate would restart the scene mid-test). let jumped = null; const origJump = s.jumpThroughGate; s.jumpThroughGate = (gt) => { jumped = gt; }; // Action-dispatch log (which button id the panel fired, in order). const actionLog = []; const origCA = s.commsAction; s.commsAction = (id, t) => { actionLog.push(id); origCA.call(s, id, t); }; // Button centers: panel-local y of btn1/btn2 (the gate layout is // shorter than the settled one — read them, don't assume). const btnPoint = (which) => { const b = s.commsPanel[which]; return worldToClient(s, s.commsPanel.x, s.commsPanel.y + b.y); }; // ---- 1. Real click ON the dormant gate → window opens, no fly ------ const f1 = center(gateA.x, gateA.y); frame(gateA.x, gateA.y, f1.sx, f1.sy); await pump(s, () => !s.commsPanel.isOpen, 1000); const c1 = worldToClient(s, gateA.x, gateA.y); clickAtClient(c1.clientX, c1.clientY); await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000); const p = s.commsPanel; step( 'click DORMANT gate → window opens + ship does NOT fly', p.isOpen && p._name === gateA.discoveryName.toUpperCase() && p.btn1.disabled === true && s.ship.target === null, { panelName: p._name, gateName: gateA.discoveryName, btn1: p.btn1.text.text, btn1Disabled: p.btn1.disabled, shipTarget: s.ship.target, }, ); step( 'dormant window reads LINK … DORMANT + REQUEST JUMP ghost', p.repLabel.text === 'LINK' && p.repValue.text === 'DORMANT' && p.btn1.text.text === 'REQUEST JUMP' && p.btn2.text.text === 'CANCEL' && p.marksG.visible === false, { link: p.repLabel.text, status: p.repValue.text, btn1: p.btn1.text.text, btn2: p.btn2.text.text }, ); // ---- 2. Click CANCEL → closes, no jump ------------------------------ const b2 = btnPoint('btn2'); clickAtClient(b2.clientX, b2.clientY); await pump(s, () => !s.commsPanel.isOpen, 2500); step('click CANCEL → closes, no jump', !s.commsPanel.isOpen && jumped === null, { jumped }); // ---- 3. Click the ACTIVE gate → enabled REQUEST JUMP ----------------- gateA.active = true; // (the research tech would do this — same flag) await pump(s, () => true, 200); const f3 = center(gateA.x, gateA.y); frame(gateA.x, gateA.y, f3.sx, f3.sy); const c3 = worldToClient(s, gateA.x, gateA.y); clickAtClient(c3.clientX, c3.clientY); await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000); step( 'click ACTIVE gate → LINK … ACTIVE + REQUEST JUMP enabled', s.commsPanel.isOpen && s.commsPanel.repValue.text === 'ACTIVE' && s.commsPanel.btn1.disabled === false && s.commsPanel.btn1.text.text === 'REQUEST JUMP', { status: s.commsPanel.repValue.text, btn1Disabled: s.commsPanel.btn1.disabled }, ); // ---- 4. Click REQUEST JUMP → the seam fires with the gate + closes -- jumped = null; actionLog.length = 0; const b4 = btnPoint('btn1'); clickAtClient(b4.clientX, b4.clientY); await pump(s, () => !s.commsPanel.isOpen, 2500); step( 'click REQUEST JUMP → jumpThroughGate(gate) fires + closes', !s.commsPanel.isOpen && jumped === gateA, { jumpedGate: jumped?.discoveryName ?? null, isGateA: jumped === gateA, actionLog: [...actionLog], btn1LocalY: s.commsPanel.btn1.y, clickAt: [b4.clientX, b4.clientY], panelAt: [s.commsPanel.x, s.commsPanel.y], }, ); // ---- 5. ESC closes the gate window ----------------------------------- const c5 = worldToClient(s, gateA.x, gateA.y); clickAtClient(c5.clientX, c5.clientY); await pump(s, () => s.commsPanel.isOpen && s.commsPanel.nameDec === null, 4000); pressEscape(); await pump(s, () => !s.commsPanel.isOpen, 2500); step('ESC → closes', !s.commsPanel.isOpen); // ---- 6. Gate window open → click ANOTHER gate → re-anchors ----------- const c6 = worldToClient(s, gateA.x, gateA.y); clickAtClient(c6.clientX, c6.clientY); await pump(s, () => s.commsPanel.isOpen && s.commsPanel.nameDec === null, 4000); // Re-frame on gate B (the camera still shows gate A), then click it. // The panel stays anchored at gate A (world) and never covers gate B // (gate spacing ≥ 2·size + gateGap = 384 px > the panel's 156 px reach). frame(gateB.x, gateB.y, 640, 300); // gateA is ACTIVE now, gateB dormant — the re-anchor must re-read the // target gate's state (enabled button → ghost again). const c6b = worldToClient(s, gateB.x, gateB.y); clickAtClient(c6b.clientX, c6b.clientY); await pump(s, () => s.commsPanel.nameDec === null && s.commsPanel._name === gateB.discoveryName.toUpperCase(), 4000); // The panel anchors on a SIDE of the click (pickSideAndPlace): its // center sits H/2 + LIFT (vertical) or W/2 + LIFT (horizontal) away // from the gate — check the adjacency, not the exact center. const dAB = Math.hypot(s.commsPanel.x - gateB.x, s.commsPanel.y - gateB.y); const adjOK = Math.abs(dAB - (s.commsPanel.H / 2 + 14)) < 4 || Math.abs(dAB - (s.commsPanel.W / 2 + 14)) < 4; step( 'gate window open → click another gate → re-anchors (name + state)', s.commsPanel.isOpen && s.commsPanel._name === gateB.discoveryName.toUpperCase() && adjOK && s.commsPanel.repValue.text === 'DORMANT' && s.commsPanel.btn1.disabled === true, { panelName: s.commsPanel._name, panelAt: [Math.round(s.commsPanel.x), Math.round(s.commsPanel.y)], gateBAt: [Math.round(gateB.x), Math.round(gateB.y)], dist: Math.round(dAB), status: s.commsPanel.repValue.text, btn1Disabled: s.commsPanel.btn1.disabled, }, ); s.commsPanel.close(); s.jumpThroughGate = origJump; s.commsAction = origCA; out.bootErrors = window.__BOOT_ERRORS__ ?? []; out.allPass = out.steps.every((x) => x.pass) && out.bootErrors.length === 0; return out; }; /* ---- auto-run (the CDP runner polls window.__GATE_COMMS__) ------------- */ let done = false; game.events.once('ready', async () => { try { window.__GATE_COMMS__ = await window.__GATE_COMMS_TEST__(); } catch (err) { console.error(err); window.__GATE_COMMS__ = { allPass: false, steps: [], error: String(err?.stack || err) }; } done = true; const r = window.__GATE_COMMS__; console.log(`GATE-COMMS ${r.allPass ? 'PASS' : 'FAIL'}`); }); // Hard stop: if the flow never finishes, the runner will report the // failure (same pattern as dev/saves-ui-test.mjs). const hardStop = (t0) => { if (performance.now() - t0 >= 240000) { if (!done) { window.__GATE_COMMS__ = { allPass: false, steps: [], error: 'TIMED OUT (240s)' }; console.log('GATE-COMMS FAIL (timed out)'); } return; } requestAnimationFrame(() => hardStop(t0)); }; requestAnimationFrame(() => hardStop(performance.now()));