🌸🐻 Unified Stock (Compact Header v4.3)

Compact one-line display-case (inv) + YATA stock (stk) embedded between Torn header left & right. Short names, sets/points shown on expand, single ✈ fly suggestion. Refresh every 45s.

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

// ==UserScript==
// @name         🌸🐻 Unified Stock (Compact Header v4.3)
// @namespace    http://tampermonkey.net/
// @version      4.3
// @description  Compact one-line display-case (inv) + YATA stock (stk) embedded between Torn header left & right. Short names, sets/points shown on expand, single ✈ fly suggestion. Refresh every 45s.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @connect      yata.yt
// @run-at       document-end
// ==/UserScript==

(function(){
  'use strict';

  /* ====== CONFIG ====== */
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const REFRESH_MS = 45 * 1000;
  const LOW_STOCK = 500;
  const POINTS_PER_SET = 10;
  const PANEL_ID = 'tm_uni_stock_compact_v4_3';

  /* ====== SHORT NAMES & ORDER ====== */
  const SHORT_MAP = {
    "Dahlia":"Dahlia","Orchid":"Orchid","African Violet":"A.Violet","Cherry Blossom":"C.Blossom",
    "Peony":"Peony","Ceibo Flower":"Ceibo","Edelweiss":"Edelweiss","Crocus":"Crocus",
    "Heather":"Heather","Tribulus Omanense":"Tribulus","Banana Orchid":"Banana",
    "Sheep Plushie":"Sheep","Teddy Bear Plushie":"Teddy","Kitten Plushie":"Kitten",
    "Jaguar Plushie":"Jaguar","Wolverine Plushie":"Wolverine","Nessie Plushie":"Nessie",
    "Red Fox Plushie":"R.Fox","Monkey Plushie":"Monkey","Chamois Plushie":"Chamois",
    "Panda Plushie":"Panda","Lion Plushie":"Lion","Camel Plushie":"Camel","Stingray Plushie":"Stingray",
    "Xanax":"Xanax"
  };

  const FLOWERS_ORDER = [
    ["Dahlia",{code:'mex',flag:'🇲🇽'}],
    ["Orchid",{code:'haw',flag:'🏝️'}],
    ["African Violet",{code:'sou',flag:'🇿🇦'}],
    ["Cherry Blossom",{code:'jap',flag:'🇯🇵'}],
    ["Peony",{code:'chi',flag:'🇨🇳'}],
    ["Ceibo Flower",{code:'arg',flag:'🇦🇷'}],
    ["Edelweiss",{code:'swi',flag:'🇨🇭'}],
    ["Crocus",{code:'can',flag:'🇨🇦'}],
    ["Heather",{code:'uni',flag:'🇬🇧'}],
    ["Tribulus Omanense",{code:'uae',flag:'🇦🇪'}],
    ["Banana Orchid",{code:'cay',flag:'🇰🇾'}]
  ];
  const PLUSHIES_ORDER = [
    ["Sheep Plushie",{code:null,flag:'🏪'}],
    ["Teddy Bear Plushie",{code:null,flag:'🏪'}],
    ["Kitten Plushie",{code:null,flag:'🏪'}],
    ["Jaguar Plushie",{code:'mex',flag:'🇲🇽'}],
    ["Wolverine Plushie",{code:'can',flag:'🇨🇦'}],
    ["Nessie Plushie",{code:'uni',flag:'🇬🇧'}],
    ["Red Fox Plushie",{code:'uni',flag:'🇬🇧'}],
    ["Monkey Plushie",{code:'arg',flag:'🇦🇷'}],
    ["Chamois Plushie",{code:'swi',flag:'🇨🇭'}],
    ["Panda Plushie",{code:'chi',flag:'🇨🇳'}],
    ["Lion Plushie",{code:'sou',flag:'🇿🇦'}],
    ["Camel Plushie",{code:'uae',flag:'🇦🇪'}],
    ["Stingray Plushie",{code:'cay',flag:'🇰🇾'}]
  ];
  const SPECIAL_DRUG = "Xanax";
  const TRACKED_ORDER = [...FLOWERS_ORDER.map(x=>x[0]), ...PLUSHIES_ORDER.map(x=>x[0]), SPECIAL_DRUG];
  const COUNTRY_NAMES = { mex:'Mexico', cay:'Cayman Islands', can:'Canada', haw:'Hawaii', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', jap:'Japan', chi:'China', uae:'UAE', sou:'South Africa' };

  /* ====== STYLES (compact + slide) ====== */
  GM_addStyle(`
    #${PANEL_ID} { display:inline-block; vertical-align:middle; margin:0 6px; position:relative; z-index:999999; }
    #${PANEL_ID} .compact { background: rgba(10,10,10,0.62); color:#eee; border:1px solid rgba(255,255,255,0.06);
      border-radius:6px; font-family:"DejaVu Sans Mono",monospace; font-size:12px; padding:6px 8px; display:flex;
      align-items:center; gap:10px; cursor:pointer; min-width:120px; max-width:520px; box-sizing:border-box; }
    #${PANEL_ID} .compact .title { font-weight:700; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .compact .controls { display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .compact .btn { background:transparent; color:#eaeaea; border:1px solid rgba(255,255,255,0.06); padding:2px 6px; border-radius:4px; cursor:pointer; font-size:12px; }
    #${PANEL_ID} .dropdown { position:absolute; left:0; top:calc(100% + 8px); width:360px; max-width:40vw; min-width:240px;
      background: rgba(11,11,11,0.95); color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 10px 26px rgba(0,0,0,0.6);
      overflow:hidden; max-height:0; transition: max-height 280ms cubic-bezier(.2,.9,.3,1), opacity 220ms ease, transform 260ms cubic-bezier(.2,.9,.3,1);
      opacity:0; transform:translateY(-6px); font-size:11px; }
    #${PANEL_ID} .dropdown.open { max-height:40vh; opacity:1; transform:translateY(0); }
    #${PANEL_ID} .dropdown .head { display:flex; justify-content:space-between; align-items:center; padding:8px; gap:8px; border-bottom:1px solid rgba(255,255,255,0.03); }
    #${PANEL_ID} .points { color:#bfc9d6; font-weight:700; }
    #${PANEL_ID} .list { max-height:calc(40vh - 80px); overflow:auto; padding:6px 8px; }
    #${PANEL_ID} .row { display:flex; justify-content:space-between; gap:8px; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.02); align-items:center; white-space:nowrap; }
    #${PANEL_ID} .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    #${PANEL_ID} .dot { width:10px; height:10px; border-radius:50%; flex:0 0 10px; }
    #${PANEL_ID} .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
    #${PANEL_ID} .name { min-width:0; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .meta { color:#bfc9d6; width:170px; text-align:right; flex:0 0 170px; font-size:11px; }
    #${PANEL_ID} .fly { color:#9ad0ff; font-weight:700; margin-top:6px; text-align:right; padding:6px 8px; }
    @media (max-width:720px) {
      #${PANEL_ID} .dropdown { width:92vw; left:4vw; right:4vw; max-width:92vw; }
      #${PANEL_ID} .meta { width:120px; flex:0 0 120px; }
    }
  `);

  /* ====== DOM: build panel ====== */
  function createPanelNode() {
    const root = document.createElement('div');
    root.id = PANEL_ID;

    root.innerHTML = `
      <div class="compact" title="Click to expand">
        <div class="title"><span id="${PANEL_ID}-icon">▼</span> <span style="margin-left:4px">🌸🐻 Unified Stock</span></div>
        <div class="controls">
          <button class="btn" id="${PANEL_ID}-refresh">Refresh</button>
          <button class="btn" id="${PANEL_ID}-setkey">Set Key</button>
        </div>
      </div>
      <div class="dropdown" id="${PANEL_ID}-dropdown" aria-hidden="true">
        <div class="head">
          <div class="points" id="${PANEL_ID}-points">Sets: - | Points: -</div>
          <div style="color:#9ea6b3;font-size:11px" id="${PANEL_ID}-updated">Updated: -</div>
        </div>
        <div class="list" id="${PANEL_ID}-list"></div>
        <div class="fly" id="${PANEL_ID}-fly"></div>
      </div>
    `;

    // events
    root.querySelector('.compact').addEventListener('click', (ev) => {
      if (ev.target && (ev.target.id === `${PANEL_ID}-refresh` || ev.target.id === `${PANEL_ID}-setkey`)) return;
      toggleDropdown();
    });
    root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
    root.querySelector(`#${PANEL_ID}-setkey`).addEventListener('click', (ev) => { ev.stopPropagation(); askApiKey(); });

    return root;
  }

  let panelNode = createPanelNode();
  let dropdownOpen = false;
  let collapsedStored = GM_getValue(`${PANEL_ID}-collapsed`, false);

  function toggleDropdown(force) {
    const dd = panelNode.querySelector(`#${PANEL_ID}-dropdown`);
    const icon = panelNode.querySelector(`#${PANEL_ID}-icon`);
    dropdownOpen = (typeof force === 'boolean') ? force : !dropdownOpen;
    if (dropdownOpen) {
      dd.classList.add('open'); dd.setAttribute('aria-hidden','false'); icon.textContent = '▲';
    } else {
      dd.classList.remove('open'); dd.setAttribute('aria-hidden','true'); icon.textContent = '▼';
    }
    GM_setValue(`${PANEL_ID}-collapsed`, !dropdownOpen);
  }

  /* ====== Insert in header (between left & right groups) & observe ====== */
  function findHeaderGroups() {
    const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
    const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud, #topbar-right');
    return { left, right };
  }

  function insertPanel() {
    const { left, right } = findHeaderGroups();
    // remove existing
    const existing = document.getElementById(PANEL_ID);
    if (existing) existing.remove();
    // insert
    if (left && right && left.parentNode === right.parentNode) {
      left.parentNode.insertBefore(panelNode, right);
    } else {
      // fallback to top header or body
      const header = document.querySelector('header, #header, .topbar, .header') || document.body;
      header.appendChild(panelNode);
    }
    // restore collapse state
    const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
    toggleDropdown(!wasCollapsed);
    // ensure dropdown doesn't overlap too much: position dropdown under panel with absolute already set relative to panel
  }

  // reinsert if header mutated
  const headerObserver = new MutationObserver(() => {
    if (!document.body.contains(panelNode)) {
      panelNode = createPanelNode();
      insertPanel();
    }
  });
  headerObserver.observe(document.documentElement || document.body, { childList:true, subtree:true });

  insertPanel();

  /* ====== Network helper using GM_xmlhttpRequest ====== */
  function gmGetJson(url, timeout = 12000) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: (res) => {
          try {
            let d = res.response;
            if (!d && res.responseText) d = JSON.parse(res.responseText);
            resolve(d);
          } catch (e) { reject(e); }
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  /* ====== Torn display & YATA fetches ====== */
  async function askApiKey() {
    const current = GM_getValue('tornAPIKey','');
    const k = prompt('Enter Torn user API key (needs display permission):', current || '');
    if (k !== null) {
      GM_setValue('tornAPIKey', String(k).trim());
      // immediate refresh
      await refreshAll(true);
    }
  }

  async function fetchDisplayCase() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return {};
      const out = {};
      const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
        if (!name) continue;
        out[name] = (out[name] || 0) + qty;
      }
      return out;
    } catch (err) {
      console.warn('fetchDisplayCase error', err);
      return {};
    }
  }

  async function fetchYata() {
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (err) {
      console.warn('fetchYata error', err);
      return null;
    }
  }

  /* ====== Logic: build unified list, sets, lowest item ====== */
  function computeSets(displayMap) {
    const flowerCounts = FLOWERS_ORDER.map(([name]) => Number(displayMap[name] || 0));
    const plushCounts  = PLUSHIES_ORDER.map(([name]) => Number(displayMap[name] || 0));
    const fSets = flowerCounts.length ? Math.min(...flowerCounts) : 0;
    const pSets = plushCounts.length ? Math.min(...plushCounts) : 0;
    const totalSets = (isFinite(fSets) ? fSets : 0) + (isFinite(pSets) ? pSets : 0);
    return { totalSets, points: totalSets * POINTS_PER_SET, fSets, pSets };
  }

  function dotClass(inv, stk) {
    if ((stk > 0 && stk < LOW_STOCK) || (inv > 0 && stk > 0 && stk < LOW_STOCK)) return 'y';
    if (inv > 0 || stk > 0) return 'g';
    return 'r';
  }

  function buildUnifiedData(displayMap, yataData) {
    const yataStocks = {};
    if (yataData && yataData.stocks) {
      for (const [code, obj] of Object.entries(yataData.stocks)) {
        const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
        yataStocks[code] = {};
        for (const it of arr) {
          if (!it || !it.name) continue;
          yataStocks[code][it.name] = Number(it.quantity ?? 0) || 0;
        }
      }
    }

    const items = [];
    for (const name of TRACKED_ORDER) {
      if (name === SPECIAL_DRUG) {
        const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
        const inv = Number(displayMap[name] ?? 0) || 0;
        items.push({ name, inv, totalStk: stk, bestCode: stk>0 ? 'sou' : null, bestStk: stk, locFlag:'🇿🇦' });
        continue;
      }
      let total = 0, bestCode = null, bestStk = 0;
      for (const [code, map] of Object.entries(yataStocks)) {
        const q = Number(map[name] ?? 0) || 0;
        total += q;
        if (q > bestStk) { bestStk = q; bestCode = code; }
      }
      let locFlag = '';
      const f = FLOWERS_ORDER.find(x=>x[0]===name); if (f) locFlag = f[1].flag;
      const p = PLUSHIES_ORDER.find(x=>x[0]===name); if (p) locFlag = p[1].flag;
      const inv = Number(displayMap[name] ?? 0) || 0;
      items.push({ name, inv, totalStk: total, bestCode, bestStk, locFlag });
    }

    // lowest by inv then by totalStk
    let lowest = null;
    for (const it of items) {
      if (!lowest) { lowest = it; continue; }
      if ((it.inv||0) < (lowest.inv||0)) lowest = it;
      else if ((it.inv||0) === (lowest.inv||0) && (it.totalStk||0) < (lowest.totalStk||0)) lowest = it;
    }

    return { items, lowest };
  }

  function getFlagByCode(code) {
    if (!code) return '';
    const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
    return map[code] || '';
  }

  /* ====== Render dropdown content (compact lines) ====== */
  function renderDropdown(displayMap, yataData) {
    const { totalSets, points } = computeSets(displayMap);
    const pointsEl = panelNode.querySelector(`#${PANEL_ID}-points`);
    pointsEl.textContent = `Sets: ${totalSets} | Points: ${points}`;

    const { items, lowest } = buildUnifiedData(displayMap, yataData);
    const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
    let html = '';
    for (const name of TRACKED_ORDER) {
      const it = items.find(x=>x.name===name);
      if (!it) continue;
      const inv = Number(it.inv||0), stk = Number(it.totalStk||0);
      const cls = dotClass(inv, stk);
      const short = SHORT_MAP[name] || name;
      const meta = `(inv: ${inv} | stk: ${stk}${it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : ''})`;
      html += `<div class="row"><div class="left"><div class="dot ${cls}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">${escapeHtml(meta)} ${it.locFlag ? escapeHtml(it.locFlag) : ''}</div></div>`;
    }
    listEl.innerHTML = html || '<div style="color:#999">No tracked items</div>';

    // fly hint bottom
    const flyEl = panelNode.querySelector(`#${PANEL_ID}-fly`);
    if (lowest && lowest.bestCode && lowest.bestStk > 0) {
      flyEl.innerHTML = `✈ Fly: ${ (COUNTRY_NAMES[lowest.bestCode] || lowest.bestCode.toUpperCase()) } ${ getFlagByCode(lowest.bestCode) } — ${escapeHtml(SHORT_MAP[lowest.name] || lowest.name)}`;
    } else {
      flyEl.innerHTML = '';
    }

    // updated time
    const upEl = panelNode.querySelector(`#${PANEL_ID}-updated`);
    if (upEl) upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
  }

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

  /* ====== Master refresh ====== */
  let refreshTimer = null;
  async function refreshAll(force=false) {
    const pointsNode = panelNode.querySelector(`#${PANEL_ID}-points`);
    if (pointsNode) pointsNode.textContent = 'Updating...';
    try {
      const [displayMap, yataData] = await Promise.all([ fetchDisplayCase(), fetchYata() ]);
      renderDropdown(displayMap||{}, yataData||null);
    } catch (e) {
      console.warn('refreshAll err', e);
      const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
      if (listEl) listEl.innerHTML = `<div style="color:#f88">Update failed</div>`;
    }
  }

  /* ====== Fetch wrappers ====== */
  async function fetchDisplayCase() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return {};
      const out = {};
      const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
        if (!name) continue;
        out[name] = (out[name] || 0) + qty;
      }
      return out;
    } catch (err) {
      console.warn('fetchDisplayCase err', err);
      return {};
    }
  }

  async function fetchYata() {
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (err) {
      console.warn('fetchYata err', err);
      return null;
    }
  }

  // gmGetJson wrapper
  function gmGetJson(url, timeout=14000) {
    return new Promise((resolve,reject) => {
      GM_xmlhttpRequest({
        method:'GET', url, timeout,
        onload: res => {
          try {
            let d = res.response;
            if (!d && res.responseText) d = JSON.parse(res.responseText);
            resolve(d);
          } catch (e) { reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  /* ====== Init ====== */
  // ensure panel exists & inserted
  if (!panelNode) panelNode = createPanelNode();
  insertPanel();

  // restore collapse flag and set dropdown according to stored state
  const storedCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  toggleDropdown(!storedCollapsed);

  // initial fetch + polling
  refreshAll(true);
  if (refreshTimer) clearInterval(refreshTimer);
  refreshTimer = setInterval(() => refreshAll(false), REFRESH_MS);

  // cleanup
  window.addEventListener('beforeunload', () => {
    if (refreshTimer) clearInterval(refreshTimer);
    headerObserver.disconnect();
  });

  // highlight set key button if key missing
  if (!GM_getValue('tornAPIKey', null)) {
    setTimeout(() => {
      const btn = panelNode.querySelector(`#${PANEL_ID}-setkey`);
      if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.18)'; }
    }, 1500);
  }

})();