Rework BUY-list rows: title line carries name · inventory · price · step

- Split each row into a title line (name, "N left"/"SOLD OUT" strip, price,
  stepper with `stock.gap` padding between blocks) and a full-width desc line.
- Add `buyList.stock` config (w, gap, fontSize, color) for the inventory strip
  and use it for sizing/truncation so the name never collides with the strip.
- Update `_btnXs`, `_nameMaxW`, `_descMaxW` layout helpers to place the right
  block on the title line and give line 2 the row's full text width.
- Refresh shop-ui tests to assert title-line padding, truncation budget, and
  desc wrap within the full row width.
This commit is contained in:
Brian Fertig 2026-09-14 18:56:27 -06:00
parent 828a5b8c38
commit 8810ec1b3d
3 changed files with 100 additions and 49 deletions

View File

@ -56,7 +56,7 @@
"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.",
"_comment": "The BUY side (js/ui/shop/BuyList.js): the shop's stock as a SCROLLABLE LIST of WIDE ROWS — line 1 (the TITLE line): [icon] NAME … (padding) INVENTORY (\"N left\" / \"SOLD OUT\", the origin's remaining daily batch — model v2) … (padding) PRICE, − qty +; line 2: the good's `flavor` (data/goods.json), wrapped to the row's full width (desc.maxLines). The name is truncated to the title line's budget so it can never collide with the inventory strip or the price (`stock.gap` is the padding between name→inventory and inventory→price; `stock.w` reserves the strip's width). 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 lines. `pad` insets rows from the panel edge; the title bar reuses panel.title.",
"w": 560,
"pad": 14,
"rowH": 72,
@ -90,6 +90,12 @@
"glow": 5,
"glowAlpha": 0.3,
"core": 1.5
},
"stock": {
"w": 78,
"gap": 16,
"fontSize": 14,
"color": "#ffc94d"
}
},
"tooltip": {
@ -143,7 +149,10 @@
"scout": {
"_comment": "The TRADE SCOUT — the strategy panel that replaces the cargo grid when the player taps a BUY-list row (outside the stepper). Tap the same box again, the list dead space, or the panel itself to close it (the cargo grid returns) — EXCEPT the best-exit rows: tapping one offers the SET DESTINATION popup (the destination names the exit's WORLD — its planet of that class, the station, or the home world — not just the system; each exit carries objectId/objectName; setDestination below; the scene's ConfirmOverlay, same dialog as the map's). Data: js/economy/TradeScout.js (pure); UI: js/ui/shop/ScoutPanel.js. It FILLS the SELL grid box — only type/ink/colors live here.",
"topN": 3,
"setDestination": { "width": 520, "height": 176 },
"setDestination": {
"width": 520,
"height": 176
},
"revealMs": 140,
"pad": 18,
"title": {
@ -198,4 +207,4 @@
],
"empty": "No charted exit yet — every CAPITAL buys everything. Chart a world that wants this good (or a capital) and it will rank here."
}
}
}

View File

@ -476,29 +476,39 @@ 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: no tooltip — the WIDE ROW carries name + description + price
// inline (the row is wide enough, unlike the icon-only sell cells).
// BUY side: no tooltip — the WIDE ROW carries the info inline. The
// TITLE LINE is name · inventory · price · stepper (one line, with
// padding between each); line 2 is the description.
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 nameText = row0.objs.name.text;
assert.ok(nameText === nameB || (nameText.endsWith('…') && nameText.startsWith(nameB.slice(0, 3))),
`row carries the good name on the title line (whole, or truncated to the title-line budget) — got "${nameText}" for "${nameB}"`);
assert.equal(row0.objs.price.text, `${Math.round(buyB)} cr`, 'row carries the buy price on the title line');
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).
// TITLE-LINE PADDING (the layout ask): name ─ gap ─ inventory ─ gap ─
// price, all on the product-title line. stockGap pads both gaps; the
// name's budget (_nameMaxW) stops short of the inventory strip.
const bx = view.buyList._btnXs();
const nameW = view.buyList._nameMaxW(row0);
assert.ok(view.buyList._textX() + nameW + view.buyList.stockGap <= bx.left,
'padding between the product title and the inventory level');
assert.ok(bx.left + view.buyList.stockGap <= bx.price,
'padding between the inventory level and the price');
// DESCRIPTION (line 2 — its own line; the right block is on the title
// line above): smaller font, wraps to up to 2 lines, full row width.
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(descMaxW > 0, 'the desc line 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');
assert.ok(view.buyList._textX() + descMaxW <= view.buyList.vpW / 2 - view.buyList.pad + 0.001,
'desc column fits the row (line 2 is its own line, the right block is above)');
// THEME FONTS — the shop UI speaks the project's type (data/theme.json):
// Ethnocentric for section headers, Centauri for body text + buttons.

