🌺🧸 Display Case Unified Compact v2.8

Compact display-case + YATA stock: flowers, plushies, Xanax (SA). inv | stk | CODE one-line rows, sets, missing, flight hints. 45s refresh, 210px width.

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

// ==UserScript==
// @name         🌺🧸 Display Case Unified Compact v2.8
// @namespace    http://tampermonkey.net/
// @version      2.8
// @description  Compact display-case + YATA stock: flowers, plushies, Xanax (SA). inv | stk | CODE one-line rows, sets, missing, flight hints. 45s refresh, 210px width.
// @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 PANEL_ID = 'dc_unified_compact_v2_8';
  const POINTS_PER_SET = 10;

  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 COUNTRY_NAMES = { mex:'Mexico', can:'Canada', jap:'Japan', chi:'China', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', haw:'Hawaii', uae:'UAE', cay:'Cayman Islands', sou:'South Africa' };

  /* ---------- Styles (narrow 210px, compact) ---------- */
  GM_addStyle(`
    #${PANEL_ID} { position:relative; display:inline-block; vertical-align:middle; margin:0 8px; z-index:999999; }
    #${PANEL_ID} .compact { background: rgba(8,8,8,0.64); color:#e9eef8; border-radius:6px; font-family:"DejaVu Sans Mono",monospace; font-size:11px; padding:6px 8px; display:flex; align-items:center; gap:8px; cursor:pointer; min-width:110px; max-width:210px; box-sizing:border-box; border:1px solid #333; }
    #${PANEL_ID} .compact .title { font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
    #${PANEL_ID} .compact .btns{ margin-left:auto; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .btn { background:transparent; border:1px solid rgba(255,255,255,0.06); color:#eaeaea; padding:2px 6px; border-radius:4px; cursor:pointer; font-size:11px; }
    #${PANEL_ID} .dropdown { position:absolute; left:0; top:calc(100% + 6px); width:210px; min-width:210px; max-width:210px; background: rgba(10,10,10,0.94); border:1px solid #222; border-radius:6px; box-shadow: 0 10px 22px rgba(0,0,0,0.6); overflow:hidden; opacity:0; transform:translateY(-6px); transition:opacity .18s, transform .22s, max-height .28s; max-height:0; }
    #${PANEL_ID} .dropdown.open { opacity:1; transform:translateY(0); max-height:60vh; }
    #${PANEL_ID} .head { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.03); font-size:11px; color:#bfc9d6; }
    #${PANEL_ID} .list { padding:6px 6px 8px 6px; font-size:11px; max-height:calc(60vh - 110px); overflow:auto; }
    .section-title { font-weight:700; color:#dfe7ff; margin:6px 0 4px; font-size:12px; }
    .row { display:flex; justify-content:space-between; gap:6px; padding:3px 0; align-items:center; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .left { display:flex; align-items:center; gap:6px; min-width:0; overflow:hidden; }
    .dot { width:9px; height:9px; border-radius:50%; flex:0 0 9px; }
    .stock-high { background:#00c853; } .stock-low { background:#ff1744; } .stock-zero { background:#ff9800; } .stock-mid { background:#9ea6b3; }
    .name { overflow:hidden; text-overflow:ellipsis; min-width:0; font-size:11px; }
    .meta { color:#bfc9d6; flex:0 0 135px; text-align:right; font-size:11px; }
    .fly { padding:6px 8px; color:#9ad0ff; font-weight:700; font-size:11px; text-align:right; line-height:1.1; }
    .small { padding:6px 8px 8px; color:#9ea6b3; font-size:11px; }
    @media (max-width:740px){ #${PANEL_ID} .dropdown{ width:92vw; left:4vw; min-width:unset; } .meta{ flex:0 0 110px; } }
  `);

  /* ---------- Build DOM ---------- */
  function createPanel() {
    const root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="compact" title="Click to expand">
        <div class="title">🌺🧸 Display Unified</div>
        <div class="btns">
          <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 id="${PANEL_ID}-points">Sets: - | Points: -</div><div id="${PANEL_ID}-updated" style="color:#9ea6b3;font-size:11px">Updated: -</div></div>
        <div class="list" id="${PANEL_ID}-list"></div>
        <div class="fly" id="${PANEL_ID}-fly"></div>
        <div class="small">Format: Short — (inv: X | stk: Y | CODE)</div>
      </div>
    `;
    // events:
    const compact = root.querySelector('.compact');
    compact.addEventListener('click', (e) => {
      if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.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 panel = createPanel();
  document.body.appendChild(panel);

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

  // Preserve position between header left & right if available (attempt)
  function insertBetweenHeader() {
    const left = document.querySelector('.header-links-left, .headerLeft, .leftLinks, #nav-left');
    const right = document.querySelector('.header-links-right, .headerRight, .rightLinks, #topbar-right');
    try {
      if (left && right && left.parentNode === right.parentNode) left.parentNode.insertBefore(panel, right);
    } catch (e) { /* fallback already appended */ }
  }
  insertBetweenHeader();

  /* ---------- Networking wrappers ---------- */
  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'))
      });
    });
  }

  /* ---------- Fetchers ---------- */
  async function askApiKey() {
    const current = GM_getValue('tornAPIKey', '');
    const v = prompt('Enter your Torn user API key (display permission):', current || '');
    if (v !== null) {
      GM_setValue('tornAPIKey', String(v).trim());
      await refreshAll(true);
    }
  }

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

  function fetchDisplayViaDOM() {
    // fallback: parse DOM display case if on displaycase page
    const map = {};
    // common Torn display case layout variations: search for elements containing item names + qty
    // Attempt several selectors
    const itemEls = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item');
    if (itemEls && itemEls.length) {
      itemEls.forEach(el => {
        // try to identify name and quantity
        let name = '';
        let qty = 0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a');
        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;
      });
    } else {
      // as last resort, try to parse any text nodes with known names
      const allText = document.body.innerText;
      // skip heavy parsing; return empty to avoid wrong counts
    }
    return map;
  }

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

  /* ---------- Helpers: YATA map, sums ---------- */
  function buildYataMap(yataData) {
    const map = {};
    if (!yataData || !yataData.stocks) return map;
    for (const [code, obj] of Object.entries(yataData.stocks)) {
      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 ?? 0) || 0;
      map[code] = m;
    }
    return map;
  }

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

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

  /* ---------- Computation: sets, missing ---------- */
  function computeSetInfo(displayMap, groupOrder) {
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    // missing total to reach next set (for user-friendly display)
    const need = counts.reduce((s,c) => s + Math.max(0, (sets+1) - (c || 0)), 0);
    return { sets, need, countsMap: groupOrder.reduce((acc,[name])=>{acc[name]=Number(displayMap[name]||0);return acc;},{}) };
  }

  function stkClass(q) {
    if (q === 0) return 'stock-zero';
    if (q > 1000) return 'stock-high';
    if (q < 600) return 'stock-low';
    return 'stock-mid';
  }

  /* ---------- Render ---------- */
  function renderDisplayAndStock(displayMap, yataData) {
    const yataMap = buildYataMap(yataData);
    const flowers = computeSetInfo(displayMap, FLOWERS_ORDER);
    const plush = computeSetInfo(displayMap, PLUSHIES_ORDER);
    const totalSets = (flowers.sets || 0) + (plush.sets || 0);
    const totalPoints = totalSets * POINTS_PER_SET;
    panel.querySelector(`#${PANEL_ID}-points`).textContent = `Sets: ${totalSets} | Points: ${totalPoints}`;

    let html = '';

    // Flowers
    html += `<div class="section-title">Flowers — Missing: ${flowers.need}</div>`;
    for (const [name, meta] of FLOWERS_ORDER) {
      const inv = Number(displayMap[name] || 0);
      const stk = sumYataFor(name, yataMap);
      const best = bestCountryFor(name, yataMap);
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      const cls = stkClass(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag||''}</div></div>`;
    }

    // Plushies
    html += `<div class="section-title" style="margin-top:6px">Plushies — Missing: ${plush.need}</div>`;
    for (const [name, meta] of PLUSHIES_ORDER) {
      const inv = Number(displayMap[name] || 0);
      const stk = sumYataFor(name, yataMap);
      const best = bestCountryFor(name, yataMap);
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      const cls = stkClass(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag||''}</div></div>`;
    }

    // Xanax (SA)
    {
      const name = SPECIAL_DRUG;
      const inv = Number(displayMap[name] || 0);
      const stk = Number(yataMap['sou']?.[name] || 0);
      const cls = stkClass(stk);
      const codePart = stk>0? ' | SOU' : '';
      html += `<div class="section-title" style="margin-top:6px">Drugs</div>`;
      html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(SHORT_MAP[name]||name)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) 🇿🇦</div></div>`;
    }

    panel.querySelector(`#${PANEL_ID}-list`).innerHTML = html;

    // Flight suggestions: choose lowest inv in each group, then give best country with stock
    const bestFlower = lowestWithBest(FLOWERS_ORDER, displayMap, yataMap);
    const bestPlush = lowestWithBest(PLUSHIES_ORDER, displayMap, yataMap);
    let flyHtml = '';
    if (bestFlower) {
      if (bestFlower.bestCode && bestFlower.bestQty>0) flyHtml += `✈ Fly (flowers): ${(COUNTRY_NAMES[bestFlower.bestCode]||bestFlower.bestCode.toUpperCase())} ${flag(bestFlower.bestCode)} — ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
      else flyHtml += `✈ Fly (flowers): No stock abroad for ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
    }
    if (bestPlush) {
      if (flyHtml) flyHtml += '<br>';
      if (bestPlush.bestCode && bestPlush.bestQty>0) flyHtml += `✈ Fly (plushies): ${(COUNTRY_NAMES[bestPlush.bestCode]||bestPlush.bestCode.toUpperCase())} ${flag(bestPlush.bestCode)} — ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
      else flyHtml += `✈ Fly (plushies): No stock abroad for ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
    }
    panel.querySelector(`#${PANEL_ID}-fly`).innerHTML = flyHtml;
    panel.querySelector(`#${PANEL_ID}-updated`).textContent = `Updated: ${new Date().toLocaleTimeString()}`;
  }

  function lowestWithBest(groupOrder, displayMap, yataMap) {
    let lowest = null;
    for (const [name] of groupOrder) {
      const inv = Number(displayMap[name]||0);
      const totalStk = sumYataFor(name, yataMap);
      if (!lowest || inv < lowest.inv || (inv === lowest.inv && totalStk < lowest.totalStk)) lowest = { name, inv, totalStk };
    }
    if (!lowest) return null;
    const best = bestCountryFor(lowest.name, yataMap);
    return { name: lowest.name, inv: lowest.inv, bestCode: best.code, bestQty: best.qty };
  }

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

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

  /* ---------- Master refresh flow ---------- */
  let timer = null;
  async function refreshAll(force=false) {
    try {
      // Try API first (if key present), else DOM fallback for display
      const apiKey = GM_getValue('tornAPIKey', null);
      const displayPromise = apiKey ? fetchDisplayViaApi().then(m=>m||{}) : Promise.resolve({});
      const domFallbackPromise = Promise.resolve(fetchDisplayViaDOM());
      const yataPromise = fetchYata();

      // fetch yata always, combine with whichever display data is available (API if present else DOM)
      const [displayFromApi, yataData] = await Promise.all([displayPromise, yataPromise]);
      let displayMap = {};
      // If API returned a non-empty map, use it; else try DOM fallback
      if (displayFromApi && Object.keys(displayFromApi).length>0) displayMap = displayFromApi;
      else {
        // try DOM
        const domMap = fetchDisplayViaDOM();
        displayMap = domMap || displayFromApi || {};
      }
      renderDisplayAndStock(displayMap, yataData || null);
    } catch (e) {
      console.warn('refreshAll err', e);
      const listEl = panel.querySelector(`#${PANEL_ID}-list`);
      if (listEl) listEl.innerHTML = `<div style="color:#f88;padding:8px">Update failed</div>`;
    }
  }

  // start polling
  refreshAll(true);
  if (timer) clearInterval(timer);
  timer = setInterval(()=>refreshAll(false), REFRESH_MS);

  // cleanup on unload
  window.addEventListener('beforeunload', ()=>{ if (timer) clearInterval(timer); });

  // helper: prompt for key if not set (visual hint)
  if (!GM_getValue('tornAPIKey', null)) {
    setTimeout(()=> {
      const btn = panel.querySelector(`#${PANEL_ID}-setkey`);
      if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.14)'; }
    },1200);
  }

})();