💫 Points Maker

Points-style PDA panel showing Torn display + abroad stock (YATA preferred, Prometheus fallback). Collapsible, Set API Key, Refresh, auto-poll every 45s. Fixed between menu (☰) and search (🔍) on top bar.

目前為 2025-10-15 提交的版本,檢視 最新版本

// ==UserScript==
// @name         💫 Points Maker
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Points-style PDA panel showing Torn display + abroad stock (YATA preferred, Prometheus fallback). Collapsible, Set API Key, Refresh, auto-poll every 45s. Fixed between menu (☰) and search (🔍) on top bar.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const PANEL_ID = 'points_maker_pda';
  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') || 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';
  }

  // styles (Points Exporter look)
  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 DOM (Points Exporter structure) with header-position fixing between menu and search
  function buildUI() {
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;

    root.innerHTML = `
      <div id="tc_header" class="header">▶ 💫 Points Maker</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 for API key...</div>
        <div id="tc_summary"></div>
        <div id="tc_content"></div>
      </div>
    `;

    // append to body as baseline
    document.body.appendChild(root);

    // collapse handling exactly like first script
    const headerEl = root.querySelector('#tc_header');
    const contentWrapper = root.querySelector('#tc_content_wrapper');
    let collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
    function updateCollapse() {
      headerEl.textContent = (collapsed ? '▶ ' : '▼ ') + '💫 Points Maker';
      contentWrapper.style.display = collapsed ? 'none' : 'block';
    }
    updateCollapse();
    headerEl.addEventListener('click', () => {
      collapsed = !collapsed;
      GM_setValue(`${PANEL_ID}-collapsed`, collapsed);
      updateCollapse();
    });

    // buttons
    root.querySelector('#tc_refresh').addEventListener('click', () => refreshAll(true));
    root.querySelector('#tc_setkey').addEventListener('click', () => askKey(true));
    root.querySelector('#tc_resetkey').addEventListener('click', () => {
      GM_setValue('tornAPIKey', null);
      apiKey = null;
      const status = document.getElementById('tc_status');
      if (status) status.textContent = 'Key cleared. Click Set API Key.';
      const summ = document.getElementById('tc_summary'); if (summ) summ.innerHTML = '';
      const content = document.getElementById('tc_content'); if (content) content.innerHTML = '';
      stopPolling();
    });

    // Positioning: try to place the panel fixed between menu (☰) and search (🔍)
    // We'll attempt multiple selectors to find likely elements, then compute the left coordinate.
    function findMenuElement() {
      const candidates = [
        'button.menu', '.menu', '.hamburger', '.icon-menu', '.fa-bars', '[title*="menu"]',
        '.nav-toggle', '.sidebar-toggle', '.mobile-toggle', '#menu-toggle', '.nav-button'
      ];
      for (const sel of candidates) {
        const el = document.querySelector(sel);
        if (el) return el;
      }
      // look for visible element with innerText containing ☰
      const all = Array.from(document.querySelectorAll('button, a, div, span'));
      for (const el of all) {
        if (!el.innerText) continue;
        if (el.innerText.trim().includes('☰') || el.innerText.trim().includes('≡')) return el;
      }
      return null;
    }

    function findSearchElement() {
      const candidates = [
        'input[type="search"]', '.search', '.search-box', '.icon-search', '.fa-search',
        '[title*="Search"]', '.search-toggle', '#search', '.search-input'
      ];
      for (const sel of candidates) {
        const el = document.querySelector(sel);
        if (el) return el;
      }
      // fallback: find element that includes "🔍" in text
      const all = Array.from(document.querySelectorAll('button, a, div, span'));
      for (const el of all) {
        if (!el.innerText) continue;
        if (el.innerText.trim().includes('🔍')) return el;
      }
      return null;
    }

    function placeBetweenMenuAndSearch() {
      const menuEl = findMenuElement();
      const searchEl = findSearchElement();
      const panelEl = document.getElementById(PANEL_ID);
      if (!panelEl) return false;

      // if both exist, compute center between them, else fallback to near menu or default left:18px
      if (menuEl && searchEl) {
        const mRect = menuEl.getBoundingClientRect();
        const sRect = searchEl.getBoundingClientRect();
        // compute left as menu right + small gap, but ensure not to overlap search
        const gap = 8;
        let left = Math.round(mRect.right + gap);
        // if panel would overlap search, clamp it to search left - panel width - gap
        const panelWidth = panelEl.offsetWidth || 250;
        if (left + panelWidth + gap > sRect.left) {
          left = Math.max(mRect.right + gap, sRect.left - panelWidth - gap);
        }
        // compute top to align vertically with header/menu
        const top = Math.max(6, Math.round(mRect.top + (mRect.height - panelEl.offsetHeight) / 2));
        // make fixed and assign
        panelEl.style.position = 'fixed';
        panelEl.style.left = left + 'px';
        panelEl.style.top = (mRect.top + window.scrollY) + 'px';
        panelEl.style.transform = 'none';
        panelEl.style.zIndex = '2147483647';
        return true;
      }

      // if only menu exists, place to its right
      if (menuEl) {
        const mRect = menuEl.getBoundingClientRect();
        panelEl.style.position = 'fixed';
        panelEl.style.left = (Math.round(mRect.right + 8)) + 'px';
        panelEl.style.top = (mRect.top + window.scrollY) + 'px';
        panelEl.style.transform = 'none';
        panelEl.style.zIndex = '2147483647';
        return true;
      }

      // fallback: keep original left:18px and fixed top near PDA nav
      const navTop = getPDANavHeight();
      panelEl.style.position = 'fixed';
      panelEl.style.left = '18px';
      panelEl.style.top = navTop + 'px';
      panelEl.style.transform = 'none';
      panelEl.style.zIndex = '999999';
      return false;
    }

    // Try placing immediately and then keep attempting until header area settles
    placeBetweenMenuAndSearch();
    // try again on resize and after DOM changes
    window.addEventListener('resize', () => placeBetweenMenuAndSearch());
    const mo = new MutationObserver(() => placeBetweenMenuAndSearch());
    mo.observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true });

    return root;
  }

  buildUI();
  const statusEl = document.getElementById('tc_status');
  const summaryEl = document.getElementById('tc_summary');
  const contentEl = document.getElementById('tc_content');

  // API key storage & polling
  let apiKey = GM_getValue('tornAPIKey', null);
  let pollHandle = null;
  function startPolling() { if (pollHandle) return; pollHandle = setInterval(() => refreshAll(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 refreshAll(true); }
  }

  // network helper
  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 format
  function parseYata(yataData) {
    const map = {};
    if (!yataData || !yataData.stocks) return map;
    for (const [code, obj] of Object.entries(yataData.stocks)) {
      const c = 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[c] = m;
    }
    return map;
  }

  // parse Prom format
  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;
  }

  // sum across countries for a given item from a parsed map
  function sumAcrossCountriesFor(itemName, parsedMap) {
    if (!parsedMap) return 0;
    let total = 0;
    for (const c of Object.keys(parsedMap)) total += Number(parsedMap[c][itemName] || 0);
    return total;
  }

  // Torn display parsing helpers
  function aggregateFromApiResponse(data) {
    const items = {};
    const push = (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;
      }
    };
    push(data.display);
    push(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] || '' };
  }

  // rendering: Points Exporter style, Av replaced with YATA priority fallback
  function renderUI(itemsAgg, yataRaw, promRaw, sourcesUsed) {
    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);

    // decide Av source: YATA primary; if YATA has zero for that item across all countries and Prom has value, use Prom.
    function pickAvFor(fullName) {
      const yataSum = sumAcrossCountriesFor(fullName, yataMap);
      if (yataSum > 0) return { val: yataSum, src: 'Y' };
      const promSum = sumAcrossCountriesFor(fullName, promMap);
      if (promSum > 0) return { val: promSum, src: 'P' };
      const yataResponded = yataRaw && Object.keys(yataRaw).length > 0;
      const promResponded = promRaw && Object.keys(promRaw).length > 0;
      if (yataResponded) return { val: yataSum, src: 'Y' };
      if (promResponded) return { val: promSum, src: 'P' };
      return { val: null, src: null };
    }

    // status & summary
    if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets F:${fCalc.sets} P:${pCalc.sets}`;
    if (summaryEl) 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 picked = pickAvFor(full);
      const avText = (picked && picked.val != null && picked.src) ? `${picked.val} Av [${picked.src}]` : '—';
      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 picked = pickAvFor(full);
      const avText = (picked && picked.val != null && picked.src) ? `${picked.val} Av [${picked.src}]` : '—';
      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>`;

    // Drugs (Xanax)
    const xanInv = Number(itemsAgg[SPECIAL_DRUG] || 0);
    const yataX = sumAcrossCountriesFor(SPECIAL_DRUG, yataMap);
    const promX = sumAcrossCountriesFor(SPECIAL_DRUG, promMap);
    let xanPicked = { val: null, src: null };
    if (yataX > 0) xanPicked = { val: yataX, src: 'Y' };
    else if (promX > 0) xanPicked = { val: promX, src: 'P' };
    else if (yataRaw && Object.keys(yataRaw).length) xanPicked = { val: yataX, src: 'Y' };
    else if (promRaw && Object.keys(promRaw).length) xanPicked = { val: promX, src: 'P' };

    const xanAvText = xanPicked.val != null && xanPicked.src ? `${xanPicked.val} Av [${xanPicked.src}]` : '—';
    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;
  }

  // refresh flow: fetch Torn display/inventory + YATA + Prometheus in parallel
  async function refreshAll(force = false) {
    if (statusEl) statusEl.textContent = 'Fetching...';
    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);

      if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
    } catch (err) {
      console.warn('refreshAll err', err);
      if (statusEl) statusEl.textContent = 'Update failed';
    }
  }

  // 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); }
    });
  }

  // start
  if (apiKey) { startPolling(); refreshAll(true); }
  else { setTimeout(() => askKey(false), 300); }

  window.addEventListener('beforeunload', () => stopPolling());

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