1104 lines
52 KiB
JavaScript
1104 lines
52 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|