orbit/js/ui/shop/ShopView.js

750 lines
34 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 ···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.
* 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 { 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, 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.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'
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 + 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). */
_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, 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,
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).
// 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() : [] });
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;
}
// 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;
}
// ---------------------------------------------------------------------
// 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() {
// 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;
}
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;
}