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

Slim semi-transparent unified panel above PDA: display + inventory, YATA public stock, sets, points, missing, concise travel suggestions, Xanax shown. 45s refresh. Top-centered, collapsible, minimal bottom info.

Tính đến 13-10-2025. Xem phiên bản mới nhất.

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

Bạn sẽ cần cài đặt một tiện ích mở rộng như Tampermonkey hoặc Violentmonkey để cài đặt kịch bản này.

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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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         🌺🧸 Unified Display & Points (Above PDA) v3.4.2
// @namespace    http://tampermonkey.net/
// @version      3.4.2
// @description  Slim semi-transparent unified panel above PDA: display + inventory, YATA public stock, sets, points, missing, concise travel suggestions, Xanax shown. 45s refresh. Top-centered, collapsible, minimal bottom info.
// @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_2';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';

  // item definitions (short + country code + flag)
  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:'B.B',flag:'🏪',short:'Sheep'}],
    ["Teddy Bear Plushie",{code:'B.B',flag:'🏪',short:'Teddy'}],
    ["Kitten Plushie",{code:'B.B',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', 'B.B':'Torn' };

  // try to place under PDA nav without blocking nav buttons
  function getPDANavHeight(){
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
    return nav ? nav.offsetHeight : 40;
  }

  GM_addStyle(`
    /* container */
    #${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 50%; transform: translateX(-50%); z-index: 999999; width: 420px; pointer-events:auto; font-family: "DejaVu Sans Mono", monospace; font-size:12px; }
    /* semi-transparent card */
    #${PANEL_ID} .card { background: rgba(6,6,6,0.78); color:#e9eef8; border:1px solid rgba(255,255,255,0.04); border-radius:6px; box-shadow: 0 10px 30px rgba(0,0,0,0.6); overflow:hidden; }
    /* header (slim when collapsed) */
    #${PANEL_ID} .header { display:flex; align-items:center; padding:6px 8px; height:34px; box-sizing:border-box; cursor:pointer; user-select:none; gap:8px; }
    #${PANEL_ID} .title { font-weight:700; font-size:12px; line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .controls { margin-left:auto; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} button { background:transparent; color:#dfe7ff; border:1px solid rgba(255,255,255,0.04); padding:4px 7px; border-radius:4px; cursor:pointer; font-size:11px; }
    /* body compact */
    #${PANEL_ID} .body { padding:6px 6px 8px; display:none; max-height:68vh; overflow:auto; font-size:12px; line-height:1.08; }
    /* table headers & rows */
    .tbl-head { display:flex; gap:6px; padding:4px 2px; color:#bfc9d6; font-weight:700; font-size:11px; border-bottom:1px solid rgba(255,255,255,0.03); margin-bottom:6px; }
    .tbl-row { display:flex; gap:6px; align-items:center; padding:4px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .col-dot { flex:0 0 18px; display:flex; align-items:center; justify-content:flex-start; }
    .col-av { flex:0 0 44px; text-align:right; color:#cfe8c6; }
    .col-st { flex:0 0 72px; text-align:right; color:#f7b3b3; }
    .col-mis { flex:0 0 40px; text-align:right; color:#f0d08a; }
    .col-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; color:#e9eef8; }
    .dot { width:10px; height:10px; border-radius:50%; margin-right:6px; flex:0 0 10px; }
    .stock-green{ background:#00c853; } .stock-orange{ background:#ff9800; } .stock-red{ background:#ff1744; } .stock-gray{ background:#9ea6b3; }
    .footer { padding:6px 8px; border-top:1px solid rgba(255,255,255,0.03); color:#bfc9d6; font-size:12px; display:flex; justify-content:space-between; gap:8px; align-items:center; }
    .footer .left { flex:1 1 auto; }
    .footer .right { flex:0 0 auto; color:#9ea6b3; }
    @media (max-width:900px){ #${PANEL_ID}{ left:6px; transform:none; width:92vw; } .col-st{ flex:0 0 56px; } .col-av{ flex:0 0 36px; } .col-mis{ flex:0 0 34px; } }
  `);

  // build DOM
  function buildPanel(){
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="card">
        <div class="header" title="Click to expand/collapse">
          <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>
          </div>
        </div>
        <div class="body">
          <div id="${PANEL_ID}-status" style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Waiting...</div>

          <div id="${PANEL_ID}-flowers-section">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis">MIS</div><div class="col-name">Flower</div></div>
            <div id="${PANEL_ID}-flowers-rows"></div>
          </div>

          <div id="${PANEL_ID}-plush-section" style="margin-top:8px;">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis">MIS</div><div class="col-name">Plushie</div></div>
            <div id="${PANEL_ID}-plush-rows"></div>
          </div>

          <div id="${PANEL_ID}-drugs-section" style="margin-top:8px;">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis"></div><div class="col-name">Drugs</div></div>
            <div id="${PANEL_ID}-drugs-rows"></div>
          </div>
        </div>

        <div class="footer">
          <div class="left" id="${PANEL_ID}-flyline"></div>
          <div class="right" id="${PANEL_ID}-meta">Refresh: ${Math.round(REFRESH_MS/1000)}s | Points/set: ${POINTS_PER_SET}</div>
        </div>
      </div>
    `;
    document.body.appendChild(root);

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

    // highlight set-torn if missing
    setTimeout(()=> {
      const torn = GM_getValue('tornAPIKey', null);
      if (!torn) { const b=root.querySelector(`#${PANEL_ID}-set-torn`); 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';
    // when collapsed ensure small header doesn't hide nav; keep top equal to PDA nav height
    GM_setValue(`${PANEL_ID}-collapsed`, !open);
  }

  // network helper (GM_xmlhttpRequest wrapper)
  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 = txt && txt.length ? JSON.parse(txt) : res.response;
            resolve(parsed);
          } catch(e){ reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // Torn display + inventory
  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 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;
  }
  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;
  }

  // YATA public endpoint (no key)
  async function fetchYata(){
    try {
      const data = await gmGetJson(YATA_URL, 14000);
      return data || 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.toUpperCase()] = 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 (deduct completed sets, show missing to next set)
  function computeForGroup(displayMap, groupOrder){
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    const missing = groupOrder.reduce((acc,[name]) => { const c=Number(displayMap[name]||0); acc[name]=Math.max(0, (sets+1)-c); return acc; }, {});
    const countsMap = groupOrder.reduce((acc,[name])=>{ acc[name]=Number(displayMap[name]||0); return acc; }, {});
    return { sets, countsMap, missing };
  }

  // stock color rules
  function stockClassByQty(q){
    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';
  }

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

  // render helpers
  function renderGroupRows(containerId, order, countsMap, yataMap, missingMap){
    const el = document.getElementById(containerId);
    if (!el) return;
    let html = '';
    for (const [name, meta] of order){
      const av = Number(countsMap[name] || 0);
      const stk = sumYataFor(name, yataMap);
      const miss = Number(missingMap[name] || 0);
      const dotClass = stockClassByQty(stk);
      const best = bestCountryFor(name, yataMap);
      const codeInfo = best.code ? ` | ${best.code}` : '';
      html += `<div class="tbl-row"><div class="col-dot"><div class="dot ${dotClass}"></div></div><div class="col-av">${av}</div><div class="col-st">${stk}${codeInfo}</div><div class="col-mis">${miss>0?miss:'—'}</div><div class="col-name">${escapeHtml(meta.short||name)} ${meta.flag||''} ${meta.code?('|'+meta.code):''}</div></div>`;
    }
    el.innerHTML = html;
  }

  function firstMissingWithBest(order, missingMap, yataMap){
    const res = [];
    for (const [name, meta] of order){
      const miss = Number(missingMap[name] || 0);
      if (miss > 0){
        const best = bestCountryFor(name, yataMap);
        res.push({ name, short: meta.short||name, miss, bestCode: best.code, bestQty: best.qty, flag: meta.flag, code: meta.code });
        if (res.length >= 3) break; // up to 3 suggestions
      }
    }
    return res;
  }

  function flagFor(code){
    const m = { MEX:'🇲🇽', CAN:'🇨🇦', JAP:'🇯🇵', CHI:'🇨🇳', UNI:'🇬🇧', ARG:'🇦🇷', SWI:'🇨🇭', HAW:'🏝️', UAE:'🇦🇪', CAY:'🇰🇾', SOU:'🇿🇦', 'B.B':'🏪' };
    return m[code]||'';
  }

  // render main UI
  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`);
    if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets:${totalSets} Points:${totalPoints}`;

    renderGroupRows(`${PANEL_ID}-flowers-rows`, FLOWERS_ORDER, flowers.countsMap, yataMap, flowers.missing);
    renderGroupRows(`${PANEL_ID}-plush-rows`, PLUSHIES_ORDER, plush.countsMap, yataMap, plush.missing);

    // Xanax row (South Africa)
    const drugsEl = document.getElementById(`${PANEL_ID}-drugs-rows`);
    const xanInv = Number(displayMap[SPECIAL_DRUG] || 0);
    const xanStk = Number(yataMap['SOU']?.[SPECIAL_DRUG] || 0);
    const xanDot = stockClassByQty(xanStk);
    drugsEl.innerHTML = `<div class="tbl-row"><div class="col-dot"><div class="dot ${xanDot}"></div></div><div class="col-av">${xanInv}</div><div class="col-st">${xanStk} | SOU</div><div class="col-mis">—</div><div class="col-name">${escapeHtml(SPECIAL_DRUG)} 🇿🇦</div></div>`;

    // suggestions: up to 3 across flowers+plushies (flowers first)
    const suggestions = [...firstMissingWithBest(FLOWERS_ORDER, flowers.missing, yataMap), ...firstMissingWithBest(PLUSHIES_ORDER, plush.missing, yataMap)];
    const sugParts = [];
    for (const s of suggestions.slice(0,3)){
      if (s.bestQty > 0) sugParts.push(`${s.bestCode||'??'} (${s.short})`);
      else sugParts.push(`${s.short} (no stock)`);
    }
    const flyline = document.getElementById(`${PANEL_ID}-flyline`);
    flyline.textContent = sugParts.length ? `Fly to: ${sugParts.join(' | ')}` : 'Fly to: —';

    // meta already set in footer
  }

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

      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);
      if (statusEl) 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';
    }
  }

  // Torn key prompt
  function askTornKey(){
    const cur = GM_getValue('tornAPIKey','');
    const k = prompt('Enter Torn API key (display + inventory permissions):', cur || '');
    if (k !== null) { GM_setValue('tornAPIKey', String(k).trim()); refreshAll(true); }
  }

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

  // reposition on resize / DOM changes to keep below PDA nav
  function reposition(){
    const root = document.getElementById(PANEL_ID);
    if (!root) return;
    const top = getPDANavHeight();
    root.style.top = top + 'px';
  }
  reposition();
  window.addEventListener('resize', reposition);
  const obs = new MutationObserver(reposition);
  obs.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });

})();