orbit/js/ui/shop/ShopView.js

715 lines
31 KiB
JavaScript
Raw 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.

/**
* 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 │ NET +420 cr ← tab sub-bar │
* └────────────────────────────────────────────────────────────────┘
*
* 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.
* 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, right of the Buy/Sell + Buyback buttons past a
* hairline divider (tabs AND the pending trade's credit math on one
* console); Confirm/Clear stay in the center. 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 { 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';
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).
* gameScene — to refresh the credits/minerals HUD after a trade.
* onLeave — called by close() so the SurfaceScene can restore the
* surface loop (the deck's "Leave Shop" does this too).
* anchor — { x, y, w } the sub-bar's anchor: the deck's "Shop"
* button center x, the deck's top-rail y (the bar's bottom
* edge rests on it), and the button width (right-aligns the
* bar) — the MenuSubBar recipe (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.onLeave = o.onLeave;
// The tab sub-bar anchor (the deck's Shop button). 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 };
// 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'
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
this.buybackSel = new Map(); // goodId -> qty to buy back
this.sellGrid = null;
this.buyList = null;
this._content = null; // the active content container
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). 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);
}
setTab(t) {
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
this.tab = t;
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): the live NET credit readout —
// the pending trade's credit delta — sits at the bar's right end past a
// hairline divider. Tabs AND the credit math on ONE console: the readout
// is one glance away from the tabs at every moment (entry state included,
// where it 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 = (item) => new MenuButton(
this.scene, 0, -H / 2, String(item.label ?? item.id), () => this._onTabTap(item.id),
{
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(item.accent ?? c.neon ?? '#00e5ff'),
},
);
const btns = items.map(mkBtn);
const widths = btns.map((b) => b.style?.width ?? 120);
const tabsW = widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1);
// 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-aligned at the bar's end, so longer
// numbers ("+1,240 cr") never need the bar to grow.
const labelStr = String(pv.label ?? 'NET');
const valueFont = Math.max(8, Number(pv.valueFontSize) || 22);
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, '0 cr', {
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 : 4 * valueFont * 0.6) || 40;
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 ] │ NET value
const W = Math.min(this._W() - 24, pad * 2 + tabsW + gap + divW + gap + labelW + gap + valueW);
// Position: bottom edge on the deck's top rail (a.y), right edge on the
// Shop button's right edge — the MenuSubBar recipe. 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 sceneW = this._W();
let cx = a.x + a.w / 2 - W / 2;
cx = Phaser.Math.Clamp(cx, W / 2 + 12, sceneW - W / 2 - 12);
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
// divider between the tabs and the NET block is part of the body, so it
// paints under both.
const divX = -W / 2 + pad + tabsW + gap + divW / 2;
const bodyG = this.scene.add?.graphics?.();
if (bodyG) {
bodyG.setScrollFactor?.(0);
bar.add?.(bodyG);
this._drawSubBarBody(bodyG, W, H, c, divX, divH);
}
// The buttons LAST (above the body), left-anchored in the row.
let bx = -W / 2 + pad + widths[0] / 2;
items.forEach((it, i) => {
const btn = btns[i];
btn.setX?.(bx);
bar.add?.(btn);
if (it.id === 'trade') this._tabTrade = btn;
else this._tabBuyback = btn;
bx += widths[i] / 2 + gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0);
});
// The NET block: the label right after the divider, the value right-
// aligned to the bar's end (so a growing number never shifts the label).
this._netLabel.setPosition?.(divX + divW / 2 + gap + labelW / 2, -H / 2);
this._netValue.setPosition?.(W / 2 - pad - valueW / 2, -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).
* `divX`/`divH` (optional) add the hairline divider between the tabs and
* the NET readout — drawn as body so it paints under the buttons + text.
*/
_drawSubBarBody(g, W, H, c, divX, 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
if (typeof divX === 'number' && divH > 0) {
g.fillStyle(toColor(c.border ?? '#22405f'), 0.9);
g.fillRect(divX - 1, -H / 2 - divH / 2, 2, divH); // tabs │ NET
}
}
}
/** A tab button press (MenuButton → _onTabTap). */
_onTabTap(id) {
if (id === 'trade') this.setTab('trade');
else if (id === 'buyback' && this.isBuybackEnabled()) this.setTab('buyback');
}
_refreshTabs() {
// MenuButtons carry their own state (base/hover/disabled) — the only thing
// that changes here is Buyback's locked state (grayed until a sale exists).
this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled());
}
// ---------------------------------------------------------------------
// CONTENT — the trade panel (SELL grid + BUY list + buttons)
// ---------------------------------------------------------------------
_showTrade() {
const grid = config.get('shopui.grid', {});
const centerW = grid.centerW ?? 208;
const W = this._W();
const sellGrid = new TradeGrid(this.scene, {
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
});
// 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).
const buyW = Math.min(
Number(config.get('shopui.buyList.w')) || 560,
Math.max(440, W - 24 - sellGrid.width - centerW),
);
const buyList = new BuyList(this.scene, { width: buyW, height: sellGrid.height });
this.sellGrid = sellGrid;
this.buyList = buyList;
// Layout: [SELL grid] [center] [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).
//
// 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 totalW = sellGrid.width + centerW + buyList.width;
const leftX = Math.max(8, (W - totalW) / 2);
const gridH = sellGrid.height;
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);
// center column: Confirm + Clear — vertically centered with the grids,
// so the whole panel is one cohesive block near the top of the screen.
// (The live NET preview now rides on the tab sub-bar — see _buildTabs.)
const cx = leftX + sellGrid.width + centerW / 2;
const cy = this.topY + gridH / 2;
// Confirm / Clear — the same cyberpunk MenuButton as the tab bar.
const c = config.section('save.colors', {});
const btnCfg = config.section('save.subBar.button', {});
this._confirmBtn = new MenuButton(
this.scene, cx, cy + 16, config.get('shopui.buttons.confirmLabel', 'Confirm'), () => this.confirm(),
{
screenFixed: true,
fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12,
letterSpacing: btnCfg.letterSpacing ?? 2, upper: true,
textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222',
hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.confirmAccent', '#7ce8a4')),
},
);
this._confirmBtn.setAlpha?.(0);
content.add?.(this._confirmBtn);
this._clearBtn = new MenuButton(
this.scene, cx, cy + 86, config.get('shopui.buttons.clearLabel', 'Clear'), () => this.clear(),
{
screenFixed: true,
fontSize: btnCfg.fontSize ?? 13, paddingX: 26, paddingY: 12,
letterSpacing: btnCfg.letterSpacing ?? 2, upper: true,
textColor: c.ink ?? '#eaf6ff', bgColor: c.cardBg ?? '#0a1222',
hoverColor: c.cardBg ?? '#0a1222', borderColor: toCss(config.get('shopui.buttons.clearAccent', '#7d92c4')),
},
);
this._clearBtn.setAlpha?.(0);
content.add?.(this._clearBtn);
this._content = content;
this.add?.(content);
// Data + live preview wiring.
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
sellGrid.onSelectionChange = () => this._syncPreview();
buyList.onSelectionChange = () => this._syncPreview();
this._syncPreview();
// 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;
}
const cy = y + 24;
// (The live NET readout rides on the tab sub-bar — see _buildTabs.)
const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54);
this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4'));
this._confirmBtn.setPosition(cx, cy + 40);
this._confirmBtn.tap(() => this.confirm());
this._confirmBtn.setAlpha(0);
content.add?.(this._confirmBtn.container);
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;
}
// ---------------------------------------------------------------------
// 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 === 'trade') {
return E.tradePreview({
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {},
buyMap: this.buyList ? this.buyList.getBuyMap() : {},
});
}
// 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,
});
}
confirm() {
const pv = this.preview();
if (!pv.valid) {
this._setStatus(`CAN'T TRADE — ${pv.errors.join(', ')}`, true);
return false;
}
this._applyTrade(pv);
this._afterTrade(pv);
return true;
}
clear() {
if (this.tab === 'trade') {
this.sellGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
this.buybackSel = new Map();
}
this._syncPreview();
return this;
}
_applyTrade(pv) {
const ship = this.ship;
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
for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id));
}
_afterTrade(pv) {
if (this.tab === 'trade') {
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
this.sellGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
this.buybackSel = new Map();
}
this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
this.refreshHud();
this._syncPreview();
this._refreshTabs();
this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr → 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() {
this.gameScene?.refreshCreditsHud?.();
}
// ---------------------------------------------------------------------
// 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 === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false);
return [...this.buybackSel.values()].some((q) => q > 0);
}
_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.
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() {
if (this._content) {
this._content.destroy?.();
this._content = null;
}
this.sellGrid?.destroy?.();
this.buyList?.destroy?.();
this.sellGrid = null;
this.buyList = null;
this._confirmBtn = null;
this._clearBtn = null;
}
destroy() {
this._destroyContent();
this._tabTrade?.destroy?.();
this._tabBuyback?.destroy?.();
this._netLabel?.destroy?.();
this._netValue?.destroy?.();
this._netLabel = null;
this._netValue = 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;
}