orbit/dev/shop-shot.mjs

615 lines
30 KiB
JavaScript
Raw Permalink 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');
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// 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;
// NEW SEMANTICS (the trade scout): that gap click is on the row's BOX
// (outside the stepper) — the stepper stays put (the qty delta above is
// still 0), but the tap is a ROW TAP: the scout opens on this row.
const missScoutOpen = sv._scoutOpen === r0.id;
const missFocus = bl._focusId === r0.id;
sv._closeScout?.(); // hand the scout state to the dedicated section below
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,
rowTapOnMiss: { scoutOpen: missScoutOpen, focus: missFocus, ok: missScoutOpen && missFocus },
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
&& missScoutOpen && missFocus,
};
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;
// ---- SUB-BAR PROBE (full deck span + right-side actions/NET, no overlap) ----
// The ask: the sub-bar's LEFT edge aligns with the main action bar's left
// edge, its RIGHT edge with the action bar's right edge, and the credit
// readout (NET) + Confirm/Clear sit on the RIGHT — without overlapping the
// tabs (left) or each other. Add a few items to the purchasable section
// (BUY rows) and verify the NET math updates while the layout holds.
const ab = ss.actionBar;
const deck = ab?.rect ? { x: ab.rect.x, y: ab.rect.y, w: ab.rect.w, h: ab.rect.h } : null;
// A few items in the purchasable section (first three BUY rows).
const buys = [];
for (let i = 0; i < Math.min(3, bl.rows.length); i++) {
const id = bl.rows[i].id;
const qty = (i % 2) + 1; // 1, 2, 1
bl.buyQty.set(id, qty);
const price = round(bl.stock.find((s) => s.id === id)?.price ?? 0);
buys.push({ id, qty, price });
}
sv._syncPreview();
await sleep(80);
const expectedNet = -buys.reduce((s, b) => s + b.qty * b.price, 0);
const netText = sv._netValue.text ?? null;
const netNum = parseInt(String(netText).replace(/[^\-0-9]/g, ''), 10);
// World-space x ranges of the bar's items (bar local x + bar world x),
// honouring each object's originX (the NET value is right-anchored, 1).
const barCx = sv.subBar.x;
const barHalf = sv.subBarW / 2;
const barWx = (localX) => barCx + localX;
const span = (el) => {
const w = (el?.style?.width ?? el?.width) || 0;
const ox = (typeof el?.originX === 'number') ? el.originX : 0.5;
const cx = barWx(el.x);
return [round(cx - w * ox), round(cx + w * (1 - ox))];
};
const tabTrade = span(sv._tabTrade);
const tabBuy = span(sv._tabBuyback);
const clearSp = span(sv._clearBtn);
const confirmSp = span(sv._confirmBtn);
const netLabelSp = span(sv._netLabel);
const netValueSp = span(sv._netValue);
const barLeft = round(barCx - barHalf);
const barRight = round(barCx + barHalf);
const subBarOk = !!deck
&& Math.abs((deck.x + deck.w / 2) - barCx) < 1.5 // centred on the deck span
&& Math.abs(deck.w - sv.subBarW) < 1.5 // full deck width
&& tabTrade[0] >= barLeft + 8 // tabs inside the bar
&& tabBuy[1] < clearSp[0] // tabs │ Clear (no overlap)
&& clearSp[1] < confirmSp[0] // Clear │ Confirm
&& confirmSp[1] < netLabelSp[0] // Confirm │ NET label
&& netLabelSp[1] < netValueSp[0] // NET label │ live value
&& netLabelSp[1] <= barWx(sv._netValue.x) - (sv._netValueW || 0) + 1 // …and of the WIDEST value (reserved slot)
&& netValueSp[1] <= barRight - 8 // value inside the bar's end
&& netText !== null && netNum === expectedNet // the math is right
&& (sv._clearBtn.alpha ?? 0) > 0.9 && (sv._confirmBtn.alpha ?? 0) > 0.9; // revealed
out.subBar = {
deck,
bar: { cx: round(barCx), left: barLeft, right: barRight, w: round(sv.subBarW), h: sv.subBarH },
buys,
net: { text: netText, expected: expectedNet, valueColor: sv._netValue?.style?.color ?? sv._netValue?._style?.color ?? null },
spans: { tabTrade, tabBuyback: tabBuy, clear: clearSp, confirm: confirmSp, netLabel: netLabelSp, netValue: netValueSp },
alphas: { clear: sv._clearBtn.alpha, confirm: sv._confirmBtn.alpha, disabled: sv._confirmBtn.disabled },
ok: subBarOk,
};
out.pass = out.pass && out.subBar.ok;
// ---- TRADE SCOUT PROBE (tap a BUY row's box → the cargo grid swaps) ----
// The ask: tap a purchasable item's box (NOT the stepper) → a light stroke
// around that box + the SELL area replaced by the item's strategy report
// (what wants it, where it's grown, top-3 charted exits + profit). Tap the
// same box / anywhere else → the cargo grid returns. Verify with REAL canvas
// clicks (the same mousedown/up path the surface's tap logic listens to).
const scouted = bl.rows[0];
const rowY = round(scouted.y0 - bl._scroll);
const boxX = round(bl.x + bl.vpX + 40); // the box's left-center — clear of price + stepper
const boxY = bl.y + rowY;
const scoutOpenState = () => ({
openId: sv._scoutOpen,
scoutAlpha: sv._scout?.alpha ?? null,
sellAlpha: sv.sellGrid?.alpha ?? null,
focusId: bl._focusId,
data: sv._scout?.report ? {
id: sv._scout.report.id,
localBuy: sv._scout.report.localBuy,
demand: (sv._scout.report.demand ?? []).map((c) => c.label),
producer: (sv._scout.report.producer ?? []).map((c) => c.label),
exits: (sv._scout.report.exits ?? []).map((e) => ({
sys: e.systemName, cls: e.clsLabel, price: e.price, profit: e.profit, hops: e.hops,
})),
} : null,
});
const scoutChecks = {};
// DIAGNOSTIC: log every open/close call (dev probe only).
const diag = [];
const origOpen = sv._openScout.bind(sv);
const origClose = sv._closeScout.bind(sv);
sv._openScout = (id, p) => { diag.push(['open', id]); return origOpen(id, p); };
sv._closeScout = () => { diag.push(['close']); return origClose(); };
// Deeper: log every row-tap hook call + every _tapUp with the tap state.
const origHook = bl.onRowTap;
bl.onRowTap = (row) => { diag.push(['hook', row ? row.id : null]); return origHook(row); };
const origTapUp = bl._tapUp.bind(bl);
bl._tapUp = () => { diag.push(['tapUp', JSON.stringify(bl._tap), bl._focusId]); return origTapUp(); };
// (a) TAP THE BOX → opens on that good; cargo grid steps aside; row glows.
// Normalize the pre-state first: the stepper/dead-click probes' "gap" and
// "clipped glyph" clicks land on the row BOX (not a stepper) — under the
// trade-scout semantics those are ROW TAPS, so the scout may already be
// toggled. Close it so (a) starts from a known-closed state.
sv._closeScout?.();
clickCanvas(boxX, boxY);
await sleep(300); // the 140ms fade-in settles
scoutChecks.diag = diag.slice();
scoutChecks.open = scoutOpenState();
const d0 = sv._scout?.report;
scoutChecks.openOk = !!d0
&& sv._scoutOpen === scouted.id
&& (sv._scout.alpha ?? 0) > 0.99
&& (sv.sellGrid.alpha ?? 1) < 0.01
&& bl._focusId === scouted.id
&& d0.id === scouted.id
&& d0.localBuy === scouted.price
&& Array.isArray(d0.exits) && d0.exits.length <= 3
&& d0.exits.every((e) => e.profit === e.price - d0.localBuy)
&& d0.exits.every((e, i) => i === 0 || d0.exits[i - 1].price >= e.price)
&& d0.exits.some((e) => e.systemId === (ss.gameScene?.systemRecord?.id ?? sv.systemId));
// (b) TAP THE SAME BOX AGAIN → closes, cargo grid restored.
clickCanvas(boxX, boxY);
await sleep(300);
scoutChecks.closeOk = sv._scoutOpen === null
&& (sv._scout.alpha ?? 1) < 0.01
&& (sv.sellGrid.alpha ?? 0) > 0.99
&& bl._focusId === null;
// (c) TAP A DIFFERENT BOX → the report swaps.
const otherRow = bl.rows[1] ?? bl.rows[0];
clickCanvas(round(bl.x + bl.vpX + 40), bl.y + round(otherRow.y0 - bl._scroll));
await sleep(300);
scoutChecks.swapOk = sv._scoutOpen === otherRow.id
&& sv._scout?.report?.id === otherRow.id
&& (sv.sellGrid.alpha ?? 1) < 0.01;
// (d) TAP THE SCOUT ITSELF → closes ("anywhere else").
clickCanvas(round(sv._scout.x + sv._scout.width / 2), round(sv._scout.y + sv._scout.height / 2));
await sleep(300);
scoutChecks.selfCloseOk = sv._scoutOpen === null && (sv.sellGrid.alpha ?? 0) > 0.99 && bl._focusId === null;
out.tradeScout = {
box: { x: boxX, y: boxY },
diag,
open: scoutChecks.open,
swap: { row: otherRow.id, id: sv._scout?.report?.id ?? sv._scoutOpen },
checks: { openOk: scoutChecks.openOk, closeOk: scoutChecks.closeOk, swapOk: scoutChecks.swapOk, selfCloseOk: scoutChecks.selfCloseOk },
ok: scoutChecks.openOk && scoutChecks.closeOk && scoutChecks.swapOk && scoutChecks.selfCloseOk,
};
out.pass = out.pass && out.tradeScout.ok;
// ---- TOGGLE PROBE (BUY/SELL press = open; AGAIN = close the panel) ----
// Real button-handler path (_onTabTap — the MenuButton onClick). The sub-
// bar probe left 3 BUY rows selected (1,2,1): closing the panel must SAVE
// that selection (NET + Confirm stay live), re-opening must RESTORE it,
// Clear (closed) must cancel it, and Confirm must work with the panel
// closed. (The SELL side is exercised by the Confirm step below.)
const tog = {};
const savedB = {};
for (const [id, q] of bl.buyQty) savedB[id] = q;
tog.startTab = sv.tab;
// (1) BUY/SELL again → the panel CLOSES; the selection is saved.
sv._onTabTap('trade');
await sleep(120);
tog.closedTab = sv.tab;
tog.closedGrid = sv.sellGrid === null;
tog.closedList = sv.buyList === null;
tog.saved = { sell: sv._pendingSell, buy: sv._pendingBuy };
tog.netClosed = sv._netValue?.text ?? null;
tog.confirmAlphaClosed = sv._confirmBtn?.alpha ?? null;
tog.clearAlphaClosed = sv._clearBtn?.alpha ?? null;
// (2) BUY/SELL → the panel re-OPENS with the selection RESTORED.
sv._onTabTap('trade');
await sleep(200);
const bl2 = sv.buyList;
tog.reopenTab = sv.tab;
const restored = {};
for (const [id, q] of Object.entries(savedB)) restored[id] = bl2?.buyQty?.get(id) ?? 0;
tog.restored = restored;
// (3) Close again, then Clear (closed) → the saved selection is CANCELLED.
sv._onTabTap('trade');
await sleep(120);
sv.clear();
tog.afterClearNet = sv._netValue?.text ?? null;
tog.afterClearConfirmAlpha = sv._confirmBtn?.alpha ?? null;
// (4) A SELL, closed panel, Confirm from the sub-bar (trade lands).
sv._onTabTap('trade');
await sleep(200);
sv.sellGrid?.toggleBay?.(0);
sv._onTabTap('trade');
await sleep(120);
const creditsBefore = gs.ship.credits;
tog.confirmClosed = sv.confirm();
tog.creditsDelta = gs.ship.credits - creditsBefore;
tog.netAfterConfirm = sv._netValue?.text ?? null;
tog.buybackEnabled = sv.isBuybackEnabled();
// (5) Re-open → FRESH selection (the confirmed trade is gone).
sv._onTabTap('trade');
await sleep(200);
tog.freshSell = sv.sellGrid?.getSellMap?.() ?? {};
tog.freshBuy = sv.buyList?.getBuyMap?.() ?? {};
const savedBuyOk = tog.saved?.buy && Object.entries(savedB).every(([id, q]) => tog.saved.buy[id] === q);
const restoredOk = bl2 && Object.entries(savedB).every(([id, q]) => restored[id] === q);
tog.ok = tog.startTab === 'trade'
&& tog.closedTab === null && tog.closedGrid && tog.closedList
&& savedBuyOk
&& tog.netClosed === netText && tog.netClosed !== '0 cr' // NET kept the delta
&& (tog.confirmAlphaClosed ?? 0) > 0.9 && (tog.clearAlphaClosed ?? 0) > 0.9
&& tog.reopenTab === 'trade' && restoredOk
&& tog.afterClearNet === '0 cr' && (tog.afterClearConfirmAlpha ?? 1) < 0.1
&& tog.confirmClosed === true && tog.creditsDelta > 0
&& tog.netAfterConfirm === '0 cr' && tog.buybackEnabled === true
&& Object.keys(tog.freshSell).length === 0
&& Object.keys(tog.freshBuy).length === 0;
out.toggle = { ...tog, savedB };
out.pass = out.pass && out.toggle.ok;
// Leave the scout OPEN on a good with charted exits (for the screenshot).
for (const r of bl.rows) {
clickCanvas(round(bl.x + bl.vpX + 40), bl.y + round(r.y0 - bl._scroll));
await sleep(280);
if (sv._scoutOpen === r.id && (sv._scout.report?.exits?.length ?? 0) > 0) break;
clickCanvas(round(bl.x + bl.vpX + 40), bl.y + round(r.y0 - bl._scroll)); // close again
await sleep(280);
}
out.scoutShot = { openId: sv._scoutOpen, exits: sv._scout?.report?.exits?.length ?? 0 };
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)));