Compare commits

...

6 Commits

Author SHA1 Message Date
Brian Fertig 3d82316a13 Add lava planet image asset 2026-09-12 00:08:30 -06:00
Brian Fertig c3d5e7fe74 Span the trade panel to the screen edges: SELL flush with the planet text, BUY out to the same right padding
The panel was centered as a group; now the SELL grid's left edge sits at
the surface HUD's planet-text left edge (landing.hud.marginX, 24px —
same key the HUD name/line use) so the cargo grid lines up vertically
with the planet name above it, and the BUY list stretches from just
past the grid all the way to the right edge minus that same padding
(1280px window: BUY list 560 -> 770px wide; the 440px floor stays as
the narrow-window fallback). The 16px gap between the panels is
unchanged, and the sub-bar still anchors to the deck's Shop button.
shopui.json's buyList.w is now only the headless fallback width.
2026-09-12 00:06:17 -06:00
Brian Fertig cc4a15cafd Widen the BUY list right up against the SELL grid; the row description drops to 11px and wraps to two lines without touching the price/stepper
The center column is gone: Confirm + Clear move onto the tab sub-bar
([tabs] | [Clear][Confirm] | NET value), serving both the trade and
buyback tabs, so the two panels sit side by side with just a bit of
padding between (grid.centerW 208 -> 16). The row description keeps the
smaller font, word-wraps inside the text column (wrap width stops
before the price), and the char-count budget now spans two lines so the
headless path stays bounded; rowH grows 64 -> 72 to fit both lines.
The buttons persist across tab switches (they were content before and
died with it), Confirm stays grayed while the trade is invalid, and the
stub gains setX/setY so the bar layout is testable.
2026-09-11 23:47:43 -06:00
Brian Fertig 3fdd825570 Lock the deck while the shop is up: only LEAVE SHOP stays live
While the shop clip is on screen (the SHOP slot reads LEAVE SHOP), the
command deck's other slots — Build, Ship, Quests, Take Off, Menu — go
standby: dim, no hover, no press, clicks swallowed — the same inert
language as the 'ack' comm's deck hold. The press that opened the shop
is the only way out; the slots re-arm on LEAVE SHOP (and if the shop
clip fails to load).

  js/ui/ActionBar.js     setSlotEnabled(id, on) — per-slot lock: arms/
                         disarms one slot (input off, standby paint);
                         setEnabled (deck-wide standby) keeps each slot's
                         flag in sync and still owns input over a
                         per-slot unlock; setHover/press guard on it;
                         setLabel no longer repaints a locked slot live
  js/scenes/SurfaceScene setDeckLocked(locked) — lock everything but
                         SHOP on enter (toggleShop), release on leave
                         (leaveShop) + the broken-clip fallback
  dev/actionbar.test.mjs new — slot lock / deck lock / standby-priority /
                         label-kept-locked, headless
  docs/ECONOMY.md §12    the deck-lock bullet

