/** * Shop UI test (dev tool, run with Node — no browser needed): * * node --import ./dev/phaser-loader.mjs 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 both grids over a real shop (16 sell bays, * 12–16 buy slots) 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 good buys it at the shop's buy price; Confirm * spends the credits and fills the bays. * - 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. * * 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'; 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 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; }, 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; // a good the shop actually sells function makeView(ship, { reveal = true, topY = 120 } = {}) { const scene = makeScene(); const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; } }; const view = new ShopView(scene, { ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {}, // topY = the surface HUD's planet-text bottom + padding (what // SurfaceScene.shopTopY() computes). The panel top should sit here. topY, }); 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.buyGrid, null, 'no BUY grid 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'); // 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.buyGrid, 'both the SELL and BUY grids 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.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots'); assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock'); 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== 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 cell'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 (let i = 0; i < view.buyGrid.cells.length; i++) { const cell = view.buyGrid.cells[i]; const id = view.buyGrid.stock[i]?.id; if (!id) continue; const idx = goodById(id)?.icon; assert.ok(typeof idx === 'number', `good ${id} has an icon index`); assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet'); assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`); checkedIcons += 1; } assert.ok(checkedIcons >= 1, 'at least one buy cell 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.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback'); // The two panels must settle at DIFFERENT layout positions — the slide-in // tween targets each grid's set position (the ShopView centers // [SELL] [center] [BUY]). 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.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`); assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin'); } console.log('INTERACTION: 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'); 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'); } console.log('SELL: 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.buyGrid.bumpBuy(C, 2); assert.deepEqual(view.buyGrid.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'); assert.equal(gs.refreshed, 1, '… and the HUD refreshes'); } console.log('BUY: 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.buyGrid.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(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.buyGrid.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)'); 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== 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'); } console.log('LOCK + CLEAR: OK'); console.log('\nAll shop-UI checks passed \u2714');