/** * ShopView — the buy/sell (and buyback) screen over the shop clip video. * * ┌────────────────────────────────────────────────────────────────┐ * │ ┌SELL 4×4┐ [ Confirm ] ┌────────────────────────┐ │ * │ │ bays │ [ Clear ] │ [i] Fresh Produce 24cr│ │ * │ │ (cargo)│ │ Crisp, clean… − 0 +│ │ * │ └────────┘ │ [i] Refined Fuel 41cr│ │ * │ │ The lifeblood − 1 +│ │ * │ │ … (scrolls) … │ │ * │ └────────────────────────┘ │ * └────────────────────────────────────────────────────────────────┘ * ┌────────────────────────────────────────────────────────────────┐ * │ BUY/SELL BUYBACK ···console··· CLEAR CONFIRM │ NET +420 cr │ * │ ← spans the FULL command deck (left + right edges) → │ * └────────────────────────────────────────────────────────────────┘ * * The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the * player's cargo bays — tap a filled bay to check it off (a bay = 1 unit); * locked bays are the hold's upgrade room. ONE bay is not a commodity: the * 'Special Delivery' package (tradeable:false) is NOT FOR SALE anywhere * EXCEPT the home world — where it sells for zero credits, and THAT sale * is the quest's step 3 (confirm() hands it to GameScene.onQuestPackageSold; * it is not buyback-eligible). * The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's * stock as WIDE ROWS — icon + name + one-line description (the good's * flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a * full 8–16 stock always fits. The live NET preview (green ≥ 0, red < 0) * sits on the TAB SUB-BAR at its RIGHT edge: the tabs pack at the bar's * LEFT, the trade actions (Clear + Confirm) and the credit readout pack * at the RIGHT — the value right-aligned at the bar's end, so a growing * number never shifts the label, the buttons, or the tabs (no overlap). * Confirm is disabled while the trade is invalid (can't go negative, hold * would overflow). SELL→BUY happens atomically (sell to free room for a * buy). * * BUYBACK (session-local only) — the second tab. After the player SELLs * something, the goods they sold are listed here and can be bought BACK — * priced at the sale price (a neutral undo, so sell→buyback can't loop into * free credits). It disappears when the player leaves the shop (no * persistence; data/shopui.json). * * This component owns the SESSION state (soldThisSession, the active tab, the * selection). The PRICING + preview math is js/economy/Economy.js (fixed * per-shop prices + tradePreview). The credits/cargo ledger is ship.credits * + ship.cargo (CargoHold). Headless-testable: the logic (preview / confirm / * clear / buyback) is plain data; the Phaser rendering is best-effort. */ import Phaser from '../../vendor/phaser.js'; import { config } from '../../config/Config.js'; import * as E from '../../economy/Economy.js'; import * as Stock from '../../economy/Stock.js'; import { scoutGood } from '../../economy/TradeScout.js'; import { MenuButton } from '../MenuButton.js'; import { toColor, toCss } from '../../utils/Color.js'; import { fontStack } from '../../utils/Theme.js'; import { TradeGrid } from './TradeGrid.js'; import { BuyList } from './BuyList.js'; import { ScoutPanel } from './ScoutPanel.js'; export class ShopView extends Phaser.GameObjects.Container { /** * @param {object} scene — the SurfaceScene (or a headless fake). * @param {object} o — { ship, galaxy, cls, systemId, gameScene, onLeave } * ship — the Ship (credits + cargo). * galaxy — the generated galaxy (Economy pricing). * cls — the shop's economic class (Economy.economicClass). * systemId — this system (the distance term for sell prices). * worldName — this WORLD (the planet the player landed on): the * QUEST CARGO's delivery gate — the 'Special Delivery' * package is saleable ONLY on the home world (zero * credits), so the home check runs against it (GameScene. * isHomeWorld; a scene without the method = the package * is not saleable — the old behavior). * gameScene — to refresh the credits/minerals HUD after a trade, and * to read the TRADE gate (isTradeUnlocked — the Buy/Sell * tab stays grayed until the 'Special Delivery' capital * landing; a headless fake without the method = open). * onLeave — called by close() so the SurfaceScene can restore the * surface loop (the deck's "Leave Shop" does this too). * anchor — { x, y, w, left, right } the sub-bar's anchor: the * deck's "Shop" button (x, w — legacy), the deck's * top-rail y (the bar's bottom edge rests on it), and * left/right — the deck's left/right edges. The bar * spans the FULL deck — the same span as the main * action bar (see SurfaceScene.shopAnchor). */ constructor(scene, o) { super(scene, 0, 0); this.scene = scene; this.ship = o.ship; this.galaxy = o.galaxy; this.cls = o.cls; this.systemId = o.systemId; this.gameScene = o.gameScene; this.worldName = typeof o.worldName === 'string' ? o.worldName.trim() : ''; // The QUEST CARGO's delivery (the 'Special Delivery' package — // tradeable:false, data/goods.json): saleable ONLY on the home world, // where it prices at its flat `sellFor` (zero credits — the delivery, // not a commodity). That home-world sale is the quest's step 3: // confirm() hands it to the ledger (GameScene.onQuestPackageSold). // TradeGrid reads questSell per bay; the price comes off the good. this.questSellable = this.worldName !== '' && typeof this.gameScene?.isHomeWorld === 'function' && this.gameScene.isHomeWorld(this.worldName) === true; this.onLeave = o.onLeave; // The tab sub-bar anchor (the deck's span). Falls back to a // bottom-center bar if the scene doesn't provide one (headless/dev). this.anchor = o.anchor ?? { x: this._W() / 2, y: this._H() - 58, w: 120, left: 20, right: this._W() - 20 }; // The trade panel's TOP edge — just under the surface HUD's planet text // (the scene computes it from its own hudName/hudLine). Falls back to a // small top margin if the scene doesn't provide one (headless/dev). this.topY = Number.isFinite(o.topY) ? o.topY : (this._H() * 0.16); this.maxSlots = Math.max(1, Math.floor(Number(config.get('economy.cargo.maxSlots', 16)) || 16)); this.capacity = this.ship.cargo ? this.ship.cargo.capacity : 0; // --- session state --------------------------------------------------- this.tab = 'trade'; // 'trade' | 'buyback' | null (closed) this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source) this.buybackSel = new Map(); // goodId -> qty to buy back this._pendingSell = null; // { goodId: qty } — the pending trade's this._pendingBuy = null; // SELL/BUY side, saved when the // BUY/SELL toggle closes the panel // (it survives until Confirm/Clear) this.sellGrid = null; this.buyList = null; this._content = null; // the active content container this._scout = null; // the trade scout (a BUY row's strategy view) this._scoutOpen = null; // the scouted good id (null = cargo grid up) this._netLabel = null; // the sub-bar NET label (persistent) this._netValue = null; // the sub-bar NET value (live) this._confirmBtn = null; this._clearBtn = null; this._tabBuyback = null; this._buildTabs(); this.enter(); // Present in the scene, above the shop clip (depth 10) + deck (50), below // toasts. setScrollFactor(0) = screen-fixed (the SurfaceScene is a full // overlay). Best-effort so a headless fake scene still constructs. this.setScrollFactor?.(0); this.setDepth?.(100); this.scene.add?.existing?.(this); } // --------------------------------------------------------------------- // ENTRY + TABS // --------------------------------------------------------------------- enter() { // Entry state: the shop video + tab bar only. The player must click // "Buy/Sell" to reveal the trade panel (this keeps the screen open so the // shop clip plays before the UI takes over). Buy/Sell is grayed while the // trade gate is closed (GameScene.isTradeUnlocked — the 'Special // Delivery' capital landing lifts it); Buyback is grayed until a sale // exists this visit. this.tab = null; this._refreshTabs(); return this; } isBuybackEnabled() { return this.soldThisSession.some((r) => r.qty > 0); } /** The TRADE gate (GameScene.isTradeUnlocked — the 'Special Delivery' * capital landing lifts it; it's the shop's Buy/Sell tab that stays * dark, the shop button itself is always live). A scene without the * method (headless/dev) has no ledger, so the gate reads open there. */ isTradeUnlocked() { const g = this.gameScene; if (typeof g?.isTradeUnlocked !== 'function') return true; return g.isTradeUnlocked() === true; } setTab(t) { if (t === 'trade' && !this.isTradeUnlocked()) return this; // trade gate closed if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked // Leaving an OPEN trade panel with a pending trade? Save the selection // (same as the toggle-close) so a tab switch never loses it; _showTrade // re-applies it when the panel comes back. if (this.tab === 'trade' && t !== 'trade') { const s = this.sellGrid?.getSellMap?.() ?? {}; const b = this.buyList?.getBuyMap?.() ?? {}; this._pendingSell = Object.keys(s).length > 0 ? s : null; this._pendingBuy = Object.keys(b).length > 0 ? b : null; } this.tab = t; this._closeScout(); // the content (and the scout) is rebuilt this._destroyContent(); if (t === 'trade') this._showTrade(); else this._showBuyback(); this._refreshTabs(); return this; } _buildTabs() { // The tab sub-bar: the same recipe as the deck's Menu sub-bar // (js/ui/MenuSubBar.js) — a console-blue panel with cut corners + the // signature top rail, holding cyberpunk MenuButtons. It rests ABOVE the // command deck, right-aligned to the deck's "Shop" button (this.anchor), // so it reads as the same family of UI as the Menu drawer. // // EXTENDED (data/shopui.json → preview + buttons): the trade ACTIONS // (Clear + Confirm) and the live NET credit readout ride on the same // console, packed at the RIGHT edge — // [tabs ···console··· actions] │ [NET value] // while the tabs pack at the LEFT edge. One glance sees state, the // pending credit delta, and the button that executes it (entry state // included, where the actions hide and the readout dims to "0 cr"). const a = this.anchor; const H = config.get('save.subBar.height', 58); const pad = config.get('save.subBar.padding', 16); const gap = config.get('save.subBar.gap', 12); const tabs = config.get('shopui.tabs', {}); const c = config.section('save.colors', {}); const btnCfg = config.section('save.subBar.button', {}); const pv = config.section('shopui.preview', {}); const items = [ { id: 'trade', label: tabs.buySell ?? 'Buy/Sell', accent: tabs.activeAccent ?? c.neon ?? '#00e5ff' }, { id: 'buyback', label: tabs.buyback ?? 'Buyback', accent: c.dim ?? '#7d92c4' }, ]; // Build the buttons first (they measure their own width) so the row // layout uses the REAL widths — no second, divergent text-measure pass. const mkBtn = (label, accent, onClick) => new MenuButton( this.scene, 0, -H / 2, String(label), onClick, { screenFixed: true, fontSize: btnCfg.fontSize ?? 13, paddingX: btnCfg.paddingX ?? 18, paddingY: btnCfg.paddingY ?? 9, letterSpacing: btnCfg.letterSpacing ?? 2, upper: true, textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222', hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(accent ?? c.neon ?? '#00e5ff'), }, ); const btns = items.map((it) => mkBtn(String(it.label ?? it.id), it.accent, () => this._onTabTap(it.id))); const widths = btns.map((b) => b.style?.width ?? 120); const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1); // The trade actions — Clear (secondary, dim) + Confirm (primary, green) // — live on the bar too (both trade AND buyback confirm here, since // preview()/confirm() already branch on the tab). const buttons = config.section('shopui.buttons', {}); this._clearBtn = mkBtn(buttons.clearLabel ?? 'Clear', buttons.clearAccent ?? c.dim ?? '#7d92c4', () => this.clear()); this._confirmBtn = mkBtn(buttons.confirmLabel ?? 'Confirm', buttons.confirmAccent ?? '#7ce8a4', () => this.confirm()); this._confirmBtn.setAlpha?.(0); // hidden until a selection exists (_syncPreview) this._clearBtn.setAlpha?.(0); this._confirmBtn.setDisabled?.(true); // + invalid-trade gray, via _syncPreview const clearW = this._clearBtn.style?.width ?? 90; const confirmW = this._confirmBtn.style?.width ?? 110; const actionsW = clearW + gap + confirmW; // The NET readout — created BEFORE the panel body so its REAL width feeds // the bar width (a headless fake without text metrics falls back to an // estimate). The value is RIGHT-ANCHORED (origin 1, 0.5) at the bar's // end, so a longer number ("−9,999 cr") extends LEFT and never spills // past the bar or shifts the label. The label sits clear of the WIDEST // plausible value (reserved below) so nothing overlaps at any total. const labelStr = String(pv.label ?? 'NET'); const valueFont = Math.max(8, Number(pv.valueFontSize) || 22); // Reserve the value slot with the widest plausible reading so the label // never overlaps a long total (the value itself only grows leftward). const reserveStr = String(pv.reserve ?? '\u221299,999 cr'); this._netLabel = this._makeText(0, -H / 2, labelStr, { fontSize: `${Math.max(8, Number(pv.labelFontSize) || 12)}px`, color: toCss(pv.labelColor ?? c.dim ?? '#7d92c4'), align: 'left', }); this._netValue = this._makeText(0, -H / 2, reserveStr, { fontSize: `${valueFont}px`, color: toCss(pv.zeroColor ?? pv.labelColor ?? '#7d92c4'), align: 'left', }); const labelW = (Number(this._netLabel?.width) > 0 ? this._netLabel.width : labelStr.length * 8) || 30; const valueW = (Number(this._netValue?.width) > 0 ? this._netValue.width : reserveStr.length * valueFont * 0.55) || 90; this._netValueW = valueW; // reserved (widest) width — the label keeps clear of it // Reset to the live (zero) reading and anchor the RIGHT edge so the // value stays pinned to the bar's end as its length changes. this._netValue.setText?.('0 cr'); this._netValue.setOrigin?.(1, 0.5); const divW = Math.max(1, Number(pv.divider?.width) || 2); const divH = Math.max(10, Number(pv.divider?.height) || Math.round(H * 0.45)); // Row: [ BUY/SELL ][ BUYBACK ] ···console··· [ CLEAR ][ CONFIRM ] │ NET value // The bar spans the FULL DECK (anchor.left/right — the main action // bar's left and right edges): tabs packed at the LEFT edge, the trade // actions + credit readout packed at the RIGHT edge, the value // right-aligned at the bar's end (a growing number never shifts the // label, the buttons, or the tabs — nothing overlaps on either end). const sceneW = this._W(); const fixedW = pad * 2 + tabsW + divW * 2 + actionsW + labelW + valueW; const span = (Number.isFinite(a.left) && Number.isFinite(a.right) && a.right - a.left > fixedW + 28) ? { left: a.left, right: a.right } : null; // Seven gap slots: tab1·tab2, tab2·div1, div1·clear, clear·confirm, // confirm·div2, div2·label, label·value. If the content can't fit the // span (a narrow deck), the gaps compress — never the button widths. let W, g; if (span) { W = span.right - span.left; g = Math.min(gap, Math.max(4, (W - fixedW) / 7)); } else { g = gap; W = Math.min(sceneW - 24, fixedW + 7 * g); } const cx = span ? (span.left + span.right) / 2 : sceneW / 2; // All the x's, bar-local (the bar's origin is its BOTTOM CENTER, a.y): // the right group packs inward from the bar's right edge; the tabs // pack outward from the left; the dividers sit between the groups. const lEdge = -W / 2 + pad; const rEdge = W / 2 - pad; const tX = [lEdge + widths[0] / 2, lEdge + widths[0] + g + widths[1] / 2]; const vX = rEdge; // NET value — right edge anchored at the bar's end (origin 1,0.5) const lCx = rEdge - valueW - g - labelW / 2; // NET label, clear of the widest value const d2X = lCx - labelW / 2 - g - divW / 2; // divider: actions │ NET const cCx = d2X - divW / 2 - g - confirmW / 2; // Confirm const qCx = cCx - confirmW / 2 - g - clearW / 2; // Clear const d1X = qCx - clearW / 2 - g - divW / 2; // divider: tabs │ actions // Position: bottom edge on the deck's top rail (a.y), centred on the // deck's span. The sub-bar is a CHILD of the ShopView (which stays at // the screen origin, 0,0) at (cx, a.y) — so the trade panel (a SIBLING // at 0,0) still renders in screen space, not offset down to the deck. const bar = new Phaser.GameObjects.Container(this.scene, cx, a.y); bar.setScrollFactor?.(0); this.subBar = bar; this.subBarW = W; this.subBarH = H; this.add?.(bar); // The body FIRST (v4 paints children in list order — the opaque panel // fill must come before the buttons or it would cover them). The // hairline dividers are part of the body, so they paint under both. const bodyG = this.scene.add?.graphics?.(); if (bodyG) { bodyG.setScrollFactor?.(0); bar.add?.(bodyG); this._drawSubBarBody(bodyG, W, H, c, [d1X, d2X], divH); } // The buttons LAST (above the body): tabs at the left edge, the trade // actions + NET readout at the right edge. items.forEach((it, i) => { const btn = btns[i]; btn.setX?.(tX[i]); bar.add?.(btn); if (it.id === 'trade') this._tabTrade = btn; else this._tabBuyback = btn; }); this._clearBtn.setX?.(qCx); this._confirmBtn.setX?.(cCx); bar.add?.(this._clearBtn); bar.add?.(this._confirmBtn); this._netLabel.setPosition?.(lCx, -H / 2); this._netValue.setPosition?.(vX, -H / 2); bar.add?.(this._netLabel); bar.add?.(this._netValue); // Buyback starts grayed (disabled) until a sale exists this visit. this._refreshTabs(); } /** * The sub-bar panel body (the MenuSubBar recipe at full height). * `dividers` (optional) — hairline divider x's (one between the tabs and * the actions, one between the actions and the NET readout) — drawn as * body so they paint under the buttons + text. */ _drawSubBarBody(g, W, H, c, dividers, divH) { if (!g || typeof g.clear !== 'function') return; const panelTop = toColor(c.panelTop ?? '#0e1930'); const panelBottom = toColor(c.panelBottom ?? '#050912'); const neon = toColor(c.neon ?? '#00e5ff'); const magenta = toColor(c.magenta ?? '#ff2d6f'); const ink = toColor(c.ink ?? '#eaf6ff'); const cut = Math.max(3, Math.min(12, H * 0.18)); g.clear(); const pts = [ { x: -W / 2 + cut, y: -H }, { x: W / 2 - cut, y: -H }, { x: W / 2, y: -H + cut }, { x: W / 2, y: 0 }, { x: -W / 2, y: 0 }, { x: -W / 2, y: -H + cut }, ]; const has = (fn) => typeof g[fn] === 'function'; if (has('fillPoints')) { g.fillStyle(panelBottom, 1); g.fillPoints(pts, true); g.fillStyle(panelTop, 0.55); g.fillPoints(pts, true); } if (has('fillRect')) { g.fillStyle(neon, 0.05); g.fillRect(-W / 2, -H, 26, H); // cyan left g.fillStyle(magenta, 0.06); g.fillRect(W / 2 - 26, -H, 26, H); // magenta right const rail = 2; g.fillStyle(neon, 0.9); g.fillRect(-W / 2 + cut, -H, W - cut * 2, rail); g.fillStyle(ink, 0.8); g.fillRect(-W / 2 + cut, -H + 0.5, W - cut * 2, 0.8); g.fillStyle(magenta, 0.5); g.fillRect(-W / 2 + cut, -H + rail, W - cut * 2, 1); g.fillStyle(magenta, 0.35); g.fillRect(-W / 2, -1.5, W, 1.5); // seam const divs = Array.isArray(dividers) ? dividers : (typeof dividers === 'number' ? [dividers] : []); if (divs.length > 0 && divH > 0) { g.fillStyle(toColor(c.border ?? '#22405f'), 0.9); for (const dx of divs) g.fillRect(dx - 1, -H / 2 - divH / 2, 2, divH); // section dividers } } } /** * A tab button press (MenuButton → _onTabTap). The tabs are TOGGLES: * pressing the one that's already open closes the panel (back to the * entry state — shop clip + sub-bar); pressing it again re-opens it. * The pending trade selection is SAVED across the close (see * _closePanel) — hiding the panel never loses the trade. */ _onTabTap(id) { if (id === 'trade' && !this.isTradeUnlocked()) return; // the trade gate (the disabled tab swallows the click too) if (id === 'trade') { if (this.tab === 'trade') this._closePanel(); else this.setTab('trade'); } else if (id === 'buyback' && this.isBuybackEnabled()) { if (this.tab === 'buyback') this._closePanel(); else this.setTab('buyback'); } } /** * The toggle-close: the panel above the sub-bar hides (back to the * entry state — tab null, content gone, shop clip playing). The player's * pending trade is NOT lost — its SELL/BUY maps are saved onto the view * (_pendingSell/_pendingBuy) and re-applied when the panel re-opens * (_restorePendingSelection). While closed, the sub-bar stays live over * the saved selection: NET shows the delta, Clear cancels it, Confirm * executes it. (The buyback selection is already session-persistent.) */ _closePanel() { if (this.tab === 'trade') { const s = this.sellGrid?.getSellMap?.() ?? {}; const b = this.buyList?.getBuyMap?.() ?? {}; this._pendingSell = Object.keys(s).length > 0 ? s : null; this._pendingBuy = Object.keys(b).length > 0 ? b : null; } this.tab = null; this._destroyContent(); // kills the panel + scout + focus this._syncPreview(); // NET + Clear/Confirm over the saved sel return this; } _refreshTabs() { // MenuButtons carry their own state (base/hover/disabled) — what changes // here: Buy/Sell's TRADE gate (grayed until GameScene.isTradeUnlocked — // the 'Special Delivery' capital landing) and Buyback's locked state // (grayed until a sale exists this visit). this._tabTrade?.setDisabled?.(!this.isTradeUnlocked()); this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled()); } // --------------------------------------------------------------------- // CONTENT — the trade panel (SELL grid + BUY list, side by side) // --------------------------------------------------------------------- _showTrade() { const grid = config.get('shopui.grid', {}); // The two panels sit NEXT TO each other with just a bit of padding // between (the trade actions + NET readout ride on the tab sub-bar — // see _buildTabs — so the panel is pure selection). const centerW = grid.centerW ?? 16; const W = this._W(); // The panel's LEFT edge = the surface HUD's planet text's LEFT edge — // the same margin key (data/landing.json → landing.hud.marginX) the // name/line are drawn at (origin 0,0), so the cargo grid lines up // vertically with the planet name above it. The BUY list then STRETCHES // to the right, ending at the SAME padding from the screen's right edge. const leftPad = Math.max(8, Number(config.get('landing.hud.marginX')) || 24); const sellGrid = new TradeGrid(this.scene, { side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity, cls: this.cls, systemId: this.systemId, // The per-unit price: ordinary goods at the shop's fixed sell price; // quest cargo (the 'Special Delivery' package) at its flat delivery // price — the good's `sellFor` (zero credits). The tooltip's "sell // for N" matches what tradePreview pays for the same bay. sellUnit: (id) => E.isQuestCargo(id) ? E.questCargoSellPrice(id) : E.sellPrice(id, this.cls, this.systemId, this.galaxy), // The home world's floor: the package's bay is SALEABLE (the // delivery). Every other world's grid refuses it (NOT FOR SALE). questSell: this.questSellable, }); // The BUY side: a scrollable LIST of stock rows (wide bars — icon + // name + description + price + −/qty/+ stepper). Sized to the SELL // grid's height so the two panels align top AND bottom; the list scrolls // when the stock outgrows the viewport (it always does — 8–16 rows). // Width: from just past the SELL grid out to the right padding — the // wide row gets all the room (the 440 floor is the narrow-window // fallback, where stretching to the right edge isn't possible). const buyW = Math.max(440, W - leftPad - (leftPad + sellGrid.width + centerW)); const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height }); this.sellGrid = sellGrid; this.buyList = buyList; // Layout: [SELL grid] [gap] [BUY list]. The panel's TOP edge sits at // this.topY — just under the surface HUD's planet text (upper-left) — // so the whole panel reads as one unit near the top of the screen (not // hanging down to the deck/sub-bar); its LEFT edge is the planet text's // left edge (leftPad), and the BUY list ends at the right padding. // // IMPORTANT: a TradeGrid/BuyList lays its plate out in local (0,0)→(w,h) // and a Container's (x,y) is a pure TRANSLATION of that local space // (verified with dev/cbox-test.html) — so the panel's position IS its // top-left corner. Treating it as a centered origin (topY + h/2) drops // the panel half a grid lower and clips the bottom rows. const leftX = leftPad; sellGrid.setPosition(leftX, this.topY); const rightX = leftX + sellGrid.width + centerW; buyList.setPosition(rightX, this.topY); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); content.setScrollFactor?.(0); content.add?.(sellGrid); content.add?.(buyList); // Confirm / Clear live on the tab sub-bar (persistent — see // _buildTabs); the panel is pure selection. this._content = content; this.add?.(content); // Data + live preview wiring. sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] }); this._refreshBuyStock(); // rows + the "N left" strip (the daily batch) sellGrid.onSelectionChange = () => this._syncPreview(); buyList.onSelectionChange = () => this._syncPreview(); // The BUY/SELL toggle closed the panel WITH a pending trade? Re-apply // it onto these fresh panels (hiding the panel never loses the trade). this._restorePendingSelection(); // Tap a BUY row's box (outside the stepper) → the TRADE SCOUT: the cargo // grid is swapped for the row's strategy report (what wants it, where // it's grown, the top charted exits + profit). Tap again / anywhere → // the cargo grid comes back. See _onRowTap + js/ui/shop/ScoutPanel.js. buyList.onRowTap = (row) => this._onRowTap(row); this._syncPreview(); // The scout panel: sized to the SELL grid's box (it replaces the cargo // area), added LAST so it paints above the grid while open. A BEST-EXIT // row's tap goes to the scene (the "SET DESTINATION" popup — // SurfaceScene.askSetDestination); anything else closes the panel. const scout = new ScoutPanel(this.scene, { width: sellGrid.width, height: sellGrid.height, onClose: () => this._closeScout(), onExitTap: (exit) => this.scene?.askSetDestination?.(exit), }); scout.setPosition?.(leftX, this.topY); this._scout = scout; this._scoutOpen = null; content.add?.(scout); // Reveal: SELL slides in from the left, BUY from the right. sellGrid.slideIn(); buyList.slideIn(); } // --------------------------------------------------------------------- // CONTENT — the buyback panel (goods sold this session, buy back) // --------------------------------------------------------------------- _showBuyback() { const W = this._W(); const content = new Phaser.GameObjects.Container(this.scene, 0, 0); content.setScrollFactor?.(0); const rows = this._bbRows(); const contentY = this._H() / 2; const cx = W / 2; const title = this._makeText(cx, contentY + 10, 'BUY BACK', { fontSize: '21px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('header') }); // Ethnocentric — section face content.add?.(title); let y = contentY + 56; const rowH = 56; for (const r of rows) { const line = this._makeText(cx - 120, y, `${r.name} · sold ${r.qty} @ ${Math.round(r.price)} cr`, { fontSize: '15px', color: '#eaf6ff', align: 'left' }); content.add?.(line); const minus = this._makeText(cx + 60, y, '−', { fontSize: '20px', color: '#7d92c4', align: 'center' }); const qty = this._makeText(cx + 110, y, String(r.sel), { fontSize: '20px', color: '#00e5ff', align: 'center' }); const plus = this._makeText(cx + 160, y, '+', { fontSize: '20px', color: '#7d92c4', align: 'center' }); for (const el of [line, minus, qty, plus]) content.add?.(el); const hit = [-16, -14, 32, 28]; this._bindTap(minus, () => this.bumpBuyback(r.id, -1), hit); this._bindTap(plus, () => this.bumpBuyback(r.id, +1), hit); y += rowH; } // Confirm / Clear / the live NET readout all ride on the tab sub-bar // (persistent — see _buildTabs); the buyback panel is the pure list of // sold goods. this._content = content; this.add?.(content); this._syncPreview(); } _bbRows() { return this.soldThisSession.map((r) => ({ id: r.id, name: r.name ?? r.id, qty: r.qty, price: r.price, sel: this.buybackSel.get(r.id) ?? 0, })); } bumpBuyback(id, d = 1) { const row = this.soldThisSession.find((r) => r.id === id); if (!row) return this; const cur = this.buybackSel.get(id) ?? 0; const next = Math.max(0, Math.min(row.qty, cur + Math.floor(Number(d) || 0))); if (next === 0) this.buybackSel.delete(id); else this.buybackSel.set(id, next); this._syncPreview(); return this; } getBuybackMap() { const m = {}; for (const [id, q] of this.buybackSel) if (q > 0) m[id] = q; return m; } // --------------------------------------------------------------------- // THE DAILY BATCH (model v2 — the economy's one stateful seam) // --------------------------------------------------------------------- /** * The run's stock map (the origin worlds' remaining daily batches — * GameScene.economyStock, registry-backed, save/restore in * js/save/SaveData.js). Read LIVE on every query so a confirmed trade * (which swaps the reference) is visible without a rebuild. * @returns {{[systemId:string]: {day:number,left:number}}} */ stockMap() { const m = this.gameScene?.economyStock; return (m && typeof m === 'object') ? m : {}; } /** * The remaining batch for a good AT THIS SHOP — finite (0..dailyBatch) * only when THIS system is the good's ORIGIN (the world's signature * good, the one batch-limited row; its batch is per day, regenerated * on the day clock — js/economy/Stock.js). null = unlimited (hub * resale; the good isn't this system's signature). The buy row's * "N left" / "SOLD OUT" strip and the stepper's cap both read this. * @returns {number|null} */ stockRemaining(goodId) { if (!goodId || !this.galaxy) return null; if (E.originOf(goodId, this.galaxy) !== this.systemId) return null; return Stock.batchRemaining(this.stockMap(), this.systemId); } /** The stockCaps map for a buy side (only batch-limited goods at their * origin carry a cap — tradePreview's backstop; the UI clamps first). */ stockCapsFor(buyMap) { const caps = {}; for (const id of Object.keys(buyMap ?? {})) { const left = this.stockRemaining(id); if (left != null) caps[id] = left; } return caps; } /** Refresh the BUY list's rows with the current batch remainders. */ _refreshBuyStock() { if (!this.buyList) return; const stock = E.shopFor(this.cls, this.systemId, this.galaxy).buy.map((r) => ({ ...r, left: this.stockRemaining(r.id), })); this.buyList.refresh({ stock }); } // --------------------------------------------------------------------- // PREVIEW + CONFIRM + CLEAR (the testable core) // --------------------------------------------------------------------- preview() { const cargo = this.ship.cargo ? this.ship.cargo.snapshot() : []; const capacity = this.capacity; const credits = this.ship.credits; if (this.tab === 'buyback') { // buyback — priced at the sale price (buyOverrides), a neutral undo. const buyMap = {}; const buyOverrides = {}; for (const r of this._bbRows()) { if (r.sel > 0) { buyMap[r.id] = r.sel; buyOverrides[r.id] = r.price; } } return E.tradePreview({ cls: this.cls, systemId: this.systemId, galaxy: this.galaxy, cargo, capacity, credits, sellMap: {}, buyMap, buyOverrides, }); } // The trade — the LIVE panel selection when it's open, the SAVED // selection when the BUY/SELL toggle closed the panel (tab === null; // the trade is still pending, just not visible). const sellMap = this.tab === 'trade' ? (this.sellGrid ? this.sellGrid.getSellMap() : {}) : (this._pendingSell ?? {}); const buyMap = this.tab === 'trade' ? (this.buyList ? this.buyList.getBuyMap() : {}) : (this._pendingBuy ?? {}); return E.tradePreview({ cls: this.cls, systemId: this.systemId, galaxy: this.galaxy, cargo, capacity, credits, sellMap, buyMap, // THE BATCH CAP (model v2) — a buy of this system's signature good // can't exceed its remaining daily stock (the UI clamps the stepper // at the same number; this is the backstop). Hubs' resale + buybacks // carry no cap (the sell side is always unlimited). stockCaps: this.stockCapsFor(buyMap), }); } confirm() { const pv = this.preview(); if (!pv.valid) { this._setStatus(`CAN'T TRADE — ${pv.errors.join(', ')}`, true); return false; } this._applyTrade(pv); // THE DAILY BATCH (model v2) — the buy side is the only stock-limited // side: each batch-limited good bought (finite remaining = this system // is its origin) drops the origin's remaining batch by its units. // The SELL side never consumes stock (the market is a sink), and a // BUYBACK returns what the shop JUST bought from the player (neutral // undo — it never draws on the batch either). for (const b of pv.buy) { if (this.stockRemaining(b.id) != null) this.gameScene?.spendStock?.(this.systemId, b.qty); } // The 'Special Delivery' step 3 — the parcel's HOME-WORLD SALE (zero // credits) is the delivery itself. Only quest cargo (tradeable:false // — the package) can ride a sell here (everywhere else the grid // refuses it), and the ledger's owner double-checks the world before // the delivery stands (GameScene.onQuestPackageSold — the witness // the 'packageDelivered' check reads). if (pv.sell.some((s) => E.isQuestCargo(s.id))) { this.gameScene?.onQuestPackageSold?.(this.worldName); } // The REPUTATION bump (data/reputation.json → bonuses.perGoodSold — // 2% of the scale's top per good sold, +0.4 at the default +20): the // world the shop stands on earns it, the sub-point fraction crediting // across sales until it pays out whole points (five units → +2). The // home world is pinned (creditFraction no-ops), so a sale there earns // nothing — the delivery the package sale already stood. const soldUnits = pv.sell.reduce((a, s) => a + Math.max(0, Math.floor(Number(s.qty) || 0)), 0); if (soldUnits > 0) this.gameScene?.onGoodsSold?.(this.worldName, soldUnits); this._afterTrade(pv); return true; } clear() { if (this.tab === 'buyback') { this.buybackSel = new Map(); } else if (this.tab === 'trade') { this.sellGrid?.clearSelection(); this.buyList?.clearSelection(); } else { // Closed panel — cancel the SAVED pending trade (the toggle-close kept it). this._pendingSell = null; this._pendingBuy = null; } this._syncPreview(); return this; } _applyTrade(pv) { const ship = this.ship; // THE PROFIT XP (1 XP per 1 credit earned above each good's original // purchase price) — computed from the per-bay cost ledger BEFORE the // bays are removed (the FIFO basis remove() uses). A sale at/below cost, // or a bay with no cost on file (the quest package; an old save), yields // 0 — the grant below simply doesn't fire. const profitXp = E.tradeProfitXp(pv.sell, (id, n) => (ship.cargo && typeof ship.cargo.costsFor === 'function') ? ship.cargo.costsFor(id, n) : []); this._lastProfitXp = profitXp; // the sale's XP (the CONFIRMED line shows it) if (pv.sellTotal > 0) ship.addCredits(pv.sellTotal); if (pv.buyTotal > 0) ship.spendCredits(pv.buyTotal); for (const s of pv.sell) ship.cargo.remove(s.id, s.qty); // free bays first // THE COST RECORD — b.unit is what the player ACTUALLY paid for these // units (the shop's buy price, or the sale price on a buyback): stamp it // onto every bay added, so the ship console's manifest later compares // the exit sell prices against the real purchase cost (not a guess). for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id), b.unit); // The sale's profit, paid out in XP — the SAME pipe as kills and sidequests // (the level-up marquee + armor bump fire downstream; the 'trade' tag is // the ledger's source). A 0-profit sale grants nothing. if (profitXp > 0 && this.gameScene?.combat?.xp?.grant) { this.gameScene.combat.xp.grant(Math.floor(profitXp), 'trade'); } } _afterTrade(pv) { if (this.tab === 'buyback') { for (const b of pv.buy) this._reduceSold(b.id, b.qty); this.buybackSel = new Map(); } else { // The trade — open tab OR the closed panel (tab === null, selection in // the _pending* store). Record the sale for buyback, reset both. for (const s of pv.sell) { // Quest cargo (the 'Special Delivery' package) is DELIVERED when // it sells — the shop doesn't deal in it outside the delivery, // so it never enters the buyback's undo list (the session is the // shop's, and the shop's answer to the parcel is "zero, take // your delivery", not "here, buy it back"). if (E.isQuestCargo(s.id)) continue; this._addSold(s.id, s.qty, s.unit, s.name); } this.sellGrid?.clearSelection(); this.buyList?.clearSelection(); this._pendingSell = null; this._pendingBuy = null; } this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] }); // The BUY rows re-render with the batch's NEW remainder (the confirm // just spent from it — "6 left" → "3 left" → "SOLD OUT"). this._refreshBuyStock(); this.refreshHud(); this._syncPreview(); this._refreshTabs(); // The CONFIRMED line — the credit delta, the hold, and (only when the // sale made a profit) the XP that payout just earned (1 XP / credit // above cost; a no-profit or buyback confirm shows no XP). const xp = Math.max(0, Math.floor(this._lastProfitXp ?? 0)); const xpPart = xp > 0 ? ` · +${xp} XP` : ''; this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr${xpPart} → hold ${pv.cargoAfter}/${this.capacity}`, false); } _addSold(id, qty, price, name) { let row = this.soldThisSession.find((r) => r.id === id); if (!row) { row = { id, name: name ?? id, qty: 0, price: price ?? 0 }; this.soldThisSession.push(row); } row.qty += qty; if (price) row.price = price; } _reduceSold(id, qty) { const row = this.soldThisSession.find((r) => r.id === id); if (!row) return; row.qty -= qty; if (row.qty <= 0) this.soldThisSession = this.soldThisSession.filter((r) => r.id !== id); } refreshHud() { // The GAME scene's HUD (the main-flight corner readout) — and the // SURFACE scene's HUD (this screen's own upper-right pair, which is the // one the player actually sees over the shop clip — the GameScene is // sleeping beneath us, so its HUD update is belt-and-braces). this.gameScene?.refreshCreditsHud?.(); this.scene?.refreshShopHud?.(); } // --------------------------------------------------------------------- // PREVIEW SYNC (live NET + Confirm/Clear visibility) // --------------------------------------------------------------------- _syncPreview() { const pv = this.preview(); const anySel = this._hasAnySelection(); // The live NET readout lives on the tab sub-bar (this._netValue) — one // glance sees the tabs AND the pending trade's credit delta. Right- // aligned, so a growing number never shifts the "NET" label. if (this._netValue && typeof this._netValue.setText === 'function') { const n = Math.round(pv.net); this._netValue.setText(n === 0 ? '0 cr' : `${n > 0 ? '+' : ''}${n} cr`); const col = n === 0 ? config.get('shopui.preview.zeroColor', '#7d92c4') : (n > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f')); if (typeof this._netValue.setColor === 'function') this._netValue.setColor(col); } const confirmOn = anySel; const confirmEnabled = anySel && pv.valid; // MenuButton: hide (alpha) when no selection, and GRAY (setDisabled) while // the trade is invalid — a disabled button swallows its own clicks. if (this._confirmBtn) { this._confirmBtn.setAlpha?.(confirmOn ? 1 : 0); this._confirmBtn.setDisabled?.(!confirmEnabled); } this._clearBtn?.setAlpha?.(confirmOn ? 1 : 0); } _hasAnySelection() { if (this.tab === 'buyback') return [...this.buybackSel.values()].some((q) => q > 0); if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false); // Closed panel: the saved pending trade is the selection. return Object.keys(this._pendingSell ?? {}).length > 0 || Object.keys(this._pendingBuy ?? {}).length > 0; } /** * Re-apply the selection _closePanel() saved (the player closed the PANEL * via the BUY/SELL toggle, not the trade). Only called from _showTrade, * right after the fresh panels' refresh() + handler wiring. Clears the * store so a later confirm/clear sees the live panels as the source. */ _restorePendingSelection() { const s = this._pendingSell; const b = this._pendingBuy; this._pendingSell = null; this._pendingBuy = null; if (!s && !b) return this; if (s && Object.keys(s).length > 0) this.sellGrid?.setSellMap?.(s); if (b && Object.keys(b).length > 0) this.buyList?.setBuyMap?.(b); this._syncPreview(); return this; } // --------------------------------------------------------------------- // THE TRADE SCOUT — a BUY row's strategy view // --------------------------------------------------------------------- /** * A BUY row's box was tapped (BuyList → onRowTap), or the list's dead * space was (row === null). The scout is that row's STRATEGY view — the * cargo grid is swapped for the report (js/ui/shop/ScoutPanel.js, fed by * the pure js/economy/TradeScout.js): * * · WHAT it is (icon, name, class, tier, flavor) * · WHERE it's GROWN (the producer classes — cheap to buy there) * · WHO WANTS it (the demand classes + their economy line) * · WHERE it sells BEST — the top-N CHARTED (visited) systems ranked by * sell price, each with the per-unit PROFIT vs. this shop's buy price * * TAP RULES (the player's ask): the SAME box again closes it; a DIFFERENT * box swaps the report; ANYWHERE ELSE (list dead space, the scout panel * itself, a tab switch) closes it and restores the cargo grid. */ _onRowTap(row) { if (this.tab !== 'trade') return; // only over the trade panel if (!row) { this._closeScout(); return; } // "anywhere else" if (this._scoutOpen === row.id) { this._closeScout(); return; } // same box → toggle off this._openScout(row.id, row.price); } _openScout(id, localBuy) { const data = scoutGood(id, { galaxy: this.galaxy, systemId: this.systemId, cls: this.cls, buyUnit: localBuy, // the row's price here — the profit baseline visited: this._visitedIds(), topN: config.get('shopui.scout.topN', 3), }); if (!data || !this._scout) return; this._scoutOpen = id; this.buyList?.focusRow?.(id); // the light stroke on the box this.scene.tweens?.killTweensOf?.(this.sellGrid); // no slide-in fighting this.sellGrid?.setAlpha?.(0); // the cargo grid steps aside… this._setSellGridInput(false); // …and can't be tapped through this._scout.show(data).reveal(); // the report fades in } _closeScout() { if (!this._scoutOpen && this.buyList?._focusId == null) return; this._scoutOpen = null; this.buyList?.focusRow?.(null); // the stroke fades with the panel this._scout?.hide(); this.sellGrid?.setAlpha?.(1); // the cargo grid is back this._setSellGridInput(true); } // The systems the run has ENTERED (the "charted" set the exits rank over). // GameScene.visitedSystems (a Set, seeded with the home system); the // current system always counts (we're here). _visitedIds() { const v = this.gameScene?.visitedSystems; if (v && typeof v[Symbol.iterator] === 'function') return [...v]; return [this.systemId].filter(Boolean); } // The SELL grid's bays are interactive (hover tooltips + tap-select). While // the scout covers them, switch their input off (Phaser's input.enabled — // inputCandidate() honors it) so a tap through the panel can't flick a bay; // back on when the cargo grid is restored. _setSellGridInput(on) { for (const cell of this.sellGrid?.cells ?? []) { const plate = cell?.plate; if (plate?.input) plate.input.enabled = !!on; } } _setStatus(msg, bad) { // Best-effort status line (headless no-ops). The credits HUD count-up is // the primary feedback; this is the in-panel confirmation. `this._lastStatus` // always records the latest line (the tests read it; the text object is // the on-screen surface). this._lastStatus = msg; if (this._statusText && typeof this._statusText.setText === 'function') { this._statusText.setText(msg); if (typeof this._statusText.setColor === 'function') this._statusText.setColor(bad ? config.get('shopui.preview.negColor', '#ff2d6f') : config.get('shopui.preview.posColor', '#7ce8a4')); } } // --------------------------------------------------------------------- // LIFECYCLE // --------------------------------------------------------------------- close() { this._destroyContent(); this.destroy(); if (typeof this.onLeave === 'function') this.onLeave(); return this; } _destroyContent() { // The panel content goes; the sub-bar (tabs + actions + NET readout) // PERSISTS across tab switches — it's not part of the content. if (this._content) { this._content.destroy?.(); this._content = null; } this.sellGrid?.destroy?.(); this.buyList?.destroy?.(); this.sellGrid = null; this.buyList = null; this._scout = null; // the scout died with the content this._scoutOpen = null; } destroy() { this._destroyContent(); this._tabTrade?.destroy?.(); this._tabBuyback?.destroy?.(); this._confirmBtn?.destroy?.(); this._clearBtn?.destroy?.(); this._netLabel?.destroy?.(); this._netValue?.destroy?.(); this._tabTrade = null; this._tabBuyback = null; this._confirmBtn = null; this._clearBtn = null; this._netLabel = null; this._netValue = null; this.subBar?.destroy?.(); this.subBar = null; if (typeof super.destroy === 'function') super.destroy(); } // --------------------------------------------------------------------- // SMALL HELPERS (Phaser-agnostic; the headless fake provides add.text/ // graphics and the stub Container; real Phaser provides the rest) // --------------------------------------------------------------------- _W() { return this.scene.scale?.width ?? 1280; } _H() { return this.scene.scale?.height ?? 720; } _makeText(x, y, str, style = {}) { // Owned by whichever container the caller adds it to (or this) — real // Phaser tears the whole tree down on destroy(); the headless stub doesn't // recurse, but the tests build a fresh scene each time so that's fine. const t = this.scene.add?.text?.(x, y, str, { align: 'center', fontFamily: fontStack('body'), ...style }); // Centauri default; callers may override if (t) t.setOrigin?.(0.5, 0.5); return t; } _makeButton(label, w, h, accent) { const g = this.scene.add?.graphics?.(); const t = this.scene.add?.text?.(0, 0, label, { fontSize: '18px', color: '#eaf6ff', align: 'center', fontFamily: fontStack('body') }); const btn = new Phaser.GameObjects.Container(this.scene, 0, 0); if (g) { this._drawBtn(g, w, h, accent); btn.add?.(g); } if (t) { t.setOrigin?.(0.5, 0.5); btn.add?.(t); } const api = { container: btn, setPosition: (x, y) => { btn.setPosition?.(x, y); }, setAlpha: (a) => { btn.setAlpha?.(a); }, setAccent: (color) => { this._drawBtn(g, w, h, color); }, tap: (fn) => { this._bindTap(g ?? t, fn, [-w / 2, -h / 2, w, h]); }, destroy: () => { g?.destroy?.(); t?.destroy?.(); btn.destroy?.(); }, }; return api; } _drawBtn(g, w, h, accent) { if (!g || typeof g.clear !== 'function') return; g.clear?.(); if (typeof g.fillStyle === 'function') { const a = _hex(accent ?? '#00e5ff'); g.fillStyle(a, 0.16); g.lineStyle?.(1, a, 1); } g.fillRect?.(-w / 2, -h / 2, w, h); } _bindTap(obj, fn, hitRect) { if (!obj || typeof obj.setInteractive !== 'function' || typeof obj.on !== 'function') return; // Phaser v4 (Giedi): a bare setInteractive() leaves no hitAreaCallback and // crashes the input (MenuButton sets an explicit hitArea + callback for this). const opts = { useHandCursor: true }; const Rect = Phaser.Geom?.Rectangle; if (Rect && hitRect) { const area = new Rect(hitRect[0], hitRect[1], hitRect[2], hitRect[3]); opts.hitArea = area; opts.hitAreaCallback = (p, px, py) => Rect.Contains(p, px, py); } obj.setInteractive(opts).on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); }); } } function _hex(v) { const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i); return m ? parseInt(m[1], 16) : 0xffffff; }