orbit/dev/shop-shot.mjs

285 lines
13 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;
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)));