/** * Click-outside the sub-menu (headless browser — NOT a Node test). * * Drives the REAL game's input pipeline (mousedown on the canvas) and * checks the dismiss rule in GameScene's pointerdown handler: * * sub-bar OPEN + click OUTSIDE it (world) → closes, no fly-here * sub-bar OPEN + click on its own panel → stays open * sub-bar OPEN + a sub-bar button (Save Game) → the button still fires * sub-bar OPEN + click on the MENU deck button → closes, no re-open * sub-bar OPEN + compass autopilot seam → closes, no navigation * after any of those, MENU still re-opens it * * Served by dev/clickout-test.html; the results land in * `window.__CLICKOUT__` for the CDP runner (dev/cdp-firefox.mjs): * * python3 -m http.server 8080 * node dev/cdp-firefox.mjs http://localhost:8080/dev/clickout-test.html */ 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); // A deterministic galaxy (same system every run) + a quiet run (no audio // files to fetch in headless). globalThis.__ORBIT_DEV_SEED = 'CLICKOUT'; 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; const results = []; const check = (label, cond) => { const pass = !!cond; results.push({ label, pass }); console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`); }; // WAIT — throttling-proof. This headless box starves setTimeout and // stretches its compositor clock, so the only honest barrier is the // GAME'S OWN CLOCK: Phaser v4 sets scene time.now from the engine loop // time (monotonic across scene transitions). Poll it on rAF. const gameClock = () => { try { const s = window.game.scene.getScenes(true)[0]; if (s && typeof s.time.now === 'number') return s.time.now; } catch {} return null; }; const wait = (ms) => new Promise((resolve) => { const base = gameClock(); if (base === null) { const start = performance.now(); setTimeout(() => resolve(), ms); return; } const poll = () => { const now = gameClock(); if (now !== null && now - base >= ms) return resolve(); requestAnimationFrame(poll); }; requestAnimationFrame(poll); }); // A real mouse press at CANVAS screen coords (x, y). Phaser 4.2.1 binds // mousedown/mouseup/mousemove on the canvas element, so a dispatched // MouseEvent drives the full input pipeline (pointer → scene handler → // interactive objects). FIT mode scales the canvas, so map canvas → // client space with the rendered rect (not 1:1 pixels). const press = (x, y) => { const canvas = game.canvas; const r = canvas.getBoundingClientRect(); const zoom = r.width / canvas.width; const mk = (type, buttons) => new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0, buttons, clientX: r.left + x * zoom, clientY: r.top + y * zoom, }); canvas.dispatchEvent(mk('mouseover', 0)); canvas.dispatchEvent(mk('mousedown', 1)); canvas.dispatchEvent(mk('mouseup', 0)); }; const OPEN_MS = 450; // openMs (340) + button decode margin const CLOSE_MS = 350; // closeMs (200) + margin const run = async () => { await wait(900); // boot: galaxy, system, deck, sub-bar const scene = game.scene.getScene('GameScene'); check('boots into GameScene', game.scene.isActive('GameScene')); check('the sub-bar starts closed', scene.menuSubBar.isOpen === false); // ---- 1) open with MENU, dismiss with a click in open space ----------- scene.menuAction(); await wait(OPEN_MS); check('MENU folds the sub-bar up', scene.menuSubBar.isOpen); const targetBefore = scene.ship.target; press(scene.scale.width / 2, scene.scale.height * 0.4); // open space await wait(CLOSE_MS); check('a click OUTSIDE the sub-bar closes it', scene.menuSubBar.isOpen === false && scene.menuSubBar.state === 'closed'); check('the dismissing click does NOT fly the ship', scene.ship.target === targetBefore); // ---- 2) a click on the sub-bar's own panel keeps it open ------------- scene.menuAction(); await wait(OPEN_MS); check('the sub-bar re-opens', scene.menuSubBar.isOpen); const bar = scene.menuSubBar.rect; press(bar.x + 5, bar.y + bar.h / 2); // left padding — no button there await wait(250); check('a click on the sub-bar panel (not a button) keeps it open', scene.menuSubBar.isOpen === true); // ---- 3) a sub-bar button still fires while the bar is open ----------- const saveBtn = scene.menuSubBar.buttons.find((b) => b.id === 'save'); check('the Save Game button is inside the open bar', scene.menuSubBar.contains( scene.menuSubBar.x + saveBtn.btn.x, scene.menuSubBar.y + saveBtn.btn.y)); press(scene.menuSubBar.x + saveBtn.btn.x, scene.menuSubBar.y + saveBtn.btn.y); await wait(500); check('Save Game still opens the 10-slot pop-up', scene.savePanel.isOpen && scene.savePanel.mode === 'save'); scene.savePanel.close(); // the sub-bar stays open behind it (existing) await wait(500); // ---- 4) MENU toggles it closed again (toggle path intact) ------------ check('the sub-bar is still open under the closed pop-up', scene.menuSubBar.isOpen === true); scene.menuAction(); await wait(CLOSE_MS); check('MENU folds the sub-bar back down', scene.menuSubBar.state === 'closed'); // ---- 5) clicking the MENU deck button closes it — no re-open --------- scene.menuAction(); await wait(OPEN_MS); check('the sub-bar re-opens (deck-button test)', scene.menuSubBar.isOpen); const menuSlot = scene.actionBar.slots.find((s) => s.id === 'menu'); press(menuSlot.slot.x, menuSlot.slot.y); await wait(CLOSE_MS); check('clicking the MENU button closes the sub-bar (no re-open)', scene.menuSubBar.isOpen === false && scene.menuSubBar.state === 'closed'); // ---- 6) the compass seam can't navigate while the bar is open -------- scene.menuAction(); await wait(OPEN_MS); check('the sub-bar re-opens (compass test)', scene.menuSubBar.isOpen); const targetBefore2 = scene.ship.target; scene.autopilotTo('home'); // the chip's seam, with the bar open await wait(CLOSE_MS); check('a compass autopilot while open: bar closes, ship NOT targeted', scene.menuSubBar.state === 'closed' && scene.ship.target === targetBefore2); // ---- 7) all dismiss paths left the bar re-openable ------------------- scene.menuAction(); await wait(OPEN_MS); check('the sub-bar re-opens after every dismiss path', scene.menuSubBar.isOpen); }; let done = false; game.events.once('ready', async () => { try { await run(); } catch (err) { results.push({ label: `THREW: ${String(err && err.message || err)}`, pass: false }); } const pass = results.length > 0 && results.every((r) => r.pass); window.__CLICKOUT__ = { pass, results, errors: window.__CAPTURED_ERRORS__ || [] }; done = true; console.log(pass ? 'CLICKOUT PASS' : 'CLICKOUT FAIL'); }); // Hard timeout: a hung flow must not hang the driver forever. setTimeout(() => { if (!done) { window.__CLICKOUT__ = { pass: false, results: [...results, { label: 'TIMED OUT (300s)', pass: false }] }; } }, 300000);