/** * Dev-only SHOP PANEL geometry probe (real browser, via cdp-firefox.mjs): * boots the game, lands on the home world, forces the surface stage, opens * the shop, clicks "Buy/Sell", waits for the slide-in, then reports the * on-screen geometry of the planet HUD text + the SELL/BUY panels + the * sub-bar — so the panel's top edge vs. the planet text is verifiable * numerically. Also sets window.__SHOP_SHOT__ so a driver can screenshot * the moment the numbers are ready. * * node dev/server.mjs 8126 * node dev/cdp-firefox.mjs http://127.0.0.1:8126/dev/shop-shot.html \ * 'return window.__SHOP_SHOT__;' 60000 */ 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'; import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; const fail = (msg, extra = {}) => { window.__SHOP_SHOT__ = { pass: false, error: String(msg), ...extra }; console.error('[shop-shot] FAIL:', msg); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const data = await ConfigLoader.load(); config.init(data); // Await the theme web fonts (mirrors js/main.js) so canvas text draws with // Ethnocentric/Centauri, not the fallback stack. Hard timeout: boot never // hangs on a flaky font. { const fonts = globalThis.document?.fonts; if (fonts?.load) { const fams = ['Ethnocentric', 'Centauri']; await Promise.race([ Promise.allSettled(fams.map((f) => fonts.load(`16px "${f}"`).catch(() => {}))), new Promise((r) => setTimeout(r, 2000)), ]); } } const gameConfig = createGameConfig(); gameConfig.scene = [GameScene, SurfaceScene]; const game = new Phaser.Game(gameConfig); window.game = game; const bounds = (o) => { if (!o) return null; const wb = o.worldBounds ?? (o.getBounds && o.getBounds()); if (wb && Number.isFinite(wb.x)) { return { x: Math.round(wb.x), y: Math.round(wb.y), w: Math.round(wb.width), h: Math.round(wb.height) }; } return { x: Math.round(o.x ?? 0), y: Math.round(o.y ?? 0) }; }; (async () => { let gs = null; for (let i = 0; i < 300; i++) { gs = game.scene?.getScene('GameScene') ?? null; if (gs && gs.ship) break; await sleep(100); } if (!gs || !gs.ship) throw new Error('GameScene never came up'); // Land on the home world + force the surface stage (skip the clip). const target = gs.commsTargetFor(gs.planet); gs.startLanding(target); await sleep(300); const ss = game.scene.getScene('SurfaceScene'); if (!ss) throw new Error('SurfaceScene never registered'); // Wait for the scene to finish loading (this build: RUNNING = 5) before // driving it. for (let i = 0; i < 120 && ss.sys.getStatus() !== 5; i++) await sleep(250); ss.startSurface(); for (let i = 0; i < 40 && ss.phase !== 'surface'; i++) await sleep(100); if (ss.phase !== 'surface') throw new Error('surface stage never reached (phase=' + ss.phase + ')'); await sleep(300); // Give the hold a couple of goods so the SELL grid shows filled bays. const goods = config.get('goods.goods', []); gs.ship.cargo.add([goods[0].id, goods[1].id]); // Open the shop the real way; if the shop clip is missing in this // environment (headless video), build the view directly (same UI path). ss.toggleShop(); await sleep(200); if (!ss.shopView) ss.buildShopView(); const sv = ss.shopView; if (!sv) throw new Error('no shopView'); // Click "Buy/Sell" (reveal the trade panel) and let the slide-in settle. sv.setTab('trade'); await sleep(900); // ---- HOVER TOOLTIP PROBE (name + price on hover, not over the icon) ---- const round = (v) => Math.round(v); const worldTopLeft = (o) => o?.getTopLeft ? (() => { const v = o.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; })() : null; const tipState = (grid) => ({ visible: !!grid.tipVisible, tipAlpha: grid.tip?.alpha ?? null, name: grid.tipName?.text ?? null, price: grid.tipPrice?.text ?? null, nameFont: grid.tipName?.style?.fontFamily ?? grid.tipName?._style?.fontFamily ?? null, tipTopLeft: worldTopLeft(grid.tipName), tipPlateTopLeft: worldTopLeft(grid.tipPlate), tipPos: grid.tip ? { x: round(grid.tip.x), y: round(grid.tip.y) } : null, }); const cellWorldCenter = (grid, i) => { const { x, y } = grid._cellXY(i); const c = grid.children?.list?.find?.((o) => o === grid.plate) ?? null; // grid local (x,y) → world: the grid container IS a pure translation (dev/cbox-test.html) return { x: round(grid.x + x), y: round(grid.y + y) }; }; const toClient = (wx, wy) => { const r = game.canvas.getBoundingClientRect(); return { cx: r.left + (wx / 1280) * r.width, cy: r.top + (wy / 720) * r.height }; }; const mouseMove = (wx, wy) => { const { cx, cy } = toClient(wx, wy); // This Giedi build's input listens for classic mouse events on the canvas // (mousedown/up/move + mouseover/out) — NOT pointer events — so hover is // driven with a real DOM mousemove (bubbles: true). const el = game.canvas; el.dispatchEvent(new MouseEvent('mousemove', { clientX: cx, clientY: cy, bubbles: true, view: window })); return { cx: round(cx), cy: round(cy) }; }; const out = { pass: true, tooltip: null }; // 1) structure: the old per-cell name/price text is GONE from usable cells const sellCell = sv.sellGrid.cells[0]; const struct = { sellCellName: sellCell.name === undefined ? 'undefined' : 'present', sellCellPrice: sellCell.price === undefined ? 'undefined' : 'present', sellLabel: { text: sellCell.label?.text ?? null, alpha: sellCell.label?.alpha ?? null }, sellLockedLabel: { text: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.text ?? null, alpha: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.alpha ?? null }, sellIcon: { alpha: sellCell.icon?.alpha ?? null, scale: round((sellCell.icon?.scaleX ?? 0) * 100) / 100, pos: { x: sellCell.icon?.x, y: sellCell.icon?.y } }, titleFont: sv.sellGrid.title?.style?.fontFamily ?? sv.sellGrid.title?._style?.fontFamily ?? null, buyQtyFont: sv.buyList.rows[0]?.objs?.qty?.style?.fontFamily ?? sv.buyList.rows[0]?.objs?.qty?._style?.fontFamily ?? null, }; out.tooltip = { struct }; // 2) programmatic hover on a filled SELL bay (row 0 → tip flips BELOW) const tipSellA = sv.sellGrid.hoverCell(0); out.tooltip.sellHover = { ...tipState(sv.sellGrid), expectedName: goods[0].name, cell: cellWorldCenter(sv.sellGrid, 0) }; // 3) BUY side: the WIDE ROW carries name + description + price INLINE (no // tooltip — the row is wide enough). const row0 = sv.buyList.rows[0]; const rowNameEl = row0?.objs?.name; out.tooltip.buyRow = { name: rowNameEl?.text ?? null, desc: row0?.objs?.desc?.text ?? null, price: row0?.objs?.price?.text ?? null, qty: row0?.objs?.qty?.text ?? null, nameFont: rowNameEl?.style?.fontFamily ?? rowNameEl?._style?.fontFamily ?? null, rowWorld: rowNameEl ? { x: round(sv.buyList.x + rowNameEl.x), y: round(sv.buyList.y + rowNameEl.y) } : null, }; // 4) move the pointer OFF the panel → pointerout hides the tip const off = toClient(Math.max(4, sv.buyList.x - 30), 360); game.canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: off.cx, clientY: off.cy, bubbles: true, view: window })); await sleep(150); out.tooltip.mouseOff = { state: tipState(sv.sellGrid) }; // 6) end with the SELL tooltip visible for the screenshot sv.sellGrid.hoverCell(0); out.tooltip.finalSell = tipState(sv.sellGrid); // ---- BUY ROW BAR PROBE (the semi-transparent plates behind each row) --- // The bar must sit BEHIND its row: the bg object is positioned at the row // CENTER, and its fillRect is drawn around the object's OWN local origin. // (A double offset — bg moved to the row center AND filled at the row's // absolute coords — used to fling the bars right + down with stretched // spacing. Capture the real fillRect call to verify both halves.) const bl = sv.buyList; const barRows = []; for (const r of bl.rows.slice(0, 3)) { const calls = []; const orig = r.objs.bg?.fillRect; if (orig) r.objs.bg.fillRect = (...a) => { calls.push(a.map(Math.round)); return orig.apply(r.objs.bg, a); }; bl._renderRows(); if (orig) r.objs.bg.fillRect = orig; barRows.push({ id: r.id, bgCenter: { x: round(r.objs.bg?.x ?? -1), y: round(r.objs.bg?.y ?? -1) }, rowCenter: { x: round(bl.vpX + bl.vpW / 2), y: round(r.y0 - bl._scroll) }, fillRect: calls[0] ?? null, }); } out.buyRowBars = { vpW: bl.vpW, rowH: bl.rowH, rows: barRows, ok: barRows.every((b) => b.bgCenter.x === b.rowCenter.x && b.bgCenter.y === b.rowCenter.y && b.fillRect?.length === 4 && b.fillRect[0] === -Math.round(bl.vpW / 2) && b.fillRect[1] === -Math.round(bl.rowH / 2) && b.fillRect[2] === bl.vpW && b.fillRect[3] === bl.rowH), }; out.pass = out.pass && out.buyRowBars.ok; // ---- BUY STEPPER HITBOX PROBE (− / qty / + taps) ----------------------- // This build calls hitAreaCallback(hitArea, localX, localY, obj) with // localX/Y in the object's TOP-LEFT-anchored frame (the input adds // displayOrigin — see pointWithinHitArea/getLocalPoint in the phaser // build), so the glyph spans (0,0)-(w,h) there regardless of origin and // the tap square must be centered on the glyph (w/2, h/2). A square // centered on (0,0) sits upper-left of the visible button. Verify // end-to-end with REAL canvas mouse events: // - a click ON a visible glyph center must register (plus +1, minus −1); // - a click just outside the new square, still inside the span of the // old (mis-anchored) square, must NOT register. const r0 = bl.rows[0]; const stepProbe = (el) => ({ origin: { x: el?.originX ?? null, y: el?.originY ?? null }, size: { w: round(el?.width ?? 0), h: round(el?.height ?? 0) }, }); const stepNames = ['minus', 'qty', 'plus']; const stepChecks = {}; for (const k of stepNames) stepChecks[k] = stepProbe(r0.objs[k]); const clickCanvas = (wx, wy) => { const c = game.canvas; const r = c.getBoundingClientRect(); const cx = r.left + (wx / 1280) * r.width; const cy = r.top + (wy / 720) * r.height; const opts = { clientX: cx, clientY: cy, bubbles: true, view: window, button: 0 }; c.dispatchEvent(new MouseEvent('mousedown', opts)); c.dispatchEvent(new MouseEvent('mouseup', opts)); }; const plusEl = r0.objs.plus; const minusEl = r0.objs.minus; const qtyEl = r0.objs.qty; const pCx = bl.x + plusEl.x; // glyph center in world/canvas coords (origin 0.5) const pCy = bl.y + plusEl.y; const mCx = bl.x + minusEl.x; const PAD = 6; // must match _bindStep's tap padding bl.buyQty.delete(r0.id); bl._renderRows(); await sleep(80); const qStart = bl.buyQty.get(r0.id) ?? 0; clickCanvas(pCx, pCy); // PLUS: on the visible glyph await sleep(120); const qAfterPlus = bl.buyQty.get(r0.id) ?? 0; // Discriminator for the OLD (mis-anchored) box: the old plus box spanned // [pCx-16, pCx+4] (anchored on the glyph top-left corner). The NEW boxes // (glyph-centered) leave a small GAP between qty and plus — intersect it // with the old box and pick the middle: hits under the old code, misses // under the new. const spacing = Math.round(plusEl.x - qtyEl.x); const qtyWNow = qtyEl.width; const gapL = pCx - spacing + qtyWNow / 2 + PAD; // qty box right edge const gapR = pCx - plusEl.width / 2 - PAD; // plus box left edge const L = Math.max(gapL, pCx - 16); const R = Math.min(gapR, pCx + 4); const missX = Math.round((L + R) / 2); const miss = { missX, window: [Math.round(gapL), Math.round(gapR)], gapPx: Math.round((gapR - gapL) * 10) / 10, insideOldPlusBox: missX >= pCx - 16 && missX <= pCx + 4 }; clickCanvas(missX, pCy); await sleep(120); const qAfterMiss = bl.buyQty.get(r0.id) ?? 0; clickCanvas(mCx, pCy); // MINUS: on the visible glyph await sleep(120); const qAfterMinus = bl.buyQty.get(r0.id) ?? 0; out.buySteppers = { rows: stepChecks, clicks: { plusOnGlyph: qAfterPlus - qStart, outsideBox: qAfterMiss - qAfterPlus, minusOnGlyph: qAfterMinus - qAfterMiss, }, miss, ok: stepNames.every((k) => stepChecks[k].origin?.x === 0.5 && stepChecks[k].origin?.y === 0.5) && gapR > gapL && (qAfterPlus - qStart) === 1 && (qAfterMiss - qAfterPlus) === 0 && (qAfterMinus - qAfterMiss) === -1, }; out.pass = out.pass && out.buySteppers.ok; out.screen = { w: ss.scale.width, h: ss.scale.height }; out.topY = sv.topY ?? null; out.hudName = (() => { const t = ss.hudName; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; } return null; })(); out.sellGrid = { x: round(sv.sellGrid.x), y: round(sv.sellGrid.y), w: sv.sellGrid.width, h: sv.sellGrid.height }; out.buyList = { x: round(sv.buyList.x), y: round(sv.buyList.y), w: sv.buyList.width, h: sv.buyList.height, rows: sv.buyList.rows.length, maxScroll: sv.buyList.maxScroll }; window.__SHOP_SHOT__ = out; console.info('[shop-shot] OK:', JSON.stringify(out)); })().catch((e) => fail(String(e?.message || e)));