orbit/dev/shop-ui.test.mjs

1149 lines
63 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.

/**
* Shop UI test (dev tool, run with Node — no browser needed):
*
* node dev/shop-ui.test.mjs
*
* The SHOP SCREEN's economic core (js/ui/shop/ShopView.js + TradeGrid.js),
* driven headlessly against the real config + a real galaxy + a real
* CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
* verified in the browser) — it tests the LOGIC the player feels:
*
* - CONSTRUCT — the view builds the SELL grid + the BUY list over a real
* shop (16 sell bays; one wide row per stocked good) and starts on the
* Buy/Sell tab.
* - SELL — checking a cargo bay sells one unit at the shop's sell price;
* Confirm earns the credits and drops the good out of the hold.
* - BUY — stepping a stock row buys that good at the shop's buy price;
* Confirm spends the credits and fills the bays.
* - SCROLL — the stock list outgrows the viewport: scroll clamps at both
* ends, rows are CLIPPED to the window (plate slice at the edge, text
* only while fully inside — nothing may render over the title or onto
* the Confirm bar), and a right-edge SCROLLBAR (track + thumb) tracks
* the scroll position; selection is data (stepping an off-screen row
* still works).
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
* credits) or overflow the hold (cargo-full); selling to free room for a
* buy lands in one confirm.
* - BUYBACK — disabled until a sale; after a sale the goods you sold can be
* bought back at the SALE price (a neutral undo — sell→buyback nets zero,
* no infinite-credits loop); Clear resets the selection.
* - QUEST CARGO — the 'Special Delivery' package (tradeable:false) is NOT
* FOR SALE anywhere except the home world, where it sells for ZERO
* credits and the sale itself stands the delivery (the seam fires with
* the world's name; the parcel is not buyback-eligible).
*
* A fake Scene provides add.text/image/graphics/tweens/etc.; the phaser stub
* (dev/phaser-stub.mjs) provides the Container the components extend.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only)
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
const { CargoHold } = await import(pathToFileURL(join(__dirname, '../js/economy/CargoHold.js')).href);
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { ShopView } = await import(pathToFileURL(join(__dirname, '../js/ui/shop/ShopView.js')).href);
const { sellsHere } = await import(pathToFileURL(join(__dirname, '../js/economy/TradeScout.js')).href);
const Stock = await import(pathToFileURL(join(__dirname, '../js/economy/Stock.js')).href);
const goods = config.get('goods.goods', []);
assert.ok(goods.length >= 2, 'catalog has goods to trade');
// ---------------------------------------------------------------------------
// A headless Scene: just enough for the components to CONSTRUCT + render to
// no-op stubs. The LOGIC under test lives in the components + Economy, not
// here — this only keeps `scene.add.text/image/graphics/...` from throwing.
// ---------------------------------------------------------------------------
function stubObject(extra = {}) {
return {
setOrigin() { return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setStrokeStyle() { return this; },
setSize(w, h) { this.width = w; this.height = h; return this; },
setBlendMode() { return this; },
setInteractive(opts) { this._interactive = opts; return this; },
setTexture(key, frame) { this._textureKey = key; this.frame = frame; return this; },
on() { return this; },
once() { return this; },
off() { return this; },
emit() { return this; },
setText(s) { this.text = String(s); return this; },
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
setWordWrap() { return this; },
setWordWrapWidth(w) { this._wrapW = w; return this; },
setLineSpacing() { return this; },
setPadding() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
set() { return this; },
clear() { return this; },
fillStyle() { return this; },
lineStyle() { return this; },
fillPoints() { return this; },
strokePoints() { return this; },
lineBetween() { return this; },
rect() { return this; },
fillRect() { return this; },
lineRect() { return this; },
fill() { return this; },
stroke() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
}
function makeScene() {
const scene = {
scale: { width: 1280, height: 720, on() { return this; } },
add: {
text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style, width: 60, height: 18 }),
image: (x, y, key, frame) => stubObject({ x, y, key, frame }),
graphics: () => stubObject({}),
rectangle: (x, y, w, h, key, alpha) => stubObject({ x, y, width: w, height: h, key, alpha }),
existing: (o) => o,
},
tweens: {
// Apply the target values immediately (so the grid ends at its final
// layout position — the real tween animates to the same place).
add: (t) => {
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
else if (t?.targets) apply(t.targets);
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: { now: 0, delayCall: () => ({ stop() {} }) },
input: { pointer: {}, on() { return this; }, off() { return this; } },
events: { on() { return this; }, off() { return this; }, once() { return this; }, emit() { return this; } },
textures: { exists: (k) => k === 'goods_icons' },
cameras: { main: { setScroll() { return this; }, flash() { return this; } } },
game: { loop: { now: 0 } },
};
scene.add.container = (x, y) => new Phaser.GameObjects.Container(scene, x, y);
return scene;
}
// A minimal ship: the real CargoHold + the credit ledger the ShopView drives.
function makeShip(credits = 0) {
const cargo = new CargoHold(4);
return {
credits,
cargo,
addCredits(n) { this.credits = Math.max(0, this.credits + Math.round(Number(n) || 0)); return this; },
spendCredits(n) { n = Math.round(Number(n) || 0); if (n > this.credits) return false; this.credits -= n; return true; },
};
}
const galaxy = Galaxy.create('orbit-shop-ui');
const classesOf = (id) => {
const s = galaxy.economy.classBySystem.get(id);
return s ? [...s] : [];
};
const sysId = [...galaxy.byId.keys()].find((id) => classesOf(id).some((c) => c !== 'capital'));
const cls = classesOf(sysId).find((c) => c !== 'capital') ?? 'rocky';
const stock = E.shopFor(cls, sysId, galaxy).buy;
assert.ok(stock.length >= 1, 'the shop has goods to buy');
const A = goods[0].id;
const B = goods[1].id;
const C = stock[0].id; // the world's ONE good (model v2: its signature, batch-limited)
// HUB fixtures — model v2: a plain world sells exactly ONE good (its
// signature), so the multi-row tests (SCROLL, the scout's row-swap) run on
// a station, whose floor is the hub-pool selection + its own signature.
const hubSys = [...galaxy.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const hubCls = 'station';
const hubStock = E.shopFor(hubCls, hubSys, galaxy).buy;
assert.ok(hubStock.length >= 8, `the hub floor carries the hub-pool selection (${hubStock.length} rows, ≥ 8)`);
function makeView(ship, { reveal = true, topY = 120, tradeUnlocked = true, worldName = 'Somewhere', homeWorld = null, soldSpy = null, sysId: SYS = sysId, cls: CLS = cls } = {}) {
const scene = makeScene();
// gameScene carries the HUD refresh seam + the TRADE gate
// (GameScene.isTradeUnlocked — the 'Special Delivery' capital landing).
const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; }, isTradeUnlocked: () => tradeUnlocked };
if (homeWorld !== null) {
// The delivery gate + seam (GameScene.isHomeWorld / onQuestPackageSold
// — the 'Special Delivery' step 3: the parcel's home-world sale).
gs.isHomeWorld = (n) => String(n ?? '') === homeWorld;
gs.onQuestPackageSold = (n) => { soldSpy?.push(n); return true; };
}
const view = new ShopView(scene, {
ship, galaxy, cls: CLS, systemId: SYS, gameScene: gs, onLeave() {},
// topY = the surface HUD's planet-text bottom + padding (what
// SurfaceScene.shopTopY() computes). The panel top should sit here.
topY,
// worldName = this WORLD (SurfaceScene.planetName — the delivery gate
// runs the home check against it; the default is no home, ever).
worldName,
});
if (reveal) view.setTab('trade'); // reveal the panel (what clicking "Buy/Sell" does)
return { scene, view, gs, topY };
}
// ===========================================================================
console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals the panel ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view, topY } = makeView(ship, { reveal: false });
// Entry state: shop video + tab bar only. No trade panel yet — the player
// must click "Buy/Sell" to reveal it (showcase the clip before the UI).
assert.equal(view.tab, null, 'starts unopened (entry state)');
assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)');
assert.equal(view.buyList, null, 'no BUY list yet');
assert.ok(view._tabTrade, 'the Buy/Sell tab is present in the entry state');
assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)');
assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale');
// The tabs are cyberpunk MenuButtons in a sub-bar that rests ABOVE the
// deck (the MenuSubBar recipe) — not the old top-of-screen custom buttons.
assert.equal(view._tabBuyback.disabled, true, 'Buyback tab is disabled (grayed) before a sale');
assert.equal(view._tabTrade.disabled, false, 'Buy/Sell tab is enabled (the entry point)');
assert.ok(view.subBar, 'the sub-bar container exists');
assert.ok(view.subBar.y > view._H() - 80, `sub-bar rests above the deck (bar.y=${view.subBar.y}, H=${view._H()})`);
assert.ok(view.y === 0, 'ShopView root stays at the screen origin (0,0) so the trade panel renders in screen space');
assert.ok(view.subBarW > 0 && view.subBarH > 0, 'sub-bar has a measured width + height');
// The trade ACTIONS live on the sub-bar too — [tabs] │ [Clear][Confirm] │
// [NET value] — created up front (persistent across tabs), hidden until a
// selection exists (_syncPreview reveals them), Confirm grayed while the
// trade is invalid.
assert.ok(view._clearBtn, 'Clear lives on the sub-bar (persistent, not in the panel)');
assert.ok(view._confirmBtn, 'Confirm lives on the sub-bar (persistent, not in the panel)');
assert.ok(view._clearBtn.x < view._confirmBtn.x, 'Clear sits left of Confirm on the bar');
assert.ok(view._tabBuyback.x < view._clearBtn.x, '… after the tabs');
assert.ok(view._confirmBtn.x < view._netLabel.x, '… and the NET readout rides to their right');
assert.equal(view._clearBtn.alpha, 0, 'entry state: Clear is hidden (no selection yet)');
assert.equal(view._confirmBtn.alpha, 0, 'entry state: Confirm is hidden (no selection yet)');
assert.equal(view._confirmBtn.disabled, true, 'entry state: Confirm is grayed');
// The bar spans the FULL DECK — the anchor's left/right edges (the main
// action bar's own span): tabs packed at the LEFT edge, the trade actions
// + NET readout packed at the RIGHT edge, the value right-aligned at the
// bar's end (a growing number never shifts the label or the buttons).
const pad = config.get('save.subBar.padding', 16);
assert.ok(view.subBarW > 0 && view.anchor.left < view.anchor.right, 'anchor carries the deck span (left < right)');
assert.equal(view.subBarW, view.anchor.right - view.anchor.left, 'sub-bar spans the full deck width (left edge → right edge)');
assert.equal(view.subBar.x, (view.anchor.left + view.anchor.right) / 2, 'sub-bar is centred on the deck span');
const half = view.subBarW / 2;
const tw = view._tabTrade.style.width;
assert.ok(Math.abs(view._tabTrade.x - (-half + pad + tw / 2)) < 1, `tabs pack at the bar's LEFT edge (tabTrade.x=${view._tabTrade.x}, want ${(-half + pad + tw / 2).toFixed(1)})`);
// The NET value is RIGHT-ANCHORED (origin 1, 0.5) at the bar's end: its
// anchor sits at barRight − pad and its width extends leftward, so a
// longer total never overflows the bar. The label stays clear of even the
// WIDEST value (the reserved slot, _netValueW), so nothing overlaps.
assert.ok(Math.abs(view._netValue.x - (half - pad)) < 1, `NET value right-anchored at the bar's RIGHT edge (netValue.x=${view._netValue.x}, want ${(half - pad).toFixed(1)})`);
assert.ok(view._netLabel.x + view._netLabel.width / 2 < view._netValue.x - view._netValueW + 0.001, 'NET label sits clear of the widest value (reserved slot)');
// No overlap, left to right: tabs │ Clear │ Confirm │ NET label.
const rightEdge = (el) => el.x + (el.style?.width ?? el.width) / 2;
const leftEdge = (el) => el.x - (el.style?.width ?? el.width) / 2;
assert.ok(rightEdge(view._tabBuyback) < leftEdge(view._clearBtn), 'BUYBACK tab does not overlap Clear');
assert.ok(rightEdge(view._clearBtn) < leftEdge(view._confirmBtn), 'Clear does not overlap Confirm');
assert.ok(rightEdge(view._confirmBtn) < leftEdge(view._netLabel), 'Confirm does not overlap the NET label');
// Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear.
view.setTab('trade');
assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab');
assert.ok(view.sellGrid && view.buyList, 'both the SELL grid and BUY list exist after reveal');
// The panel's TOP edge sits at topY (just under the planet text). A
// TradeGrid lays its plate/cells out in local (0,0)→(w,h) and a
// Container's (x,y) is a pure translation of that local space (verified
// in dev/cbox-test.html) — so the grid's position IS the panel's
// top-left corner: gridTop == grid.y. (Setting it to topY + h/2 treated
// the position like a centered origin and dropped the panel half a grid
// lower, clipping the bottom rows.)
const gridTop = view.sellGrid.y;
assert.ok(Math.abs(gridTop - topY) < 2, `panel top is at topY (gridTop=${gridTop.toFixed(1)}, topY=${topY})`);
assert.ok(view.sellGrid.y > 80 && view.sellGrid.y < view._H() - 40, `trade panel is on-screen (sellGrid.y=${view.sellGrid.y})`);
assert.ok(view.sellGrid.y + view.sellGrid.height <= view._H(), `panel bottom stays on-screen (${view.sellGrid.y} + ${view.sellGrid.height} <= ${view._H()})`);
assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays');
assert.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock');
assert.equal(view.buyList.rows.length, view.buyList.stock.length, 'BUY list has one row per stocked good');
assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet');
}
console.log('CONSTRUCT: OK');
// ===========================================================================
console.log('\n== TRADE GATE — Buy/Sell stays grayed + dead until GameScene.isTradeUnlocked ==');
{
// The gate CLOSED (pre-'Special Delivery' capital landing): the shop screen
// itself opens (the deck's Shop button is always live), but the Buy/Sell
// tab is grayed and refuses to reveal the trade panel.
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship, { reveal: false, tradeUnlocked: false });
assert.equal(view._tabTrade.disabled, true, 'gate closed: Buy/Sell tab is grayed');
assert.equal(view._tabBuyback.disabled, true, 'buyback stays grayed (no sale)');
view.setTab('trade');
assert.equal(view.tab, null, 'setTab(trade) is refused while the gate is closed');
assert.equal(view.sellGrid, null, 'no SELL grid while the gate is closed');
assert.equal(view.buyList, null, 'no BUY list while the gate is closed');
view._onTabTap('trade');
assert.equal(view.tab, null, 'the tab tap is swallowed while the gate is closed');
// The gate LIFTED (the capital-landing witness lands): the same view's tab
// re-arms on the next refresh and the panel reveals.
view.gameScene = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; }, isTradeUnlocked: () => true };
view._refreshTabs();
assert.equal(view._tabTrade.disabled, false, 'gate lifted: Buy/Sell tab re-arms');
view.setTab('trade');
assert.equal(view.tab, 'trade', 'gate lifted: Buy/Sell reveals the panel');
assert.ok(view.sellGrid && view.buyList, 'both panels exist after the gate lifts');
}
console.log('TRADE GATE: OK');
// ===========================================================================
console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCallback ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view } = makeView(ship);
const goodById = (id) => goods.find((g) => g.id === id);
// Each BUY row's icon must carry the correct spritesheet frame (set via
// setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render).
let checkedIcons = 0;
for (const row of view.buyList.rows) {
const idx = goodById(row.id)?.icon;
assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`);
assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`);
checkedIcons += 1;
}
assert.ok(checkedIcons >= 1, 'at least one buy row got its icon bound');
// Every tappable element must be interactive WITH a hitAreaCallback — a bare
// setInteractive() leaves no callback and crashes Phaser v4's input (the
// exact browser error). The stub now mirrors Phaser.Geom.Rectangle so the
// real hitArea/hitAreaCallback path is exercised.
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback');
assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect');
assert.equal(typeof view.buyList.rows[0].objs.minus?._interactive?.hitAreaCallback, 'function', 'buy row stepper tap has a hitAreaCallback');
assert.equal(typeof view.buyList.rows[0].objs.plus?._interactive?.hitAreaCallback, 'function', 'buy row + tap has a hitAreaCallback');
// The two panels must settle at DIFFERENT layout positions — the slide-in
// tween targets each grid's set position. The old bug tweened to x:0,
// stacking both panels at the left edge so the cargo/sell panel sat hidden
// behind the buy panel.
assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`);
assert.ok(view.buyList.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyList.x})`);
assert.notEqual(view.sellGrid.x, view.buyList.x, 'the panels do not overlap at the origin');
assert.ok(view.buyList.width > view.sellGrid.width, 'the BUY list is the wide panel (rows, not 4×4 cells)');
// The panel spans the screen like the HUD: the SELL grid's LEFT edge is
// the planet text's left edge (landing.hud.marginX), and the BUY list's
// RIGHT edge is the SAME padding in from the screen's right edge.
const leftPad = Number(config.get('landing.hud.marginX')) || 24;
const W = 1280; // the stub scene's width
assert.equal(view.sellGrid.x, leftPad, `SELL's left edge is flush with the planet text's left edge (x=${view.sellGrid.x}, hud margin=${leftPad})`);
const rightEdge = view.buyList.x + view.buyList.width;
assert.ok(Math.abs(rightEdge - (W - leftPad)) < 1, `BUY's right edge is the same padding in from the right (rightEdge=${rightEdge}, want ${W - leftPad})`);
// The two panels sit NEXT TO each other with just a bit of padding between
// (grid.centerW — the actions moved onto the sub-bar, so there's no center
// column anymore).
const gapPx = view.buyList.x - (view.sellGrid.x + view.sellGrid.width);
assert.ok(Math.abs(gapPx - 16) < 1, `BUY is right next to SELL, small padding (got gap ${gapPx.toFixed(1)} px, want ~16)`);
}
console.log('INTERACTION: OK');
// ===========================================================================
console.log('\n== SELECT VISUAL — checked bay: green stroke + tint + corner check; uncheck reverts ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
const grid = view.sellGrid;
const cell = grid.cells[0];
const slotCfg = config.get('shopui.slot', {});
const hexInt = (h) => {
const m = String(h).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : null;
};
const GREEN = hexInt(slotCfg.selected ?? '#7ce8a4');
const BG = hexInt(slotCfg.bgColor ?? '#0e1728');
const BORDER = hexInt(slotCfg.border ?? '#1d2e4c');
const SELBG = hexInt(slotCfg.selBgColor ?? BG);
assert.ok(GREEN != null && BG != null && SELBG != null, 'the slot config carries the selection colors');
assert.notEqual(SELBG, BG, 'the selected fill is a distinct (tinted) shade');
// Record the draw calls on the cell's plate + check graphics.
const wrap = (o) => {
const calls = { fill: [], line: [], between: [] };
if (!o) return calls;
if (typeof o.fillStyle === 'function') o.fillStyle = (...a) => { calls.fill.push(a); return o; };
if (typeof o.lineStyle === 'function') o.lineStyle = (...a) => { calls.line.push(a); return o; };
if (typeof o.lineBetween === 'function') o.lineBetween = (...a) => { calls.between.push(a); return o; };
return calls;
};
// CHECK the bay → GREEN stroke + tinted fill on the plate, and the
// corner check (green strokes in the upper-left) on the cell's sel graphics.
const plate = wrap(cell.plate);
const sel = wrap(cell.sel);
grid.toggleBay(0);
assert.deepEqual(grid.getSellMap(), { [A]: 1 }, 'bay 0 is checked (1 unit queued)');
assert.ok(plate.line.length >= 1, 'the plate was re-styled on the check');
assert.equal(plate.line[0][1], GREEN, `checked bay strokes the SELECTION GREEN (got 0x${(plate.line[0]?.[1] ?? 0).toString(16)}, want 0x${GREEN.toString(16)})`);
assert.ok(plate.line[0][0] >= 2, `… a heavier stroke than the idle border (width ${plate.line[0][0]})`);
assert.equal(plate.fill[0][0], SELBG, `… and the fill is the selected tint (got 0x${(plate.fill[0]?.[0] ?? 0).toString(16)}, want 0x${SELBG.toString(16)})`);
assert.ok(sel.between.length >= 2, `the corner check is drawn (≥ 2 strokes, got ${sel.between.length})`);
assert.ok(sel.line.some((l) => l[1] === GREEN), `… in the selection green (line styles: ${JSON.stringify(sel.line)})`);
// The check sits in the cell's UPPER-LEFT quadrant (up + left of centre).
const { x, y } = grid._cellXY(0);
for (const b of sel.between) {
assert.ok(b[0] < x && b[1] < y && b[2] < x && b[3] < y,
`check stroke (${b.map((v) => Math.round(v)).join(',')}) is up-left of the cell centre (${x},${y})`);
}
// UNCHECK the bay → the plate reverts to the idle border/fill and the
// check is cleared (nothing new drawn on the sel graphics).
const plate2 = wrap(cell.plate);
const sel2 = wrap(cell.sel);
grid.toggleBay(0);
assert.deepEqual(grid.getSellMap(), {}, 'bay 0 is checked off again');
assert.equal(plate2.line[0][1], BORDER, `unchecked bay strokes the IDLE border (got 0x${(plate2.line[0]?.[1] ?? 0).toString(16)}, want 0x${BORDER.toString(16)})`);
assert.equal(plate2.line[0][0], 1, `… the thin idle border again (width ${plate2.line[0][0]})`);
assert.equal(plate2.fill[0][0], BG, `… and the plain idle fill (got 0x${(plate2.fill[0]?.[0] ?? 0).toString(16)}, want 0x${BG.toString(16)})`);
assert.equal(sel2.between.length, 0, 'the corner check is gone (cleared, no new strokes)');
}
console.log('SELECT VISUAL: OK');
// ===========================================================================
console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view } = makeView(ship);
const nameA = goods.find((g) => g.id === A).name;
const sellA = E.sellPrice(A, cls, sysId, galaxy);
const buyId = view.buyList.stock[0]?.id;
const nameB = view.buyList.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
const buyB = E.buyPrice(buyId, cls, sysId, galaxy);
// Cells no longer paint their own name/price text — the info lives in the
// shared per-grid tooltip (one per side), revealed by hover.
assert.equal(view.sellGrid.cells[0].name, undefined, 'no name text over the icon anymore');
assert.equal(view.sellGrid.cells[0].price, undefined, 'no price text over the icon anymore');
assert.ok(view.sellGrid.tip, 'the SELL grid has a shared tooltip');
assert.equal(view.sellGrid.tipVisible, false, 'tooltip starts hidden');
// Hover a filled bay → name + "sell for N cr" appear.
view.sellGrid.hoverCell(0);
assert.equal(view.sellGrid.tipVisible, true, 'hovering a filled bay reveals the tooltip');
assert.ok(view.sellGrid.tip.alpha > 0.5, `tooltip is visible (alpha=${view.sellGrid.tip.alpha})`);
assert.equal(view.sellGrid.tipName.text, nameA, `tooltip shows the good's name (${nameA})`);
assert.equal(view.sellGrid.tipPrice.text, `sell for ${Math.round(sellA)} cr`, `tooltip shows the sell price (${sellA})`);
// Locked bays (past capacity) + empty bays have nothing to reveal.
const lockedIdx = view.sellGrid.capacity; // first locked bay
view.sellGrid.hoverCell(lockedIdx);
assert.equal(view.sellGrid.tipVisible, false, 'locked bay: no tooltip (nothing to sell)');
if (view.sellGrid.bays[2] == null) {
view.sellGrid.hoverCell(2);
assert.equal(view.sellGrid.tipVisible, false, 'empty bay: no tooltip (no good in it)');
}
// pointerout → hidden again.
view.sellGrid.hoverCell(0);
view.sellGrid.unhover();
assert.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip');
assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down');
// BUY side: no tooltip — the WIDE ROW carries the info inline. The
// TITLE LINE is name · inventory · price · stepper (one line, with
// padding between each); line 2 is the description.
const row0 = view.buyList.rows[0];
const nameText = row0.objs.name.text;
assert.ok(nameText === nameB || (nameText.endsWith('…') && nameText.startsWith(nameB.slice(0, 3))),
`row carries the good name on the title line (whole, or truncated to the title-line budget) — got "${nameText}" for "${nameB}"`);
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price on the title line');
const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? '';
assert.ok(row0.objs.desc.text.length >= 3, 'row carries the description');
assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)');
// TITLE-LINE PADDING (the layout ask): name ─ gap ─ inventory ─ gap ─
// price, all on the product-title line. stockGap pads both gaps; the
// name's budget (_nameMaxW) stops short of the inventory strip.
const bx = view.buyList._btnXs();
const nameW = view.buyList._nameMaxW(row0);
assert.ok(view.buyList._textX() + nameW + view.buyList.stockGap <= bx.left,
'padding between the product title and the inventory level');
assert.ok(bx.left + view.buyList.stockGap <= bx.price,
'padding between the inventory level and the price');
// DESCRIPTION (line 2 — its own line; the right block is on the title
// line above): smaller font, wraps to up to 2 lines, full row width.
assert.equal(row0.objs.desc._style?.fontSize, '11px', 'desc uses the smaller 11px font');
assert.ok(row0.objs.desc._wrapW > 0, 'desc has a word-wrap width set (real Phaser wraps inside the text column)');
const descMaxW = view.buyList._descMaxW();
assert.ok(descMaxW > 0, 'the desc line has a positive width');
assert.ok(row0.objs.desc._wrapW <= descMaxW + 0.001, `… and the wrap width fits inside it (wrap=${row0.objs.desc._wrapW}, col=${descMaxW.toFixed(1)})`);
const budget = Math.max(4, Math.floor(descMaxW / (11 * 0.55))) * 2 + 1; // 2 lines + ellipsis room
assert.ok(row0.objs.desc.text.length <= budget, `desc stays inside the two-line budget (len=${row0.objs.desc.text.length} ≤ ${budget})`);
assert.ok(view.buyList._textX() + descMaxW <= view.buyList.vpW / 2 - view.buyList.pad + 0.001,
'desc column fits the row (line 2 is its own line, the right block is above)');
// THEME FONTS — the shop UI speaks the project's type (data/theme.json):
// Ethnocentric for section headers, Centauri for body text + buttons.
const fam = (o) => o?._style?.fontFamily ?? '';
assert.ok(/Ethnocentric/.test(fam(view.sellGrid.title)), `SELL/BUY title uses the header face (got "${fam(view.sellGrid.title)}")`);
assert.ok(/Centauri/.test(fam(view.sellGrid.tipName)), `tooltip name uses the body face (got "${fam(view.sellGrid.tipName)}")`);
assert.ok(/Centauri/.test(fam(view.sellGrid.tipPrice)), 'tooltip price uses the body face');
assert.ok(/Centauri/.test(fam(view.buyList.rows[0].objs.qty)), 'buy row stepper uses the body face');
assert.ok(/Centauri/.test(fam(view.sellGrid.cells[view.sellGrid.capacity]?.label)), 'locked-bay label uses the body face');
}
console.log('HOVER TOOLTIP: OK');
// ===========================================================================
console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view, gs } = makeView(ship);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
assert.ok(sellA > 0, 'the sell price is positive');
view.sellGrid.toggleBay(0); // bay 0 holds A
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 1 }, 'checking bay 0 → sell one A');
let pv = view.preview();
assert.equal(pv.sellTotal, sellA, 'sellTotal = the A sale');
assert.equal(pv.net, sellA, '… net = the sale (nothing bought)');
assert.equal(pv.valid, true, '… and it\u2019s legal');
// The live NET readout rides on the TAB SUB-BAR (not the center column) —
// it mirrors the pending trade's credit delta, one glance from the tabs —
// alongside the Confirm/Clear actions (which light up with a selection).
assert.ok(view._netValue, 'the sub-bar carries the NET value readout');
assert.ok(view._netLabel, '… with the NET label');
assert.equal(view._netValue.text, `+${sellA} cr`, '… it shows the pending +cr');
assert.ok(view._confirmBtn.alpha > 0.5, 'a selection reveals the sub-bar Confirm');
assert.ok(view._clearBtn.alpha > 0.5, '… and Clear');
assert.equal(view._confirmBtn.disabled, false, '… and Confirm is live (valid trade)');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before + sellA, 'credits rise by the sale');
assert.deepEqual(ship.cargo.snapshot(), [B], 'A leaves the hold, B stays');
assert.equal(view.isBuybackEnabled(), true, '… and A is now buyback-eligible');
assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed');
assert.equal(view._netValue.text, '0 cr', '… and the bar’s NET settles back to 0 cr');
assert.equal(view._confirmBtn.alpha, 0, '… and the Confirm/Clear actions hide again (selection gone)');
assert.equal(view._clearBtn.alpha, 0, '… Clear hides too');
}
console.log('SELL: OK');
// ===========================================================================
console.log('\n== PROFIT XP — a profitable sale pays 1 XP per 1 credit above cost ==');
{
const ship = makeShip(10000);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
assert.ok(sellA > 5, 'the sell price clears the cost (a profitable sale)');
const COST = 5; // the credits/unit the player PAID for these two bays
ship.cargo.add([A, A], COST);
const grants = [];
const { view, gs } = makeView(ship);
// The scene's XP pipe (GameScene.combat.xp) — the SAME pipe as kills /
// sidequests; a spy records every grant + its source tag.
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); return { gained: n, level: 1, levelsGained: 0, leveledUp: false }; } } };
view.sellGrid.toggleBay(0); // bay 0 holds A
view.sellGrid.toggleBay(1); // bay 1 holds A
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 2 }, 'both A bays are selected');
const pv = view.preview();
assert.equal(pv.sellTotal, sellA * 2, 'sellTotal = 2 × the A sale');
assert.equal(pv.valid, true, '… and it is legal');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before + sellA * 2, 'credits rise by the sale');
assert.deepEqual(ship.cargo.snapshot(), [], '… and the hold is empty');
// THE PROFIT XP — 1 XP per 1 credit above the purchase cost, per bay:
// (sellA − COST) × 2, paid through the scene's XP pipe (source 'trade').
const expectedXp = (sellA - COST) * 2;
assert.ok(expectedXp > 0, '… a positive profit (the sale beats the cost)');
assert.deepEqual(grants, [[expectedXp, 'trade']], `profit XP ${expectedXp} granted ONCE, tagged 'trade'`);
// The CONFIRMED line tells the player the XP the payout earned (the
// credit delta is unchanged; the XP is appended, not a separate grant).
assert.ok(view._lastStatus, 'the status line is recorded');
assert.ok(view._lastStatus.includes(`+${expectedXp} XP`), `the CONFIRMED line shows the +${expectedXp} XP payout`);
assert.ok(view._lastStatus.includes(`+${sellA * 2} cr`), '… and still shows the credit delta');
}
// …and a sale with NO profit basis (no cost on file; or at/below cost)
// grants NOTHING — the pipe is never called (no 0-grant, no negative XP).
{
// (a) a bay with no cost on file (a handoff) — no basis, no profit.
{
const ship = makeShip(10000);
ship.cargo.add([A, A]); // NO unitCost → null cost on file
const grants = [];
const { view, gs } = makeView(ship);
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); } } };
view.sellGrid.toggleBay(0);
view.sellGrid.toggleBay(1);
assert.equal(view.confirm(), true, 'Confirm goes through (the sale itself stands)');
assert.deepEqual(grants, [], 'no cost on file → no profit basis → NO XP grant');
assert.ok(!view._lastStatus.includes('XP'), '… and the CONFIRMED line shows no XP payout');
}
// (b) a sale AT/BELOW cost — no margin, no XP.
{
const ship = makeShip(10000);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
ship.cargo.add([A, A], sellA); // bought at exactly the sell price → 0 margin
const grants = [];
const { view, gs } = makeView(ship);
gs.combat = { xp: { grant(n, src) { grants.push([n, src]); } } };
view.sellGrid.toggleBay(0);
view.sellGrid.toggleBay(1);
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.deepEqual(grants, [], 'a zero-margin sale earns NO XP');
assert.ok(!view._lastStatus.includes('XP'), '… and the CONFIRMED line shows no XP payout');
}
}
console.log('PROFIT XP: profitable sale pays 1 XP/credit above cost; no-basis / no-margin sells earn none: OK');
// ===========================================================================
console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays ==');
{
const ship = makeShip(10000); // empty hold (room for 4)
const { view, gs } = makeView(ship);
const buyC = E.buyPrice(C, cls, sysId, galaxy);
assert.ok(buyC > 0, 'the buy price is positive');
view.buyList.bumpBuy(C, 2);
assert.deepEqual(view.buyList.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
let pv = view.preview();
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
assert.equal(pv.net, -buyC * 2, '… net = the spend');
assert.equal(pv.valid, true, '… affordable and room for two');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before - buyC * 2, 'credits fall by the spend');
assert.deepEqual(ship.cargo.snapshot(), [C, C], '… and the two C fill the hold');
// THE COST RECORD — the bays now carry the credits/unit actually paid
// (the shop's own price): the ship console's bay manifest compares its
// exit sell prices against this number, not a guess.
assert.deepEqual(ship.cargo.costs, [buyC, buyC], '… each bought bay records the shop price it was bought at');
assert.equal(ship.cargo.costAt(0), buyC, 'costAt() reads the bay\u2019s recorded cost');
assert.equal(ship.cargo.totalCost(), buyC * 2, 'totalCost() = the credits actually spent');
assert.equal(gs.refreshed, 1, '… and the HUD refreshes');
}
console.log('BUY: OK');
// ===========================================================================
console.log('\n== BATCH — the origin\u2019s daily batch: "N left", the clamp, SOLD OUT ==');
{
const batch = Stock.dailyBatch(); // the daily batch (data/economy.json)
const ship = makeShip(100000);
ship.cargo.capacity = batch; // room for the full batch (the default 4 bays are the starting ship)
// A REAL stock seam: the scene owns economyStock + spendStock (the
// reference-swap the ShopView reads live on every query).
const gs = {
refreshed: 0,
refreshCreditsHud() { this.refreshed += 1; },
isTradeUnlocked: () => true,
economyStock: {}, // a fresh day → every batch starts full
spendStock(sys, qty) {
const day = Stock.economyDay();
const cur = this.economyStock[sys] ?? { day, left: batch };
const left = Math.max(0, (cur.day === day ? cur.left : batch) - qty);
this.economyStock = { ...this.economyStock, [sys]: { day, left } }; // NEW reference (the seam)
return left;
},
};
const scene = makeScene();
const view = new ShopView(scene, { ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {}, topY: 120, worldName: 'Somewhere' });
view.setTab('trade');
const row = view.buyList.rows.find((r) => r.id === C);
assert.ok(row, 'the world\u2019s signature row is on the floor');
assert.equal(row.left, batch, `… showing its FULL daily batch ("${batch} left")`);
// The stepper allows EXACTLY the batch, no more.
view.buyList.bumpBuy(C, batch);
assert.deepEqual(view.buyList.getBuyMap(), { [C]: batch }, 'stepping to the full batch is allowed');
let pv = view.preview();
const unit = E.buyPrice(C, cls, sysId, galaxy);
assert.equal(pv.buyTotal, unit * batch, '… all batch units at the origin price');
assert.equal(pv.valid, true, '… affordable and room for the batch');
view.buyList.bumpBuy(C, 1);
assert.equal(view.buyList.getBuyMap()[C], batch, '… the stepper refuses to exceed the batch');
// Confirm drains the batch (the save state) + the row goes SOLD OUT.
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before - unit * batch, '… credits fall by the full-batch spend');
assert.equal(gs.economyStock[sysId].left, 0, '… and the origin\u2019s batch is now 0 (the saved state)');
const row2 = view.buyList.rows.find((r) => r.id === C);
assert.equal(row2.left, 0, '… the re-rendered row drops to 0 left');
assert.equal(String(row2.objs.left.text), 'SOLD OUT', '… and it reads SOLD OUT');
// A fresh selection is blocked at BOTH seams: the stepper clamps at 0,
// and even a forced selection fails the engine's stock-cap backstop.
view.buyList.bumpBuy(C, 1);
assert.deepEqual(view.buyList.getBuyMap(), {}, '… the stepper can\u2019t select a sold-out batch');
view.buyList.buyQty.set(C, 1); // force a selection past the clamp (backstop check)
pv = view.preview();
assert.equal(pv.valid, false, '… and the engine refuses it (insufficient-stock)');
assert.ok(pv.errors.includes('insufficient-stock'), '… flagged as insufficient-stock');
}
console.log('BATCH: OK');
// ===========================================================================
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
{
const ship = makeShip(10000);
// A HUB (model v2 — a plain world's floor is exactly ONE row, no scroll).
const { view } = makeView(ship, { sysId: hubSys, cls: hubCls });
const list = view.buyList;
assert.ok(list.rows.length >= 8, `the hub stock has ${list.rows.length} rows (≥ 8)`);
// 64+6 px per row vs a ~420 px viewport → the content is TALLER than the
// window, so the list must scroll (rows that don't fit were the old bug).
assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`);
// Scrolling clamps at both ends.
list.scrollBy(100000);
assert.equal(list._scroll, list.maxScroll, 'scroll clamps at the bottom');
list.scrollBy(-100000);
assert.equal(list._scroll, 0, 'scroll clamps back at the top');
// Row-based clipping: mid-scroll, only the window's rows are shown.
list.scrollTo(list.maxScroll / 2);
const shown = list.rows.filter((r) => r.shown);
const hidden = list.rows.filter((r) => !r.shown);
assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`);
assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)');
// ELEMENT CLIPPING — the spill the player saw: a partially-out row used to
// render WHOLE (ghosting over the title above / the Confirm bar below).
// Now: `shown` = overlaps the window; every element that IS rendered fits
// WHOLLY inside (this build can't crop text pixels, so partially-out
// elements hide). Check the invariant on the whole list, at the top of
// the scroll where the bottom rows are the edge case.
list.scrollTo(0);
const vpTop = list.vpY, vpBot = list.vpY + list.vpH;
const inBox = (el) => { const h = (Number(el.height) > 0 ? el.height : 20) * (el.scaleY ?? 1) / 2; return el.y - h >= vpTop - 0.5 && el.y + h <= vpBot + 0.5; };
let edgeRow = null;
for (const row of list.rows) {
const eL = [...Object.values(row.objs)];
const visible = eL.filter((el) => el && el.alpha === 1);
if (row.shown) {
for (const el of visible) assert.ok(inBox(el), `rendered element of row ${row.id} is WHOLLY inside the viewport (y=${el.y.toFixed(1)}, h=${el.height ?? '?'})`);
// a slice row: the window cuts it — some elements in, some out
if (visible.length < eL.filter((el) => el).length) edgeRow = row;
} else {
assert.equal(visible.length, 0, `row ${row.id} is off-window → nothing of it renders`);
}
}
assert.ok(edgeRow, 'at scroll 0 a bottom row is PARTIALLY in (plate slice) with its text clipped out');
assert.ok(edgeRow.clipBot <= vpBot + 0.5, '… the plate slice ends at the window bottom (not past it)');
assert.ok(list.rows[0].objs.name.alpha === 1 && list.rows[0].objs.plus.alpha === 1, '… and the fully-inside top row renders everything');
// SCROLLBAR — a track at the viewport's right edge + a thumb that tracks
// the scroll (and stays inside the panel). Hidden when content fits.
const sb = list._sbGeom();
assert.ok(sb.x >= list.vpX + list.vpW, 'scrollbar sits right of the viewport (in the right pad)');
assert.ok(sb.x + sb.w <= list.width, '… inside the panel');
assert.equal(sb.y, list.vpY, '… aligned to the viewport top');
assert.equal(sb.h, list.vpH, '… spanning the viewport height');
assert.ok(list._sbThumb, 'content outgrows the window → a thumb exists');
const th = list._sbThumb;
assert.ok(th.h >= sb.min && th.h <= sb.h, `thumb height is sane (${th.h.toFixed(1)}px, min ${sb.min}, max ${sb.h})`);
assert.ok(th.h < sb.h, '… and shorter than the track (content > viewport)');
assert.ok(Math.abs(th.h - sb.h * (list.vpH / list.contentH)) < 1, 'thumb height ∝ viewport/content share');
assert.ok(Math.abs(th.y - sb.y) < 0.5, 'scroll 0 → thumb at the track top');
list.scrollTo(list.maxScroll);
assert.ok(Math.abs(list._sbThumb.y - (sb.y + sb.h - th.h)) < 0.5, 'scroll max → thumb at the track bottom');
list.scrollTo(list.maxScroll / 2);
assert.ok(list._sbThumb.y > sb.y + 1 && list._sbThumb.y < sb.y + sb.h - th.h, '… mid-scroll sits between');
// Jump math: press at the track top/bottom → scroll 0 / maxScroll.
assert.equal(list._sbScrollForPoint(sb.y), 0, 'press at track top → scroll 0');
assert.ok(Math.abs(list._sbScrollForPoint(sb.y + sb.h) - list.maxScroll) < 1, 'press at track bottom → max scroll');
// Content that FITS the window → no scrollbar at all.
const fullStock = list.stock.map((s) => ({ ...s }));
list.refresh({ stock: [{ id: 'solo', name: 'Solo', price: 1 }] });
assert.equal(list.maxScroll, 0, 'single row → no scroll');
assert.equal(list._sbThumb, null, '… and no thumb (a full-track bar would lie)');
// Selection is DATA, not position: stepping a good works whether its row
// is on screen or not (the player can scroll, then adjust, freely).
list.refresh({ stock: fullStock });
const last = list.stock[list.stock.length - 1].id;
list.bumpBuy(last, 1);
assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects');
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
list.clearSelection();
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
// Hub-pool rows are UNLIMITED (left === null) — the stepper has no batch cap.
assert.equal(list.rows[0].left, null, 'hub rows carry no "N left" (unlimited resale)');
}
console.log('SCROLL: OK');
// ===========================================================================
console.log('\n== CAPS — blocked when negative or over the hold ==');
{
// Not enough credits.
const broke = makeShip(0);
const buyC = E.buyPrice(C, cls, sysId, galaxy);
const { view: vb } = makeView(broke);
vb.buyList.bumpBuy(C, 1);
let pv = vb.preview();
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
assert.equal(vb.confirm(), false, '… and Confirm is refused');
assert.equal(vb._confirmBtn.disabled, true, '… and the sub-bar Confirm is grayed (invalid trade)');
assert.equal(broke.credits, 0, '… nothing spent');
assert.equal(broke.cargo.used, 0, '… nothing bought');
// Over the hold (full 4, buy 1 → 5).
const full = makeShip(100000);
full.cargo.add([A, B, A, B]); // 4 bays, no room
const { view: vf } = makeView(full);
vf.buyList.bumpBuy(C, 1);
pv = vf.preview();
assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full');
assert.equal(vf.confirm(), false, '… and Confirm is refused');
// …but SELL one to free a bay, then buy one → valid in the same confirm.
vf.sellGrid.toggleBay(0); // sell bay 0 (an A)
pv = vf.preview();
assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits');
assert.equal(pv.valid, true, '… now it\u2019s legal (room freed)');
assert.equal(vf._confirmBtn.disabled, false, '… and Confirm un-grays (the trade is valid again)');
const before = full.credits;
assert.equal(vf.confirm(), true, '… and it goes through');
assert.deepEqual(full.cargo.snapshot().sort(), [A, B, B, C].sort(), 'one A left, one C arrived (net hold 4)');
assert.equal(full.credits, before + E.sellPrice(A, cls, sysId, galaxy) - E.buyPrice(C, cls, sysId, galaxy), '… credits = +sale − buy');
}
console.log('CAPS: OK');
// ===========================================================================
console.log('\n== BUYBACK — sell, then buy it back at the sale price (neutral) ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
// Sell one A.
view.sellGrid.toggleBay(0);
assert.equal(view.confirm(), true, 'sell the A');
assert.equal(ship.credits, 10000 + sellA, '… earned the sale price');
assert.deepEqual(ship.cargo.snapshot(), [], '… hold is empty');
assert.equal(view.isBuybackEnabled(), true, 'Buyback is now enabled');
// Switch to the Buyback tab and buy the A back.
view.setTab('buyback');
assert.equal(view.tab, 'buyback', '… on the Buyback tab');
assert.deepEqual(view._bbRows(), [{ id: A, name: goods[0].name, qty: 1, price: sellA, sel: 0 }], '… the sold A is listed at the sale price');
view.bumpBuyback(A, 1);
assert.deepEqual(view.getBuybackMap(), { [A]: 1 }, 'stepping once → buy back one A');
let pv = view.preview();
assert.equal(pv.buyTotal, sellA, '… it costs the SALE price (the neutral undo)');
assert.equal(pv.net, -sellA, '… net = −sale');
assert.equal(pv.valid, true, '… affordable and room for one');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before - sellA, '… spends the sale price → back to 10000');
assert.equal(ship.credits, 10000, '… so sell→buyback nets ZERO (no free-credit loop)');
assert.deepEqual(ship.cargo.snapshot(), [A], '… and the A is back in the hold');
assert.equal(view.isBuybackEnabled(), false, '… fully bought back → Buyback disables again');
}
console.log('BUYBACK: OK');
// ===========================================================================
console.log('\n== QUEST CARGO — the "Special Delivery" package: home-world-only, zero-credit sale ==');
{
const PKG = 'special-package';
assert.ok(goods.some((g) => g.id === PKG), 'the catalog carries the package');
const mkShip = (list) => { const s = makeShip(10000); s.cargo.add(list); return s; };
// 1) OFF the home world: the package's bay is NOT FOR SALE.
{
const ship = mkShip([A, PKG]);
const spy = [];
const { view } = makeView(ship, { worldName: 'Capital City', homeWorld: 'Home World', soldSpy: spy });
const pkgIdx = ship.cargo.items.indexOf(PKG);
assert.ok(pkgIdx >= 0, 'the package is aboard');
view.sellGrid.toggleBay(pkgIdx);
assert.equal(view.sellGrid.sellSel.size, 0, 'off-home: the package bay refuses the check (NOT FOR SALE)');
assert.deepEqual(view.sellGrid.getSellMap(), {}, '… nothing queued');
view.sellGrid.hoverCell(pkgIdx);
assert.equal(view.sellGrid.tipPrice.text, 'NOT FOR SALE', 'off-home: the tooltip says so');
// Ordinary bays still sell (only the parcel is refused).
view.sellGrid.toggleBay(0);
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 1 }, 'off-home: ordinary goods are still saleable');
assert.equal(view.preview().sell.length, 1, '… the preview carries only the ordinary sale');
assert.equal(spy.length, 0, '… and the delivery seam never fires');
}
// 2) ON the home world: the package is saleable — at ZERO credits — and
// the sale stands the delivery (the seam fires with the world's name).
{
const ship = mkShip([A, PKG]);
const spy = [];
const { view } = makeView(ship, { worldName: 'Home World', homeWorld: 'Home World', soldSpy: spy });
const pkgIdx = ship.cargo.items.indexOf(PKG);
view.sellGrid.toggleBay(pkgIdx);
assert.ok(view.sellGrid.sellSel.has(pkgIdx), 'home: the package bay IS saleable (the delivery)');
view.sellGrid.hoverCell(pkgIdx);
assert.equal(view.sellGrid.tipPrice.text, 'sell for 0 cr', 'home: the tooltip prices the delivery at zero');
const pv = view.preview();
const pkgRow = pv.sell.find((s) => s.id === PKG);
assert.ok(pkgRow, 'the preview carries the parcel sale');
assert.equal(pkgRow.unit, 0, '… at zero credits (the delivery, not a reward)');
assert.equal(pkgRow.total, 0, '… zero total');
assert.equal(pv.valid, true, '… and the trade is legal');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before, '… the sale earns ZERO credits');
assert.ok(!ship.cargo.items.includes(PKG), '… the parcel leaves the hold (delivered)');
assert.equal(spy.length, 1, '… and the delivery seam fired exactly once');
assert.equal(spy[0], 'Home World', '… with the world the sale ran on');
// The delivery is not a trade: the parcel is NOT buyback-eligible.
assert.ok(!view.soldThisSession.some((r) => r.id === PKG), '… and the parcel is not on the Buyback list (the delivery, not a trade)');
}
// 3) The home check is name-based: the same package on a world the scene
// does NOT call home stays NOT FOR SALE.
{
const ship = mkShip([PKG]);
const spy = [];
const { view } = makeView(ship, { worldName: 'Astra-6', homeWorld: 'Home World', soldSpy: spy });
view.sellGrid.toggleBay(0);
assert.equal(view.sellGrid.sellSel.size, 0, 'non-home world: the parcel stays NOT FOR SALE');
assert.equal(view.preview().sell.length, 0, '… nothing queued, nothing priced');
assert.equal(spy.length, 0, '… no seam (the shop never offered the sale)');
}
}
console.log('QUEST CARGO: OK');
// ===========================================================================
console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
assert.equal(view.isBuybackEnabled(), false, 'no sale yet → Buyback disabled');
view.setTab('buyback');
assert.equal(view.tab, 'trade', '… and it refuses to open (stays on Buy/Sell)');
view.sellGrid.toggleBay(0);
assert.equal(view.sellGrid.hasSelection(), true, 'a bay is checked');
view.clear();
assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection');
assert.equal(view.preview().net, 0, '… and the net returns to zero');
assert.equal(view._confirmBtn.alpha, 0, '… and the sub-bar Confirm hides again');
assert.equal(view._clearBtn.alpha, 0, '… and Clear');
}
console.log('LOCK + CLEAR: OK');
// ===========================================================================
console.log('\n== TOGGLE — BUY/SELL presses open the panel; again closes it ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship, { reveal: false });
// Entry state: panel closed, sub-bar idle.
assert.equal(view.tab, null, 'entry state: the panel is closed');
assert.equal(view._netValue.text, '0 cr', '… and the NET readout is zero');
assert.equal(view._confirmBtn.alpha, 0, '… and Confirm is hidden');
// Press BUY/SELL (the button handler is _onTabTap) → the panel opens.
view._onTabTap('trade');
assert.equal(view.tab, 'trade', 'pressing BUY/SELL opens the trade panel');
assert.ok(view.sellGrid && view.buyList, '… with both panels built');
// Build a pending trade: sell A (bay 0) + buy 2 of the stocked good.
view.sellGrid.toggleBay(0);
view.buyList.bumpBuy(C, 2);
const netBefore = view.preview().net;
// Press BUY/SELL AGAIN → the panel CLOSES (toggle off)…
view._onTabTap('trade');
assert.equal(view.tab, null, 'pressing BUY/SELL again closes the panel');
assert.equal(view.sellGrid, null, '… the SELL grid is torn down');
assert.equal(view.buyList, null, '… and the BUY list');
// … but the pending trade is SAVED, not lost — the sub-bar stays live.
assert.deepEqual(view._pendingSell, { [A]: 1 }, '… the SELL selection is saved');
assert.deepEqual(view._pendingBuy, { [C]: 2 }, '… and the BUY selection');
assert.equal(view.preview().net, netBefore, '… the preview still sees the same trade');
assert.equal(view._confirmBtn.alpha, 1, '… Confirm is live on the closed sub-bar');
assert.equal(view._clearBtn.alpha, 1, '… and Clear');
// Clear (with the panel closed) cancels the saved trade.
view.clear();
assert.equal(view.preview().net, 0, 'Clear (closed) cancels the saved trade');
assert.equal(view._confirmBtn.alpha, 0, '… Confirm hides again');
// Rebuild a sell, close again, and CONFIRM from the closed state.
view._onTabTap('trade'); // open again (toggle on)
assert.equal(view.tab, 'trade', 'pressing BUY/SELL re-opens the panel');
view.sellGrid.toggleBay(0);
view._onTabTap('trade'); // close again (toggle off)
const creditsBefore = ship.credits;
assert.equal(view.confirm(), true, 'Confirm works with the panel closed');
assert.ok(ship.credits > creditsBefore, '… and the sale landed (credits up)');
assert.equal(view.preview().net, 0, '… the trade is spent');
assert.equal(view.isBuybackEnabled(), true, '… and the sale registered for Buyback');
// Re-open: a FRESH selection (the confirmed trade is gone, not restored).
view._onTabTap('trade');
assert.equal(view.tab, 'trade', '… the panel re-opens');
assert.deepEqual(view.sellGrid.getSellMap(), {}, '… with no carry-over sell selection');
assert.deepEqual(view.buyList.getBuyMap(), {}, '… and no carry-over buy selection');
}
console.log('TOGGLE: OK');
// ===========================================================================
console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the report ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
// A HUB — the scout's row-swap test needs ≥ 2 stock rows (a plain world
// sells exactly one good, model v2).
const { view } = makeView(ship, { sysId: hubSys, cls: hubCls });
// The scout exists (over the cargo grid) and starts closed.
assert.ok(view._scout, 'the scout panel exists with the trade content');
assert.equal(view._scoutOpen, null, 'starts closed (cargo grid up)');
assert.equal(view._scout.alpha, 0, '… hidden (alpha 0)');
assert.equal(view.sellGrid.alpha, 1, '… and the cargo grid is visible');
// A stock row THIS system wants — so the current (charted) system is a
// valid exit in the report (any stock row would do for the UI
// assertions; rows[0] is not guaranteed to be bought locally).
const row =
view.buyList.rows.find((r) => classesOf(hubSys).some((c) => sellsHere(r.id, c))) ??
view.buyList.rows[0];
// TAP THE ROW'S BOX → the scout opens on that good.
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, 'the tapped row is scouted');
assert.equal(view._scout.alpha, 1, 'the report is visible (the stub tween lands on alpha 1)');
assert.equal(view.sellGrid.alpha, 0, '… the cargo grid steps aside (alpha 0)');
assert.equal(view.buyList._focusId, row.id, '… the row keeps its focus stroke');
assert.equal(view._scout.report.id, row.id, '… and it shows THAT good');
// The report: identity + baseline + charted exits + profit math.
const d = view._scout.report;
assert.equal(d.localBuy, row.price, 'the profit baseline is this shop\'s buy price for the row');
assert.ok(Array.isArray(d.demand) && d.demand.every((c) => c.label), 'demand ("sought on") classes carry labels');
assert.ok(Array.isArray(d.producer) && d.producer.every((c) => c.label), 'producer ("grown on") classes carry labels');
assert.ok(d.exits.length <= 3, 'top-N (≤ 3) charted exits');
assert.ok(d.exits.every((e) => e.profit === e.price - d.localBuy), 'profit = exit price − local buy');
assert.ok(d.exits.every((e) => e.systemName && e.clsLabel), 'exits carry system + class labels');
assert.ok(d.exits.every((e, i) => i === 0 || d.exits[i - 1].price >= e.price), 'exits ranked high → low');
assert.ok(d.exits.some((e) => e.systemId === hubSys), 'the current (charted) system is a candidate exit');
// TAP THE SAME BOX AGAIN → closes, cargo grid restored.
view._onRowTap(row);
assert.equal(view._scoutOpen, null, 'same box again → the scout closes');
assert.equal(view._scout.alpha, 0, '… the report hides');
assert.equal(view.sellGrid.alpha, 1, '… the cargo grid is back');
assert.equal(view.buyList._focusId, null, '… and the row\'s focus stroke is cleared');
// TAP A DIFFERENT ROW → the report swaps (still open).
const other = view.buyList.rows[1] ?? view.buyList.rows[0];
view._onRowTap(other);
assert.equal(view._scoutOpen, other.id, 'a different box swaps the report');
assert.equal(view._scout.report.id, other.id, '… to that good');
assert.equal(view.sellGrid.alpha, 0, '… the cargo grid stays aside');
// TAP THE LIST'S DEAD SPACE (null) → closes too ("anywhere else").
view._onRowTap(null);
assert.equal(view._scoutOpen, null, 'a dead-space tap closes the scout');
assert.equal(view.sellGrid.alpha, 1, '… cargo grid restored');
// The scout's OWN tap (its tap layer → onClose) closes it too.
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, '… reopened for the tap-self test');
view._scout.onClose();
assert.equal(view._scoutOpen, null, 'tapping the scout itself closes it');
assert.equal(view.sellGrid.alpha, 1, '… cargo grid restored');
}
console.log('TRADE SCOUT: OK');
console.log('\n== SET DESTINATION — tap a best-exit row on the scout: the scene is offered the exit ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view, scene } = makeView(ship, { sysId: hubSys, cls: hubCls });
const calls = [];
scene.askSetDestination = (exit) => { calls.push(exit); };
// A row whose report has exits to tap (one the current system wants —
// rows[0] is not guaranteed to be bought locally).
const row =
view.buyList.rows.find((r) => classesOf(hubSys).some((c) => sellsHere(r.id, c))) ??
view.buyList.rows[0];
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, 'scout open on the row');
const d = view._scout.report;
assert.ok(d.exits.length > 0, 'the report has charted exits to tap');
// One tappable record per exit row, in rank order.
assert.equal(view._scout._exitRows.length, d.exits.length, 'one tappable record per exit row');
assert.ok(view._scout._exitRows.every((r, i) => r.exit.systemId === d.exits[i].systemId), '… rows keep the report\'s rank order');
// TAP ROW 1 → the scene is offered THAT exit, and the panel does NOT close.
const best = d.exits[0];
let stopped = false;
view._scout._exitRows[0].onTap({ stopPropagation() { stopped = true; } });
assert.equal(calls.length, 1, 'the scene was offered the exit');
assert.equal(calls[0].systemId, best.systemId, '… for the tapped (best) system');
assert.equal(stopped, true, '… propagation stopped (the panel tap-layer is not fired)');
assert.equal(view._scoutOpen, row.id, '… and the scout stays open (no close)');
// TAP ROW 2 (when there is one) → the scene is offered THAT row\'s exit.
if (d.exits.length > 1) {
view._scout._exitRows[1].onTap({});
assert.equal(calls.length, 2, 'second row tap → second offer');
assert.equal(calls[1].systemId, d.exits[1].systemId, '… for that row\'s system');
}
// Re-showing the report rebuilds the rows (no stale tap surfaces).
view._onRowTap(null); // close
view._onRowTap(row); // reopen → show() runs again
assert.equal(view._scout._exitRows.length, view._scout.report.exits.length, 'rows rebuilt for the fresh report');
}
console.log('SET DESTINATION (panel → scene): OK');
console.log('\nAll shop-UI checks passed \u2714');