615 lines
30 KiB
JavaScript
615 lines
30 KiB
JavaScript
/**
|
||
* 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)));
|