orbit/dev/shop-shot.mjs

376 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
// ---- BUY LIST CLIP + SCROLLBAR PROBE (rows stay INSIDE the window) ----
// The reported bug: rows rendered OUTSIDE the panel bounds — spilling over
// the BUY title (top) and onto the Confirm sub-bar (below). Verify:
// (a) the bottom edge row at scroll 0 is a SLICE — shown, but every one
// of its elements clipped OUT (alpha 0, would ghost onto the bar);
// (b) invariant: every element that IS rendered (alpha 1) of every row
// fits WHOLLY inside the viewport, at several scroll positions;
// (c) the edge row's clipped-out stepper is UNCLICKABLE (hidden buttons
// stay dead);
// (d) the scrollbar track sits in the right pad inside the panel, and
// the thumb tracks the scroll (top at 0, lower when scrolled).
const clipSnapshot = () => bl.rows.map((row) => {
const y = row.y0 - bl._scroll;
const els = ['icon', 'name', 'desc', 'price', 'minus', 'qty', 'plus'];
const alphas = {};
let allIn = true;
for (const k of els) {
const el = row.objs[k];
if (!el) continue;
alphas[k] = el.alpha;
if (el.alpha === 1) {
const h = (Number(el.height) > 0 ? el.height : 20) * (el.scaleY ?? 1) / 2; // icons: displayed size
if (!(el.y - h >= bl.vpY - 0.5 && el.y + h <= bl.vpY + bl.vpH + 0.5)) allIn = false;
}
}
return {
id: row.id, rowTop: round(y - bl.rowH / 2), rowBot: round(y + bl.rowH / 2),
shown: !!row.shown, clipTop: round(row.clipTop), clipBot: round(row.clipBot),
alphas, allIn,
};
});
const sbGeom = bl._sbGeom ? bl._sbGeom() : null;
const thumbAt = () => bl._sbThumb ? { x: round(bl._sbThumb.x), y: round(bl._sbThumb.y), w: round(bl._sbThumb.w), h: round(bl._sbThumb.h) } : null;
bl.scrollTo(0);
await sleep(120);
const atTop = clipSnapshot();
const thumbTop = thumbAt();
// The edge row: its body crosses the viewport bottom at scroll 0.
const edgeRowObj = bl.rows.find((r) => r.shown && (r.y0 - bl._scroll + bl.rowH / 2) > bl.vpY + bl.vpH + 0.5 && (r.y0 - bl._scroll - bl.rowH / 2) < bl.vpY + bl.vpH + 0.5);
const edge = atTop.find((r) => r.id === edgeRowObj?.id) ?? null;
// (c) the clipped-out stepper of the edge row must be DEAD (only when that
// geometry actually clips the stepper out — otherwise it stays clickable).
let deadClick = null;
if (edgeRowObj) {
const el = edgeRowObj.objs.plus;
const plusClipped = (edge?.alphas?.plus ?? el.alpha) === 0;
if (plusClipped) {
const before = bl.buyQty.get(edgeRowObj.id) ?? 0;
clickCanvas(bl.x + el.x, bl.y + el.y);
await sleep(120);
const after = bl.buyQty.get(edgeRowObj.id) ?? 0;
deadClick = { alpha: el.alpha, before, after, registered: after - before };
bl.buyQty.delete(edgeRowObj.id);
bl._renderRows();
} else {
deadClick = { alpha: el.alpha, skipped: 'plus is visible in this geometry' };
}
}
// (b)+(d) mid-scroll: invariant still holds, thumb moved down.
bl.scrollTo(Math.min(80, bl.maxScroll));
await sleep(120);
const atMid = clipSnapshot();
const thumbMid = thumbAt();
bl.scrollTo(bl.maxScroll);
await sleep(120);
const thumbBot = thumbAt();
bl.scrollTo(0);
await sleep(120);
const outRowsOk = atTop.every((r) => r.allIn) && atMid.every((r) => r.allIn);
const edgeOk = !!edge
&& edge.clipBot === round(bl.vpY + bl.vpH)
&& edge.alphas.plus === 0; // the edge row's stepper is clipped out here
const sbOk = !!sbGeom
&& sbGeom.x >= bl.vpX + bl.vpW
&& sbGeom.x + sbGeom.w <= bl.width
&& !!thumbTop && Math.abs(thumbTop.y - sbGeom.y) < 1
&& thumbTop.h < sbGeom.h
&& !!thumbMid && thumbMid.y > thumbTop.y
&& !!thumbBot && thumbBot.y > thumbMid.y;
out.buyClip = {
vp: { x: bl.vpX, y: bl.vpY, w: bl.vpW, h: bl.vpH, bot: bl.vpY + bl.vpH },
panel: { h: bl.width && bl.height, w: bl.width },
edgeRow: edge ? { id: edge.id, rowTop: edge.rowTop, rowBot: edge.rowBot, clipTop: edge.clipTop, clipBot: edge.clipBot } : null,
deadClick,
sb: sbGeom ? { x: round(sbGeom.x), y: round(sbGeom.y), w: round(sbGeom.w), h: round(sbGeom.h) } : null,
thumb: { top: thumbTop, mid: thumbMid, bottom: thumbBot },
rowsAtTop: atTop.map((r) => ({ id: r.id, shown: r.shown, clip: [r.clipTop, r.clipBot], alphas: r.alphas, allIn: r.allIn })),
ok: outRowsOk && edgeOk && sbOk && (!deadClick || deadClick.skipped || deadClick.registered === 0),
};
out.pass = out.pass && out.buyClip.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)));