🌺🧸 Unified Display & Points (Above PDA) v3.4

Unified display-case, points, inventory + YATA stock. Fixed above PDA nav. Shows sets, points, missing, foreign stock (color-coded) and travel suggestions. 45s refresh.

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

// ==UserScript==
// @name         🌺🧸 Unified Display & Points (Above PDA) v3.4
// @namespace    http://tampermonkey.net/
// @version      3.4
// @description  Unified display-case, points, inventory + YATA stock. Fixed above PDA nav. Shows sets, points, missing, foreign stock (color-coded) and travel suggestions. 45s refresh.
// @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';

  const PANEL_ID = 'unified_points_display_v3_4';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL_DEFAULT = 'https://yata.yt/api/v1/travel/export/';

  /* -------------------- item maps -------------------- */
  const FLOWERS_ORDER = [
    ["Dahlia",{code:'mex', flag:'🇲🇽', short:'Dahlia'}],
    ["Orchid",{code:'haw', flag:'🏝️', short:'Orchid'}],
    ["African Violet",{code:'sou', flag:'🇿🇦', short:'A.Violet'}],
    ["Cherry Blossom",{code:'jap', flag:'🇯🇵', short:'C.Blossom'}],
    ["Peony",{code:'chi', flag:'🇨🇳', short:'Peony'}],
    ["Ceibo Flower",{code:'arg', flag:'🇦🇷', short:'Ceibo'}],
    ["Edelweiss",{code:'swi', flag:'🇨🇭', short:'Edelweiss'}],
    ["Crocus",{code:'can', flag:'🇨🇦', short:'Crocus'}],
    ["Heather",{code:'uni', flag:'🇬🇧', short:'Heather'}],
    ["Tribulus Omanense",{code:'uae', flag:'🇦🇪', short:'Tribulus'}],
    ["Banana Orchid",{code:'cay', flag:'🇰🇾', short:'Banana'}]
  ];

  const PLUSHIES_ORDER = [
    ["Sheep Plushie",{code:null, flag:'🏪', short:'Sheep'}],
    ["Teddy Bear Plushie",{code:null, flag:'🏪', short:'Teddy'}],
    ["Kitten Plushie",{code:null, flag:'🏪', short:'Kitten'}],
    ["Jaguar Plushie",{code:'mex', flag:'🇲🇽', short:'Jaguar'}],
    ["Wolverine Plushie",{code:'can', flag:'🇨🇦', short:'Wolverine'}],
    ["Nessie Plushie",{code:'uni', flag:'🇬🇧', short:'Nessie'}],
    ["Red Fox Plushie",{code:'uni', flag:'🇬🇧', short:'R.Fox'}],
    ["Monkey Plushie",{code:'arg', flag:'🇦🇷', short:'Monkey'}],
    ["Chamois Plushie",{code:'swi', flag:'🇨🇭', short:'Chamois'}],
    ["Panda Plushie",{code:'chi', flag:'🇨🇳', short:'Panda'}],
    ["Lion Plushie",{code:'sou', flag:'🇿🇦', short:'Lion'}],
    ["Camel Plushie",{code:'uae', flag:'🇦🇪', short:'Camel'}],
    ["Stingray Plushie",{code:'cay', flag:'🇰🇾', short:'Stingray'}]
  ];

  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 -------------------- */
  function getPDANavHeight(){
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda') ;
    return nav ? nav.offsetHeight : 40;
  }

  GM_addStyle(`
    #${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 18px; width: 260px; z-index: 999999; font-family: "DejaVu Sans Mono", monospace; font-size:11px; }
    #${PANEL_ID} .panel { background:#0b0b0b; color:#eaeaea; border:1px solid #333; border-radius:6px; box-shadow:0 8px 20px rgba(0,0,0,0.6); overflow:hidden; max-height:75vh; }
    #${PANEL_ID} .header { display:flex; align-items:center; gap:8px; padding:6px; background:#121212; cursor:pointer; user-select:none; font-weight:700; }
    #${PANEL_ID} .title { font-size:12px; }
    #${PANEL_ID} .controls { margin-left:auto; display:flex; gap:6px; }
    #${PANEL_ID} button { background:#171717; color:#eaeaea; border:1px solid #333; padding:3px 7px; border-radius:4px; cursor:pointer; font-size:11px; }
    #${PANEL_ID} .body { padding:6px; display:none; }
    #${PANEL_ID} .summary { font-weight:700; margin-bottom:6px; color:#dfe7ff; font-size:12px; }
    .row { display:flex; align-items:center; justify-content:space-between; gap:6px; padding:3px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.03); }
    .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    .short { min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .meta { flex:0 0 120px; text-align:right; color:#bfc9d6; font-size:11px; }
    .dot { width:10px; height:10px; border-radius:50%; flex:0 0 10px; }
    .stock-green { background:#00c853; } .stock-orange { background:#ff9800; } .stock-red { background:#ff1744; } .stock-gray { background:#9ea6b3; }
    .note { margin-top:6px; color:#9ea6b3; font-size:11px; }
    @media (max-width:740px){ #${PANEL_ID}{ left:6px; right:6px; width:auto; } .meta{ flex:0 0 110px; } }
  `);

  /* -------------------- DOM build -------------------- */
  function buildPanel(){
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="panel">
        <div class="header">
          <div class="title">▶ 🌺🧸 Unified Display & Points</div>
          <div class="controls">
            <button id="${PANEL_ID}-refresh">Refresh</button>
            <button id="${PANEL_ID}-set-torn">Set Torn Key</button>
            <button id="${PANEL_ID}-set-yata">Set Yata Key</button>
          </div>
        </div>
        <div class="body">
          <div id="${PANEL_ID}-status" class="summary">Waiting...</div>
          <div id="${PANEL_ID}-content"></div>
          <div id="${PANEL_ID}-fly" class="note"></div>
          <div class="note">Refresh: ${Math.round(REFRESH_MS/1000)}s | Points per set: ${POINTS_PER_SET}</div>
        </div>
      </div>
    `;
    document.body.appendChild(root);

    // events
    const header = root.querySelector('.header');
    header.addEventListener('click', (e) => {
      if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.target.id === `${PANEL_ID}-set-torn` || e.target.id === `${PANEL_ID}-set-yata`)) return;
      toggleBody();
    });
    root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (e) => { e.stopPropagation(); refreshAll(true); });
    root.querySelector(`#${PANEL_ID}-set-torn`).addEventListener('click', (e) => { e.stopPropagation(); askTornKey(); });
    root.querySelector(`#${PANEL_ID}-set-yata`).addEventListener('click', (e) => { e.stopPropagation(); askYataKey(); });

    // style pawn: highlight if keys missing
    setTimeout(() => {
      const torn = GM_getValue('tornAPIKey', null);
      const yata = GM_getValue('yataApiKey', null);
      if (!torn) { const b=root.querySelector(`#${PANEL_ID}-set-torn`); if (b) b.style.boxShadow='0 0 8px rgba(200,100,255,0.12)'; }
      if (!yata) { const b=root.querySelector(`#${PANEL_ID}-set-yata`); if (b) b.style.boxShadow='0 0 8px rgba(137,183,255,0.14)'; }
    },1200);

    return root;
  }

  function toggleBody(force){
    const body = document.querySelector(`#${PANEL_ID} .body`);
    const title = document.querySelector(`#${PANEL_ID} .title`);
    const open = (typeof force === 'boolean') ? force : (body.style.display !== 'block');
    body.style.display = open ? 'block' : 'none';
    title.textContent = (open ? '▼' : '▶') + ' 🌺🧸 Unified Display & Points';
    GM_setValue(`${PANEL_ID}-collapsed`, !open);
  }

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

  /* -------------------- fetch display/inv -------------------- */
  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 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;
  }

  function fetchDisplayViaDOM(){
    const map = {};
    const itemEls = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (itemEls && itemEls.length){
      itemEls.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;
  }

  /* -------------------- YATA -------------------- */
  async function fetchYata(){
    const yataKey = GM_getValue('yataApiKey', null);
    // second script used public YATA_URL. We support optional key param if provided by user.
    let url = YATA_URL_DEFAULT;
    if (yataKey) url += `?key=${encodeURIComponent(yataKey)}`;
    try {
      const res = await gmGetJson(url, 14000);
      return res || null;
    } catch (e){ console.warn('fetchYata', e); return null; }
  }

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

  /* -------------------- compute sets & missing -------------------- */
  function computeForGroup(displayMap, groupOrder){
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    // Deduct sets to compute missing to next set.
    // For each item: missing = max(0, (sets+1) - count)
    const missing = groupOrder.reduce((acc, [name]) => {
      const c = Number(displayMap[name]||0);
      acc[name] = Math.max(0, (sets + 1) - c);
      return acc;
    }, {});
    return { sets, countsMap: groupOrder.reduce((acc,[name])=>{acc[name]=Number(displayMap[name]||0);return acc;},{}) , missing };
  }

  /* -------------------- stock color logic -------------------- */
  function stockClassByQty(q){
    // user wanted green/orange/red
    // thresholds: >1000 green, 600-1000 orange, 1-599 red, 0 gray
    q = Number(q || 0);
    if (q === 0) return 'stock-gray';
    if (q > 1000) return 'stock-green';
    if (q >= 600) return 'stock-orange';
    return 'stock-red';
  }

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

  function renderUI(displayMap, yataData){
    const yataMap = buildYataMap(yataData);
    const flowers = computeForGroup(displayMap, FLOWERS_ORDER);
    const plush = computeForGroup(displayMap, PLUSHIES_ORDER);
    const totalSets = (flowers.sets || 0) + (plush.sets || 0);
    const totalPoints = totalSets * POINTS_PER_SET;

    const statusEl = document.getElementById(`${PANEL_ID}-status`);
    statusEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints} | Flowers sets: ${flowers.sets} | Plush sets: ${plush.sets}`;

    let html = '';
    // Flowers
    html += `<div style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Flowers — missing to next set: ${Object.values(flowers.missing).reduce((s,v)=>s+v,0)}</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 dotClass = stockClassByQty(stk);
      const short = meta.short || name;
      const missing = flowers.missing[name] || 0;
      const needDisplay = missing > 0 ? ` | missing: ${missing}` : '';
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      html += `<div class="row"><div class="left"><div class="dot ${dotClass}"></div><div class="short">${escapeHtml(short)}</div></div><div class="meta">(inv:${inv} | stk:${stk}${codePart})${needDisplay} ${meta.flag||''}</div></div>`;
    }

    // Plushies
    html += `<div style="font-weight:700;margin:10px 0 6px;color:#dfe7ff">Plushies — missing to next set: ${Object.values(plush.missing).reduce((s,v)=>s+v,0)}</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 dotClass = stockClassByQty(stk);
      const short = meta.short || name;
      const missing = plush.missing[name] || 0;
      const needDisplay = missing > 0 ? ` | missing: ${missing}` : '';
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      html += `<div class="row"><div class="left"><div class="dot ${dotClass}"></div><div class="short">${escapeHtml(short)}</div></div><div class="meta">(inv:${inv} | stk:${stk}${codePart})${needDisplay} ${meta.flag||''}</div></div>`;
    }

    // Xanax (South Africa)
    {
      html += `<div style="font-weight:700;margin:10px 0 6px;color:#dfe7ff">Drugs</div>`;
      const inv = Number(displayMap[SPECIAL_DRUG]||0);
      const stk = Number(yataMap['sou']?.[SPECIAL_DRUG] || 0);
      const dotClass = stockClassByQty(stk);
      html += `<div class="row"><div class="left"><div class="dot ${dotClass}"></div><div class="short">${escapeHtml(SPECIAL_DRUG)}</div></div><div class="meta">(inv:${inv} | stk:${stk} | SOU) 🇿🇦</div></div>`;
    }

    document.getElementById(`${PANEL_ID}-content`).innerHTML = html;

    // Suggest next destination: pick lowest missing item (flowers first, then plush), and show best country with stock
    const lowFlower = lowestMissingWithBest(FLOWERS_ORDER, displayMap, yataMap, flowers.missing);
    const lowPlush = lowestMissingWithBest(PLUSHIES_ORDER, displayMap, yataMap, plush.missing);
    let flyHtml = '';
    if (lowFlower){
      if (lowFlower.bestQty > 0) flyHtml += `✈ Fly (flower): ${COUNTRY_NAMES[lowFlower.bestCode]||lowFlower.bestCode} ${flag(lowFlower.bestCode)} — ${escapeHtml(lowFlower.short)} (stk ${lowFlower.bestQty})`;
      else flyHtml += `✈ Fly (flower): No stock abroad for ${escapeHtml(lowFlower.short)}`;
    }
    if (lowPlush){
      if (flyHtml) flyHtml += '<br>';
      if (lowPlush.bestQty > 0) flyHtml += `✈ Fly (plush): ${COUNTRY_NAMES[lowPlush.bestCode]||lowPlush.bestCode} ${flag(lowPlush.bestCode)} — ${escapeHtml(lowPlush.short)} (stk ${lowPlush.bestQty})`;
      else flyHtml += `✈ Fly (plush): No stock abroad for ${escapeHtml(lowPlush.short)}`;
    }
    document.getElementById(`${PANEL_ID}-fly`).innerHTML = flyHtml;
  }

  function lowestMissingWithBest(order, displayMap, yataMap, missingMap){
    let lowest = null;
    for (const [name, meta] of order){
      const miss = Number(missingMap[name] || 0);
      if (miss <= 0) continue;
      if (!lowest || miss > 0 && miss > 0 && miss > -1 && miss > -1 && miss > 0 && miss > 0 && miss > 0 && miss > lowest.miss) {
        // not used; we want smallest missing value first (most urgent). So choose highest miss? choose largest missing? 
        // pick the item with greatest missing (largest gap) to recommend where to fly to fill
      }
    }
    // simpler: pick the first item in order that has missing>0 (preserve order). Then find best country for it.
    for (const [name, meta] of order){
      const miss = Number(missingMap[name] || 0);
      if (miss > 0){
        const best = bestCountryFor(name, yataMap);
        return { name, short: meta.short || name, miss, bestCode: best.code, bestQty: best.qty };
      }
    }
    return null;
  }

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

  /* -------------------- refresh flow -------------------- */
  let timer = null;
  async function refreshAll(force=false){
    try {
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      statusEl.textContent = 'Fetching data...';

      const tornPromise = fetchTornDisplayInventory();
      const yataPromise = fetchYata();

      const [displayFromApi, yataData] = await Promise.all([tornPromise, yataPromise]);
      let displayMap = {};
      if (displayFromApi && Object.keys(displayFromApi).length > 0) displayMap = displayFromApi;
      else {
        const dom = fetchDisplayViaDOM();
        displayMap = dom || displayFromApi || {};
      }
      renderUI(displayMap, yataData || null);

      statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
    } catch (e){
      console.warn('refreshAll err', e);
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if (statusEl) statusEl.textContent = 'Update failed';
    }
  }

  /* -------------------- key prompts -------------------- */
  async function askTornKey(){
    const current = GM_getValue('tornAPIKey', '');
    const k = prompt('Enter Torn API key (display + inventory permissions):', current || '');
    if (k !== null) { GM_setValue('tornAPIKey', String(k).trim()); refreshAll(true); }
  }
  async function askYataKey(){
    const current = GM_getValue('yataApiKey', '');
    const k = prompt('Enter YATA API key (optional). Leave blank for public endpoint:', current || '');
    if (k !== null) { GM_setValue('yataApiKey', String(k).trim()); refreshAll(true); }
  }

  /* -------------------- boot -------------------- */
  buildPanel();
  // restore collapse state
  const collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  toggleBody(!collapsed);

  refreshAll(true);
  if (timer) clearInterval(timer);
  timer = setInterval(()=>refreshAll(false), REFRESH_MS);
  window.addEventListener('beforeunload', ()=>{ if (timer) clearInterval(timer); });

})();