All headless suites that passed before still pass (the five pre-existing
failures — music/quest-tracker/system-hud/galaxy-labels/jumpgate —
import SurfaceScene, which extends Phaser.Scene that the headless stub
doesn't provide; they fail identically without this change).
2026-09-11 23:31:54 -06:00
Brian Fertig a2d211a981 Rework the BUY side: a scrollable list of wide rows (icon + name +
description + price + stepper) instead of the 4×4 icon grid

Each stock good is now ONE WIDE ROW — the good's icon, its name + a
one-line description (the good's flavor), and the price + a −/qty/+
stepper on the right — the way the user wanted the shop list to read.
The stock (12–16 goods) outgrows the panel, so the list SCROLLS:
drag on the row area or the mouse wheel; rows outside the window are
clipped (row-based alpha clipping — this Phaser build has no
ScrollContainer/camera clip), and hidden rows' steppers are
non-interactive. The selection is data (goodId→qty), so stepping a
good works whether its row is on screen or not.

  js/ui/shop/BuyList.js   new — the scrollable BUY list (rows, drag +
                          wheel scroll, per-row steppers, flavor text)
  js/ui/shop/TradeGrid.js SELL-only now — the BUY mode (steppers, stock
                          rendering, buy-selection data) is stripped;
                          one grid, one purpose
  js/ui/shop/ShopView.js  composes TradeGrid (SELL) + BuyList (BUY);
                          the BUY panel sizes itself to the SELL grid's
                          height (top- AND bottom-aligned)
  data/shopui.json        buyList row/typography knobs (name, desc,
                          price, row bg/border, selection accent)
  dev/shop-ui.test.mjs    BUY assertions rewritten for the list + a new
                          SCROLL section (scroll clamps, row clipping,
                          stepping off-screen rows)
  dev/shop-shot.mjs       BUY probe → the row's inline text (no tooltip)
  docs/ECONOMY.md §12     the BUY bullet + mockup now describe the list

The BUY side's name + price used to live in a hover tooltip because a
grid cell is too small for text; a wide row carries them inline, so the
tooltip stays on the SELL side only. All headless suites green
(shop-ui, economy, cargo, builds, ship-behavior).
2026-09-11 23:23:15 -06:00
Brian Fertig 2b860cc899 Move the live NET readout from the panel center onto the tab sub-bar
The pending trade's credit delta now sits at the right end of the
Buy/Sell + Buyback sub-bar, past a hairline divider — tabs AND the
credit math on one console, one glance apart (entry state dims it to
0 cr). The center column keeps Confirm/Clear (re-centered); the
buyback panel's Confirm moves up to sit just below its rows.

Knobs: data/shopui.json → preview (bar sizing, zeroColor, divider).
Docs: docs/ECONOMY.md §12 mockups + bullets follow the new layout.
Tests: dev/shop-ui.test.mjs asserts the bar's NET readout mirrors the
pending trade (+N cr while picking, 0 cr after Confirm).
2026-09-11 22:09:49 -06:00
12 changed files with 1309 additions and 387 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@ -1,19 +1,17 @@
{
"_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the two 4×4 grids sit side by side (SELL = the player's cargo bays on the LEFT, BUY = the shop's stock on the RIGHT), a center column carries the live NET preview + Confirm/Clear, and a tab bar up top toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. GEOMETRY (slot/gap/gridCols/gridRows/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
"_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the SELL grid (the player's cargo bays) sits on the LEFT, the BUY list (the shop's stock, as a scrollable list of wide rows) RIGHT NEXT TO it with just a bit of padding between (grid.centerW) — the panel is pure selection. The panel SPANS the screen: the SELL grid's left edge is flush with the planet text (data/landing.json → landing.hud.marginX), and the BUY list stretches to the same right padding. The trade ACTIONS (Confirm/Clear) + the live NET-credits readout ride on the TAB SUB-BAR (→ tabs/preview/buttons), and the tab sub-bar toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. SELL CELLS show the good's ICON only — the name + price appear in a hover TOOLTIP (→ tooltip) instead of over the icon. BUY ROWS carry their name + description (wraps to two lines, never over the price/stepper) + price INLINE (no tooltip — the row is wide enough). GEOMETRY (slot/gap/grid/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
"enabled": true,
"tabs": {
"_comment": "The tab sub-bar (two MenuButtons, the deck's cyberpunk style — js/ui/MenuButton.js). Buy/Sell opens the trade panel; Buyback opens the buyback panel (goods sold this session, session-local only). Size/palette come from data/save.json → subBar + colors (the Menu sub-bar recipe); this section just holds the labels + Buy/Sell accent. The sub-bar rests above the command deck, anchored to the deck's Shop button (SurfaceScene.shopAnchor).",
"_comment": "The tab sub-bar (the deck's cyberpunk style — js/ui/MenuButton.js): [Buy/Sell][Buyback] │ [Clear][Confirm] │ NET <value> — the tabs, the trade ACTIONS (→ buttons), and the live NET readout (→ preview), past hairline dividers, on ONE console. Buy/Sell opens the trade panel; Buyback opens the buyback panel (goods sold this session, session-local only). Size/palette come from data/save.json → subBar + colors (the Menu sub-bar recipe); this section just holds the labels + Buy/Sell accent. The sub-bar rests above the command deck, anchored to the deck's Shop button (SurfaceScene.shopAnchor).",
"buySell": "Buy/Sell",
"buyback": "Buyback",
"w": 184, "h": 46, "gap": 14,
"y": 44,
"activeAccent": "#00e5ff",
"disabledAlpha": 0.34
},
"panel": {
"_comment": "The semi-transparent panel behind each side's grid — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
"_comment": "The semi-transparent panel behind each side (the SELL grid's bays / the BUY list's rows) — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
"bgColor": "#0a1120",
"bgAlpha": 0.70,
"border": "#22405f",
@ -22,27 +20,46 @@
},
"grid": {
"_comment": "The 4×4 grid shared by both sides (data/economy.json → cargo.maxSlots = 16 slots). SELL: the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). BUY: the shop's stock (12–16) fills the first N, the rest are empty.",
"_comment": "The SELL side's 4×4 bay grid (data/economy.json → cargo.maxSlots = 16 slots): the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). `centerW` is the small PADDING gap between the SELL grid and the BUY list (the panels sit side by side; the trade actions live on the tab sub-bar — → buttons). (The BUY side is a list now — → buyList.)",
"cols": 4, "rows": 4,
"slot": 96, "gap": 10,
"centerW": 208
"centerW": 16
},
"slot": {
"_comment": "A single bay/stock cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `stepper` the BUY quantity control. `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which now fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors still apply — they feed the tooltip's name/price lines + the buy stepper. Colors follow the theme (cyan selection, gold buy, green sell, magenta = bad).",
"_comment": "A single SELL bay cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `lockAlpha` dims the locked/empty cells. `iconScale` sizes the good icon, which fills the cell (the name + price live in the hover tooltip, not over the icon). `name`/`price` colors feed the tooltip's name/price lines. Colors follow the theme (cyan selection, green sell, magenta = bad).",
"bgColor": "#0e1728",
"border": "#1d2e4c",
"selected": "#00e5ff",
"lockAlpha": 0.30,
"iconScale": 0.62,
"name": { "fontSize": 11, "color": "#eaf6ff" },
"price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" },
"price": { "fontSize": 12, "sell": "#7ce8a4" },
"emptyLabel": "—",
"lockLabel": "locked"
},
"buyList": {
"_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — [icon] NAME / description (the good's flavor, data/goods.json, wraps to up to desc.maxLines lines and NEVER over the price/stepper) … PRICE, − qty +. Live, ShopView STRETCHES the list from just past the SELL grid out to the right padding (data/landing.json → landing.hud.marginX) — `w` is only the fallback when no width is passed (headless/dev). Sized to the SELL grid's height so the panels align top AND bottom; the list scrolls (drag on the body / mouse wheel) when the stock outgrows the viewport — rows outside it are hidden (row-based clipping; this Phaser build has no camera/mask clip). A row in the pending trade (qty > 0) gets the accent border + lit qty. `rowH` is tall enough for the two-line description. `pad` insets rows from the panel edge; the title bar reuses panel.title.",
"w": 560,
"pad": 14,
"rowH": 72,
"rowGap": 6,
"icon": 44,
"iconGap": 12,
"name": { "fontSize": 14, "color": "#eaf6ff" },
"desc": { "fontSize": 11, "color": "#7d92c4", "maxLines": 2 },
"price": { "fontSize": 14, "color": "#ffc94d", "w": 64 },
"rowBg": "#0e1728",
"rowBgAlpha": 0.55,
"rowBorder": "#1d2e4c",
"rowSelBg": "#12283a",
"rowSelBgAlpha": 0.85,
"selected": "#00e5ff"
},
"tooltip": {
"_comment": "The hover tooltip — one per grid (shared), revealed when the pointer is over a usable cell: the good's NAME + the price line (\"sell for N cr\" / \"buy for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri).",
"_comment": "The hover tooltip — one per SELL grid (shared), revealed when the pointer is over a usable bay: the good's NAME + the sell-price line (\"sell for N cr\"). Anchored above the cell, or below when there's no room (top row); centred on the cell, clamped to the panel. Size/palette follow the theme; text is the body face (Centauri). (The BUY list has no tooltip — its rows carry the name/price inline.)",
"bgColor": "#0a1120",
"bgAlpha": 0.96,
"border": "#22405f",
@ -53,37 +70,37 @@
},
"stepper": {
"_comment": "The BUY-side quantity stepper (− n +). Clicking −/+ adjusts that good's buy quantity; the live preview recalcs. The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
"_comment": "The BUY list's per-row quantity stepper (− n +): clicking −/+ adjusts that good's buy quantity (the qty tap = +1); the live preview recalcs. `w` is the stepper block's width (sizes the price gap). The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
"fontSize": 19,
"w": 88,
"color": "#eaf6ff",
"btn": "#7d92c4",
"activeColor": "#00e5ff"
},
"preview": {
"_comment": "The live NET-credits readout in the center column. Positive (you're up) = green, negative (you'd spend past your credits / it's a net spend) = the value shown; the Confirm button is disabled while the trade is invalid (can't go negative, hold overflows).",
"_comment": "The live NET-credits readout — on the TAB SUB-BAR, right of the Buy/Sell + Buyback buttons, past a hairline divider (→ divider): tabs AND the pending trade's credit delta on one console, one glance apart. Positive (you're up) = green, negative (a net spend) = the value shown in magenta, zero = dim (entry state / nothing selected). The value is right-aligned at the bar's end so longer numbers (+1,240 cr) never resize the bar. Confirm is disabled while the trade is invalid (can't go negative, hold overflows).",
"label": "NET",
"labelColor": "#7d92c4",
"labelFontSize": 13,
"valueFontSize": 34,
"labelFontSize": 12,
"valueFontSize": 22,
"zeroColor": "#7d92c4",
"posColor": "#7ce8a4",
"negColor": "#ff2d6f"
"negColor": "#ff2d6f",
"gap": 12,
"divider": { "width": 2, "height": 26, "color": "#22405f", "alpha": 0.9 }
},
"buttons": {
"_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — they appear once the player starts checking/stepping items. `disabled` dims Confirm while the trade is invalid. `back` is a reserved return-to-menu label (the deck's Leave Shop is the real exit). Separate *Label (text) and *Accent (color) keys so they don't collide.",
"w": 158, "h": 54,
"_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — on the TAB SUB-BAR (→ tabs), between the tabs and the NET readout: they appear once the player starts checking/stepping items, and serve BOTH the trade and the buyback tabs. Confirm is grayed (disabled) while the trade is invalid. Button size/palette come from data/save.json → subBar (the same MenuButtons as the tabs); this section just holds the labels + accents.",
"confirmLabel": "Confirm",
"clearLabel": "Clear",
"backLabel": "‹ Menu",
"accent": "#00e5ff",
"confirmAccent": "#7ce8a4",
"clearAccent": "#7d92c4",
"disabled": "#7d92c4"
"clearAccent": "#7d92c4"
},
"animation": {
"_comment": "Reveal: the SELL grid slides in from the left, the BUY grid from the right, the center column fades up (data/shopui.json → animation). A short stagger per row keeps it feeling assembled rather than popped.",
"_comment": "Reveal: the SELL grid slides in from the left, the BUY list from the right (data/shopui.json → animation). A short stagger keeps it feeling assembled rather than popped.",
"slideMs": 340,
"staggerMs": 26,
"ease": "Cubic.Out"

250
dev/actionbar.test.mjs Normal file
View File

@ -0,0 +1,250 @@
/**
* ActionBar test (dev tool, run with Node — no browser needed):
*
* node --import ./dev/phaser-loader.mjs dev/actionbar.test.mjs
*
* The command deck's PER-SLOT lock (js/ui/ActionBar.js → setSlotEnabled),
* the "shop is up" state: every slot but SHOP goes standby (dim, inert —
* no hover, no press, clicks swallowed) and only LEAVE SHOP is the live
* button on the deck. Verified:
*
* - CONSTRUCT — the surface deck builds all six live slots.
* - SLOT LOCK — setSlotEnabled(id, false) arms the slot inert (input
* disabled, press blocked, standby paint) and (id, true) re-arms it.
* - DECK LOCK — the SurfaceScene recipe: lock everything but 'shop' →
* only the shop slot fires onAction; unlock brings the rest back.
* - STANDBY PRIORITY — the deck-wide comm hold (setEnabled(false))
* owns input: a per-slot unlock can't re-open a slot underneath it.
* - LABEL + LOCK — relabeling a locked slot keeps its standby paint.
*
* A fake Scene provides the add.* factories + tweens/time as no-ops; the
* phaser stub (dev/phaser-stub.mjs) provides the Container the bar
* extends.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
const { ActionBar } = await import(pathToFileURL(join(__dirname, '../js/ui/ActionBar.js')).href);
const SURFACE = config.get('actionbar.surface.buttons');
assert.ok(Array.isArray(SURFACE) && SURFACE.length >= 6, 'surface deck has six slots');
const IDS = SURFACE.map((b) => b.id).filter(Boolean);
assert.ok(IDS.includes('shop') && IDS.includes('menu'), 'surface deck has the shop + menu slots');
// ---------------------------------------------------------------------------
// A headless Scene: just enough for the bar to CONSTRUCT + drive the slot
// state machine. `setInteractive`/`disableInteractive` on the slot panels
// record the input state, so the test can assert which slots are armed.
// ---------------------------------------------------------------------------
function stubObject(extra = {}) {
const o = {
x: 0, y: 0, width: 60, height: 18, alpha: 1,
setOrigin() { return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setStrokeStyle() { return this; },
setSize(w, h) { this.width = w; this.height = h; return this; },
setBlendMode() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setInteractive(opts) { this._interactive = opts; return this; },
disableInteractive() { this._interactive = null; return this; },
setDisplaySize() { return this; },
on() { return this; },
once() { return this; },
off() { return this; },
setText(s) { this.text = String(s); return this; },
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
clear() { return this; },
fillStyle() { return this; },
lineStyle() { return this; },
fillPoints() { return this; },
strokePoints() { return this; },
fillRect() { return this; },
strokeRect() { return this; },
lineBetween() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
return o;
}
function makeScene() {
const scene = {
scale: { width: 1280, height: 720 },
add: {
existing: (o) => o,
text: (x, y, str) => stubObject({ x, y, text: String(str) }),
image: (x, y, key) => stubObject({ x, y, key }),
graphics: () => stubObject({}),
rectangle: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
tileSprite: (x, y, w, h) => stubObject({ x, y, width: w, height: h }),
circle: (x, y, r) => stubObject({ x, y, radius: r }),
},
tweens: {
add: (t) => {
// Apply the target values immediately (the real tween eases to the
// same place) so hover punches / boot flicker end at their finals.
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
else if (t?.targets) apply(t.targets);
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: {
now: 1000,
// Run the callback immediately: press()'s 130 ms press-restore and
// boot()'s end-of-boot burst are the only scheduled calls — both are
// no-op-safe, and running them keeps the state machine honest.
delayedCall: (_ms, fn) => { if (typeof fn === 'function') fn(); return { stop() {} }; },
},
textures: { exists: () => true }, // canvasTexture caches → no DOM canvas
};
return scene;
}
function makeBar() {
const pressed = [];
const scene = makeScene();
const bar = new ActionBar(scene, {
onAction: (id) => pressed.push(id),
buttons: SURFACE,
});
return { scene, bar, pressed };
}
const slot = (bar, id) => bar.slots.find((s) => s.id === id);
const armed = (s) => Boolean(s.panel._interactive);
// ===========================================================================
console.log('== CONSTRUCT — the surface deck builds all six live slots ==');
{
const { bar } = makeBar();
assert.equal(bar.slots.length, 6, 'six slots built');
for (const id of IDS) {
const s = slot(bar, id);
assert.ok(s, `slot '${id}' exists`);
assert.ok(s.live, `slot '${id}' is live`);
assert.ok(armed(s), `slot '${id}' starts armed (interactive)`);
assert.equal(s.disabled, undefined, `slot '${id}' is not locked at construction`);
}
}
// ===========================================================================
console.log('== SLOT LOCK — setSlotEnabled arms/disarms one slot ==');
{
const { bar, pressed } = makeBar();
const s = slot(bar, 'build');
bar.setSlotEnabled('build', false);
assert.equal(s.disabled, true, 'the slot is flagged disabled');
assert.equal(armed(s), false, 'the slot is disarmed (input off)');
assert.equal(s.label.color, '#5a6478', 'the slot paints standby (grayed label)');
bar.press(s); // a stray click over the dead slot
assert.equal(pressed.length, 0, 'pressing a locked slot fires no onAction');
// Idempotent: a second lock is a no-op (no re-paint churn, state kept).
bar.setSlotEnabled('build', false);
assert.equal(s.disabled, true, 're-locking is a no-op');
bar.setSlotEnabled('build', true);
assert.equal(s.disabled, false, 'the slot is un-flagged');
assert.ok(armed(s), 'the slot is re-armed (input on)');
assert.notEqual(s.label.color, '#5a6478', 'the slot paints base again');
bar.press(s);
assert.deepEqual(pressed, ['build'], 'pressing an unlocked slot fires onAction');
// Unknown ids are a safe no-op.
assert.equal(bar.setSlotEnabled('nope', false), null, 'unknown id → null, no crash');
}
// ===========================================================================
console.log('== DECK LOCK — while the shop is up, only the shop slot is live ==');
{
const { bar, pressed } = makeBar();
// The SurfaceScene recipe: lock every live slot except 'shop'.
const lock = (locked) => {
for (const s of bar.slots) {
if (!s.live || s.id === 'shop') continue;
bar.setSlotEnabled(s.id, !locked);
}
};
lock(true);
assert.ok(armed(slot(bar, 'shop')), 'the SHOP slot stays armed (the way out)');
for (const id of IDS.filter((x) => x !== 'shop')) {
const s = slot(bar, id);
assert.equal(armed(s), false, `slot '${id}' is disarmed while the shop is up`);
}
// The deck's presses: only the shop slot can get through.
bar.press(slot(bar, 'build'));
bar.press(slot(bar, 'takeoff'));
bar.press(slot(bar, 'menu'));
assert.equal(pressed.length, 0, 'locked slots swallow the press');
bar.press(slot(bar, 'shop'));
assert.deepEqual(pressed, ['shop'], 'LEAVE SHOP still fires onAction');
lock(false);
for (const id of IDS.filter((x) => x !== 'shop')) {
assert.ok(armed(slot(bar, id)), `slot '${id}' is re-armed after unlock`);
}
pressed.length = 0;
bar.press(slot(bar, 'menu'));
assert.deepEqual(pressed, ['menu'], 'the deck is fully live again');
}
// ===========================================================================
console.log('== STANDBY PRIORITY — the comm hold owns input over a slot unlock ==');
{
const { bar } = makeBar();
const s = slot(bar, 'build');
bar.setEnabled(false); // the 'ack' comm's hold: the whole deck goes inert
for (const id of IDS) assert.equal(armed(slot(bar, id)), false, `slot '${id}' inert under deck standby`);
// A per-slot unlock must NOT re-open a slot underneath the deck hold.
bar.setSlotEnabled('build', true);
assert.equal(armed(s), false, 'the slot stays disarmed (deck standby wins)');
assert.equal(s.disabled, false, '…but its per-slot flag is cleared (the deck, not the slot, owns it)');
bar.setEnabled(true); // the comm releases
assert.ok(armed(s), 'the slot re-arms when the deck hold lifts');
for (const id of IDS) assert.ok(armed(slot(bar, id)), `slot '${id}' re-armed with the deck`);
}
// ===========================================================================
console.log('== LABEL + LOCK — relabeling a locked slot keeps the standby paint ==');
{
const { bar } = makeBar();
const s = slot(bar, 'menu');
bar.setSlotEnabled('menu', false);
bar.setLabel('menu', 'Menu 2');
assert.equal(s.label.text, 'MENU 2', 'the label text updates');
assert.equal(s.label.color, '#5a6478', 'the standby paint survives the relabel');
bar.setSlotEnabled('menu', true);
assert.notEqual(s.label.color, '#5a6478', 'unlocking paints the base label back');
}
console.log('\nAll ActionBar checks passed ✔');

View File

@ -21,6 +21,8 @@ class GameObject {
setTint(t) { this.tint = t; return this; }
setBlendMode() { return this; }
setPosition(x, y) { this.x = x; this.y = y; return this; }
setX(x) { this.x = x; return this; }
setY(y) { this.y = y; return this; }
setStrokeStyle() { return this; }
setSize(w, h) { this.width = w; this.height = h; return this; }
set() { return this; }

View File

@ -126,7 +126,6 @@ const bounds = (o) => {
// 1) structure: the old per-cell name/price text is GONE from usable cells
const sellCell = sv.sellGrid.cells[0];
const buyCell = sv.buyGrid.cells[0];
const struct = {
sellCellName: sellCell.name === undefined ? 'undefined' : 'present',
sellCellPrice: sellCell.price === undefined ? 'undefined' : 'present',
@ -134,7 +133,7 @@ const bounds = (o) => {
sellLockedLabel: { text: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.text ?? null, alpha: sv.sellGrid.cells[sv.sellGrid.capacity]?.label?.alpha ?? null },
sellIcon: { alpha: sellCell.icon?.alpha ?? null, scale: round((sellCell.icon?.scaleX ?? 0) * 100) / 100, pos: { x: sellCell.icon?.x, y: sellCell.icon?.y } },
titleFont: sv.sellGrid.title?.style?.fontFamily ?? sv.sellGrid.title?._style?.fontFamily ?? null,
stepperFont: sv.buyGrid.cells[0].stepQty?.style?.fontFamily ?? sv.buyGrid.cells[0].stepQty?._style?.fontFamily ?? null,
buyQtyFont: sv.buyList.rows[0]?.objs?.qty?.style?.fontFamily ?? sv.buyList.rows[0]?.objs?.qty?._style?.fontFamily ?? null,
};
out.tooltip = { struct };
@ -142,21 +141,24 @@ const bounds = (o) => {
const tipSellA = sv.sellGrid.hoverCell(0);
out.tooltip.sellHover = { ...tipState(sv.sellGrid), expectedName: goods[0].name, cell: cellWorldCenter(sv.sellGrid, 0) };
// 3) REAL mouse hover over a BUY cell — row 1 → tip flips ABOVE
const buyIdxAbove = 6; // row 1, col 2 (stock is filled there)
const bc = cellWorldCenter(sv.buyGrid, buyIdxAbove);
out.tooltip.mouseAbove = { cell: bc, move: mouseMove(bc.x, bc.y), state: tipState(sv.buyGrid) };
// 3) BUY side: the WIDE ROW carries name + description + price INLINE (no
// tooltip — the row is wide enough).
const row0 = sv.buyList.rows[0];
const rowNameEl = row0?.objs?.name;
out.tooltip.buyRow = {
name: rowNameEl?.text ?? null,
desc: row0?.objs?.desc?.text ?? null,
price: row0?.objs?.price?.text ?? null,
qty: row0?.objs?.qty?.text ?? null,
nameFont: rowNameEl?.style?.fontFamily ?? rowNameEl?._style?.fontFamily ?? null,
rowWorld: rowNameEl ? { x: round(sv.buyList.x + rowNameEl.x), y: round(sv.buyList.y + rowNameEl.y) } : null,
};
// 4) REAL mouse hover over a BUY cell — row 0 → tip flips BELOW
const buyIdxBelow = 0;
const bc0 = cellWorldCenter(sv.buyGrid, buyIdxBelow);
out.tooltip.mouseBelow = { cell: bc0, move: mouseMove(bc0.x, bc0.y), state: tipState(sv.buyGrid) };
// 5) move the pointer OFF the panel → pointerout hides the tip
const off = toClient(Math.max(4, sv.buyGrid.x - 30), 360);
// 4) move the pointer OFF the panel → pointerout hides the tip
const off = toClient(Math.max(4, sv.buyList.x - 30), 360);
game.canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: off.cx, clientY: off.cy, bubbles: true, view: window }));
await sleep(150);
out.tooltip.mouseOff = { state: tipState(sv.buyGrid) };
out.tooltip.mouseOff = { state: tipState(sv.sellGrid) };
// 6) end with the SELL tooltip visible for the screenshot
sv.sellGrid.hoverCell(0);
@ -166,7 +168,7 @@ const bounds = (o) => {
out.topY = sv.topY ?? null;
out.hudName = (() => { const t = ss.hudName; if (t?.getTopLeft) { const v = t.getTopLeft(undefined, true); return { x: round(v.x), y: round(v.y) }; } return null; })();
out.sellGrid = { x: round(sv.sellGrid.x), y: round(sv.sellGrid.y), w: sv.sellGrid.width, h: sv.sellGrid.height };
out.buyGrid = { x: round(sv.buyGrid.x), y: round(sv.buyGrid.y), w: sv.buyGrid.width, h: sv.buyGrid.height };
out.buyList = { x: round(sv.buyList.x), y: round(sv.buyList.y), w: sv.buyList.width, h: sv.buyList.height, rows: sv.buyList.rows.length, maxScroll: sv.buyList.maxScroll };
window.__SHOP_SHOT__ = out;
console.info('[shop-shot] OK:', JSON.stringify(out));

View File

@ -8,12 +8,16 @@
* CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
* verified in the browser) — it tests the LOGIC the player feels:
*
* - CONSTRUCT — the view builds both grids over a real shop (16 sell bays,
* 12–16 buy slots) and starts on the Buy/Sell tab.
* - CONSTRUCT — the view builds the SELL grid + the BUY list over a real
* shop (16 sell bays; one wide row per stocked good) and starts on the
* Buy/Sell tab.
* - SELL — checking a cargo bay sells one unit at the shop's sell price;
* Confirm earns the credits and drops the good out of the hold.
* - BUY — stepping a stock good buys it at the shop's buy price; Confirm
* spends the credits and fills the bays.
* - BUY — stepping a stock row buys that good at the shop's buy price;
* Confirm spends the credits and fills the bays.
* - SCROLL — the stock list outgrows the viewport: scroll clamps at both
* ends, off-window rows are clipped, and selection is data (stepping an
* off-screen row still works).
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
* credits) or overflow the hold (cargo-full); selling to free room for a
* buy lands in one confirm.
@ -79,6 +83,7 @@ function stubObject(extra = {}) {
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
setWordWrap() { return this; },
setWordWrapWidth(w) { this._wrapW = w; return this; },
setLineSpacing() { return this; },
setPadding() { return this; },
setScrollFactor() { return this; },
@ -180,7 +185,7 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
// must click "Buy/Sell" to reveal it (showcase the clip before the UI).
assert.equal(view.tab, null, 'starts unopened (entry state)');
assert.equal(view.sellGrid, null, 'no SELL grid yet (revealed on Buy/Sell)');
assert.equal(view.buyGrid, null, 'no BUY grid yet');
assert.equal(view.buyList, null, 'no BUY list yet');
assert.ok(view._tabTrade, 'the Buy/Sell tab is present in the entry state');
assert.ok(view._tabBuyback, 'the Buyback tab is present (grayed)');
assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale');
@ -194,10 +199,23 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
assert.ok(view.y === 0, 'ShopView root stays at the screen origin (0,0) so the trade panel renders in screen space');
assert.ok(view.subBarW > 0 && view.subBarH > 0, 'sub-bar has a measured width + height');
// The trade ACTIONS live on the sub-bar too — [tabs] │ [Clear][Confirm] │
// [NET value] — created up front (persistent across tabs), hidden until a
// selection exists (_syncPreview reveals them), Confirm grayed while the
// trade is invalid.
assert.ok(view._clearBtn, 'Clear lives on the sub-bar (persistent, not in the panel)');
assert.ok(view._confirmBtn, 'Confirm lives on the sub-bar (persistent, not in the panel)');
assert.ok(view._clearBtn.x < view._confirmBtn.x, 'Clear sits left of Confirm on the bar');
assert.ok(view._tabBuyback.x < view._clearBtn.x, '… after the tabs');
assert.ok(view._confirmBtn.x < view._netLabel.x, '… and the NET readout rides to their right');
assert.equal(view._clearBtn.alpha, 0, 'entry state: Clear is hidden (no selection yet)');
assert.equal(view._confirmBtn.alpha, 0, 'entry state: Confirm is hidden (no selection yet)');
assert.equal(view._confirmBtn.disabled, true, 'entry state: Confirm is grayed');
// Reveal the trade panel (what clicking "Buy/Sell" does) — both grids appear.
view.setTab('trade');
assert.equal(view.tab, 'trade', 'Buy/Sell reveals the trade tab');
assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist after reveal');
assert.ok(view.sellGrid && view.buyList, 'both the SELL grid and BUY list exist after reveal');
// The panel's TOP edge sits at topY (just under the planet text). A
// TradeGrid lays its plate/cells out in local (0,0)→(w,h) and a
// Container's (x,y) is a pure translation of that local space (verified
@ -210,8 +228,8 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
assert.ok(view.sellGrid.y > 80 && view.sellGrid.y < view._H() - 40, `trade panel is on-screen (sellGrid.y=${view.sellGrid.y})`);
assert.ok(view.sellGrid.y + view.sellGrid.height <= view._H(), `panel bottom stays on-screen (${view.sellGrid.y} + ${view.sellGrid.height} <= ${view._H()})`);
assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays');
assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots');
assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock');
assert.ok(view.buyList.stock.length >= 1, 'BUY list carries the shop stock');
assert.equal(view.buyList.rows.length, view.buyList.stock.length, 'BUY list has one row per stocked good');
assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet');
}
@ -225,21 +243,18 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
const { view } = makeView(ship);
const goodById = (id) => goods.find((g) => g.id === id);
// Each BUY cell's icon must carry the correct spritesheet frame (set via
// Each BUY row's icon must carry the correct spritesheet frame (set via
// setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render).
let checkedIcons = 0;
for (let i = 0; i < view.buyGrid.cells.length; i++) {
const cell = view.buyGrid.cells[i];
const id = view.buyGrid.stock[i]?.id;
if (!id) continue;
const idx = goodById(id)?.icon;
assert.ok(typeof idx === 'number', `good ${id} has an icon index`);
assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`);
for (const row of view.buyList.rows) {
const idx = goodById(row.id)?.icon;
assert.ok(typeof idx === 'number', `good ${row.id} has an icon index`);
assert.equal(row.objs.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(row.objs.icon?.frame, idx, `buy row icon frame = good icon index (${idx})`);
checkedIcons += 1;
}
assert.ok(checkedIcons >= 1, 'at least one buy cell got its icon bound');
assert.ok(checkedIcons >= 1, 'at least one buy row got its icon bound');
// Every tappable element must be interactive WITH a hitAreaCallback — a bare
// setInteractive() leaves no callback and crashes Phaser v4's input (the
@ -247,15 +262,32 @@ console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCal
// real hitArea/hitAreaCallback path is exercised.
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback');
assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect');
assert.equal(typeof view.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback');
assert.equal(typeof view.buyList.rows[0].objs.minus?._interactive?.hitAreaCallback, 'function', 'buy row stepper tap has a hitAreaCallback');
assert.equal(typeof view.buyList.rows[0].objs.plus?._interactive?.hitAreaCallback, 'function', 'buy row + tap has a hitAreaCallback');
// The two panels must settle at DIFFERENT layout positions — the slide-in
// tween targets each grid's set position (the ShopView centers
// [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels
// at the left edge so the cargo/sell panel sat hidden behind the buy panel.
// tween targets each grid's set position. The old bug tweened to x:0,
// stacking both panels at the left edge so the cargo/sell panel sat hidden
// behind the buy panel.
assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`);
assert.ok(view.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`);
assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin');
assert.ok(view.buyList.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyList.x})`);
assert.notEqual(view.sellGrid.x, view.buyList.x, 'the panels do not overlap at the origin');
assert.ok(view.buyList.width > view.sellGrid.width, 'the BUY list is the wide panel (rows, not 4×4 cells)');
// The panel spans the screen like the HUD: the SELL grid's LEFT edge is
// the planet text's left edge (landing.hud.marginX), and the BUY list's
// RIGHT edge is the SAME padding in from the screen's right edge.
const leftPad = Number(config.get('landing.hud.marginX')) || 24;
const W = 1280; // the stub scene's width
assert.equal(view.sellGrid.x, leftPad, `SELL's left edge is flush with the planet text's left edge (x=${view.sellGrid.x}, hud margin=${leftPad})`);
const rightEdge = view.buyList.x + view.buyList.width;
assert.ok(Math.abs(rightEdge - (W - leftPad)) < 1, `BUY's right edge is the same padding in from the right (rightEdge=${rightEdge}, want ${W - leftPad})`);
// The two panels sit NEXT TO each other with just a bit of padding between
// (grid.centerW — the actions moved onto the sub-bar, so there's no center
// column anymore).
const gapPx = view.buyList.x - (view.sellGrid.x + view.sellGrid.width);
assert.ok(Math.abs(gapPx - 16) < 1, `BUY is right next to SELL, small padding (got gap ${gapPx.toFixed(1)} px, want ~16)`);
}
console.log('INTERACTION: OK');
@ -267,8 +299,8 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
const { view } = makeView(ship);
const nameA = goods.find((g) => g.id === A).name;
const sellA = E.sellPrice(A, cls, sysId, galaxy);
const buyId = view.buyGrid.stock[0]?.id;
const nameB = view.buyGrid.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
const buyId = view.buyList.stock[0]?.id;
const nameB = view.buyList.stock[0]?.name ?? goods.find((g) => g.id === buyId)?.name;
const buyB = E.buyPrice(buyId, cls, sysId, galaxy);
// Cells no longer paint their own name/price text — the info lives in the
@ -300,11 +332,29 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
assert.equal(view.sellGrid.tipVisible, false, 'unhover hides the tooltip');
assert.ok(view.sellGrid.tip.alpha <= 0.5, 'tooltip alpha is down');
// BUY side: name + "buy for N cr".
view.buyGrid.hoverCell(0);
assert.equal(view.buyGrid.tipVisible, true, 'hovering a stock cell reveals the tooltip');
assert.equal(view.buyGrid.tipName.text, nameB, 'buy tooltip shows the good name');
assert.equal(view.buyGrid.tipPrice.text, `buy for ${Math.round(buyB)} cr`, `buy tooltip shows the buy price (${buyB})`);
// BUY side: no tooltip — the WIDE ROW carries name + description + price
// inline (the row is wide enough, unlike the icon-only sell cells).
const row0 = view.buyList.rows[0];
assert.equal(row0.objs.name.text, nameB, 'row carries the good name inline');
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price inline');
const flavor0 = goods.find((g) => g.id === buyId)?.flavor ?? '';
assert.ok(row0.objs.desc.text.length >= 3, 'row carries the description');
assert.ok(row0.objs.desc.text.startsWith(flavor0.slice(0, 6)), 'description is the good flavor (truncated to fit)');
// DESCRIPTION WRAP: smaller font (11px), wraps to up to 2 lines, and the
// wrap width stops BEFORE the price/stepper so a wrapped line can never
// overlap them (the char-count budget is the headless fail-safe).
assert.equal(row0.objs.desc._style?.fontSize, '11px', 'desc uses the smaller 11px font');
assert.ok(row0.objs.desc._wrapW > 0, 'desc has a word-wrap width set (real Phaser wraps inside the text column)');
const descMaxW = view.buyList._descMaxW();
assert.ok(descMaxW > 0, 'the text column has a positive width');
assert.ok(row0.objs.desc._wrapW <= descMaxW + 0.001, `… and the wrap width fits inside it (wrap=${row0.objs.desc._wrapW}, col=${descMaxW.toFixed(1)})`);
const budget = Math.max(4, Math.floor(descMaxW / (11 * 0.55))) * 2 + 1; // 2 lines + ellipsis room
assert.ok(row0.objs.desc.text.length <= budget, `desc stays inside the two-line budget (len=${row0.objs.desc.text.length} ≤ ${budget})`);
// The wrapped desc never bleeds into the price: name/desc start at the text
// column's left edge, well clear of the price's right-aligned x.
const priceX = view.buyList._btnXs().price;
assert.ok(view.buyList._textX() + descMaxW < priceX, 'text column + max desc width ends before the price');
// THEME FONTS — the shop UI speaks the project's type (data/theme.json):
// Ethnocentric for section headers, Centauri for body text + buttons.
@ -312,7 +362,7 @@ console.log('\n== HOVER TOOLTIP — name + price on hover, NOT over the icon =='
assert.ok(/Ethnocentric/.test(fam(view.sellGrid.title)), `SELL/BUY title uses the header face (got "${fam(view.sellGrid.title)}")`);
assert.ok(/Centauri/.test(fam(view.sellGrid.tipName)), `tooltip name uses the body face (got "${fam(view.sellGrid.tipName)}")`);
assert.ok(/Centauri/.test(fam(view.sellGrid.tipPrice)), 'tooltip price uses the body face');
assert.ok(/Centauri/.test(fam(view.buyGrid.cells[0].stepQty)), 'buy stepper uses the body face');
assert.ok(/Centauri/.test(fam(view.buyList.rows[0].objs.qty)), 'buy row stepper uses the body face');
assert.ok(/Centauri/.test(fam(view.sellGrid.cells[view.sellGrid.capacity]?.label)), 'locked-bay label uses the body face');
}
console.log('HOVER TOOLTIP: OK');
@ -333,12 +383,25 @@ console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold
assert.equal(pv.net, sellA, '… net = the sale (nothing bought)');
assert.equal(pv.valid, true, '… and it\u2019s legal');
// The live NET readout rides on the TAB SUB-BAR (not the center column) —
// it mirrors the pending trade's credit delta, one glance from the tabs —
// alongside the Confirm/Clear actions (which light up with a selection).
assert.ok(view._netValue, 'the sub-bar carries the NET value readout');
assert.ok(view._netLabel, '… with the NET label');
assert.equal(view._netValue.text, `+${sellA} cr`, '… it shows the pending +cr');
assert.ok(view._confirmBtn.alpha > 0.5, 'a selection reveals the sub-bar Confirm');
assert.ok(view._clearBtn.alpha > 0.5, '… and Clear');
assert.equal(view._confirmBtn.disabled, false, '… and Confirm is live (valid trade)');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before + sellA, 'credits rise by the sale');
assert.deepEqual(ship.cargo.snapshot(), [B], 'A leaves the hold, B stays');
assert.equal(view.isBuybackEnabled(), true, '… and A is now buyback-eligible');
assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed');
assert.equal(view._netValue.text, '0 cr', '… and the bar’s NET settles back to 0 cr');
assert.equal(view._confirmBtn.alpha, 0, '… and the Confirm/Clear actions hide again (selection gone)');
assert.equal(view._clearBtn.alpha, 0, '… Clear hides too');
}
console.log('SELL: OK');
@ -350,8 +413,8 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
const buyC = E.buyPrice(C, cls, sysId, galaxy);
assert.ok(buyC > 0, 'the buy price is positive');
view.buyGrid.bumpBuy(C, 2);
assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
view.buyList.bumpBuy(C, 2);
assert.deepEqual(view.buyList.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
let pv = view.preview();
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
assert.equal(pv.net, -buyC * 2, '… net = the spend');
@ -365,6 +428,41 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
}
console.log('BUY: OK');
// ===========================================================================
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
{
const ship = makeShip(10000);
const { view } = makeView(ship);
const list = view.buyList;
assert.ok(list.rows.length >= 8, `the shop stock has ${list.rows.length} rows (≥ 8)`);
// 64+6 px per row vs a ~420 px viewport → the content is TALLER than the
// window, so the list must scroll (rows that don't fit were the old bug).
assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`);
// Scrolling clamps at both ends.
list.scrollBy(100000);
assert.equal(list._scroll, list.maxScroll, 'scroll clamps at the bottom');
list.scrollBy(-100000);
assert.equal(list._scroll, 0, 'scroll clamps back at the top');
// Row-based clipping: mid-scroll, only the window's rows are shown.
list.scrollTo(list.maxScroll / 2);
const shown = list.rows.filter((r) => r.shown);
const hidden = list.rows.filter((r) => !r.shown);
assert.ok(shown.length >= 1 && shown.length <= 8, `${shown.length} rows visible in the window (not all ${list.rows.length})`);
assert.ok(hidden.length >= 1, 'rows outside the viewport are clipped (hidden)');
// Selection is DATA, not position: stepping a good works whether its row
// is on screen or not (the player can scroll, then adjust, freely).
const last = list.stock[list.stock.length - 1].id;
list.bumpBuy(last, 1);
assert.deepEqual(list.getBuyMap(), { [last]: 1 }, 'stepping an off-screen row still selects');
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
list.clearSelection();
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
}
console.log('SCROLL: OK');
// ===========================================================================
console.log('\n== CAPS — blocked when negative or over the hold ==');
{
@ -372,11 +470,12 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
const broke = makeShip(0);
const buyC = E.buyPrice(C, cls, sysId, galaxy);
const { view: vb } = makeView(broke);
vb.buyGrid.bumpBuy(C, 1);
vb.buyList.bumpBuy(C, 1);
let pv = vb.preview();
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
assert.equal(vb.confirm(), false, '… and Confirm is refused');
assert.equal(vb._confirmBtn.disabled, true, '… and the sub-bar Confirm is grayed (invalid trade)');
assert.equal(broke.credits, 0, '… nothing spent');
assert.equal(broke.cargo.used, 0, '… nothing bought');
@ -384,7 +483,7 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
const full = makeShip(100000);
full.cargo.add([A, B, A, B]); // 4 bays, no room
const { view: vf } = makeView(full);
vf.buyGrid.bumpBuy(C, 1);
vf.buyList.bumpBuy(C, 1);
pv = vf.preview();
assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full');
@ -395,6 +494,7 @@ console.log('\n== CAPS — blocked when negative or over the hold ==');
pv = vf.preview();
assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits');
assert.equal(pv.valid, true, '… now it\u2019s legal (room freed)');
assert.equal(vf._confirmBtn.disabled, false, '… and Confirm un-grays (the trade is valid again)');
const before = full.credits;
assert.equal(vf.confirm(), true, '… and it goes through');
assert.deepEqual(full.cargo.snapshot().sort(), [A, B, B, C].sort(), 'one A left, one C arrived (net hold 4)');
@ -453,6 +553,8 @@ console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==
view.clear();
assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection');
assert.equal(view.preview().net, 0, '… and the net returns to zero');
assert.equal(view._confirmBtn.alpha, 0, '… and the sub-bar Confirm hides again');
assert.equal(view._clearBtn.alpha, 0, '… and Clear');
}
console.log('LOCK + CLEAR: OK');

View File

@ -247,7 +247,8 @@ That's flavor, not management.
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (the ledger) and `ship.cargo` (the `CargoHold`) + `maxCargoSlots`/`setCargoHold`/`cargoRoom`. |
| `js/economy/CargoHold.js` | The trade-goods hold — a **pure, framework-free** class (capacity, items, add/remove/count/has/clear/set/snapshot/save-clone). One good per bay. |
| `js/ui/shop/TradeGrid.js` | One side of the shop: a 4×4 grid of goods. SELL mode = the player's cargo bays (tap to check); BUY mode = the shop's stock (−/qty/+ stepper). |
| `js/ui/shop/TradeGrid.js` | The shop's SELL side: a 4×4 grid of the player's cargo bays (tap to check off, hover tooltip with name + price). |
| `js/ui/shop/BuyList.js` | The shop's BUY side: the stock as a scrollable list of WIDE ROWS (icon + name + one-line description + price + −/qty/+ stepper; drag/wheel to scroll). |
| `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + a live NET preview + Confirm/Clear, a Buy/Sell ↔ Buyback tab bar, the session-local `soldThisSession` buyback, and the transaction apply. |
| `data/shopui.json` | The shop screen's layout/animation knobs (tabs, panels, grid geometry, slot colors, stepper, preview, buttons) — presentation only; the economy is `Economy.js`. |
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
@ -295,58 +296,90 @@ Implemented this pass: the **price/stock engine**, the **generation pass**, **cr
Where the loop is actually played. You open it from the surface deck's **SHOP** button (the
existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over**
it. It's one component — `js/ui/shop/ShopView.js` — composing two `TradeGrid`s.
it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL
side) + a `BuyList` (the BUY side).
**Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar**
is up — the cyberpunk **MenuButton** pair `[ BUY/SELL | BUYBACK ]` (Buyback grayed out), the
same style + placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above
is up — one console-blue bar in the deck's cyberpunk **MenuButton** style
(`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr` — the tabs, the trade
actions, and the live **NET** readout, past hairline dividers; Buyback grayed, the
actions hidden until you start picking, the readout dim `0 cr`), the same style +
placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above
the command deck**, right-aligned to the deck's **Shop** button — so it reads as the same family
of UI as the Menu drawer. The screen stays open on the shop video so the clip plays. Clicking
**Buy/Sell** reveals the trade panel in the middle of the screen (the grids slide in).
**Buy/Sell** reveals the trade panel in the middle of the screen: the **SELL grid** and the
**BUY list** side by side with just a bit of padding between (the panels slide in). The
panel **spans the screen** — the SELL grid's left edge is **flush with the planet name**
(the HUD's left margin), and the BUY list stretches to the **same padding** in from the
right edge.
```
# first click — shop video + the tab sub-bar (rests above the deck):
┌─────────────────────────────────────────────────────────────────┐
│ (clip plays) │
│ ┌─────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ ← sub-bar │
│ └─────────────────────────┘ │
│ ┌────────────────────────────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET 0 cr │ │
│ └────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
└─────────────────────────────────────────────────────────────────┘
# after clicking Buy/Sell — the trade panel reveals (middle of the screen):
│ SELL and BUY sit NEXT TO each other (a small gap between);
│ the trade actions + NET readout ride on the sub-bar below.
┌─────────────────────────────────────────────────────────────────┐
│ ┌SELL 4×4┐ NET +420 cr ┌BUY 4×4┐ │
│ │ bays │ (green ≥ 0 / │ stock │ │
│ │(cargo) │ red < 0) │(12–16) │ │
│ └────────┘ [ CONFIRM ] └────────┘ │
│ [ CLEAR ] │
│ ┌─────────────────┐ ┌───────────────────────────────────┐ │
│ │ SELL (4×4 bays) │ │[i] Fresh Produce 24 cr − 0 +│ │
│ │ your cargo │ │ Crisp, clean, alive. A small mir… │ │
│ │ (tap = sell) │ │[i] Refined Fuel 41 cr − 1 +│ │
│ │ │ │ The lifeblood of the fleet. │ │
│ └─────────────────┘ │ … (scrolls) … │ │
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ ← sub-bar │
│ └─────────────────────────┘ │
│ ┌────────────────────────────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET +420 cr │ │
│ └────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
└─────────────────────────────────────────────────────────────────┘
```
- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons are `MenuButton`s (the deck's
cyberpunk style: cut-corner panel, neon edge, hover sweep, press punch) in a console-blue
panel that rests above the command deck, anchored to the Shop button — the exact recipe of
the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this visit.
- **Confirm / Clear** — the same `MenuButton` style; Confirm is **disabled** (grayed) while
the trade is invalid (can't go negative, hold would overflow), and both appear once you start
- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons, the **Confirm/Clear**
trade actions, and the live **NET** readout — all `MenuButton`s (the deck's cyberpunk
style: cut-corner panel, neon edge, hover sweep, press punch) in one console-blue panel
that rests above the command deck, anchored to the Shop button — the exact recipe of
the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this
visit. The bar is **extended** past hairline dividers to carry the actions *and* the
**NET** readout — one glance sees the tabs, the pending trade's credit delta, and the
button that executes it.
- **Deck lock (while the shop is up)** — the command deck's other slots (Build, Ship,
Quests, Take Off, Menu) go **standby**: dim, no hover, no press — the same inert
language as the comm-hold pause (`ActionBar.setSlotEnabled`). **LEAVE SHOP is the only
live button on the deck**; the press that opened the shop is the only way out. They
re-arm when you leave. (`ActionBar.setSlotEnabled` + `SurfaceScene.setDeckLocked`; the
deck-wide comm hold still owns input if it fires mid-shop.)
- **Confirm / Clear** — the same `MenuButton` style, on the **tab sub-bar** between the
tabs and the NET readout (they serve BOTH the trade and the buyback tabs, and persist
while you switch between them); Confirm is **disabled** (grayed) while the trade is
invalid (can't go negative, hold would overflow), and both appear once you start
picking.
- **SELL (left)** — your **cargo bays**. 16 cells (the fixed grid); the first `cargoHold`
are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade
room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the
quantity is simply the number of its bays that are checked.
- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`). Each
cell has a **−/qty/+ stepper**; buy as many as you like (capped by credits + hold room).
- **NET preview (center)** — the live credit delta. Green when you're up (a net sale), red when
you'd spend. Updates as you check bays / step goods.
- **BUY (right, next to the SELL grid)** — the shop's **stock** (12–16 distinct goods from
`Economy.shopFor`) as a **scrollable list of WIDE ROWS**: each row is the good's
**icon**, its **name** + a **description** (the good's `flavor`, a smaller font that
**wraps to up to two lines** and never overlaps the price/stepper), and the **price** +
a **−/qty/+ stepper** on the right. The stock is longer than the panel, so the list
**scrolls** (drag on the list body or the mouse wheel) — rows outside the window are
clipped; the selection is data, so stepping a good works whether its row is on screen
or not. Buy as many as you like (capped by credits + hold room).
- **NET readout (tab sub-bar)** — the live credit delta, at the bar's right end past the
second divider (right of the tabs and the Confirm/Clear actions).
Green when you're up (a net sale), red when you'd spend, dim at zero. Updates as you check
bays / step goods (it's the same `preview()` the Confirm button is gated on).
- **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm
executes it: credits `+ sale − buy`, cargo `− sold + bought`, in that order (free room
first). Selling to free a bay for a buy lands in the same Confirm.
@ -363,7 +396,7 @@ the same stock from `(seed, placeId, economyVersion)`. The only session state is
list (explicitly reset on leave). So the economy stays "interact, don't control": you trade
against fixed regional prices, never against your own history.
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct both grids, sell a bay and earn the
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct the SELL grid + BUY list, sell a bay and earn the
sale price, step a stock good and pay the buy price, the two caps (credits / cargo) blocking
Confirm, the sell→buy-in-one-confirm, the buyback lock + neutral undo, and Clear. Pixel layout
and the slide-in animation are verified in the browser (the stub doesn't render).

View File

@ -287,6 +287,7 @@ export class SurfaceScene extends Phaser.Scene {
this.shopVideo = null;
this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
this.setDeckLocked(false); // the deck was locked to the shop — release it
this.consoleNote('SHOP CLIP UNAVAILABLE');
});
}
@ -298,6 +299,7 @@ export class SurfaceScene extends Phaser.Scene {
this.shopVideo.resume();
this.shopMode = true;
this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP
this.setDeckLocked(true); // the shop is up — the deck's other slots go standby
this.buildShopView(); // the buy/sell UI over the clip (js/ui/shop/ShopView.js)
}
@ -316,6 +318,24 @@ export class SurfaceScene extends Phaser.Scene {
this.shopVideo?.setVisible(false); // its frozen last frame would cover the surface
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
this.setDeckLocked(false); // the shop is down — the deck's other slots come back
}
/**
* DECK LOCK — while the shop clip is up, the deck's slots go standby
* (dim, inert: no hover, no press) EXCEPT the SHOP slot — LEAVE SHOP
* is the only live button on the deck, the same one-live-control
* language as the 'ack' comm's hold (ActionBar.setEnabled). Re-enables
* everything on unlock; the shop slot is untouched in both directions
* (it's the toggle that owns the lock). Idempotent — safe on re-entry.
*/
setDeckLocked(locked) {
const bar = this.actionBar;
if (!bar || !Array.isArray(bar.slots)) return;
for (const s of bar.slots) {
if (!s.live || s.id === 'shop') continue; // SHOP is the way out
bar.setSlotEnabled(s.id, !locked);
}
}
/**

View File

@ -359,6 +359,8 @@ export class ActionBar extends Phaser.GameObjects.Container {
* reserved sockets — so the ACKNOWLEDGE button is the only live
* control on the screen while the comm holds. Idempotent (a no-op
* when the state is unchanged) — the scene may call it every frame.
* Sets each slot's `disabled` flag too, so a per-slot lock (see
* setSlotEnabled) and the deck-wide standby can't disagree.
*/
setEnabled(on) {
const want = on !== false;
@ -366,6 +368,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
this.enabled = want;
for (const s of this.slots) {
if (!s.live) continue;
s.disabled = !want;
if (want) {
s.panel.setInteractive(this.slotHit()); // re-arm (v4: flips input.enabled)
} else {
@ -383,6 +386,41 @@ export class ActionBar extends Phaser.GameObjects.Container {
}
}
/**
* setSlotEnabled(id, on) — the DECK-LOCK state (one slot stays live,
* the rest go standby — e.g. the surface deck while the shop clip is
* up: every slot but SHOP is inert, and LEAVE SHOP is the only way
* back). Same standby language as setEnabled: no hover, no press,
* clicks swallowed, dim like the reserved sockets. Idempotent.
* Returns the slot, or null if id has no live slot.
*/
setSlotEnabled(id, on) {
const s = (this.slots ?? []).find((sl) => sl.id === id);
if (!s || !s.live || this.dead) return null;
const want = on !== false;
if ((s.disabled !== true) === want) return s; // already in the wanted state
s.disabled = !want;
if (want) {
// Re-arm — unless the DECK is in standby (setEnabled(false)): the
// deck-wide hold owns input (the 'ack' comm's contract), so a
// per-slot unlock must not re-open a slot underneath it.
if (this.enabled !== false) s.panel.setInteractive(this.slotHit());
this.paintSlot(s, 'base');
} else {
s.hoverOn = false; // hover is over (the paint below is the truth)
s.sweep.setAlpha(0);
if (s._sweep) {
s._sweep.remove();
s._sweep = null;
}
if (s._scaleTw) s._scaleTw.stop(); // the hover scale punch is a tween
s.slot.setScale(1); // land any in-flight hover scale
s.panel.disableInteractive(); // no more pointer events, cursor reset
this.paintSlot(s, 'disabled');
}
return s;
}
/** Repaint a slot for a visual state: 'base' | 'hover' | 'press' | 'disabled'. */
paintSlot(s, state) {
const st = this.style;
@ -454,7 +492,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
}
setHover(s, on) {
if (!s.live || this.dead || this.enabled === false) return; // standby: inert
if (!s.live || this.dead || this.enabled === false || s.disabled === true) return; // standby: inert
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
s.hoverOn = on;
this.paintSlot(s, on ? 'hover' : 'base');
@ -488,7 +526,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
/** Click feedback: white flash + scale punch, then restore. Fires onAction. */
press(s) {
if (!s.live || s.pressing || this.dead || this.enabled === false) return; // standby: inert
if (!s.live || s.pressing || this.dead || this.enabled === false || s.disabled === true) return; // standby: inert
// SCAN plays its own sonar ping (the scene's onAction) — no double tick.
if (s.id !== 'scan') this.scene.playSfx?.('ui_click');
s.pressing = true;
@ -532,8 +570,9 @@ export class ActionBar extends Phaser.GameObjects.Container {
const k = Math.max(10, (s.fontSize * max) / s.label.width);
for (const o of [s.label, s.ghostCyan, s.ghostMagenta]) o?.setFontSize(`${k}px`);
}
// Re-assert the current visual state (hover keeps the white label).
this.paintSlot(s, s.hoverOn ? 'hover' : 'base');
// Re-assert the current visual state (hover keeps the white label;
// a locked slot keeps its standby paint — setSlotEnabled's truth).
this.paintSlot(s, s.disabled === true ? 'disabled' : (s.hoverOn ? 'hover' : 'base'));
return s;
}

495
js/ui/shop/BuyList.js Normal file
View File

@ -0,0 +1,495 @@
/**
* BuyList — the shop's BUY side: a scrollable list of stock rows.
*
* Each stock good is ONE WIDE ROW (replacing the old 4×4 icon grid):
*
* ┌────────────────────────────────────────────────────────────┐
* │ BUY │
* │ ┌────────────────────────────────────────────────────────┐ │
* │ │ [icon] Fresh Produce 24 cr − 0 +│ │
* │ │ Crisp, clean, alive. A small mir… │ │
* │ │ [icon] Refined Fuel 41 cr − 1 +│ │
* │ │ The lifeblood of the fleet. │ │
* │ └────────────────────────────────────────────────────────┘ │
* └────────────────────────────────────────────────────────────┘
*
* · LEFT — the good's icon (its goods-sheet frame).
* · MIDDLE — the NAME (top line) + its one-line DESCRIPTION (the good's
* `flavor` from data/goods.json), truncated to fit.
* · RIGHT — the BUY PRICE ("24 cr") + the −/qty/+ stepper.
*
* A row with qty > 0 (in the pending trade) is highlighted — the same
* selection the old grid's steppers tracked (getBuyMap).
*
* SCROLLING — 8–16 rows don't fit in the panel, so the list scrolls:
* drag on the list body, or the mouse wheel (Phaser 4's object-level
* pointermove + wheel events, the MapWindow.js recipe). Rows outside the
* viewport are hidden (row-based clipping — this Phaser build, 4.2.1 Giedi,
* has no camera/mask clip and no ScrollContainer). Scrolling never touches
* the SELECTION: buyQty is plain data keyed by good id, so stepping a good
* works whether or not its row is on screen.
*
* The PRICING lives in js/economy/Economy.js (shopFor → the fixed
* per-(good, shop) buy price); this component only shows it and tracks the
* SELECTION. ShopView (js/ui/shop/ShopView.js) composes it beside the SELL
* grid (TradeGrid), runs the trade preview (Economy.tradePreview) and
* applies it to the ship.
*
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
* selection + scroll LOGIC (bumpBuy / getBuyMap / scrollBy) is plain data so
* dev/shop-ui.test.mjs can drive it with no scene.
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import { toColor, toCss } from '../../utils/Color.js';
import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
import { fontStack } from '../../utils/Theme.js';
export class BuyList extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { width, height } (ShopView sizes it; height matches
* the SELL grid so the two panels align top AND bottom).
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene = scene;
const bl = config.section('shopui.buyList', {});
this.width = Math.max(320, Number(o.width) || bl.w || 560);
this.height = Math.max(160, Number(o.height) || 480);
this.pad = bl.pad ?? 14;
this.titleH = config.get('shopui.panel.title.h', 34);
this.rowH = bl.rowH ?? 72;
this.rowGap = bl.rowGap ?? 6;
this.iconSize = bl.icon ?? 44;
this.iconGap = bl.iconGap ?? 12;
// The description may WRAP onto up to `maxLines` lines (real Phaser
// word-wraps it at the text column's width — see _addRow; the headless
// char-count fit below budgets maxLines lines so it never bleeds into
// the price/stepper either way).
this.descLines = Math.max(1, Math.floor(Number(bl.desc?.maxLines) || 2));
this.st = config.section('shopui.stepper', {}); // shared stepper knobs
this.stepperW = this.st.w ?? 88;
this.priceW = bl.price?.w ?? 64;
this.bl = bl;
// The viewport (the scroll window, under the title) — the list's
// content taller than this is what scrolls.
this.vpX = this.pad;
this.vpY = this.titleH + this.pad;
this.vpW = this.width - this.pad * 2;
this.vpH = Math.max(40, this.height - this.vpY - this.pad);
// --- state -----------------------------------------------------------
this.stock = []; // [{ id, name, price }] (shopFor output)
this.buyQty = new Map(); // goodId -> qty in the pending trade
this.rows = []; // per row: { id, name, desc, price, objs, y0, shown }
this._objs = []; // everything we create (destroy())
this._surface = null; // the scroll surface (drag + wheel)
this._drag = null; // { y } while a press drags the list
// --- geometry (scroll) ----------------------------------------------
this._scroll = 0; // px scrolled from the top
this.maxScroll = 0; // max(0, contentH - vpH), set on refresh
this._buildPanel();
this._buildTitle();
this._buildSurface(); // BEFORE the rows (rows paint above it)
}
// ------------------------------------------------------------------ panel
_buildPanel() {
const p = config.get('shopui.panel', {});
const gr = this.scene.add?.graphics?.();
if (gr) {
this._objs.push(gr);
gr.clear?.();
if (typeof gr.fillStyle === 'function') {
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), p.bgAlpha ?? 0.7);
gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), 255);
}
gr.fillRect?.(0, 0, this.width, this.height);
// Parent it to the list (same reason as TradeGrid — scene-level depth
// 0 renders BEHIND the shop video in this build; container children
// inherit the list's depth + translation). Added first = behind rows.
this.add?.(gr);
}
}
_buildTitle() {
const t = this.scene.add?.text?.(
this.width / 2, 8, 'BUY',
{
fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`,
color: config.get('shopui.panel.title.color', '#eaf6ff'),
align: 'center', fontFamily: fontStack('header'), // Ethnocentric — the theme's section face
},
);
if (t) {
t.setOrigin?.(0.5, 0);
this.title = t;
this._objs.push(t);
this.add?.(t);
}
}
// ------------------------------------------------------- scroll surface
/**
* The scroll surface: an (undrawn) interactive rect over the viewport.
* Drag (pointerdown → pointermove → pointerup/out) and wheel scroll the
* list — the MapWindow.js recipe (Phaser 4's input plugin delivers object-
* level pointermove + wheel over the hit area). The row steppers sit ABOVE
* it and stopPropagation() their clicks, so tapping −/+ never drags.
*/
_buildSurface() {
const g = this.scene.add?.graphics?.();
if (!g) return;
this._surface = g;
this._objs.push(g);
this.add?.(g);
if (typeof g.setInteractive !== 'function' || typeof g.on !== 'function') return;
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(this.vpX, this.vpY, this.vpW, this.vpH) : null;
g.setInteractive({
useHandCursor: false,
hitArea: area,
hitAreaCallback: (p, px, py) => (area ? Rect.Contains(p, px, py) : true),
});
g.on('pointerdown', (p) => { this._drag = { y: p?.y ?? 0 }; });
g.on('pointermove', (p) => {
if (this._drag && p && typeof p.y === 'number') {
this.scrollBy(p.y - this._drag.y);
this._drag = { y: p.y };
}
});
g.on('pointerup', () => { this._drag = null; });
g.on('pointerout', () => { this._drag = null; }); // left the list → end drag
g.on('wheel', (p, _dx, dy) => {
// line-mode wheels report small dy (MapWindow's deltaMode check).
const dyPx = (p?.event?.deltaMode ?? 0) === 1 ? dy * 32 : dy;
if (dyPx) this.scrollBy(dyPx);
});
}
// ------------------------------------------------------------------ rows
/**
* Set the list's contents from the shop's stock (Economy.shopFor → buy:
* [{ id, name, price, produced }]). The description is the good's `flavor`
* line (data/goods.json). Resets the selection (a fresh shop view).
*/
refresh(o = {}) {
const stock = Array.isArray(o.stock) ? o.stock : [];
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
this.buyQty = new Map();
this._destroyRows();
for (const s of this.stock) this._addRow(s);
this._layoutRows();
this._clipRows();
return this;
}
_addRow(s) {
const bl = this.bl;
const id = s.id;
const name = s.name ?? id;
const price = Math.round(Number(s.price) || 0);
const flavor = this._flavorOf(id);
const row = { id, name, desc: '', price, objs: {}, y0: 0, shown: true };
// row plate (bg + border) — the whole row's width, in the viewport
const bg = this.scene.add?.graphics?.();
if (bg) { row.objs.bg = bg; this._objs.push(bg); this.add?.(bg); }
// icon (guarded — headless has no texture). Created with a placeholder
// frame, then bound to THIS good's frame via setTexture(key, frame) — the
// TradeGrid recipe (NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render).
if (this.scene.textures && typeof this.scene.textures.exists === 'function' &&
this.scene.textures.exists(GOODS_TEXTURE_KEY)) {
const icon = this.scene.add?.image?.(0, 0, GOODS_TEXTURE_KEY, 0);
if (icon) {
icon.setOrigin?.(0.5, 0.5);
icon.setScale?.(this.iconSize / 128); // the sheet's frames are 128×128
icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(id));
row.objs.icon = icon; this._objs.push(icon); this.add?.(icon);
}
}
// name + description (left of the price/stepper block)
const descFont = Number(bl.desc?.fontSize) || 12;
const textX = this._textX();
const nameEl = this._makeText(
textX, 0, name,
{ fontSize: `${bl.name?.fontSize ?? 14}px`, color: bl.name?.color ?? '#eaf6ff', align: 'left' },
);
if (nameEl) { nameEl.setOrigin?.(0, 0.5); row.objs.name = nameEl; this._objs.push(nameEl); this.add?.(nameEl); }
row.desc = this._fitText(flavor, descFont, this.descLines);
const descEl = this._makeText(
textX, 0, row.desc,
{ fontSize: `${descFont}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' },
);
if (descEl) {
// Word-wrap inside the text column — the wrap width stops BEFORE the
// price and stepper (_descMaxW()), so a wrapped second line can never
// overlap them. The stub no-ops these (the char-count fit keeps it
// bounded there).
descEl.setWordWrap?.(true);
descEl.setWordWrapWidth?.(this._descMaxW());
descEl.setOrigin?.(0, 0.5);
row.objs.desc = descEl; this._objs.push(descEl); this.add?.(descEl);
}
// price (right of the text, left of the stepper) — "24 cr"
const priceEl = this._makeText(
0, 0, `${price} cr`,
{ fontSize: `${bl.price?.fontSize ?? 14}px`, color: bl.price?.color ?? '#ffc94d', align: 'right' },
);
if (priceEl) { priceEl.setOrigin?.(1, 0.5); row.objs.price = priceEl; this._objs.push(priceEl); this.add?.(priceEl); }
// stepper: − qty + (the qty tap = +1, the old grid's behavior)
const mkStep = (str) => this._makeText(0, 0, str, {
fontSize: `${this.st.fontSize ?? 19}px`,
color: this.st.btn ?? '#7d92c4', align: 'center',
});
const minus = mkStep('−');
const qty = mkStep('0');
const plus = mkStep('+');
const hit = (el) => (el && el.width > 0 ? el.width + 8 : 22);
const bx = this._btnXs();
const stepHit = Math.max(hit(minus), hit(plus), 22) / 2;
if (minus) { row.objs.minus = minus; this._objs.push(minus); this.add?.(minus); }
if (qty) { row.objs.qty = qty; this._objs.push(qty); this.add?.(qty); }
if (plus) { row.objs.plus = plus; this._objs.push(plus); this.add?.(plus); }
// The steppers sit on the scroll surface; stopPropagation keeps a
// stepper tap from starting a list drag (TradeGrid's recipe).
this._bindStep(minus, () => this.bumpBuy(id, -1), bx.minus, stepHit, row);
this._bindStep(qty, () => this.bumpBuy(id, +1), bx.qty, stepHit, row);
this._bindStep(plus, () => this.bumpBuy(id, +1), bx.plus, stepHit, row);
this.rows.push(row);
}
_bindStep(el, fn, x, half, row) {
if (!el || typeof el.setInteractive !== 'function' || typeof el.on !== 'function') return;
const Rect = Phaser.Geom?.Rectangle;
const area = Rect ? new Rect(-half, -half, half * 2, half * 2) : null;
el.setInteractive({
useHandCursor: true,
hitArea: area,
// Dead when the row is scrolled out of the viewport (alpha-0 buttons
// must not be tappable — gate the hit test on row visibility too).
hitAreaCallback: (p, px, py) => (row.shown ? (area ? Rect.Contains(p, px, py) : true) : false),
});
el.on('pointerdown', (p) => {
p?.stopPropagation?.();
if (row.shown) fn();
});
}
// Text block's left edge (after the icon) — shared by name + desc.
_textX() {
return -this.vpW / 2 + this.pad + this.iconSize + this.iconGap;
}
// Stepper button x's (row-local, right end of the row).
_btnXs() {
const right = this.vpW / 2 - this.pad;
return {
plus: right - 11,
qty: right - 44,
minus: right - 77,
price: right - (this.stepperW) - 10, // price's RIGHT edge (origin 1,0.5)
};
}
// Max description width (text block → price), in px. The price reserves
// its full width ("1,240 cr") so the description never collides with it.
_descMaxW() {
return this._btnXs().price - this.priceW - 14 - this._textX();
}
// Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize
// (deterministic char estimate — no text metrics needed; the headless
// stubs have none). Real Phaser word-wraps at _descMaxW() per line;
// this cap is the fail-safe that keeps the total inside maxLines lines.
_fitText(str, fontSize, maxLines = 1) {
const s = String(str ?? '');
if (!s) return '';
const perLine = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55)));
const maxChars = perLine * Math.max(1, Math.floor(Number(maxLines) || 1));
if (s.length <= maxChars) return s;
return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
}
_flavorOf(id) {
const list = config.get('goods.goods', []);
return list.find((g) => g.id === id)?.flavor ?? '';
}
_makeText(x, y, str, style) {
const t = this.scene.add?.text?.(x, y, str, {
fontFamily: fontStack('body'), // Centauri — the theme's body face
fontSize: style.fontSize, color: style.color, align: style.align,
});
return t ?? null;
}
// ---------------------------------------------------------------- scroll
/** Scroll by `dy` px (clamped to [0, maxScroll]); re-clips the rows. */
scrollBy(dy) {
this.scrollTo(this._scroll + Math.floor(Number(dy) || 0));
return this;
}
scrollTo(v) {
const n = Math.max(0, Math.min(this.maxScroll, Number(v) || 0));
if (n === this._scroll) return this;
this._scroll = n;
this._clipRows();
return this;
}
// Row-based clip: lay rows at (y0 − scroll), hide the ones off-viewport
// (this Phaser build has no camera/mask clip). A row counts as visible
// while it OVERLAPS the viewport, so partial rows fade in as you scroll.
_layoutRows() {
const n = this.rows.length;
const contentH = n * this.rowH + Math.max(0, n - 1) * this.rowGap;
this.maxScroll = Math.max(0, contentH - this.vpH);
this._scroll = Math.max(0, Math.min(this._scroll, this.maxScroll));
for (let i = 0; i < n; i++) {
this.rows[i].y0 = this.vpY + i * (this.rowH + this.rowGap) + this.rowH / 2;
}
}
_clipRows() {
const cx = this.vpX + this.vpW / 2;
for (const row of this.rows) {
const y = row.y0 - this._scroll;
const top = y - this.rowH / 2;
const bottom = y + this.rowH / 2;
row.shown = top < this.vpY + this.vpH && bottom > this.vpY;
const a = row.shown ? 1 : 0;
const o = row.objs;
const tx = cx + this._textX(); // row-local → list-local (row center at cx)
const bx = this._btnXs();
o.bg?.setPosition?.(cx, y);
o.icon?.setPosition?.(cx - this.vpW / 2 + this.pad + this.iconSize / 2, y);
o.name?.setPosition?.(tx, y - this.rowH * 0.18);
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
o.price?.setPosition?.(cx + bx.price, y);
o.minus?.setPosition?.(cx + bx.minus, y);
o.qty?.setPosition?.(cx + bx.qty, y);
o.plus?.setPosition?.(cx + bx.plus, y);
for (const el of Object.values(o)) el?.setAlpha?.(a);
}
this._renderRows();
}
// --------------------------------------------------------- DATA (the core)
/** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
bumpBuy(id, d = 1) {
if (!id) return this;
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
const cur = this.buyQty.get(id) ?? 0;
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
if (next === 0) this.buyQty.delete(id);
else this.buyQty.set(id, next);
this._renderRow(id);
this._emitSelChange();
return this;
}
/** { goodId: qty } from the steppers. */
getBuyMap() {
const m = {};
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
return m;
}
hasSelection() {
return this.buyQty.size > 0;
}
clearSelection() {
this.buyQty = new Map();
this._renderRows();
this._emitSelChange();
return this;
}
/** The ShopView hooks this to live-update the preview + Confirm/Clear. */
set onSelectionChange(fn) { this._onSel = fn; }
_emitSelChange() {
if (typeof this._onSel === 'function') this._onSel(this);
}
// --------------------------------------------------------------- render
_renderRows() {
for (const row of this.rows) this._renderRow(row.id);
}
_renderRow(id) {
const row = this.rows.find((r) => r.id === id);
if (!row) return;
const q = this.buyQty.get(id) ?? 0;
const o = row.objs;
if (o.qty && typeof o.qty.setText === 'function') o.qty.setText(String(q));
// in-basket highlight: brighter plate + accent border (the theme's
// selection cyan), the qty text lit.
const sel = q > 0;
const bl = this.bl;
const cx = this.vpX + this.vpW / 2;
const y = row.y0 - this._scroll;
if (o.bg && typeof o.bg.clear === 'function' && typeof o.bg.fillStyle === 'function') {
o.bg.clear?.();
o.bg.fillStyle(_hex(sel ? (bl.rowSelBg ?? '#12283a') : (bl.rowBg ?? '#0e1728')), sel ? (bl.rowSelBgAlpha ?? 0.85) : (bl.rowBgAlpha ?? 0.55));
o.bg.lineStyle?.(sel ? 2 : 1, _hex(sel ? (bl.selected ?? '#00e5ff') : (bl.rowBorder ?? '#1d2e4c')), 255);
o.bg.fillRect?.(cx - this.vpW / 2, y - this.rowH / 2, this.vpW, this.rowH);
}
if (o.qty && typeof o.qty.setColor === 'function') {
o.qty.setColor(toCss(sel ? (this.st.activeColor ?? '#00e5ff') : (this.st.color ?? '#eaf6ff')));
}
}
_destroyRows() {
for (const row of this.rows) for (const el of Object.values(row.objs)) el?.destroy?.();
this.rows = [];
}
// --------------------------------------------------------- animation
/** Slide the panel in from the right (the TradeGrid recipe). */
slideIn() {
const ms = config.get('shopui.animation.slideMs', 340);
const ease = config.get('shopui.animation.ease', 'Cubic.Out');
const off = this.width + 24;
const targetX = this.x; // the final x (set by ShopView.setPosition before this)
const targetY = this.y;
if (this.scene.tweens && typeof this.scene.tweens.add === 'function') {
this.x = targetX + off;
this.y = targetY;
this.scene.tweens.add({ targets: this, x: targetX, y: targetY, duration: ms, ease, delay: 40 });
} else {
this.x = targetX;
}
return this;
}
destroy() {
for (const o of this._objs) o?.destroy?.();
this._objs = [];
this.rows = [];
if (typeof super.destroy === 'function') super.destroy();
}
}
// '#rrggbb' → int (Phaser color) — safe on hex + '#'-prefixed.
function _hex(v) {
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : 0xffffff;
}

View File

@ -1,24 +1,31 @@
/**
* ShopView — the buy/sell (and buyback) screen over the shop clip video.
*
* ┌─────────────────────────────────────────────────────────────┐
* │ [ Buy/Sell | Buyback ] ← tabs │
* │ │
* │ ┌SELL 4×4┐ ┌ NET ┐ ┌BUY 4×4┐ │
* │ │ bays │ │ +420 │ │ stock │ │
* │ │ (cargo)│ │ cr │ │ (12–16)│ │
* │ └────────┘ │[Conf]│ └────────┘ │
* │ │[Clear]│ │
* └─────────────────────────────────────────────────────────────┘
* ┌────────────────────────────────────────────────────────────────┐
* │ ┌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 two sides are TradeGrids (js/ui/shop/TradeGrid.js):
* · SELL (left) — 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.
* · BUY (right) — the shop's stock, each with a −/qty/+ stepper.
* The live NET preview (green ≥ 0, red < 0) and Confirm/Clear live 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).
* 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 —
@ -39,6 +46,7 @@ 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 {
/**
@ -81,9 +89,10 @@ export class ShopView extends Phaser.GameObjects.Container {
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
this.buybackSel = new Map(); // goodId -> qty to buy back
this.sellGrid = null;
this.buyGrid = null;
this.buyList = null;
this._content = null; // the active content container
this._previewText = null;
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;
@ -132,6 +141,13 @@ export class ShopView extends Phaser.GameObjects.Container {
// 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 — [tabs] │ [clear][confirm] │ [NET value]. Tabs AND the trade
// math AND its actions on ONE bar: 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);
@ -139,6 +155,7 @@ export class ShopView extends Phaser.GameObjects.Container {
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' },
@ -147,8 +164,8 @@ export class ShopView extends Phaser.GameObjects.Container {
// 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),
const mkBtn = (label, accent, onClick) => new MenuButton(
this.scene, 0, -H / 2, String(label), onClick,
{
screenFixed: true,
fontSize: btnCfg.fontSize ?? 13,
@ -159,15 +176,52 @@ export class ShopView extends Phaser.GameObjects.Container {
textColor: c.ink ?? '#eaf6ff',
bgColor: c.cardBg ?? '#0a1222',
hoverColor: c.cardBg ?? '#0a1222',
borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'),
borderColor: toCss(accent ?? c.neon ?? '#00e5ff'),
},
);
const btns = items.map(mkBtn);
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-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 ] │ [ CLEAR ][ CONFIRM ] │ NET value
const W = Math.min(
this._W() - 24,
pad * 2 + widths.reduce((s, w) => s + w, 0) + gap * (widths.length - 1),
pad * 2 + tabsW + gap + divW + gap + actionsW + gap + divW + gap + labelW + gap + valueW,
);
// Position: bottom edge on the deck's top rail (a.y), right edge on the
@ -187,15 +241,18 @@ export class ShopView extends Phaser.GameObjects.Container {
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).
// 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 divX1 = -W / 2 + pad + tabsW + gap + divW / 2;
const divX2 = divX1 + divW / 2 + gap + actionsW + gap + divW / 2;
const bodyG = this.scene.add?.graphics?.();
if (bodyG) {
bodyG.setScrollFactor?.(0);
bar.add?.(bodyG);
this._drawSubBarBody(bodyG, W, H, c);
this._drawSubBarBody(bodyG, W, H, c, [divX1, divX2], divH);
}
// The buttons LAST (above the body), centered in the row.
// 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];
@ -206,12 +263,32 @@ export class ShopView extends Phaser.GameObjects.Container {
bx += widths[i] / 2 + gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0);
});
// The actions: Clear then Confirm, right after the first divider.
const ax0 = divX1 + divW / 2 + gap;
this._clearBtn.setX?.(ax0 + clearW / 2);
this._confirmBtn.setX?.(ax0 + clearW + gap + confirmW / 2);
bar.add?.(this._clearBtn);
bar.add?.(this._confirmBtn);
// The NET block: the label right after the second divider, the value
// right-aligned to the bar's end (so a growing number never shifts the
// label).
this._netLabel.setPosition?.(divX2 + 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). */
_drawSubBarBody(g, W, H, c) {
/**
* 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');
@ -238,6 +315,11 @@ export class ShopView extends Phaser.GameObjects.Container {
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
}
}
}
@ -254,108 +336,75 @@ export class ShopView extends Phaser.GameObjects.Container {
}
// ---------------------------------------------------------------------
// CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons)
// CONTENT — the trade panel (SELL grid + BUY list, side by side)
// ---------------------------------------------------------------------
_showTrade() {
const grid = config.get('shopui.grid', {});
const centerW = grid.centerW ?? 208;
// 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),
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
});
const buyGrid = new TradeGrid(this.scene, {
side: 'buy', maxSlots: this.maxSlots,
cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
buyUnit: (id) => E.buyPrice(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.buyGrid = buyGrid;
this.buyList = buyList;
// Layout: [SELL grid] [center] [BUY grid]. 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).
// 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 lays its plate/cells out in local (0,0)→(w,h)
// 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 grid's position IS the
// panel's 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 + buyGrid.width;
const leftX = Math.max(8, (W - totalW) / 2);
const gridH = sellGrid.height;
// (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;
buyGrid.setPosition(rightX, this.topY);
buyList.setPosition(rightX, this.topY);
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
content.setScrollFactor?.(0);
content.add?.(sellGrid);
content.add?.(buyGrid);
// center column: NET preview + Confirm + Clear — vertically centered with
// the grids (the NET above, the buttons below), so the whole panel is one
// cohesive block near the top of the screen.
const cx = leftX + sellGrid.width + centerW / 2;
const cy = this.topY + gridH / 2;
this._previewLabel = this._makeText(cx, cy - 52, config.get('shopui.preview.label', 'NET'), {
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`,
color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center',
});
this._previewText = this._makeText(cx, cy - 14, 'NET 0 cr', {
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`,
color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center',
});
content.add?.(this._previewLabel);
content.add?.(this._previewText);
// 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 + 34, 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 + 104, 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);
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() : [] });
buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
sellGrid.onSelectionChange = () => this._syncPreview();
buyGrid.onSelectionChange = () => this._syncPreview();
buyList.onSelectionChange = () => this._syncPreview();
this._syncPreview();
// Reveal: SELL slides in from the left, BUY from the right.
sellGrid.slideIn();
buyGrid.slideIn();
buyList.slideIn();
}
// ---------------------------------------------------------------------
@ -389,21 +438,9 @@ export class ShopView extends Phaser.GameObjects.Container {
y += rowH;
}
const cy = y + 24;
this._previewLabel = this._makeText(cx, cy - 8, config.get('shopui.preview.label', 'NET'), {
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center' });
content.add?.(this._previewLabel);
this._previewText = this._makeText(cx, cy + 22, 'NET 0 cr', {
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center' });
content.add?.(this._previewText);
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 + 92);
this._confirmBtn.tap(() => this.confirm());
this._confirmBtn.setAlpha(0);
content.add?.(this._confirmBtn.container);
// 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();
@ -445,7 +482,7 @@ export class ShopView extends Phaser.GameObjects.Container {
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {},
buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {},
buyMap: this.buyList ? this.buyList.getBuyMap() : {},
});
}
// buyback — priced at the sale price (buyOverrides), a neutral undo.
@ -475,7 +512,7 @@ export class ShopView extends Phaser.GameObjects.Container {
clear() {
if (this.tab === 'trade') {
this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
this.buybackSel = new Map();
}
@ -495,7 +532,7 @@ export class ShopView extends Phaser.GameObjects.Container {
if (this.tab === 'trade') {
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection();
this.buyList?.clearSelection();
} else {
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
this.buybackSel = new Map();
@ -531,13 +568,16 @@ export class ShopView extends Phaser.GameObjects.Container {
_syncPreview() {
const pv = this.preview();
const anySel = this._hasAnySelection();
if (this._previewText && typeof this._previewText.setText === 'function') {
const sign = pv.net >= 0 ? '+' : '';
this._previewText.setText(`NET ${sign}${Math.round(pv.net)} cr`);
const col = pv.net === 0
// 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')
: (pv.net > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f'));
if (typeof this._previewText.setColor === 'function') this._previewText.setColor(col);
: (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;
@ -551,7 +591,7 @@ export class ShopView extends Phaser.GameObjects.Container {
}
_hasAnySelection() {
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false);
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyList?.hasSelection() ?? false);
return [...this.buybackSel.values()].some((q) => q > 0);
}
@ -576,24 +616,34 @@ export class ShopView extends Phaser.GameObjects.Container {
}
_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.buyGrid?.destroy?.();
this.buyList?.destroy?.();
this.sellGrid = null;
this.buyGrid = null;
this._previewText = null;
this._previewLabel = null;
this._confirmBtn = null;
this._clearBtn = 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();
}

View File

@ -1,29 +1,36 @@
/**
* TradeGrid — ONE side of the shop trade panel: a 4×4 grid of goods cells.
* TradeGrid — the shop's SELL side: the player's cargo bays as a 4×4 grid.
*
* Two modes, one component:
* · SELL (the left panel) — the grid is the player's CARGO. Each cell is a
* BAY (one good each, data/economy.json → cargo). The first `capacity`
* cells are live bays (filled with a good, or empty); the rest are LOCKED
* (the hold's upgrade room — the ship can't carry there yet). Tapping a
* filled bay checks it off to sell (a bay = 1 unit); the sell quantity for
* a good is just the number of its bays that are checked.
* · BUY (the right panel) — the grid is the SHOP'S STOCK (12–16 distinct
* goods from Economy.shopFor). Each cell has a −/qty/+ stepper; the buy
* quantity is whatever the stepper says.
* ┌────────────┐
* │ SELL │
* │ ┌────┐┌────┐ │
* │ │icon││icon│ │ filled bay → good icon
* │ └────┘└────┘ │ empty bay → dim plate
* │ ┌────┐┌────┐ │ locked bay → "locked" label (the hold's upgrade room)
* │ │ ││lock│ │ checked → cyan selection ring (1 unit queued)
* │ └────┘└────┘ │
* └────────────┘
*
* Cells show the good's ICON only — the name + price are revealed in a
* shared hover tooltip (pointerover the cell; one tooltip per grid).
* A BAY is the unit (not a good): each filled bay holds 1 unit of its good
* and checking it off queues 1 unit for sale. The first `capacity` bays are
* live (filled with a good, or empty); the rest are LOCKED — the hold's
* upgrade room (the ship can't carry there yet). Hovering a cell reveals
* the good's name + sell price in a shared tooltip (icon-only cells — no
* text over the icon).
*
* The PRICING lives in js/economy/Economy.js (buyPrice/sellPrice — the fixed
* per-(good, shop) price). This component only shows the price and tracks the
* SELECTION (which bays to sell / how many of each to buy). The ShopView
* (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade
* (The BUY side is no longer a grid — the shop's stock is now a scrollable
* list of wide rows: js/ui/shop/BuyList.js. ShopView composes the two side
* by side.)
*
* The PRICING lives in js/economy/Economy.js (sellPrice — the fixed
* per-(good, shop) price). This grid only shows the price and tracks the
* SELECTION (which bays are checked off). The ShopView
* (js/ui/shop/ShopView.js) composes it with the BuyList, runs the trade
* preview (Economy.tradePreview) and applies it to the ship.
*
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
* selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data
* so dev/shop-ui.test.mjs can drive it with no scene.
* selection LOGIC (toggleBay / getSellMap) is plain data so
* dev/shop-ui.test.mjs can drive it with no scene.
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
@ -33,21 +40,19 @@ import { fontStack } from '../../utils/Theme.js';
export class TradeGrid extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { side: 'sell'|'buy', maxSlots, capacity,
* cls, systemId, galaxy, sellUnit(id), buyUnit(id) }
* sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so
* the grid can show "sell for N" / "buy for N" without re-deriving.
* @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id) }
* sellUnit is the per-unit price lookup (from the ShopView) so the grid
* can show "sell for N" without re-deriving.
*/
constructor(scene, o) {
super(scene, 0, 0);
this.scene = scene;
this.side = o.side;
this.side = 'sell'; // SELL-only now (the BUY side moved to BuyList.js)
this.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16));
this.capacity = Math.max(0, Math.floor(o.capacity ?? 0));
this.cls = o.cls;
this.systemId = o.systemId;
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
this.buyUnit = (id) => o.buyUnit?.(id) ?? 0;
const g = config.get('shopui.grid', {});
this.cols = g.cols ?? 4;
@ -66,19 +71,16 @@ export class TradeGrid extends Phaser.GameObjects.Container {
this.height = this.titleH + this.cellH + pad * 2;
// --- state -----------------------------------------------------------
// SELL: bays in fill order (goodId | null), length = maxSlots; usable =
// index < capacity. BUY: stock entries { id, name, price }, length =
// stock size (≤ maxSlots).
// bays in fill order (goodId | null), length = maxSlots; usable =
// index < capacity (locked bays are the hold's upgrade room).
this.bays = [];
this.stock = [];
this.sellSel = new Set(); // sell: checked bay indices
this.buyQty = new Map(); // buy: goodId -> qty
this.sellSel = new Set(); // checked bay indices (the pending sell)
// --- objects ---------------------------------------------------------
this._objs = []; // everything we create (destroy())
this.plate = null; // the semi-transparent panel background
this.title = null;
this.cells = []; // per cell: { plate, sel, icon, label, stepQty, stepMinus, stepPlus }
this.cells = []; // per cell: { plate, sel, icon, label }
this.tip = null; // shared hover tooltip (name + price) — the
this.tipPlate = null; // per-cell name/price text is gone; hovering
this.tipName = null; // a cell reveals it beside the icon
@ -122,7 +124,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
const gr = this.plate;
if (!gr || typeof gr.clear !== 'function') return;
const p = this.plateStyle ?? {};
const a = this.side === 'sell' ? (p.bgAlpha ?? 0.7) : (p.bgAlpha ?? 0.7);
const a = p.bgAlpha ?? 0.7;
gr.clear?.();
if (typeof gr.fillStyle === 'function') {
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a);
@ -133,8 +135,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
_buildTitle() {
const t = this.scene.add?.text?.(
this.width / 2, 8,
this.side === 'sell' ? 'SELL' : 'BUY',
this.width / 2, 8, 'SELL',
{ fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`, color: config.get('shopui.panel.title.color', '#eaf6ff'), align: 'center', fontFamily: fontStack('header') }, // Ethnocentric — the theme's section face
);
if (t) {
@ -178,37 +179,11 @@ export class TradeGrid extends Phaser.GameObjects.Container {
});
if (label) { label.setOrigin?.(0.5, 0.5); label.setAlpha?.(0); cell.label = label; cell.objects.push(label); }
// BUY stepper: − n + (bottom of the cell)
if (this.side === 'buy') {
const mk = (dx, str) => {
const el = this.scene.add?.text?.(x + dx, y + 26, str, {
fontSize: `${config.get('shopui.stepper.fontSize', 19)}px`,
color: this.sl.price?.buy ?? '#ffc94d', align: 'center',
fontFamily: fontStack('body'),
});
if (el) { el.setOrigin?.(0.5, 0.5); return el; }
return null;
};
const minus = mk(-26, '−');
const qty = mk(0, '0');
const plus = mk(26, '+');
if (minus) cell.objects.push(minus);
if (qty) { cell.stepQty = qty; cell.objects.push(qty); }
if (plus) cell.objects.push(plus);
cell.stepMinus = minus; cell.stepPlus = plus;
const hit = [-18, -14, 36, 28];
this._bindTap(minus, () => this.bumpBuy(this._stockIdAt(i), -1), hit);
this._bindTap(plus, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
this._bindTap(qty, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
}
// interaction: the plate is the whole cell's surface — tap (SELL: toggle
// the bay) + hover (either side: reveal the name/price tooltip).
// interaction: the plate is the whole cell's surface — tap (toggle the
// bay) + hover (reveal the name/price tooltip).
const hit = [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot];
if (this._interactive(cell.plate, hit)) {
if (this.side === 'sell') {
cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); });
}
cell.plate.on('pointerdown', (p) => { p?.stopPropagation?.(); this.toggleBay(i); });
cell.plate.on('pointerover', () => this.hoverCell(i));
cell.plate.on('pointerout', () => this.unhover());
}
@ -218,15 +193,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
if (cell.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon);
if (cell.label) this.add?.(cell.label);
if (cell.stepMinus) this.add?.(cell.stepMinus);
if (cell.stepQty) this.add?.(cell.stepQty);
if (cell.stepPlus) this.add?.(cell.stepPlus);
}
}
// A tap only works when the object supports interactivity (real Phaser); a
// headless fake returns no-op objects, and the test drives the logic
// methods (toggleBay / bumpBuy) directly.
// method (toggleBay) directly.
_bindTap(obj, fn, hitRect) {
if (!this._interactive(obj, hitRect)) return;
obj.on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
@ -252,36 +224,26 @@ export class TradeGrid extends Phaser.GameObjects.Container {
// --- DATA (the testable core) -----------------------------------------
/**
* Set the grid's contents. SELL: `cargo` is the filled-bay id list (order
* preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list
* ({ id, name, price } | { id }). Resets the selection (a fresh shop view).
* Set the grid's contents: `cargo` is the filled-bay id list (order
* preserved — bay i holds cargo[i]). Resets the selection (a fresh shop
* view).
*/
refresh(o = {}) {
this.unhover();
if (this.side === 'sell') {
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
this.bays = [];
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
this.sellSel = new Set();
} else {
const stock = Array.isArray(o.stock) ? o.stock : [];
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
this.buyQty = new Map();
}
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
this.bays = [];
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
this.sellSel = new Set();
this._renderAll();
return this;
}
_stockIdAt(i) {
return this.stock[i]?.id ?? null;
}
_bayIdAt(i) {
return this.bays[i] ?? null;
}
/** SELL: check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
/** Check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
toggleBay(i) {
if (this.side !== 'sell') return this;
if (i < 0 || i >= this.capacity) return this; // locked bay
const id = this._bayIdAt(i);
if (!id) return this; // empty bay
@ -292,9 +254,8 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this;
}
/** SELL: check / uncheck by good id (the Nth unchecked/checked bay of it). */
/** Check / uncheck by good id (the Nth unchecked/checked bay of it). */
toggleGood(id) {
if (this.side !== 'sell') return this;
const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity);
if (idx >= 0) this.toggleBay(idx);
else {
@ -305,20 +266,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this;
}
/** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
bumpBuy(id, d = 1) {
if (this.side !== 'buy' || !id) return this;
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
const cur = this.buyQty.get(id) ?? 0;
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
if (next === 0) this.buyQty.delete(id);
else this.buyQty.set(id, next);
this._renderStockRow(id);
this._emitSelChange();
return this;
}
/** SELL: { goodId: qty } from the checked bays. */
/** { goodId: qty } from the checked bays. */
getSellMap() {
const m = {};
for (const i of this.sellSel) {
@ -328,20 +276,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return m;
}
/** BUY: { goodId: qty } from the steppers. */
getBuyMap() {
const m = {};
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
return m;
}
hasSelection() {
return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0;
return this.sellSel.size > 0;
}
clearSelection() {
if (this.side === 'sell') this.sellSel = new Set();
else this.buyQty = new Map();
this.sellSel = new Set();
this._renderAll();
this._emitSelChange();
return this;
@ -368,19 +308,14 @@ export class TradeGrid extends Phaser.GameObjects.Container {
let goodId = null;
let locked = false;
if (this.side === 'sell') {
goodId = this._bayIdAt(i);
locked = i >= this.capacity;
} else {
goodId = this._stockIdAt(i);
locked = false;
}
goodId = this._bayIdAt(i);
locked = i >= this.capacity;
const usable = !locked && !!goodId;
// plate border (selected sell bays get the cyan ring)
if (cell.plate && typeof cell.plate.clear === 'function') {
const selected = this.side === 'sell' && this.sellSel.has(i);
const selected = this.sellSel.has(i);
cell.plate.clear?.();
if (typeof cell.plate.fillStyle === 'function') {
cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3));
@ -389,46 +324,28 @@ export class TradeGrid extends Phaser.GameObjects.Container {
cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot);
}
// icon — the cell's only content for a usable bay/stock slot (the
// name+price live in the hover tooltip). Centered; the BUY side lifts it
// a touch so the stepper under it reads as a row of its own.
// icon — the cell's only content for a usable bay (the name+price live
// in the hover tooltip). Centered.
if (cell.icon) {
const show = usable;
if (show) {
cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId));
cell.icon.setAlpha?.(1);
cell.icon.setScale?.(this.sl.iconScale ?? 0.62);
cell.icon.setPosition?.(x, this.side === 'buy' ? y - 8 : y);
cell.icon.setPosition?.(x, y);
} else {
cell.icon.setAlpha?.(0);
}
}
// state label — locked bays / empty slots only; usable cells stay clean
// state label — locked bays / empty bays only; usable cells stay clean
if (cell.label && typeof cell.label.setText === 'function') {
cell.label.setText(usable
? ''
: (this.side === 'sell'
? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))
: (sl.emptyLabel ?? '—')));
cell.label.setText(usable ? '' : (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—')));
cell.label.setAlpha?.(usable ? 0 : (sl.lockAlpha ?? 0.3));
}
// buy stepper
if (this.side === 'buy' && cell.stepQty) {
const q = goodId ? (this.buyQty.get(goodId) ?? 0) : 0;
if (typeof cell.stepQty.setText === 'function') cell.stepQty.setText(String(q));
const on = usable;
for (const el of [cell.stepMinus, cell.stepQty, cell.stepPlus]) el?.setAlpha?.(on ? (el === cell.stepQty ? 1 : 0.8) : 0);
}
}
_renderStockRow(id) {
for (let i = 0; i < this.cells.length; i++) if (this._stockIdAt(i) === id) this._renderCell(i);
}
_goodName(id) {
if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id;
const list = config.get('goods.goods', []);
return list.find((g) => g.id === id)?.name ?? id;
}
@ -462,7 +379,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
};
const plate = this.scene.add?.graphics?.() ?? null;
const name = mk(this.tipNameSize, this.sl.name?.color ?? '#eaf6ff');
const price = mk(this.tipPriceSize, this.side === 'sell' ? (this.sl.price?.sell ?? '#7ce8a4') : (this.sl.price?.buy ?? '#ffc94d'));
const price = mk(this.tipPriceSize, this.sl.price?.sell ?? '#7ce8a4');
if (!plate && !name && !price) return;
if (plate) this._objs.push(plate);
if (name) this._objs.push(name);
@ -501,17 +418,12 @@ export class TradeGrid extends Phaser.GameObjects.Container {
hoverCell(i) {
const cell = this.cells[i];
if (!cell || !this.tip) return this;
let goodId = null;
if (this.side === 'sell') {
if (i < this.capacity) goodId = this._bayIdAt(i);
} else {
goodId = this._stockIdAt(i);
}
const goodId = i < this.capacity ? this._bayIdAt(i) : null;
if (!goodId) return this.unhover();
const unit = Math.round(this.side === 'sell' ? this.sellUnit(goodId) : this.buyUnit(goodId));
const unit = Math.round(this.sellUnit(goodId));
if (this.tipName && typeof this.tipName.setText === 'function') this.tipName.setText(this._goodName(goodId) || goodId);
if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`${this.side === 'sell' ? 'sell' : 'buy'} for ${unit} cr`);
if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`sell for ${unit} cr`);
// size from the text metrics (real Phaser or the test stubs)
const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18;