🌺 🐫 Points Builder

PDA-style panel: Torn display + averaged YATA/Prometheus abroad stock (shows Av). Collapsible, Set API Key, Refresh, auto-poll. Average when both sources present; single-source otherwise.

As of 13.10.2025. See апошняя версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         🌺 🐫 Points Builder
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  PDA-style panel: Torn display + averaged YATA/Prometheus abroad stock (shows Av). Collapsible, Set API Key, Refresh, auto-poll. Average when both sources present; single-source otherwise.
// @match        https://www.torn.com/page.php?sid=travel*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const PANEL_ID = 'stock_ome_points_exporter';
  const POLL_INTERVAL_MS = 45 * 1000;
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const PROM_URL = 'https://api.prombot.co.uk/api/travel';
  const SPECIAL_DRUG = 'Xanax';

  const FLOWERS = {
    "Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico" },
    "Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii" },
    "African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa" },
    "Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan" },
    "Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China" },
    "Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina" },
    "Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland" },
    "Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada" },
    "Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE" },
    "Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands" }
  };

  const PLUSHIES = {
    "Sheep Plushie": { short: "Sheep", loc: "B.B 🏪", country: "Torn City" },
    "Teddy Bear Plushie": { short: "Teddy", loc: "B.B 🏪", country: "Torn City" },
    "Kitten Plushie": { short: "Kitten", loc: "B.B 🏪", country: "Torn City" },
    "Jaguar Plushie": { short: "Jaguar", loc: "MX 🇲🇽", country: "Mexico" },
    "Wolverine Plushie": { short: "Wolverine", loc: "CA 🇨🇦", country: "Canada" },
    "Nessie Plushie": { short: "Nessie", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Red Fox Plushie": { short: "Fox", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Monkey Plushie": { short: "Monkey", loc: "AR 🇦🇷", country: "Argentina" },
    "Chamois Plushie": { short: "Chamois", loc: "CH 🇨🇭", country: "Switzerland" },
    "Panda Plushie": { short: "Panda", loc: "CN 🇨🇳", country: "China" },
    "Lion Plushie": { short: "Lion", loc: "SA 🇿🇦", country: "South Africa" },
    "Camel Plushie": { short: "Camel", loc: "AE 🇦🇪", country: "UAE" },
    "Stingray Plushie": { short: "Stingray", loc: "KY 🇰🇾", country: "Cayman Islands" }
  };

  const COUNTRY_NAME_TO_CODE = {
    'JAPAN': 'JAP', 'MEXICO': 'MEX', 'CANADA': 'CAN', 'CHINA': 'CHI', 'UNITED KINGDOM': 'UNI',
    'ARGENTINA': 'ARG', 'SWITZERLAND': 'SWI', 'HAWAII': 'HAW', 'UAE': 'UAE', 'CAYMAN ISLANDS': 'CAY',
    'SOUTH AFRICA': 'SOU', 'S.A': 'SOU', 'SA': 'SOU', 'TORN': 'BB', 'B.B': 'BB'
  };

  function getPDANavHeight() {
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda');
    return nav ? nav.offsetHeight : 40;
  }

  function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }

  function colorForPercent(value, max) {
    if (!max || max === 0) return '#bdbdbd';
    const pct = (value / max) * 100;
    if (pct >= 75) return '#00c853';
    if (pct >= 40) return '#3399ff';
    return '#ff1744';
  }

  function stockClassByQty(q) {
    q = Number(q || 0);
    if (q === 0) return 'stock-red';
    if (q >= 1500) return 'stock-green';
    if (q >= 321 && q <= 749) return 'stock-orange';
    return 'stock-gray';
  }

  GM_addStyle(`
    #${PANEL_ID} {
      position: fixed;
      top: ${getPDANavHeight()}px;
      left: 18px;
      width: 250px;
      background: #0b0b0b;
      color: #eaeaea;
      font-family: "DejaVu Sans Mono", monospace;
      font-size: 9px;
      border: 1px solid #444;
      border-radius: 6px;
      z-index: 999999;
      box-shadow: 0 6px 16px rgba(0,0,0,0.5);
      max-height: 65vh;
      overflow-y: auto;
      line-height: 1.1;
    }
    #${PANEL_ID} .header {
      background: #121212;
      padding: 4px 6px;
      cursor: pointer;
      font-weight:700;
      font-size:10px;
      border-bottom:1px solid #333;
      user-select:none;
      display:flex;
      align-items:center;
      gap:6px;
    }
    #${PANEL_ID} .controls { padding:6px; display:flex; gap:6px; }
    #${PANEL_ID} .controls button { font-size:9px; padding:2px 6px; background:#171717; color:#eaeaea; border:1px solid #333; border-radius:3px; cursor:pointer; }
    #${PANEL_ID} .controls button:hover { background:#222; }
    #${PANEL_ID} .summary-line { font-weight:700; margin:6px; font-size:10px; color:#dfe7ff; }
    #${PANEL_ID} .low-line { color:#ff4d4d; font-weight:700; margin:6px; font-size:10px; }
    #${PANEL_ID} .group-title { font-weight:700; margin:6px 6px 2px 6px; font-size:9.5px; }
    #${PANEL_ID} ul.item-list { margin:4px 6px 8px 12px; padding:0; list-style:none; }
    #${PANEL_ID} li.item-row { display:flex; align-items:center; gap:6px; padding:2px 0; white-space:nowrap; }
    #${PANEL_ID} .item-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .item-total { flex:0 0 36px; text-align:right; color:#cfe8c6; }
    #${PANEL_ID} .item-av { flex:0 0 60px; text-align:right; color:#f7b3b3; }
    #${PANEL_ID} .item-loc { flex:0 0 36px; text-align:right; color:#bcbcbc; font-size:8.5px; }
    #${PANEL_ID} #tc_status { font-size:9px; color:#bdbdbd; margin:6px; }
    .stock-green{ color:#00c853 !important; } .stock-orange{ color:#ff9800 !important; } .stock-red{ color:#ff1744 !important; } .stock-gray{ color:#9ea6b3 !important; }
  `);

  // Build UI (Points Exporter layout)
  const panel = document.createElement('div');
  panel.id = PANEL_ID;
  panel.innerHTML = `
    <div id="tc_header" class="header">▶ 🌺 🐫 Stock ome</div>
    <div id="tc_content_wrapper">
      <div id="tc_controls" class="controls">
        <button id="tc_refresh">Refresh</button>
        <button id="tc_setkey">Set API Key</button>
        <button id="tc_resetkey">Reset Key</button>
      </div>
      <div id="tc_status">Waiting...</div>
      <div id="tc_summary"></div>
      <div id="tc_content"></div>
    </div>
  `;
  document.body.appendChild(panel);

  const headerEl = panel.querySelector('#tc_header');
  const contentWrapper = panel.querySelector('#tc_content_wrapper');
  const statusEl = panel.querySelector('#tc_status');
  const summaryEl = panel.querySelector('#tc_summary');
  const contentEl = panel.querySelector('#tc_content');

  // Collapse handling (matches first script behavior)
  let collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  function updateCollapseUI() {
    headerEl.textContent = (collapsed ? '▶ ' : '▼ ') + '🌺 🐫 Stock ome';
    contentWrapper.style.display = collapsed ? 'none' : 'block';
  }
  updateCollapseUI();
  headerEl.addEventListener('click', () => {
    collapsed = !collapsed;
    GM_setValue(`${PANEL_ID}-collapsed`, collapsed);
    updateCollapseUI();
  });

  // Buttons
  panel.querySelector('#tc_refresh').addEventListener('click', () => loadData(true));
  panel.querySelector('#tc_setkey').addEventListener('click', () => askKey(true));
  panel.querySelector('#tc_resetkey').addEventListener('click', () => {
    GM_setValue('tornAPIKey', null);
    apiKey = null;
    statusEl.textContent = 'Key cleared. Click Set API Key.';
    summaryEl.innerHTML = '';
    contentEl.innerHTML = '';
    stopPolling();
  });

  // API key & polling
  let apiKey = GM_getValue('tornAPIKey', null);
  let pollHandle = null;
  function startPolling() { if (pollHandle) return; pollHandle = setInterval(() => loadData(false), POLL_INTERVAL_MS); }
  function stopPolling() { if (!pollHandle) return; clearInterval(pollHandle); pollHandle = null; }

  async function askKey(force) {
    if (!apiKey || force) {
      const k = prompt('Enter your Torn API key (needs display + inventory permissions):', apiKey || '');
      if (k) { apiKey = k.trim(); GM_setValue('tornAPIKey', apiKey); }
    }
    if (apiKey) { startPolling(); await loadData(true); }
  }

  // GM_xmlhttpRequest wrapper
  function gmGetJson(url, timeout = 14000) {
    return new Promise((resolve, reject) => {
      try {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          timeout,
          onload: res => {
            try {
              const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
              const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
              resolve(parsed);
            } catch (e) { reject(e); }
          },
          onerror: err => reject(err),
          ontimeout: () => reject(new Error('timeout'))
        });
      } catch (e) { reject(e); }
    });
  }

  // Parse YATA
  function parseYata(yataData) {
    const map = {};
    if (!yataData || !yataData.stocks) return map;
    for (const [code, obj] of Object.entries(yataData.stocks)) {
      const countryCode = String(code).toUpperCase();
      const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
      const m = {};
      for (const it of arr) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
      map[countryCode] = m;
    }
    return map;
  }

  // Parse Prom
  function parseProm(promData) {
    const map = {};
    if (!promData) return map;
    for (const [countryKey, countryVal] of Object.entries(promData)) {
      if (!countryVal) continue;
      const up = String(countryKey).trim().toUpperCase();
      let code = up;
      if (COUNTRY_NAME_TO_CODE[up]) code = COUNTRY_NAME_TO_CODE[up];
      const m = {};
      if (Array.isArray(countryVal.stocks)) {
        for (const it of countryVal.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
      } else {
        for (const [k, v] of Object.entries(countryVal)) {
          if (v == null) continue;
          if (typeof v === 'object' && ('quantity' in v || 'qty' in v || 'amount' in v)) {
            m[k] = Number(v.quantity ?? v.qty ?? v.amount ?? 0) || 0;
          } else if (typeof v === 'number' || !isNaN(Number(v))) {
            m[k] = Number(v) || 0;
          } else if (typeof v === 'object' && v.stocks && Array.isArray(v.stocks)) {
            for (const it of v.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
          }
        }
      }
      map[String(code).toUpperCase()] = m;
    }
    return map;
  }

  // MERGE: average when both present; else use single source
  function mergeMaps(yataMap, promMap) {
    const merged = {};
    const countries = new Set([...Object.keys(yataMap || {}), ...Object.keys(promMap || {})]);
    for (const c of countries) {
      const yItems = yataMap[c] || {};
      const pItems = promMap[c] || {};
      const itemNames = new Set([...Object.keys(yItems), ...Object.keys(pItems)]);
      const m = {};
      for (const item of itemNames) {
        const yHas = yItems.hasOwnProperty(item);
        const pHas = pItems.hasOwnProperty(item);
        const yv = yHas ? Number(yItems[item]) : NaN;
        const pv = pHas ? Number(pItems[item]) : NaN;
        if (yHas && pHas && !Number.isNaN(yv) && !Number.isNaN(pv)) {
          m[item] = Math.round((yv + pv) / 2); // average
        } else if (yHas && !Number.isNaN(yv)) {
          m[item] = Math.round(yv);
        } else if (pHas && !Number.isNaN(pv)) {
          m[item] = Math.round(pv);
        } else {
          m[item] = 0;
        }
      }
      merged[c] = m;
    }
    return merged;
  }

  function sumMergedFor(itemName, mergedMap) {
    let total = 0;
    for (const c of Object.keys(mergedMap || {})) total += Number(mergedMap[c][itemName] || 0);
    return total;
  }

  function bestCountryForMerged(itemName, mergedMap) {
    let best = { code: null, qty: 0 };
    for (const [code, m] of Object.entries(mergedMap || {})) {
      const q = Number(m[itemName] || 0);
      if (q > best.qty) best = { code, qty: q };
    }
    return best;
  }

  // Torn display/inventory helpers
  function aggregateFromApiResponse(data) {
    const items = {};
    const pushSrc = (src) => {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item || null;
        if (!name) continue;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 1) || 0;
        items[name] = (items[name] || 0) + qty;
      }
    };
    pushSrc(data.display);
    pushSrc(data.inventory);
    return items;
  }

  async function fetchTornDisplayInventory() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return null;
    const url = `https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return null;
      return aggregateFromApiResponse(data);
    } catch (e) { console.warn('fetchTornDisplayInventory', e); return null; }
  }

  function fetchDisplayViaDOM() {
    const map = {};
    const els = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (els && els.length) {
      els.forEach(el => {
        let name = ''; let qty = 0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a') || el;
        if (nameEl) name = (nameEl.innerText || '').trim();
        const qtyEl = el.querySelector('.item-amount, .count, .qty, .quantity') || el.querySelector('.item-qty');
        if (qtyEl) qty = parseInt((qtyEl.innerText || '').replace(/\D/g, '')) || 0;
        if (name) map[name] = (map[name] || 0) + qty;
      });
    }
    return map;
  }

  // Build required lists (for rendering)
  function buildRequiredList(mapObj) {
    const fullNames = Object.keys(mapObj);
    const shortNames = fullNames.map(fn => mapObj[fn].short);
    const locByShort = {};
    const countryByShort = {};
    fullNames.forEach(fn => {
      const s = mapObj[fn].short;
      locByShort[s] = mapObj[fn].loc;
      countryByShort[s] = mapObj[fn].country;
    });
    return { fullNames, shortNames, locByShort, countryByShort };
  }
  const flowersReq = buildRequiredList(FLOWERS);
  const plushReq = buildRequiredList(PLUSHIES);

  function countsForReq(itemsAgg, req, mapObj) {
    const counts = {};
    req.shortNames.forEach(s => counts[s] = 0);
    req.fullNames.forEach(fn => {
      const short = mapObj[fn].short;
      const q = itemsAgg[fn] || 0;
      counts[short] = (counts[short] || 0) + q;
    });
    return counts;
  }

  function calcSetsAndRemainderFromCounts(counts, shortNames) {
    const countsArr = shortNames.map(n => counts[n] || 0);
    const sets = countsArr.length ? Math.min(...countsArr) : 0;
    const remainder = {};
    shortNames.forEach(n => remainder[n] = Math.max(0, (counts[n] || 0) - sets));
    return { sets, remainder };
  }

  function findLowest(remainder, locMap, countryMap) {
    const keys = Object.keys(remainder);
    if (!keys.length) return null;
    let min = Infinity;
    keys.forEach(k => { if (remainder[k] < min) min = remainder[k]; });
    const allEqual = keys.every(k => remainder[k] === min);
    if (allEqual) return null;
    const key = keys.find(k => remainder[k] === min);
    return { short: key, rem: min, loc: locMap[key] || '', country: countryMap[key] || '' };
  }

  // Render UI (Points Exporter style) — show (n Av)
  function renderUI(itemsAgg, yataRaw, promRaw) {
    const flowerTotals = countsForReq(itemsAgg, flowersReq, FLOWERS);
    const plushTotals = countsForReq(itemsAgg, plushReq, PLUSHIES);

    const fCalc = calcSetsAndRemainderFromCounts(flowerTotals, flowersReq.shortNames);
    const pCalc = calcSetsAndRemainderFromCounts(plushTotals, plushReq.shortNames);

    const totalSets = fCalc.sets + pCalc.sets;
    const totalPoints = totalSets * 10;

    const yataMap = parseYata(yataRaw);
    const promMap = parseProm(promRaw);
    const mergedMap = mergeMaps(yataMap, promMap);

    function abroadForFullName(fullName) { return sumMergedFor(fullName, mergedMap); }

    summaryEl.innerHTML = `<div class="summary-line">Total sets: ${totalSets} | Points: ${totalPoints}</div>`;

    const lowFlower = findLowest(fCalc.remainder, flowersReq.locByShort, flowersReq.countryByShort);
    const lowPlush = findLowest(pCalc.remainder, plushReq.locByShort, plushReq.countryByShort);

    let html = '';

    if (lowFlower) html += `<div class="low-line">🛫 Low on ${escapeHtml(lowFlower.short)} — travel to ${escapeHtml(lowFlower.country)} ${escapeHtml(lowFlower.loc)} and import 🛬</div>`;

    html += `<div class="group-title">Flowers — sets: ${fCalc.sets} | pts: ${fCalc.sets * 10}</div>`;
    html += `<ul class="item-list">`;
    flowersReq.fullNames.forEach(full => {
      const short = FLOWERS[full].short;
      const total = flowerTotals[short] ?? 0;
      const abroad = abroadForFullName(full);
      const avText = (Number.isFinite(abroad) && abroad > 0) ? `${abroad} Av` : '—';
      const col = colorForPercent(total, Math.max(...Object.values(flowerTotals), 1));
      html += `<li class="item-row" style="color:${col}">
        <span class="item-name">${escapeHtml(short)}</span>
        <span class="item-total">${total}</span>
        <span class="item-av">(${avText})</span>
        <span class="item-loc">${FLOWERS[full].loc || ''}</span>
      </li>`;
    });
    html += `</ul>`;

    if (lowPlush) html += `<div class="low-line">🛫 Low on ${escapeHtml(lowPlush.short)} — travel to ${escapeHtml(lowPlush.country)} ${escapeHtml(lowPlush.loc)} and import 🛬</div>`;

    html += `<div class="group-title">Plushies — sets: ${pCalc.sets} | pts: ${pCalc.sets * 10}</div>`;
    html += `<ul class="item-list">`;
    plushReq.fullNames.forEach(full => {
      const short = PLUSHIES[full].short;
      const total = plushTotals[short] ?? 0;
      const abroad = abroadForFullName(full);
      const avText = (Number.isFinite(abroad) && abroad > 0) ? `${abroad} Av` : '—';
      const col = colorForPercent(total, Math.max(...Object.values(plushTotals), 1));
      html += `<li class="item-row" style="color:${col}">
        <span class="item-name">${escapeHtml(short)}</span>
        <span class="item-total">${total}</span>
        <span class="item-av">(${avText})</span>
        <span class="item-loc">${PLUSHIES[full].loc || ''}</span>
      </li>`;
    });
    html += `</ul>`;

    // Xanax
    const xanInv = Number(itemsAgg[SPECIAL_DRUG] || 0);
    const xanAbroad = sumMergedFor(SPECIAL_DRUG, mergedMap) || 0;
    const xanAvText = xanAbroad > 0 ? `${xanAbroad} Av` : '—';
    html += `<div class="group-title">Drugs</div>`;
    html += `<ul class="item-list"><li class="item-row" style="color:#dfe7ff">
      <span class="item-name">${escapeHtml(SPECIAL_DRUG)}</span>
      <span class="item-total">${xanInv}</span>
      <span class="item-av">(${xanAvText})</span>
      <span class="item-loc">🇿🇦</span>
    </li></ul>`;

    contentEl.innerHTML = html;
  }

  // Load flow: Torn display + YATA + Prom in parallel
  async function loadData(force = false) {
    summaryEl.innerHTML = '';
    contentEl.innerHTML = '';
    if (!apiKey) {
      statusEl.textContent = 'No API key set. Prompting...';
      await askKey(false);
      if (!apiKey) return;
    }
    statusEl.textContent = 'Fetching display + inventory via API...';

    try {
      const tornPromise = fetchTornDisplayInventory();
      const yataPromise = gmGetJson(YATA_URL).catch(() => null);
      const promPromise = gmGetJson(PROM_URL).catch(() => null);

      const [displayFromApi, yataRaw, promRaw] = await Promise.all([tornPromise, yataPromise, promPromise]);

      let itemsAgg = {};
      if (displayFromApi && Object.keys(displayFromApi).length > 0) itemsAgg = displayFromApi;
      else {
        const dom = fetchDisplayViaDOM();
        itemsAgg = dom || displayFromApi || {};
      }

      renderUI(itemsAgg, yataRaw, promRaw);

      statusEl.textContent = 'Loaded.';
    } catch (err) {
      statusEl.textContent = 'Fetch failed.';
      contentEl.innerHTML = `<div style="color:#f88;">${escapeHtml(err && err.message ? err.message : String(err))}</div>`;
    }
  }

  // Torn helpers
  function gmGetJson(url, timeout = 14000) {
    return new Promise((resolve, reject) => {
      try {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          timeout,
          onload: res => {
            try {
              const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
              const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
              resolve(parsed);
            } catch (e) { reject(e); }
          },
          onerror: err => reject(err),
          ontimeout: () => reject(new Error('timeout'))
        });
      } catch (e) { reject(e); }
    });
  }

  function aggregateFromApiResponse(data) {
    const items = {};
    const pushSrc = (src) => {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item || null;
        if (!name) continue;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 1) || 0;
        items[name] = (items[name] || 0) + qty;
      }
    };
    pushSrc(data.display);
    pushSrc(data.inventory);
    return items;
  }

  async function fetchTornDisplayInventory() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return null;
    const url = `https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return null;
      return aggregateFromApiResponse(data);
    } catch (e) { console.warn('fetchTornDisplayInventory', e); return null; }
  }

  function fetchDisplayViaDOM() {
    const map = {};
    const els = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (els && els.length) {
      els.forEach(el => {
        let name = ''; let qty = 0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a') || el;
        if (nameEl) name = (nameEl.innerText || '').trim();
        const qtyEl = el.querySelector('.item-amount, .count, .qty, .quantity') || el.querySelector('.item-qty');
        if (qtyEl) qty = parseInt((qtyEl.innerText || '').replace(/\D/g, '')) || 0;
        if (name) map[name] = (map[name] || 0) + qty;
      });
    }
    return map;
  }

  // Start
  if (apiKey) { startPolling(); loadData(true); }
  else { setTimeout(() => askKey(false), 300); }
  window.addEventListener('beforeunload', () => stopPolling());

  // Keep aligned with PDA nav
  function reposition() {
    const el = document.getElementById(PANEL_ID);
    if (!el) return;
    el.style.top = getPDANavHeight() + 'px';
  }
  reposition();
  window.addEventListener('resize', reposition);
  new MutationObserver(reposition).observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true });

})();