diff --git a/assets/images/characters/Trestle.png b/assets/images/characters/Trestle.png new file mode 100644 index 0000000..a92673d Binary files /dev/null and b/assets/images/characters/Trestle.png differ diff --git a/assets/speech/carol/carol-q2-01.mp3 b/assets/speech/carol/carol-q2-01.mp3 new file mode 100644 index 0000000..07f14fa Binary files /dev/null and b/assets/speech/carol/carol-q2-01.mp3 differ diff --git a/assets/speech/carol/carol-q2-02.mp3 b/assets/speech/carol/carol-q2-02.mp3 new file mode 100644 index 0000000..1cb6f7b Binary files /dev/null and b/assets/speech/carol/carol-q2-02.mp3 differ diff --git a/assets/speech/carol/carol-q2-03.mp3 b/assets/speech/carol/carol-q2-03.mp3 new file mode 100644 index 0000000..1d14e47 Binary files /dev/null and b/assets/speech/carol/carol-q2-03.mp3 differ diff --git a/assets/speech/carol/carol-q2-04.mp3 b/assets/speech/carol/carol-q2-04.mp3 new file mode 100644 index 0000000..7bf524d Binary files /dev/null and b/assets/speech/carol/carol-q2-04.mp3 differ diff --git a/data/shopui.json b/data/shopui.json index 61c4a4c..6661afb 100644 --- a/data/shopui.json +++ b/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 — 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" } } diff --git a/dev/shop-shot.mjs b/dev/shop-shot.mjs index a09b35b..c350d1b 100644 --- a/dev/shop-shot.mjs +++ b/dev/shop-shot.mjs @@ -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; })(); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index 7939a71..f69c93e 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -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'); diff --git a/dev/trade-scout.test.mjs b/dev/trade-scout.test.mjs new file mode 100644 index 0000000..2eaa5f9 --- /dev/null +++ b/dev/trade-scout.test.mjs @@ -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); diff --git a/js/economy/TradeScout.js b/js/economy/TradeScout.js new file mode 100644 index 0000000..24eda62 --- /dev/null +++ b/js/economy/TradeScout.js @@ -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, + }; +} diff --git a/js/ui/shop/BuyList.js b/js/ui/shop/BuyList.js index 8f3a8ec..7cc8adf 100644 --- a/js/ui/shop/BuyList.js +++ b/js/ui/shop/BuyList.js @@ -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) diff --git a/js/ui/shop/ScoutPanel.js b/js/ui/shop/ScoutPanel.js new file mode 100644 index 0000000..5d58f6b --- /dev/null +++ b/js/ui/shop/ScoutPanel.js @@ -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; +} diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index db36b47..d90bda2 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -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() {