View File

@ -6,17 +6,22 @@
* ┌────────────────────────────────────────────────────────────┐
* │ BUY │
* │ ┌────────────────────────────────────────────────────────┐ │
* │ │ [icon] Fresh Produce 24 cr − 0 +│ │
* │ │ [icon] Fresh Produce 3 left 24 cr − 0 + │ │
* │ │ Crisp, clean, alive. A small mir… │ │
* │ │ [icon] Refined Fuel 41 cr − 1 +│ │
* │ │ [icon] Refined Fuel SOLD OUT 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.
* · TITLE — line 1: the NAME, then (padding) the INVENTORY ("3 left" /
* "SOLD OUT" — the origin's remaining daily batch, model v2),
* then (padding) the BUY PRICE ("24 cr") + the −/qty/+ stepper.
* Batch-limited rows carry the strip; unlimited rows (hubs'
* resale) don't — the name/price just get the padding room.
* · DESC — line 2: the one-line DESCRIPTION (the good's `flavor` from
* data/goods.json), wrapped to fit the row's full width (the
* title line owns the right side, so this line is unshared).
*
* A row with qty > 0 (in the pending trade) is highlighted — the same
* selection the old grid's steppers tracked (getBuyMap).
@ -76,6 +81,10 @@ export class BuyList extends Phaser.GameObjects.Container {
this.st = config.section('shopui.stepper', {}); // shared stepper knobs
this.stepperW = this.st.w ?? 88;
this.priceW = bl.price?.w ?? 64;
// The inventory strip ("N left" / "SOLD OUT", model v2) — its reserved
// width + the TITLE-LINE paddings (name → inventory, inventory → price).
this.stockW = Number(bl.stock?.w) || 78;
this.stockGap = Number(bl.stock?.gap) || 16;
this.bl = bl;
// The viewport (the scroll window, under the title) — the list's
@ -463,32 +472,38 @@ export class BuyList extends Phaser.GameObjects.Container {
}
}
// name + description (left of the price/stepper block)
// name (TITLE LINE — the first line, left of the inventory strip +
// price). Truncated to the name's budget (_nameMaxW — it stops BEFORE
// the inventory strip, or before the price on unlimited rows), so the
// title and the strip can never collide.
const nameFont = Number(bl.name?.fontSize) || 14;
const nameText = this._fitText(name, nameFont, 1, this._nameMaxW(row));
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' },
textX, 0, nameText,
{ fontSize: `${nameFont}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, row.left != null ? this._descMaxW({ left: 1 }) : null);
// description (LINE 2 — its own line; the title line owns the right
// block, so the desc gets the row's full text width).
row.desc = this._fitText(flavor, descFont, this.descLines, this._descMaxW());
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).
// Word-wrap inside the text column (the row's full width — the right
// block is on the title line above, so nothing shares this line).
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"
// price (TITLE LINE — right of the inventory strip, left of the stepper)
const bx = this._btnXs();
const priceEl = this._makeText(
0, 0, `${price} cr`,
{ fontSize: `${bl.price?.fontSize ?? 14}px`, color: bl.price?.color ?? '#ffc94d', align: 'right' },
@ -497,13 +512,14 @@ export class BuyList extends Phaser.GameObjects.Container {
// REMAINING BATCH (model v2 — the origin world's daily stock, the one
// stateful number in the economy): "3 left" sits just left of the price
// (right-aligned, like it); 0 = SOLD OUT (magenta, the stepper is
// clamped at the same number). Only batch-limited rows have one —
// hubs' resale is unlimited (left === null → no element).
// on the TITLE LINE (right-aligned, like it), with the stockGap padding
// on both sides; 0 = SOLD OUT (magenta, the stepper is clamped at the
// same number). Only batch-limited rows have one — hubs' resale is
// unlimited (left === null → no element).
if (row.left != null) {
const leftEl = this._makeText(
0, 0, row.left > 0 ? `${row.left} left` : 'SOLD OUT',
{ fontSize: `${bl.price?.fontSize ?? 14}px`, color: row.left > 0 ? (bl.price?.color ?? '#ffc94d') : '#ff2d6f', align: 'right' },
{ fontSize: `${bl.stock?.fontSize ?? bl.price?.fontSize ?? 14}px`, color: row.left > 0 ? (bl.stock?.color ?? bl.price?.color ?? '#ffc94d') : '#ff2d6f', align: 'right' },
);
if (leftEl) { leftEl.setOrigin?.(1, 0.5); row.objs.left = leftEl; this._objs.push(leftEl); this.add?.(leftEl); }
}
@ -577,25 +593,40 @@ export class BuyList extends Phaser.GameObjects.Container {
return -this.vpW / 2 + this.pad + this.iconSize + this.iconGap;
}
// Stepper button x's (row-local, right end of the row).
// Stepper button x's (row-local, right end of the row) + the title-line
// right block: [name] … [inventory strip] … [price] [stepper], all on the
// product-title line. `left` is the strip's RIGHT edge (origin 1,0.5);
// stockGap pads both the name→strip and strip→price gaps.
_btnXs() {
const right = this.vpW / 2 - this.pad;
const price = right - (this.stepperW) - 10; // price's RIGHT edge (origin 1,0.5)
const left = price - this.priceW - this.stockGap; // strip's RIGHT edge
return {
plus: right - 11,
qty: right - 44,
minus: right - 77,
price: right - (this.stepperW) - 10, // price's RIGHT edge (origin 1,0.5)
price,
left,
};
}
// Max description width (text block → price), in px. The price reserves
// its full width ("1,240 cr") so the description never collides with it;
// batch-limited rows reserve the "N left" / "SOLD OUT" strip between the
// desc and the price (`row.left` finite → the extra reservation).
_descMaxW(row = null) {
let w = this._btnXs().price - this.priceW - 14 - this._textX();
if (row && row.left != null) w -= (this.stockW ?? 66);
return Math.max(20, w);
// The NAME's budget on the title line (row-local px): from the text column
// to the inventory strip (batch-limited rows), or to the price (unlimited
// rows — no strip). stockGap padding is kept on the strip/price side.
_nameMaxW(row) {
const bx = this._btnXs();
const rightEdge = (row && row.left != null)
? bx.left - this.stockW - this.stockGap
: bx.price - this.priceW - this.stockGap;
return Math.max(20, rightEdge - this._textX());
}
// Max description width (px) — LINE 2 is the desc's own line (the
// name/inventory/price/stepper block sits on the title line above), so
// it gets the row's full text width.
_descMaxW() {
const right = this.vpW / 2 - this.pad;
return Math.max(20, right - this._textX() - 4);
}
// Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize
@ -685,15 +716,16 @@ export class BuyList extends Phaser.GameObjects.Container {
const o = row.objs;
const tx = cx + this._textX(); // row-local → list-local (row center at cx)
const bx = this._btnXs();
const titleY = y - this.rowH * 0.18; // the TITLE LINE (name · inventory · price · stepper)
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.name?.setPosition?.(tx, titleY);
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
o.price?.setPosition?.(cx + bx.price, y);
o.left?.setPosition?.(cx + bx.price - this.priceW - 8, y);
o.minus?.setPosition?.(cx + bx.minus, y);
o.qty?.setPosition?.(cx + bx.qty, y);
o.plus?.setPosition?.(cx + bx.plus, y);
o.price?.setPosition?.(cx + bx.price, titleY);
o.left?.setPosition?.(cx + bx.left, titleY);
o.minus?.setPosition?.(cx + bx.minus, titleY);
o.qty?.setPosition?.(cx + bx.qty, titleY);
o.plus?.setPosition?.(cx + bx.plus, titleY);
if (row.shown) {
for (const el of [o.icon, o.name, o.desc, o.price, o.left, o.minus, o.qty, o.plus]) {
el?.setAlpha?.(this._elInVp(el, vpTop, vpBot) ? 1 : 0);