Add trade scout panel to inspect goods and charted exits in shop
- Tap a BUY-list row's box (outside the stepper) to swap the cargo grid for a strategy report showing the good's identity, producer/demand classes, and top-N charted exits ranked by sell price with per-unit profit. - New pure TradeScout module computes the report from galaxy data and visited systems; new ScoutPanel renders it over the SELL grid area. - BuyList gains tap detection (distinguishing taps from drags), a focus glow on the scouted row, and an onRowTap hook. - ShopView wires open/close/swap semantics: same box toggles off, different box swaps report, dead-space or panel tap closes and restores cargo grid; sell-grid input is disabled while scout is shown. - Add Trestle character art, Carol Q2 speech lines, shopui.json scout config section, and tests for both the pure logic and UI integration.
This commit is contained in:
parent
071f3ac0e4
commit
564b086c6a
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
132
data/shopui.json
132
data/shopui.json
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"_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 (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",
|
||||
|
|
@ -9,36 +8,44 @@
|
|||
"activeAccent": "#00e5ff",
|
||||
"disabledAlpha": 0.34
|
||||
},
|
||||
|
||||
"panel": {
|
||||
"_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,
|
||||
"bgAlpha": 0.7,
|
||||
"border": "#22405f",
|
||||
"pad": 16,
|
||||
"title": { "fontSize": 21, "color": "#eaf6ff", "h": 34 }
|
||||
"title": {
|
||||
"fontSize": 21,
|
||||
"color": "#eaf6ff",
|
||||
"h": 34
|
||||
}
|
||||
},
|
||||
|
||||
"grid": {
|
||||
"_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,
|
||||
"cols": 4,
|
||||
"rows": 4,
|
||||
"slot": 96,
|
||||
"gap": 10,
|
||||
"centerW": 16
|
||||
},
|
||||
|
||||
"slot": {
|
||||
"_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,
|
||||
"lockAlpha": 0.3,
|
||||
"iconScale": 0.62,
|
||||
"name": { "fontSize": 11, "color": "#eaf6ff" },
|
||||
"price": { "fontSize": 12, "sell": "#7ce8a4" },
|
||||
"name": {
|
||||
"fontSize": 11,
|
||||
"color": "#eaf6ff"
|
||||
},
|
||||
"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,
|
||||
|
|
@ -47,17 +54,35 @@
|
|||
"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 },
|
||||
"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"
|
||||
"selected": "#00e5ff",
|
||||
"focus": {
|
||||
"_comment": "The light stroke around the SCOUTED row (the player tapped its box — the scout panel shows its strategy). A soft wide glow under a bright thin core; it follows the row as the list scrolls and vanishes when the row clips out. (js/ui/shop/BuyList.js → _renderFocus.)",
|
||||
"color": "#00e5ff",
|
||||
"alpha": 0.95,
|
||||
"glow": 5,
|
||||
"glowAlpha": 0.3,
|
||||
"core": 1.5
|
||||
}
|
||||
},
|
||||
|
||||
"tooltip": {
|
||||
"_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",
|
||||
|
|
@ -68,7 +93,6 @@
|
|||
"nameFontSize": 14,
|
||||
"priceFontSize": 13
|
||||
},
|
||||
|
||||
"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,
|
||||
|
|
@ -77,7 +101,6 @@
|
|||
"btn": "#7d92c4",
|
||||
"activeColor": "#00e5ff"
|
||||
},
|
||||
|
||||
"preview": {
|
||||
"_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",
|
||||
|
|
@ -88,9 +111,13 @@
|
|||
"posColor": "#7ce8a4",
|
||||
"negColor": "#ff2d6f",
|
||||
"gap": 12,
|
||||
"divider": { "width": 2, "height": 26, "color": "#22405f", "alpha": 0.9 }
|
||||
"divider": {
|
||||
"width": 2,
|
||||
"height": 26,
|
||||
"color": "#22405f",
|
||||
"alpha": 0.9
|
||||
}
|
||||
},
|
||||
|
||||
"buttons": {
|
||||
"_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",
|
||||
|
|
@ -98,11 +125,72 @@
|
|||
"confirmAccent": "#7ce8a4",
|
||||
"clearAccent": "#7d92c4"
|
||||
},
|
||||
|
||||
"animation": {
|
||||
"_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"
|
||||
},
|
||||
"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). 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,
|
||||
"revealMs": 140,
|
||||
"pad": 18,
|
||||
"title": {
|
||||
"label": "TRADE SCOUT",
|
||||
"fontSize": 18,
|
||||
"color": "#eaf6ff"
|
||||
},
|
||||
"icon": 88,
|
||||
"name": {
|
||||
"fontSize": 19,
|
||||
"color": "#eaf6ff"
|
||||
},
|
||||
"meta": {
|
||||
"fontSize": 11,
|
||||
"color": "#7d92c4"
|
||||
},
|
||||
"priceBuy": {
|
||||
"fontSize": 12,
|
||||
"color": "#ffc94d"
|
||||
},
|
||||
"priceSell": {
|
||||
"fontSize": 12,
|
||||
"color": "#7ce8a4"
|
||||
},
|
||||
"flavor": {
|
||||
"fontSize": 12,
|
||||
"color": "#7d92c4"
|
||||
},
|
||||
"section": {
|
||||
"fontSize": 10,
|
||||
"color": "#3d4c74"
|
||||
},
|
||||
"chip": {
|
||||
"fontSize": 11,
|
||||
"color": "#eaf6ff"
|
||||
},
|
||||
"exitRow": {
|
||||
"h": 40,
|
||||
"fontSize": 13,
|
||||
"color": "#eaf6ff"
|
||||
},
|
||||
"exitPrice": {
|
||||
"fontSize": 13,
|
||||
"color": "#7ce8a4"
|
||||
},
|
||||
"exitProfit": {
|
||||
"fontSize": 11,
|
||||
"posColor": "#7ce8a4",
|
||||
"negColor": "#ff2d6f"
|
||||
},
|
||||
"barColor": "#7ce8a4",
|
||||
"ranks": [
|
||||
"#ffc94d",
|
||||
"#c8d4e8",
|
||||
"#d09058"
|
||||
],
|
||||
"empty": "No charted exit yet — every CAPITAL buys everything. Chart a world that wants this good (or a capital) and it will rank here.",
|
||||
"footer": "TAP ANYWHERE TO RETURN TO CARGO"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,6 +254,12 @@ const bounds = (o) => {
|
|||
clickCanvas(missX, pCy);
|
||||
await sleep(120);
|
||||
const qAfterMiss = bl.buyQty.get(r0.id) ?? 0;
|
||||
// NEW SEMANTICS (the trade scout): that gap click is on the row's BOX
|
||||
// (outside the stepper) — the stepper stays put (the qty delta above is
|
||||
// still 0), but the tap is a ROW TAP: the scout opens on this row.
|
||||
const missScoutOpen = sv._scoutOpen === r0.id;
|
||||
const missFocus = bl._focusId === r0.id;
|
||||
sv._closeScout?.(); // hand the scout state to the dedicated section below
|
||||
clickCanvas(mCx, pCy); // MINUS: on the visible glyph
|
||||
await sleep(120);
|
||||
const qAfterMinus = bl.buyQty.get(r0.id) ?? 0;
|
||||
|
|
@ -265,11 +271,13 @@ const bounds = (o) => {
|
|||
minusOnGlyph: qAfterMinus - qAfterMiss,
|
||||
},
|
||||
miss,
|
||||
rowTapOnMiss: { scoutOpen: missScoutOpen, focus: missFocus, ok: missScoutOpen && missFocus },
|
||||
ok: stepNames.every((k) => stepChecks[k].origin?.x === 0.5 && stepChecks[k].origin?.y === 0.5)
|
||||
&& gapR > gapL
|
||||
&& (qAfterPlus - qStart) === 1
|
||||
&& (qAfterMiss - qAfterPlus) === 0
|
||||
&& (qAfterMinus - qAfterMiss) === -1,
|
||||
&& (qAfterMinus - qAfterMiss) === -1
|
||||
&& missScoutOpen && missFocus,
|
||||
};
|
||||
out.pass = out.pass && out.buySteppers.ok;
|
||||
|
||||
|
|
@ -428,6 +436,103 @@ const bounds = (o) => {
|
|||
};
|
||||
out.pass = out.pass && out.subBar.ok;
|
||||
|
||||
// ---- TRADE SCOUT PROBE (tap a BUY row's box → the cargo grid swaps) ----
|
||||
// The ask: tap a purchasable item's box (NOT the stepper) → a light stroke
|
||||
// around that box + the SELL area replaced by the item's strategy report
|
||||
// (what wants it, where it's grown, top-3 charted exits + profit). Tap the
|
||||
// same box / anywhere else → the cargo grid returns. Verify with REAL canvas
|
||||
// clicks (the same mousedown/up path the surface's tap logic listens to).
|
||||
const scouted = bl.rows[0];
|
||||
const rowY = round(scouted.y0 - bl._scroll);
|
||||
const boxX = round(bl.x + bl.vpX + 40); // the box's left-center — clear of price + stepper
|
||||
const boxY = bl.y + rowY;
|
||||
const scoutOpenState = () => ({
|
||||
openId: sv._scoutOpen,
|
||||
scoutAlpha: sv._scout?.alpha ?? null,
|
||||
sellAlpha: sv.sellGrid?.alpha ?? null,
|
||||
focusId: bl._focusId,
|
||||
data: sv._scout?.data ? {
|
||||
id: sv._scout.data.id,
|
||||
localBuy: sv._scout.data.localBuy,
|
||||
demand: (sv._scout.data.demand ?? []).map((c) => c.label),
|
||||
producer: (sv._scout.data.producer ?? []).map((c) => c.label),
|
||||
exits: (sv._scout.data.exits ?? []).map((e) => ({
|
||||
sys: e.systemName, cls: e.clsLabel, price: e.price, profit: e.profit, hops: e.hops,
|
||||
})),
|
||||
} : null,
|
||||
});
|
||||
const scoutChecks = {};
|
||||
// DIAGNOSTIC: log every open/close call (dev probe only).
|
||||
const diag = [];
|
||||
const origOpen = sv._openScout.bind(sv);
|
||||
const origClose = sv._closeScout.bind(sv);
|
||||
sv._openScout = (id, p) => { diag.push(['open', id]); return origOpen(id, p); };
|
||||
sv._closeScout = () => { diag.push(['close']); return origClose(); };
|
||||
// Deeper: log every row-tap hook call + every _tapUp with the tap state.
|
||||
const origHook = bl.onRowTap;
|
||||
bl.onRowTap = (row) => { diag.push(['hook', row ? row.id : null]); return origHook(row); };
|
||||
const origTapUp = bl._tapUp.bind(bl);
|
||||
bl._tapUp = () => { diag.push(['tapUp', JSON.stringify(bl._tap), bl._focusId]); return origTapUp(); };
|
||||
// (a) TAP THE BOX → opens on that good; cargo grid steps aside; row glows.
|
||||
// Normalize the pre-state first: the stepper/dead-click probes' "gap" and
|
||||
// "clipped glyph" clicks land on the row BOX (not a stepper) — under the
|
||||
// trade-scout semantics those are ROW TAPS, so the scout may already be
|
||||
// toggled. Close it so (a) starts from a known-closed state.
|
||||
sv._closeScout?.();
|
||||
clickCanvas(boxX, boxY);
|
||||
await sleep(300); // the 140ms fade-in settles
|
||||
scoutChecks.diag = diag.slice();
|
||||
scoutChecks.open = scoutOpenState();
|
||||
const d0 = sv._scout?.data;
|
||||
scoutChecks.openOk = !!d0
|
||||
&& sv._scoutOpen === scouted.id
|
||||
&& (sv._scout.alpha ?? 0) > 0.99
|
||||
&& (sv.sellGrid.alpha ?? 1) < 0.01
|
||||
&& bl._focusId === scouted.id
|
||||
&& d0.id === scouted.id
|
||||
&& d0.localBuy === scouted.price
|
||||
&& Array.isArray(d0.exits) && d0.exits.length <= 3
|
||||
&& d0.exits.every((e) => e.profit === e.price - d0.localBuy)
|
||||
&& d0.exits.every((e, i) => i === 0 || d0.exits[i - 1].price >= e.price)
|
||||
&& d0.exits.some((e) => e.systemId === (ss.gameScene?.systemRecord?.id ?? sv.systemId));
|
||||
// (b) TAP THE SAME BOX AGAIN → closes, cargo grid restored.
|
||||
clickCanvas(boxX, boxY);
|
||||
await sleep(300);
|
||||
scoutChecks.closeOk = sv._scoutOpen === null
|
||||
&& (sv._scout.alpha ?? 1) < 0.01
|
||||
&& (sv.sellGrid.alpha ?? 0) > 0.99
|
||||
&& bl._focusId === null;
|
||||
// (c) TAP A DIFFERENT BOX → the report swaps.
|
||||
const otherRow = bl.rows[1] ?? bl.rows[0];
|
||||
clickCanvas(round(bl.x + bl.vpX + 40), bl.y + round(otherRow.y0 - bl._scroll));
|
||||
await sleep(300);
|
||||
scoutChecks.swapOk = sv._scoutOpen === otherRow.id
|
||||
&& sv._scout?.data?.id === otherRow.id
|
||||
&& (sv.sellGrid.alpha ?? 1) < 0.01;
|
||||
// (d) TAP THE SCOUT ITSELF → closes ("anywhere else").
|
||||
clickCanvas(round(sv._scout.x + sv._scout.width / 2), round(sv._scout.y + sv._scout.height / 2));
|
||||
await sleep(300);
|
||||
scoutChecks.selfCloseOk = sv._scoutOpen === null && (sv.sellGrid.alpha ?? 0) > 0.99 && bl._focusId === null;
|
||||
out.tradeScout = {
|
||||
box: { x: boxX, y: boxY },
|
||||
diag,
|
||||
open: scoutChecks.open,
|
||||
swap: { row: otherRow.id, id: sv._scout?.data?.id ?? sv._scoutOpen },
|
||||
checks: { openOk: scoutChecks.openOk, closeOk: scoutChecks.closeOk, swapOk: scoutChecks.swapOk, selfCloseOk: scoutChecks.selfCloseOk },
|
||||
ok: scoutChecks.openOk && scoutChecks.closeOk && scoutChecks.swapOk && scoutChecks.selfCloseOk,
|
||||
};
|
||||
out.pass = out.pass && out.tradeScout.ok;
|
||||
|
||||
// Leave the scout OPEN on a good with charted exits (for the screenshot).
|
||||
for (const r of bl.rows) {
|
||||
clickCanvas(round(bl.x + bl.vpX + 40), bl.y + round(r.y0 - bl._scroll));
|
||||
await sleep(280);
|
||||
if (sv._scoutOpen === r.id && (sv._scout.data?.exits?.length ?? 0) > 0) break;
|
||||
clickCanvas(round(bl.x + bl.vpX + 40), bl.y + round(r.y0 - bl._scroll)); // close again
|
||||
await sleep(280);
|
||||
}
|
||||
out.scoutShot = { openId: sv._scoutOpen, exits: sv._scout?.data?.exits?.length ?? 0 };
|
||||
|
||||
out.screen = { w: ss.scale.width, h: ss.scale.height };
|
||||
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; })();
|
||||
|
|
|
|||
|
|
@ -637,4 +637,66 @@ console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==
|
|||
}
|
||||
console.log('LOCK + CLEAR: OK');
|
||||
|
||||
// ===========================================================================
|
||||
console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the report ==');
|
||||
{
|
||||
const ship = makeShip(10000);
|
||||
ship.cargo.add([A]);
|
||||
const { view } = makeView(ship);
|
||||
|
||||
// The scout exists (over the cargo grid) and starts closed.
|
||||
assert.ok(view._scout, 'the scout panel exists with the trade content');
|
||||
assert.equal(view._scoutOpen, null, 'starts closed (cargo grid up)');
|
||||
assert.equal(view._scout.alpha, 0, '… hidden (alpha 0)');
|
||||
assert.equal(view.sellGrid.alpha, 1, '… and the cargo grid is visible');
|
||||
|
||||
const row = view.buyList.rows[0];
|
||||
|
||||
// TAP THE ROW'S BOX → the scout opens on that good.
|
||||
view._onRowTap(row);
|
||||
assert.equal(view._scoutOpen, row.id, 'the tapped row is scouted');
|
||||
assert.equal(view._scout.alpha, 1, 'the report is visible (the stub tween lands on alpha 1)');
|
||||
assert.equal(view.sellGrid.alpha, 0, '… the cargo grid steps aside (alpha 0)');
|
||||
assert.equal(view.buyList._focusId, row.id, '… the row keeps its focus stroke');
|
||||
assert.equal(view._scout.data.id, row.id, '… and it shows THAT good');
|
||||
|
||||
// The report: identity + baseline + charted exits + profit math.
|
||||
const d = view._scout.data;
|
||||
assert.equal(d.localBuy, row.price, 'the profit baseline is this shop\'s buy price for the row');
|
||||
assert.ok(Array.isArray(d.demand) && d.demand.every((c) => c.label), 'demand ("sought on") classes carry labels');
|
||||
assert.ok(Array.isArray(d.producer) && d.producer.every((c) => c.label), 'producer ("grown on") classes carry labels');
|
||||
assert.ok(d.exits.length <= 3, 'top-N (≤ 3) charted exits');
|
||||
assert.ok(d.exits.every((e) => e.profit === e.price - d.localBuy), 'profit = exit price − local buy');
|
||||
assert.ok(d.exits.every((e) => e.systemName && e.clsLabel), 'exits carry system + class labels');
|
||||
assert.ok(d.exits.every((e, i) => i === 0 || d.exits[i - 1].price >= e.price), 'exits ranked high → low');
|
||||
assert.ok(d.exits.some((e) => e.systemId === sysId), 'the current (charted) system is a candidate exit');
|
||||
|
||||
// TAP THE SAME BOX AGAIN → closes, cargo grid restored.
|
||||
view._onRowTap(row);
|
||||
assert.equal(view._scoutOpen, null, 'same box again → the scout closes');
|
||||
assert.equal(view._scout.alpha, 0, '… the report hides');
|
||||
assert.equal(view.sellGrid.alpha, 1, '… the cargo grid is back');
|
||||
assert.equal(view.buyList._focusId, null, '… and the row\'s focus stroke is cleared');
|
||||
|
||||
// TAP A DIFFERENT ROW → the report swaps (still open).
|
||||
const other = view.buyList.rows[1] ?? view.buyList.rows[0];
|
||||
view._onRowTap(other);
|
||||
assert.equal(view._scoutOpen, other.id, 'a different box swaps the report');
|
||||
assert.equal(view._scout.data.id, other.id, '… to that good');
|
||||
assert.equal(view.sellGrid.alpha, 0, '… the cargo grid stays aside');
|
||||
|
||||
// TAP THE LIST'S DEAD SPACE (null) → closes too ("anywhere else").
|
||||
view._onRowTap(null);
|
||||
assert.equal(view._scoutOpen, null, 'a dead-space tap closes the scout');
|
||||
assert.equal(view.sellGrid.alpha, 1, '… cargo grid restored');
|
||||
|
||||
// The scout's OWN tap (its tap layer → onClose) closes it too.
|
||||
view._onRowTap(row);
|
||||
assert.equal(view._scoutOpen, row.id, '… reopened for the tap-self test');
|
||||
view._scout.onClose();
|
||||
assert.equal(view._scoutOpen, null, 'tapping the scout itself closes it');
|
||||
assert.equal(view.sellGrid.alpha, 1, '… cargo grid restored');
|
||||
}
|
||||
console.log('TRADE SCOUT: OK');
|
||||
|
||||
console.log('\nAll shop-UI checks passed \u2714');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* TradeScout test (dev tool — run headless with the Phaser loader):
|
||||
*
|
||||
* node --import ./dev/phaser-loader.mjs dev/trade-scout.test.mjs
|
||||
*
|
||||
* The PURE strategy layer behind the shop's goods inspector (tap a BUY row's
|
||||
* box → the cargo grid is swapped for the row's strategy report):
|
||||
*
|
||||
* - THE REPORT: identity (name/category/tier/flavor) + local prices +
|
||||
* producer ("GROWN ON") + demand ("SIGHT ON") classes with labels.
|
||||
* - CHARTED EXITS: only systems the run has ENTERED count; the current
|
||||
* system always counts (we're here); a system is an exit only if it
|
||||
* actually BUYS the good (wants it, or is a capital); ranked by its
|
||||
* BEST class's sell price, top-N kept.
|
||||
* - PROFIT: exit price − the shop's local buy price (the row's price);
|
||||
* maxProfit drives the panel's bars.
|
||||
* - CAPITALS: a visited capital is always an exit (the universal sink).
|
||||
* - DETERMINISM: same inputs ⇒ identical report.
|
||||
*/
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
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 { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
|
||||
const { scoutGood, goodCatalog, sellsHere } = await import(pathToFileURL(join(__dirname, '../js/economy/TradeScout.js')).href);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const g = Galaxy.create('orbit-trade-scout-test');
|
||||
const goods = config.get('goods.goods', []);
|
||||
const classOf = (sysId) => [...g.economy.classBySystem.get(sysId)];
|
||||
|
||||
// A real shop to scout from: the home system (always charted, terran world).
|
||||
const sysId = g.homeSystemId;
|
||||
const cls = 'terran';
|
||||
const stock = E.shopFor(cls, sysId, g).buy;
|
||||
const row = stock.find((s) => sellsHere(s.id, classOf(sysId).find((c) => c !== 'capital')) ) ?? stock[0];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. The report — identity + local prices + class info
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const d = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited: [sysId] });
|
||||
check('report exists for a stock good', !!d);
|
||||
check('identity: id + name', d.id === row.id && d.name.length > 0);
|
||||
check('identity: flavor from the catalog', d.flavor === (goodCatalog(row.id).flavor ?? ''));
|
||||
check('identity: category + tier from the catalog', d.category === goodCatalog(row.id).category && d.tier === goodCatalog(row.id).tier);
|
||||
check('localBuy = the row price (the profit baseline)', d.localBuy === row.price);
|
||||
const wantsHere = sellsHere(row.id, cls);
|
||||
check(`localSell ${wantsHere ? 'priced (this class wants it)' : 'null (this class does not want it)'}`,
|
||||
wantsHere ? (d.localSell != null && d.localSell > 0) : d.localSell == null);
|
||||
check('producer classes listed with labels', d.producer.every((c) => c.cls && c.label.length > 0));
|
||||
check('demand classes listed with labels', d.demand.every((c) => c.cls && c.label.length > 0));
|
||||
check('unknown good → null report', scoutGood('no-such-good', { galaxy: g, systemId: sysId, cls }) == null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. CharteD exits — only visited systems, only buyers, ranked, top-N
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Pick a demand class of this good and find a charted system carrying it.
|
||||
const wantCls = (goodCatalog(row.id).demand ?? [])[0];
|
||||
const exitSys = wantCls
|
||||
? [...g.byId.keys()].find((id) => g.economy.classBySystem.get(id).has(wantCls))
|
||||
: null;
|
||||
const visited = [sysId, exitSys].filter(Boolean);
|
||||
|
||||
const d = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited, topN: 3 });
|
||||
check('exits are a list of { systemId, systemName, cls, price, profit, hops }',
|
||||
d.exits.every((e) => e.systemId && e.systemName && e.cls && Number.isFinite(e.price) && Number.isFinite(e.profit) && e.clsLabel));
|
||||
check('every exit is a CHARTED (visited) system', d.exits.every((e) => visited.includes(e.systemId)));
|
||||
check('no exit for an UNCHARTED system', (() => {
|
||||
const others = [...g.byId.keys()].filter((id) => !visited.includes(id));
|
||||
const d2 = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited: [sysId] });
|
||||
return d2.exits.every((e) => visited.includes(e.systemId));
|
||||
})());
|
||||
check('ranked by sell price (high → low)', d.exits.every((e, i) => i === 0 || d.exits[i - 1].price >= e.price));
|
||||
check('top-N honored (≤ 3)', d.exits.length <= 3);
|
||||
if (exitSys) {
|
||||
const wantsIt = E.wants(row.id, wantCls);
|
||||
check(`the charted ${wantCls} system ${wantsIt ? 'IS' : 'is not (capital-only)'} in the exits`,
|
||||
d.exits.some((e) => e.systemId === exitSys) === wantsIt);
|
||||
}
|
||||
check('profit = exit price − local buy', d.exits.every((e) => e.profit === e.price - row.price));
|
||||
check('maxProfit = the best exit profit', d.maxProfit === Math.max(0, ...d.exits.map((e) => e.profit)));
|
||||
check('bestExit = the first ranked exit', d.bestExit == null ? d.exits.length === 0 : d.bestExit === d.exits[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Capitals are always an exit (the universal sink)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const capId = [...g.byId.keys()].find((id) => g.economy.classBySystem.get(id).has('capital'));
|
||||
const visited = [sysId, capId].filter(Boolean);
|
||||
const d = scoutGood(row.id, { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited });
|
||||
check('a charted CAPITAL always ranks as an exit', d.exits.some((e) => e.systemId === capId && g.economy.classBySystem.get(capId).has('capital')));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Determinism — same inputs ⇒ identical report
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const o = { galaxy: g, systemId: sysId, cls, buyUnit: row.price, visited: [...g.byId.keys()].slice(0, 5) };
|
||||
const a = JSON.stringify(scoutGood(row.id, { ...o }));
|
||||
const b = JSON.stringify(scoutGood(row.id, { ...o }));
|
||||
check('same inputs ⇒ identical report', a === b);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. A good that is produced locally sells ABOVE its local buy (the loop)
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Pick a good this class PRODUCES (in the stock) — somewhere far it must
|
||||
// sell for more than it costs here (hop term), when that far system is
|
||||
// charted.
|
||||
const produced = stock.find((s) => g.economy.classBySystem.get(sysId).has(cls) && E.produces(s.id, cls));
|
||||
if (produced) {
|
||||
const far = [...g.byId.keys()].find((id) => {
|
||||
const c = classOf(id);
|
||||
return c.some((x) => sellsHere(produced.id, x)) &&
|
||||
(g.economy.producerHops[produced.id][id] ?? 0) > 1;
|
||||
});
|
||||
if (far) {
|
||||
const d = scoutGood(produced.id, { galaxy: g, systemId: sysId, cls, buyUnit: produced.price, visited: [sysId, far] });
|
||||
const exit = d.exits.find((e) => e.systemId === far);
|
||||
check(`the far charted exit sells ${produced.id} above its local buy (the loop)`,
|
||||
!!exit && exit.price > produced.price);
|
||||
} else {
|
||||
check('far exit found (skipped — no suitable system)', true); // informational
|
||||
}
|
||||
} else {
|
||||
check('a locally produced stock good exists (skipped)', true); // informational
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nAll TradeScout checks passed ✔' : `\n${failures} FAILURES`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* TradeScout — the pure strategy layer behind the shop's goods inspector.
|
||||
*
|
||||
* When the player taps a BUY-list row (outside the stepper), the shop swaps
|
||||
* the cargo grid for a SCOUT PANEL (js/ui/shop/ScoutPanel.js) answering the
|
||||
* trader's questions:
|
||||
*
|
||||
* · WHAT is this good (name, category, tier, flavor)?
|
||||
* · WHERE is it GROWN (the producer classes — cheap to buy there)?
|
||||
* · WHO WANTS it (the demand classes — the premium sell exits)?
|
||||
* · WHERE does it sell BEST — the top-N CHARTED (visited) systems ranked
|
||||
* by the SELL price they'd pay, each with the per-unit PROFIT vs. the
|
||||
* player's local buy price here.
|
||||
*
|
||||
* Everything is a PURE function of (catalog, galaxy, the run's visited set)
|
||||
* — the same deterministic pricing Economy.js feeds the shop — so it's
|
||||
* testable in Node (dev/trade-scout.test.mjs) and needs no scene.
|
||||
*
|
||||
* const scout = scoutGood('refined-fuel', {
|
||||
* galaxy, systemId, cls, // this shop (Economy.economicClass)
|
||||
* buyUnit, // this row's buy price (the baseline)
|
||||
* visited, // the systems the run has entered
|
||||
* topN: 3,
|
||||
* });
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
import * as E from './Economy.js';
|
||||
|
||||
/** One economic class with its player-facing label + economy blurb. */
|
||||
function classInfo(cls) {
|
||||
const info = config.get('goods.classes', {})[cls] ?? {};
|
||||
return { cls, label: String(info.label ?? cls).toUpperCase(), economy: String(info.economy ?? '') };
|
||||
}
|
||||
|
||||
/** The full catalog entry for a good id (null when unknown). */
|
||||
export function goodCatalog(id) {
|
||||
const list = config.get('goods.goods', []);
|
||||
return list.find((g) => g.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a shop of class `cls` BUY BACK the good (is it on that shop's sell
|
||||
* side)? Capitals buy everything (the universal sink); everyone else buys
|
||||
* back exactly the goods their class WANTS (Economy.shopFor's rule).
|
||||
*/
|
||||
export function sellsHere(goodId, cls) {
|
||||
if (!cls) return false;
|
||||
return cls === 'capital' || E.wants(goodId, cls);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scout report for one good at one shop.
|
||||
*
|
||||
* @param {string} goodId — the catalog id (a BUY row's good).
|
||||
* @param {object} o —
|
||||
* galaxy — the generated galaxy (Economy's classBySystem + prices).
|
||||
* systemId — THIS system (always counts as charted — the player is here).
|
||||
* cls — this shop's economic class.
|
||||
* buyUnit — the row's local BUY price (the profit baseline; defaults to
|
||||
* Economy.buyPrice for (good, cls, system)).
|
||||
* visited — the systems the run has ENTERED (GameScene.visitedSystems).
|
||||
* topN — how many charted exits to rank (default 3).
|
||||
* @returns {{
|
||||
* id:string, name:string, flavor:string, category:string|null, tier:number|null,
|
||||
* localBuy:number, localSell:number|null,
|
||||
* producer:Array<{cls,label,economy}>, demand:Array<{cls,label,economy}>,
|
||||
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops}>,
|
||||
* bestExit:object|null, maxProfit:number, charted:number, topN:number
|
||||
* } | null} — null only for an unknown good id.
|
||||
*/
|
||||
export function scoutGood(goodId, o = {}) {
|
||||
const g = goodCatalog(goodId);
|
||||
if (!g) return null;
|
||||
const galaxy = o.galaxy ?? null;
|
||||
const systemId = o.systemId ?? null;
|
||||
const cls = o.cls ?? null;
|
||||
const localBuy = Number.isFinite(o.buyUnit) ? Math.max(0, Math.round(o.buyUnit)) : E.buyPrice(goodId, cls, systemId, galaxy);
|
||||
const localSell = sellsHere(goodId, cls) ? E.sellPrice(goodId, cls, systemId, galaxy) : null;
|
||||
|
||||
const producer = (Array.isArray(g.producer) ? g.producer : []).map(classInfo);
|
||||
const demand = (Array.isArray(g.demand) ? g.demand : []).map(classInfo);
|
||||
|
||||
// --- THE CHARTED EXITS ------------------------------------------------
|
||||
// Every system the run has entered (the current one always counts) that
|
||||
// actually BUYS the good (wants it, or is a capital) is a possible exit,
|
||||
// priced at its BEST class's sell price. Ranked high→low, top-N kept,
|
||||
// each with the per-unit profit vs. the local buy price + the hop
|
||||
// distance to the nearest producer (the engine's distance term — the
|
||||
// "far = dear" story in one number).
|
||||
const visited = new Set();
|
||||
for (const s of o.visited ?? []) if (typeof s === 'string' && s) visited.add(s);
|
||||
if (systemId) visited.add(systemId); // we are here — it's charted by definition
|
||||
|
||||
const classBySystem = galaxy?.economy?.classBySystem ?? null;
|
||||
const exits = [];
|
||||
for (const sysId of visited) {
|
||||
const rec = galaxy?.byId?.get?.(sysId);
|
||||
if (!rec) continue;
|
||||
const classes = classBySystem?.get?.(sysId);
|
||||
if (!classes) continue;
|
||||
let best = null;
|
||||
for (const c of classes) {
|
||||
if (!sellsHere(goodId, c)) continue;
|
||||
const price = E.sellPrice(goodId, c, sysId, galaxy);
|
||||
if (!best || price > best.price) best = { systemId: sysId, systemName: rec.name ?? sysId, cls: c, price };
|
||||
}
|
||||
if (!best) continue;
|
||||
const hops = galaxy?.economy?.producerHops?.[goodId]?.[sysId];
|
||||
exits.push({
|
||||
...best,
|
||||
clsLabel: classInfo(best.cls).label,
|
||||
profit: best.price - localBuy,
|
||||
hops: Number.isFinite(hops) ? hops : null,
|
||||
});
|
||||
}
|
||||
exits.sort((a, b) => (b.price - a.price) || String(a.systemName).localeCompare(String(b.systemName)));
|
||||
|
||||
const topN = Math.max(1, Math.floor(Number(o.topN) || 3));
|
||||
const top = exits.slice(0, topN);
|
||||
const maxProfit = top.reduce((m, e) => Math.max(m, e.profit), 0);
|
||||
|
||||
return {
|
||||
id: g.id,
|
||||
name: String(g.name ?? g.id),
|
||||
flavor: String(g.flavor ?? ''),
|
||||
category: g.category ?? null,
|
||||
tier: Number.isFinite(g.tier) ? g.tier : null,
|
||||
baseValue: Number.isFinite(g.baseValue) ? g.baseValue : null,
|
||||
localBuy,
|
||||
localSell,
|
||||
producer,
|
||||
demand,
|
||||
exits: top,
|
||||
bestExit: top[0] ?? null,
|
||||
maxProfit,
|
||||
charted: visited.size,
|
||||
topN,
|
||||
};
|
||||
}
|
||||
|
|
@ -92,6 +92,12 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
this._objs = []; // everything we create (destroy())
|
||||
this._surface = null; // the scroll surface (drag + wheel)
|
||||
this._drag = null; // { y } while a press drags the list
|
||||
this._tap = null; // { x, y, moved } — the tap candidate (row tap → scout)
|
||||
|
||||
// --- focus glow (the light stroke around the scouted row) -------------
|
||||
this._focusId = null; // the row id under focus (null = none)
|
||||
this._focus = null; // the glow graphics (above the rows)
|
||||
this._onRow = null; // row-tap handler (ShopView toggles the scout)
|
||||
|
||||
// --- scrollbar (right edge of the viewport) ---------------------------
|
||||
this._sb = null; // the track+thumb graphics
|
||||
|
|
@ -108,6 +114,8 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
this._buildSurface(); // BEFORE the rows (rows paint above it)
|
||||
this._buildScrollbar(); // AFTER the surface; refresh() re-adds it
|
||||
// ABOVE the rows (they're created later)
|
||||
this._buildFocus(); // the focus glow; refresh() re-adds it
|
||||
// ABOVE the rows + scrollbar
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ panel
|
||||
|
|
@ -169,15 +177,24 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
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('pointerdown', (p) => {
|
||||
this._drag = { y: p?.y ?? 0 };
|
||||
this._tap = { x: p?.x ?? 0, y: p?.y ?? 0, moved: false }; // tap candidate
|
||||
});
|
||||
g.on('pointermove', (p) => {
|
||||
if (this._drag && p && typeof p.y === 'number') {
|
||||
if (this._tap && (Math.abs(p.x - this._tap.x) > 6 || Math.abs(p.y - this._tap.y) > 6)) {
|
||||
this._tap.moved = true; // it's a drag, not a tap
|
||||
}
|
||||
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('pointerup', () => {
|
||||
this._drag = null;
|
||||
this._tapUp(); // a press released without dragging = a row tap (scout)
|
||||
});
|
||||
g.on('pointerout', () => { this._drag = null; this._tap = 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;
|
||||
|
|
@ -185,6 +202,57 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- row taps
|
||||
/**
|
||||
* The tap half of a down→up on the scroll surface with no drag in
|
||||
* between — the TRADE-SCOUT trigger (js/ui/shop/ShopView.js → the scout
|
||||
* panel replaces the cargo grid): resolve the tapped row's box (if the
|
||||
* tap is in one) and fire onRowTap. A tap on dead list space fires
|
||||
* onRowTap(null) — "click anywhere else" closes the scout. Taps over the
|
||||
* scrollbar are scroll actions, not row taps; the steppers stop their
|
||||
* own events so −/+ never reach here.
|
||||
*/
|
||||
_tapUp() {
|
||||
const t = this._tap;
|
||||
this._tap = null;
|
||||
if (!t || t.moved || typeof this._onRow !== 'function') return;
|
||||
// A tap barely moves between down and up — the down point IS the tap
|
||||
// point (and a stub scene's bare events carry no up-point anyway).
|
||||
let lx = t.x - (this.x ?? 0);
|
||||
let ly = t.y - (this.y ?? 0);
|
||||
const el = this._surface;
|
||||
if (el && typeof el.getLocalPoint === 'function') {
|
||||
const out = { x: 0, y: 0 };
|
||||
try {
|
||||
const r = el.getLocalPoint(t.x, t.y, out);
|
||||
if (r && Number.isFinite(r.x) && Number.isFinite(r.y)) { lx = r.x; ly = r.y; }
|
||||
} catch { /* fall back to the world delta */ }
|
||||
}
|
||||
// A scrollbar click scrolls — it must never open/close the scout.
|
||||
if (this.maxScroll > 0) {
|
||||
const sb = this._sbGeom();
|
||||
if (lx >= sb.x - 6 && lx <= sb.x + sb.w + 6 && ly >= sb.y - 6 && ly <= sb.y + sb.h + 6) return;
|
||||
}
|
||||
this._onRow(this._rowAt(lx, ly));
|
||||
}
|
||||
|
||||
/** The row whose box contains a list-local point (null = dead space). */
|
||||
_rowAt(lx, ly) {
|
||||
if (!Number.isFinite(lx) || !Number.isFinite(ly)) return null;
|
||||
if (ly < this.vpY || ly > this.vpY + this.vpH) return null;
|
||||
if (lx < this.vpX || lx > this.vpX + this.vpW) return null;
|
||||
for (const row of this.rows) {
|
||||
if (!row.shown) continue; // clipped out — can't be tapped
|
||||
const y = row.y0 - this._scroll;
|
||||
if (ly >= y - this.rowH / 2 && ly <= y + this.rowH / 2) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Row-tap handler (ShopView toggles the trade scout). null = background. */
|
||||
set onRowTap(fn) { this._onRow = typeof fn === 'function' ? fn : null; }
|
||||
get onRowTap() { return this._onRow; }
|
||||
|
||||
// ----------------------------------------------------------- scrollbar
|
||||
// Track + thumb at the viewport's right edge (in the panel's right pad,
|
||||
// clear of the rows). Hidden while the content fits the window.
|
||||
|
|
@ -291,6 +359,54 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
this._sbThumb = { x: sb.x, y: ty, w: sb.w, h: th };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- focus glow
|
||||
/**
|
||||
* The light stroke around the SCOUTED row — the "this box" highlight
|
||||
* (the player tapped it, the scout panel shows its strategy). A two-pass
|
||||
* outline: a soft wide glow under a bright thin core, in the row's
|
||||
* CLIPPED slice (it follows the row as the list scrolls, and vanishes
|
||||
* when the row scrolls out — same recipe as the plates' clipping).
|
||||
*/
|
||||
_buildFocus() {
|
||||
const g = this.scene.add?.graphics?.();
|
||||
if (!g) return;
|
||||
this._focus = g;
|
||||
this._objs.push(g);
|
||||
this.add?.(g); // refresh() re-adds it ABOVE the rows + scrollbar
|
||||
g.clear?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a row (or null to clear). Renders the glow immediately (when the
|
||||
* row is visible) and keeps it in step on scroll (refresh/_clipRows
|
||||
* re-render it).
|
||||
*/
|
||||
focusRow(id) {
|
||||
this._focusId = id ?? null;
|
||||
this._renderFocus();
|
||||
return this;
|
||||
}
|
||||
|
||||
_renderFocus() {
|
||||
const g = this._focus;
|
||||
if (!g || typeof g.clear !== 'function' || typeof g.lineStyle !== 'function' || typeof g.strokeRect !== 'function') return;
|
||||
g.clear();
|
||||
const row = this._focusId ? this.rows.find((r) => r.id === this._focusId) : null;
|
||||
if (!row || !row.shown || row.clipBot <= row.clipTop) { this._focusOn = false; return; }
|
||||
const f = this.bl.focus ?? {};
|
||||
const color = _hex(f.color ?? '#00e5ff');
|
||||
const y = row.y0 - this._scroll;
|
||||
const top = Math.max(y - this.rowH / 2, row.clipTop) - 1;
|
||||
const bot = Math.min(y + this.rowH / 2, row.clipBot) + 1;
|
||||
const x = this.vpX - 1;
|
||||
const w = this.vpW + 2;
|
||||
g.lineStyle(Math.max(2, Number(f.glow) || 5), color, Math.round(255 * (f.glowAlpha ?? 0.30)));
|
||||
g.strokeRect(x, top, w, bot - top); // the soft wide glow
|
||||
g.lineStyle(Math.max(1, Number(f.core) || 1.5), color, Math.round(255 * (f.alpha ?? 0.95)));
|
||||
g.strokeRect(x + 1.5, top + 1.5, w - 3, bot - top - 3); // the bright core
|
||||
this._focusOn = true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rows
|
||||
/**
|
||||
* Set the list's contents from the shop's stock (Economy.shopFor → buy:
|
||||
|
|
@ -308,6 +424,8 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
// The rows were added AFTER the scrollbar graphics — re-append it so it
|
||||
// paints on top (v4 paints children in list order). No-op when headless.
|
||||
if (this._sb) this.add?.(this._sb);
|
||||
// …and the focus glow on top of everything (it frames a row's slice).
|
||||
if (this._focus) { this.add?.(this._focus); this._renderFocus(); }
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -561,6 +679,7 @@ export class BuyList extends Phaser.GameObjects.Container {
|
|||
}
|
||||
this._renderRows();
|
||||
this._renderScrollbar();
|
||||
this._renderFocus(); // the scouted row's glow follows the scroll
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- DATA (the core)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* ScoutPanel — the shop's GOODS SCOUT: the strategy view that replaces the
|
||||
* cargo grid when the player taps a BUY-list row (outside the stepper).
|
||||
*
|
||||
* ┌────────────────────────────┐
|
||||
* │ TRADE SCOUT ✕ │
|
||||
* │ ┌────┐ Refined Fuel │
|
||||
* │ │icon│ ENERGY & FUEL · T2 │
|
||||
* │ └────┘ BUY HERE 9 cr │
|
||||
* │ The lifeblood… │
|
||||
* │ ────────────────────────── │
|
||||
* │ GROWN ON Gas Giant │
|
||||
* │ SIGHT ON Rocky · Ice … │
|
||||
* │ ────────────────────────── │
|
||||
* │ BEST EXITS — CHARTED │
|
||||
* │ ① New Concord Gas 41 cr │
|
||||
* │ ② … │
|
||||
* │ ────────────────────────── │
|
||||
* │ TAP ANYWHERE TO CLOSE │
|
||||
* └────────────────────────────┘
|
||||
*
|
||||
* The DATA (which planets want the good, the top-N charted exits + profit)
|
||||
* is computed by js/economy/TradeScout.js (pure). This component only
|
||||
* renders that report in the panel's w×h box and reports taps back to the
|
||||
* ShopView (tap anywhere = close — the ShopView restores the cargo grid).
|
||||
*
|
||||
* Headless-safe: the Phaser rendering is best-effort (guarded); the layout
|
||||
* math is fixed offsets so a stub scene still constructs.
|
||||
*/
|
||||
import Phaser from '../../vendor/phaser.js';
|
||||
import { config } from '../../config/Config.js';
|
||||
import { fontStack } from '../../utils/Theme.js';
|
||||
import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
|
||||
|
||||
export class ScoutPanel extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
* @param {object} scene — the SurfaceScene (or a headless fake).
|
||||
* @param {object} o — { width, height, onClose } (ShopView sizes it to the
|
||||
* SELL grid's box; onClose fires on any tap of the panel).
|
||||
*/
|
||||
constructor(scene, o = {}) {
|
||||
super(scene, 0, 0);
|
||||
this.scene = scene;
|
||||
const s = config.section('shopui.scout', {});
|
||||
this.width = Math.max(300, Number(o.width) || 446);
|
||||
this.height = Math.max(240, Number(o.height) || 480);
|
||||
this.pad = s.pad ?? 18;
|
||||
this.onClose = typeof o.onClose === 'function' ? o.onClose : null;
|
||||
|
||||
this._objs = []; // shell objects (plate, title, tap layer)
|
||||
this._content = []; // per-show content (rebuilt by show())
|
||||
this._data = null; // the last scoutGood() report
|
||||
|
||||
this._buildShell();
|
||||
this._buildTapLayer(); // LAST — the tap rect sits on top
|
||||
this.setAlpha?.(0); // hidden until show() + reveal()
|
||||
}
|
||||
|
||||
get data() { return this._data; }
|
||||
|
||||
// ------------------------------------------------------------------ shell
|
||||
_buildShell() {
|
||||
const s = config.section('shopui.scout', {});
|
||||
const p = config.section('shopui.panel', {});
|
||||
const gr = this.scene.add?.graphics?.();
|
||||
if (gr) {
|
||||
this.plate = gr;
|
||||
this._objs.push(gr);
|
||||
gr.clear?.();
|
||||
if (typeof gr.fillStyle === 'function') {
|
||||
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), (p.bgAlpha ?? 0.7) + 0.15); // a touch more opaque than the sides
|
||||
gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), 255);
|
||||
}
|
||||
gr.fillRect?.(0, 0, this.width, this.height);
|
||||
this.add?.(gr);
|
||||
}
|
||||
|
||||
const t = s.title ?? {};
|
||||
const title = this.scene.add?.text?.(
|
||||
this.pad, 10, String(t.label ?? 'TRADE SCOUT'),
|
||||
{ fontSize: `${t.fontSize ?? 18}px`, color: t.color ?? '#eaf6ff', align: 'left', fontFamily: fontStack('header'), letterSpacing: 2 },
|
||||
);
|
||||
if (title) { title.setOrigin?.(0, 0); this.title = title; this._objs.push(title); this.add?.(title); }
|
||||
|
||||
const close = this.scene.add?.text?.(
|
||||
this.width - this.pad, 12, 'TAP TO CLOSE',
|
||||
{ fontSize: '10px', color: '#3d4c74', align: 'right' },
|
||||
);
|
||||
if (close) { close.setOrigin?.(1, 0); this._objs.push(close); this.add?.(close); }
|
||||
}
|
||||
|
||||
// Tap ANYWHERE on the panel closes it (the "click anywhere else" rule —
|
||||
// the ShopView listens and restores the cargo grid).
|
||||
_buildTapLayer() {
|
||||
const g = this.scene.add?.graphics?.();
|
||||
if (!g) return;
|
||||
this._tapLayer = 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(0, 0, this.width, this.height) : null;
|
||||
g.setInteractive({
|
||||
useHandCursor: false,
|
||||
hitArea: area,
|
||||
hitAreaCallback: (p, px, py) => (area ? Rect.Contains(p, px, py) : true),
|
||||
});
|
||||
g.on('pointerdown', (p) => { p?.stopPropagation?.(); this.onClose?.(); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- content
|
||||
/**
|
||||
* Render a scoutGood() report (js/economy/TradeScout.js). Rebuilds the
|
||||
* content (the panel shell persists). Pure layout — no pricing here.
|
||||
*/
|
||||
show(d) {
|
||||
if (!d || typeof d.id !== 'string') return this;
|
||||
this._data = d;
|
||||
this._destroyContent();
|
||||
const s = config.section('shopui.scout', {});
|
||||
const W = this.width;
|
||||
const pad = this.pad;
|
||||
const add = (el) => { if (el) { this._content.push(el); this.add?.(el); } return el; };
|
||||
const text = (x, y, str, style) => {
|
||||
const t = this.scene.add?.text?.(x, y, String(str ?? ''), { align: 'left', fontFamily: fontStack('body'), ...style });
|
||||
return t ?? null;
|
||||
};
|
||||
|
||||
// --- identity block: icon + name + meta + local prices ---------------
|
||||
const iconPx = s.icon ?? 88;
|
||||
const iconCx = pad + iconPx / 2;
|
||||
const iconCy = 46 + iconPx / 2;
|
||||
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?.(iconPx / 128); // the sheet's frames are 128×128
|
||||
icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(d.id));
|
||||
icon.setPosition?.(iconCx, iconCy);
|
||||
add(icon);
|
||||
}
|
||||
}
|
||||
const tx = pad + iconPx + 16;
|
||||
const nm = s.name ?? {};
|
||||
add(text(tx, 58, d.name, { fontSize: `${nm.fontSize ?? 19}px`, color: nm.color ?? '#eaf6ff', fontFamily: fontStack('header') })).setOrigin?.(0, 0);
|
||||
|
||||
const cat = d.category ? (config.get(`goods.categories.${d.category}.label`, d.category)) : '';
|
||||
const meta = [cat, d.tier ? `TIER ${d.tier}` : null].filter(Boolean).join(' · ');
|
||||
const mt = s.meta ?? {};
|
||||
add(text(tx, 84, meta, { fontSize: `${mt.fontSize ?? 11}px`, color: mt.color ?? '#7d92c4' })).setOrigin?.(0, 0);
|
||||
|
||||
// local prices (the baseline the exits' profits are measured against)
|
||||
const pb = s.priceBuy ?? {};
|
||||
const priceStr = d.localSell != null
|
||||
? `BUY HERE ${d.localBuy} CR · SELLS HERE ${d.localSell} CR`
|
||||
: `BUY HERE ${d.localBuy} CR · NOT BOUGHT HERE`;
|
||||
// ONE wrapped line: the "not bought here" variant is wider than the
|
||||
// panel at 446px, so wrap instead of running into the buy list.
|
||||
const priceT = add(text(tx, 108, priceStr, { fontSize: `${pb.fontSize ?? 12}px`, color: pb.color ?? '#ffc94d' }));
|
||||
priceT?.setOrigin?.(0, 0);
|
||||
priceT?.setWordWrap?.(true);
|
||||
priceT?.setWordWrapWidth?.(W - tx - pad);
|
||||
|
||||
// flavor (full width, under the identity block)
|
||||
const fl = s.flavor ?? {};
|
||||
const flavor = add(text(pad, 150, d.flavor || '—', {
|
||||
fontSize: `${fl.fontSize ?? 12}px`, color: fl.color ?? '#7d92c4',
|
||||
}));
|
||||
flavor?.setOrigin?.(0, 0);
|
||||
flavor?.setWordWrap?.(true);
|
||||
flavor?.setWordWrapWidth?.(W - pad * 2);
|
||||
|
||||
// --- divider + class sections ----------------------------------------
|
||||
const y0 = 186;
|
||||
this._divider(pad, y0, W - pad * 2);
|
||||
|
||||
const sec = s.section ?? {};
|
||||
const label = (y, str) => {
|
||||
const t = add(text(pad, y, str.toUpperCase(), { fontSize: `${sec.fontSize ?? 10}px`, color: sec.color ?? '#3d4c74' }));
|
||||
t?.setOrigin?.(0, 0);
|
||||
return t;
|
||||
};
|
||||
const chips = (y, list) => {
|
||||
const t = list?.length
|
||||
? add(text(pad + 120, y, list.map((c) => c.label).join(' · '), { fontSize: `${(s.chip ?? {}).fontSize ?? 11}px`, color: (s.chip ?? {}).color ?? '#eaf6ff' }))
|
||||
: add(text(pad + 120, y, '—', { fontSize: '11px', color: '#3d4c74' }));
|
||||
t?.setOrigin?.(0, 0);
|
||||
// keep it INSIDE the panel — a 4-class demand list is wider than the
|
||||
// chip column, so wrap rather than run into the buy list.
|
||||
t?.setWordWrap?.(true);
|
||||
t?.setWordWrapWidth?.(W - (pad + 120) - pad);
|
||||
return t;
|
||||
};
|
||||
let y = y0 + 22;
|
||||
label(y, 'Grown on');
|
||||
const pro = chips(y, d.producer);
|
||||
y += (pro?.height ?? 14) + 10;
|
||||
label(y, 'Sought on');
|
||||
const dem = chips(y, d.demand);
|
||||
// the best demand class's economy line — below the (possibly wrapped) chip
|
||||
if (d.demand?.length) {
|
||||
const chipH = dem?.height ?? 14;
|
||||
const eco = add(text(pad + 120, y + chipH + 6, d.demand[0].economy, { fontSize: '11px', color: '#7d92c4' }));
|
||||
eco?.setOrigin?.(0, 0);
|
||||
eco?.setWordWrap?.(true);
|
||||
eco?.setWordWrapWidth?.(W - pad * 2 - 120);
|
||||
y += chipH + 6 + (eco?.height ?? 14) + 10;
|
||||
} else {
|
||||
y += 26;
|
||||
}
|
||||
|
||||
// --- BEST EXITS — the charted systems ranked by sell price ------------
|
||||
this._divider(pad, y, W - pad * 2);
|
||||
y += 20;
|
||||
label(y, `Best exits — charted (${d.exits.length ? 'top ' + d.exits.length : 'none yet'})`);
|
||||
y += 24;
|
||||
|
||||
const er = s.exitRow ?? {};
|
||||
const ranks = s.ranks ?? ['#ffc94d', '#c8d4e8', '#d09058'];
|
||||
if (d.exits.length === 0) {
|
||||
const empty = add(text(pad, y, s.empty ?? 'No charted exit yet — every CAPITAL buys everything.', { fontSize: '12px', color: '#7d92c4' }));
|
||||
empty?.setOrigin?.(0, 0);
|
||||
empty?.setWordWrap?.(true);
|
||||
empty?.setWordWrapWidth?.(W - pad * 2);
|
||||
} else {
|
||||
const rowH = er.h ?? 40;
|
||||
const nameX = pad + 22;
|
||||
const barMax = W - pad * 2 - 150; // room for the price/profit block
|
||||
d.exits.forEach((e, i) => {
|
||||
// rank medal
|
||||
const rank = add(text(pad + 4, y + 8, String(i + 1), { fontSize: '15px', color: ranks[i] ?? '#c8d4e8', fontFamily: fontStack('header') }));
|
||||
rank?.setOrigin?.(0, 0);
|
||||
// system name + its class
|
||||
const nm2 = add(text(nameX, y + 4, e.systemName, { fontSize: `${er.fontSize ?? 13}px`, color: er.color ?? '#eaf6ff' }));
|
||||
nm2?.setOrigin?.(0, 0);
|
||||
const cl = add(text(nameX, y + 20, `${e.clsLabel}${e.hops != null ? ` · ${e.hops} HOPS` : ''}`, { fontSize: '10px', color: '#7d92c4' }));
|
||||
cl?.setOrigin?.(0, 0);
|
||||
// price + profit (right block)
|
||||
const pe = s.exitPrice ?? {};
|
||||
const price = add(text(W - pad, y + 6, `${e.price} CR`, { fontSize: `${pe.fontSize ?? 13}px`, color: pe.color ?? '#7ce8a4' }));
|
||||
price?.setOrigin?.(1, 0);
|
||||
const pf = s.exitProfit ?? {};
|
||||
const pos = e.profit >= 0;
|
||||
const profit = add(text(W - pad, y + 24, `${pos ? '+' : '−'}${Math.abs(e.profit)}/UNIT`, { fontSize: `${pf.fontSize ?? 11}px`, color: pos ? (pf.posColor ?? '#7ce8a4') : (pf.negColor ?? '#ff2d6f') }));
|
||||
profit?.setOrigin?.(1, 0);
|
||||
// the profit bar — width ∝ profit (against the best exit)
|
||||
if (e.profit > 0 && d.maxProfit > 0) {
|
||||
const bar = this.scene.add?.graphics?.();
|
||||
if (bar && typeof bar.fillStyle === 'function') {
|
||||
const bw = Math.max(6, Math.round(barMax * (e.profit / d.maxProfit)));
|
||||
const by = y + 33;
|
||||
bar.fillStyle(_hex(s.barColor ?? '#7ce8a4'), 0.18);
|
||||
bar.fillRect(nameX, by, bw + 6, 4); // glow underlay
|
||||
bar.fillStyle(_hex(s.barColor ?? '#7ce8a4'), 0.8);
|
||||
bar.fillRect(nameX, by + 1, bw, 2); // core
|
||||
add(bar);
|
||||
}
|
||||
}
|
||||
y += rowH;
|
||||
});
|
||||
}
|
||||
|
||||
// --- footer hint -------------------------------------------------------
|
||||
const foot = add(text(W / 2, this.height - 14, s.footer ?? 'TAP ANYWHERE TO RETURN TO CARGO', { fontSize: '10px', color: '#3d4c74', align: 'center' }));
|
||||
foot?.setOrigin?.(0.5, 1);
|
||||
foot?.setWordWrap?.(true);
|
||||
foot?.setWordWrapWidth?.(W - pad * 2);
|
||||
return this;
|
||||
}
|
||||
|
||||
_divider(x, y, w) {
|
||||
const g = this.scene.add?.graphics?.();
|
||||
if (!g || typeof g.fillRect !== 'function') return;
|
||||
g.fillStyle(_hex('#22405f'), 0.9);
|
||||
g.fillRect(x, y, w, 1);
|
||||
// the neon tick on the left (the console's signature)
|
||||
g.fillStyle(_hex('#00e5ff'), 0.5);
|
||||
g.fillRect(x, y, 26, 1);
|
||||
this._content.push(g);
|
||||
this.add?.(g);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- animation
|
||||
/** Fade the panel in (instant on a stub scene without tweens). */
|
||||
reveal() {
|
||||
if (this.scene.tweens && typeof this.scene.tweens.add === 'function') {
|
||||
this.setAlpha?.(0);
|
||||
this.scene.tweens.add({ targets: this, alpha: 1, duration: config.get('shopui.scout.revealMs', 140), ease: 'Cubic.Out' });
|
||||
} else {
|
||||
this.setAlpha?.(1);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.setAlpha?.(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
_destroyContent() {
|
||||
for (const o of this._content) {
|
||||
// In this Giedi build container children live in `.list`; `remove(o, true)`
|
||||
// pulls the child out of the container AND destroys it. Without the
|
||||
// remove, destroyed objects would accumulate in the container's child
|
||||
// list on every show(). (Falls back to plain destroy on the stub.)
|
||||
if (o && typeof this.remove === 'function') this.remove(o, true);
|
||||
else o?.destroy?.();
|
||||
}
|
||||
this._content = [];
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyContent();
|
||||
for (const o of this._objs) o?.destroy?.();
|
||||
this._objs = [];
|
||||
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;
|
||||
}
|
||||
|
|
@ -45,11 +45,13 @@
|
|||
import Phaser from '../../vendor/phaser.js';
|
||||
import { config } from '../../config/Config.js';
|
||||
import * as E from '../../economy/Economy.js';
|
||||
import { scoutGood } from '../../economy/TradeScout.js';
|
||||
import { MenuButton } from '../MenuButton.js';
|
||||
import { toColor, toCss } from '../../utils/Color.js';
|
||||
import { fontStack } from '../../utils/Theme.js';
|
||||
import { TradeGrid } from './TradeGrid.js';
|
||||
import { BuyList } from './BuyList.js';
|
||||
import { ScoutPanel } from './ScoutPanel.js';
|
||||
|
||||
export class ShopView extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
|
|
@ -96,6 +98,8 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
this.sellGrid = null;
|
||||
this.buyList = null;
|
||||
this._content = null; // the active content container
|
||||
this._scout = null; // the trade scout (a BUY row's strategy view)
|
||||
this._scoutOpen = null; // the scouted good id (null = cargo grid up)
|
||||
this._netLabel = null; // the sub-bar NET label (persistent)
|
||||
this._netValue = null; // the sub-bar NET value (live)
|
||||
this._confirmBtn = null;
|
||||
|
|
@ -133,6 +137,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
setTab(t) {
|
||||
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
|
||||
this.tab = t;
|
||||
this._closeScout(); // the content (and the scout) is rebuilt
|
||||
this._destroyContent();
|
||||
if (t === 'trade') this._showTrade();
|
||||
else this._showBuyback();
|
||||
|
|
@ -432,8 +437,25 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
|
||||
sellGrid.onSelectionChange = () => this._syncPreview();
|
||||
buyList.onSelectionChange = () => this._syncPreview();
|
||||
// Tap a BUY row's box (outside the stepper) → the TRADE SCOUT: the cargo
|
||||
// grid is swapped for the row's strategy report (what wants it, where
|
||||
// it's grown, the top charted exits + profit). Tap again / anywhere →
|
||||
// the cargo grid comes back. See _onRowTap + js/ui/shop/ScoutPanel.js.
|
||||
buyList.onRowTap = (row) => this._onRowTap(row);
|
||||
this._syncPreview();
|
||||
|
||||
// The scout panel: sized to the SELL grid's box (it replaces the cargo
|
||||
// area), added LAST so it paints above the grid while open.
|
||||
const scout = new ScoutPanel(this.scene, {
|
||||
width: sellGrid.width,
|
||||
height: sellGrid.height,
|
||||
onClose: () => this._closeScout(),
|
||||
});
|
||||
scout.setPosition?.(leftX, this.topY);
|
||||
this._scout = scout;
|
||||
this._scoutOpen = null;
|
||||
content.add?.(scout);
|
||||
|
||||
// Reveal: SELL slides in from the left, BUY from the right.
|
||||
sellGrid.slideIn();
|
||||
buyList.slideIn();
|
||||
|
|
@ -627,6 +649,79 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
return [...this.buybackSel.values()].some((q) => q > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// THE TRADE SCOUT — a BUY row's strategy view
|
||||
// ---------------------------------------------------------------------
|
||||
/**
|
||||
* A BUY row's box was tapped (BuyList → onRowTap), or the list's dead
|
||||
* space was (row === null). The scout is that row's STRATEGY view — the
|
||||
* cargo grid is swapped for the report (js/ui/shop/ScoutPanel.js, fed by
|
||||
* the pure js/economy/TradeScout.js):
|
||||
*
|
||||
* · WHAT it is (icon, name, class, tier, flavor)
|
||||
* · WHERE it's GROWN (the producer classes — cheap to buy there)
|
||||
* · WHO WANTS it (the demand classes + their economy line)
|
||||
* · WHERE it sells BEST — the top-N CHARTED (visited) systems ranked by
|
||||
* sell price, each with the per-unit PROFIT vs. this shop's buy price
|
||||
*
|
||||
* TAP RULES (the player's ask): the SAME box again closes it; a DIFFERENT
|
||||
* box swaps the report; ANYWHERE ELSE (list dead space, the scout panel
|
||||
* itself, a tab switch) closes it and restores the cargo grid.
|
||||
*/
|
||||
_onRowTap(row) {
|
||||
if (this.tab !== 'trade') return; // only over the trade panel
|
||||
if (!row) { this._closeScout(); return; } // "anywhere else"
|
||||
if (this._scoutOpen === row.id) { this._closeScout(); return; } // same box → toggle off
|
||||
this._openScout(row.id, row.price);
|
||||
}
|
||||
|
||||
_openScout(id, localBuy) {
|
||||
const data = scoutGood(id, {
|
||||
galaxy: this.galaxy,
|
||||
systemId: this.systemId,
|
||||
cls: this.cls,
|
||||
buyUnit: localBuy, // the row's price here — the profit baseline
|
||||
visited: this._visitedIds(),
|
||||
topN: config.get('shopui.scout.topN', 3),
|
||||
});
|
||||
if (!data || !this._scout) return;
|
||||
this._scoutOpen = id;
|
||||
this.buyList?.focusRow?.(id); // the light stroke on the box
|
||||
this.scene.tweens?.killTweensOf?.(this.sellGrid); // no slide-in fighting
|
||||
this.sellGrid?.setAlpha?.(0); // the cargo grid steps aside…
|
||||
this._setSellGridInput(false); // …and can't be tapped through
|
||||
this._scout.show(data).reveal(); // the report fades in
|
||||
}
|
||||
|
||||
_closeScout() {
|
||||
if (!this._scoutOpen && this.buyList?._focusId == null) return;
|
||||
this._scoutOpen = null;
|
||||
this.buyList?.focusRow?.(null); // the stroke fades with the panel
|
||||
this._scout?.hide();
|
||||
this.sellGrid?.setAlpha?.(1); // the cargo grid is back
|
||||
this._setSellGridInput(true);
|
||||
}
|
||||
|
||||
// The systems the run has ENTERED (the "charted" set the exits rank over).
|
||||
// GameScene.visitedSystems (a Set, seeded with the home system); the
|
||||
// current system always counts (we're here).
|
||||
_visitedIds() {
|
||||
const v = this.gameScene?.visitedSystems;
|
||||
if (v && typeof v[Symbol.iterator] === 'function') return [...v];
|
||||
return [this.systemId].filter(Boolean);
|
||||
}
|
||||
|
||||
// The SELL grid's bays are interactive (hover tooltips + tap-select). While
|
||||
// the scout covers them, switch their input off (Phaser's input.enabled —
|
||||
// inputCandidate() honors it) so a tap through the panel can't flick a bay;
|
||||
// back on when the cargo grid is restored.
|
||||
_setSellGridInput(on) {
|
||||
for (const cell of this.sellGrid?.cells ?? []) {
|
||||
const plate = cell?.plate;
|
||||
if (plate?.input) plate.input.enabled = !!on;
|
||||
}
|
||||
}
|
||||
|
||||
_setStatus(msg, bad) {
|
||||
// Best-effort status line (headless no-ops). The credits HUD count-up is
|
||||
// the primary feedback; this is the in-panel confirmation.
|
||||
|
|
@ -658,6 +753,8 @@ export class ShopView extends Phaser.GameObjects.Container {
|
|||
this.buyList?.destroy?.();
|
||||
this.sellGrid = null;
|
||||
this.buyList = null;
|
||||
this._scout = null; // the scout died with the content
|
||||
this._scoutOpen = null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue