/** * Dev-only: repro + regression check for the MAP console's object click. * * Scenario under test: * 1. open the map console (deck MAP button) * 2. hover a discovered object on the chart → the tooltip * (name + "TAP TO PLOT COURSE") must be part of the window * 3. click it → autopilot (ship.target set) * 4. close the map → NOTHING of the tooltip may linger on screen * * The known bug: ttPlate/ttName/ttSub/ttHint are created with * scene.add() but never added to the MapWindow container (only the * EMPTY ttCont is) — so they paint at depth 5, BEHIND the window * (depth 80), and survive close() (nothing ever hides/clears them). * * node dev/server.mjs 8091 * node dev/shot-firefox.mjs \ * "http://127.0.0.1:8091/dev/map-click.html" \ * "window.__MAPCLICK ? window.__MAPCLICK.ready : null" \ * /tmp/map-click.png 30000 */ 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 errors = []; const origErr = console.error.bind(console); console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; window.addEventListener('error', (e) => errors.push(String(e.message))); window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); const gameConfig = createGameConfig(); gameConfig.scene = [GameScene]; const game = new Phaser.Game(gameConfig); window.game = game; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitScene() { for (let i = 0; i < 100; i++) { const s = game.scene.getScene('GameScene'); if (s && s.ship && s.mapWindow) return s; await sleep(100); } throw new Error('GameScene never booted'); } /** Walk up the container parents to the object's top-level display-list ancestor. */ function topLevel(obj) { let top = obj; let c = obj.parentContainer; // Phaser 4: container children track their container here while (c) { top = c; c = c.parentContainer; } return top; } /** Effective (chained) visibility down to the top-level object. */ function effVisible(obj) { let v = true; let o = obj; while (o) { v = v && o.visible !== false; o = o.parentContainer; } return v; } try { const s = await waitScene(); await sleep(800); // Discover the central body + first planet + first station so the // chart (and _hits) has something to point at. const sysId = s.systemRecord.id; let known = s.discovery.bySystem.get(sysId); if (!known) { known = new Set(); s.discovery.bySystem.set(sysId, known); } const push = (obj) => { if (obj && !known.has(obj.id)) s.discovery.check(sysId, obj.x, obj.y, [obj]); }; if (s.isHomeSystem && s.planet) push({ id: 'home', x: 0, y: 0, radius: s.planet.radius }); if (s.systemPlanets[0]) push({ id: s.systemPlanets[0].discoveryId, x: s.systemPlanets[0].x, y: s.systemPlanets[0].y, radius: s.systemPlanets[0].radius }); if (s.systemStations[0]) push({ id: s.systemStations[0].discoveryId, x: s.systemStations[0].x, y: s.systemStations[0].y, radius: s.systemStations[0].bound ?? 60 }); // Open the console, wait for the reveal + first paint. s.deckAction('map'); await sleep(1800); const win = s.mapWindow; if (!win.isOpen) throw new Error('map window did not open'); // Force a fresh snapshot so _hits is populated. const snap = win._snapOf(); if (snap && win._fp !== win._fpOf(snap)) win._applySnap(snap); if (!win._hits || win._hits.length === 0) throw new Error('no hits on the chart'); const hit = win._hits[0]; const sx = win.geo.mapX + hit.x; const sy = win.geo.mapY + hit.y; const dl = s.sys.displayList.getChildren(); const iWin = dl.indexOf(win); const iMapImg = dl.indexOf(win.mapImg); const describe = (o, label) => { const top = topLevel(o); const iTop = dl.indexOf(top); return { label, visible: o.visible, effVisible: effVisible(o), inWindow: top === win, topInDisplayList: top === win ? 'the map window' : (iTop >= 0 ? `${top.type || top.constructor.name} @ dl[${iTop}] (win=${iWin})` : 'NOT in display list'), paintsAboveMap: top === win || (iTop > iWin), }; }; // ---- 1: hover the object ------------------------------------------------ win._setHover({ x: sx, y: sy }); await sleep(120); const before = { ttPlate: describe(win.ttPlate, 'ttPlate'), ttName: describe(win.ttName, 'ttName'), ttHint: describe(win.ttHint, 'ttHint'), ttCont: describe(win.ttCont, 'ttCont'), hoverG: describe(win.hoverG, 'hoverG'), hover: !!win._hover, hoverId: win._hover?.id ?? null, ttNameText: win.ttName.text, mapImg: { inDL: iMapImg >= 0, interactive: !!(win.mapImg.input && win.mapImg.input.enabled) }, }; // ---- 2: click the object (through the real handler) -------------------- const targetBefore = s.ship.target; win.mapImg.emit('pointerdown', { x: sx, y: sy }); await sleep(120); const click = { targetWasSet: s.ship.target !== null && s.ship.target !== targetBefore, target: s.ship.target ? { x: Math.round(s.ship.target.x), y: Math.round(s.ship.target.y) } : null, flash: !!win._flash, }; // a click on EMPTY plate must not retarget s.ship.target = null; win.mapImg.emit('pointerdown', { x: win.geo.mapX + 4, y: win.geo.mapY + 4 }); await sleep(120); const missClick = { targetStayedNull: s.ship.target === null }; // ?keepOpen=1 — stop here (map still open, tooltip up) so a screenshot // harness can capture the chart + tooltip on screen. const keepOpen = new URLSearchParams(location.search).has('keepOpen'); let after = null, reopen = null, after2 = null; if (keepOpen) { const pass = click.targetWasSet && missClick.targetStayedNull; window.__MAPCLICK = { ready: true, pass, keepOpen: true, before, click, missClick, errors, }; console.log('MAPCLICK (keepOpen) ready'); } else { // ---- 3: close the map, then inspect what lingers ------------------------ win.close(); await sleep(700); // close tween is 150 ms after = { openState: win.openState, ttPlate: describe(win.ttPlate, 'ttPlate'), ttName: describe(win.ttName, 'ttName'), ttHint: describe(win.ttHint, 'ttHint'), ttCont: describe(win.ttCont, 'ttCont'), hoverG: describe(win.hoverG, 'hoverG'), }; // ---- 4: re-open, hover, close again (no leftovers, no crash) ----------- win.open(); await sleep(1800); win._setHover({ x: sx, y: sy }); await sleep(120); reopen = { open: win.isOpen, hoverVisible: effVisible(win.ttName) }; win.close(); await sleep(700); after2 = { openState: win.openState, ttContHidden: !effVisible(win.ttCont), ttPlateHidden: !effVisible(win.ttPlate), ttNameHidden: !effVisible(win.ttName), ttHintHidden: !effVisible(win.ttHint), }; } const results = []; const check = (label, cond) => results.push({ label, pass: !!cond }); check('tooltip (ttPlate) belongs to the map window (paints with it)', topLevel(win.ttPlate) === win); check('tooltip name text belongs to the map window', topLevel(win.ttName) === win); check('tooltip hint ("TAP TO PLOT COURSE") belongs to the map window', topLevel(win.ttHint) === win); check('while open: tooltip effectively visible on the chart', before.ttPlate.effVisible && before.ttName.effVisible); check('while open: tooltip paints ABOVE the map surface (inside the window)', before.ttPlate.paintsAboveMap && before.ttName.paintsAboveMap); check('clicking a discovered object sets the ship target (autopilot)', click.targetWasSet); check('clicking empty plate does NOT retarget', missClick.targetStayedNull); if (!keepOpen) { check('after close: tooltip plate hidden (no lingering button)', !after.ttPlate.effVisible); check('after close: tooltip name hidden', !after.ttName.effVisible); check('after close: tooltip hint hidden', !after.ttHint.effVisible); check('after close: hover ring cleared', !after.hoverG.effVisible || win.hoverG.commandBuffer.length === 0); check('re-open: hover tooltip visible on the chart again', reopen.open && reopen.hoverVisible); check('second close: tooltip fully hidden (no leftovers)', after2.ttContHidden && after2.ttPlateHidden && after2.ttNameHidden && after2.ttHintHidden); } check('no console errors', errors.length === 0); const pass = results.every((r) => r.pass); window.__MAPCLICK = { ready: true, pass, results, before, click, missClick, after, reopen, after2, errors }; console.log(pass ? 'MAPCLICK PASS' : 'MAPCLICK FAIL'); console.log(JSON.stringify({ results, before, click, after }, null, 1)); } catch (err) { const results = [{ label: `THREW: ${err.message}`, pass: false }]; window.__MAPCLICK = { ready: true, pass: false, results, errors }; console.error('MAPCLICK FAIL (threw)', err); }