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

Slim unified panel above PDA: display + inventory, YATA stock (public endpoint), sets, points, missing, travel suggestions. Compact single-line rows. Xanax shown at bottom. 45s refresh.

Από την 13/10/2025. Δείτε την τελευταία έκδοση.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         🌺🧸 Unified Display & Points (Above PDA) v3.4.1
// @namespace    http://tampermonkey.net/
// @version      3.4.1
// @description  Slim unified panel above PDA: display + inventory, YATA stock (public endpoint), sets, points, missing, travel suggestions. Compact single-line rows. Xanax shown at bottom. 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_1';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';

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

  // ----- styling: slim header & narrow width so it doesn't hide navigation -----
  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: 210px; z-index: 999999; font-family: "DejaVu Sans Mono", monospace; font-size:11px; pointer-events:auto; }
    #${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:80vh; }
    #${PANEL_ID} .header { display:flex; align-items:center; gap:8px; padding:6px 8px; background:#121212; cursor:pointer; user-select:none; font-weight:700; height:30px; box-sizing:border-box; }
    #${PANEL_ID} .title { font-size:12px; line-height:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
    #${PANEL_ID} .controls { margin-left:auto; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} button { background:#171717; color:#eaeaea; border:1px solid #333; padding:3px 6px; border-radius:4px; cursor:pointer; font-size:11px; }
    #${PANEL_ID} .body { padding:6px 6px 8px; display:none; font-size:11px; line-height:1.05; }
    #${PANEL_ID} .summary { font-weight:700; margin-bottom:6px; color:#dfe7ff; font-size:12px; }
    .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); }
    .tbl-row { display:flex; gap:6px; align-items:center; padding:3px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .col-av { flex:0 0 42px; text-align:right; color:#cfe8c6; }
    .col-st { flex:0 0 56px; text-align:right; color:#f7b3b3; }
    .col-miss { flex:0 0 46px; 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%; flex:0 0 10px; margin-right:6px; }
    .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; } .col-st{ flex:0 0 44px; } .col-av{ flex:0 0 36px; } .col-miss{ flex:0 0 40px; } }
  `);

  // ----- DOM build -----
  function createPanel() {
    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" 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" class="summary">Waiting...</div>

          <div class="section" id="${PANEL_ID}-flowers">
            <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-miss">MIS</div><div class="col-name">Flower</div></div>
            <div id="${PANEL_ID}-flowers-rows"></div>
          </div>

          <div class="section" id="${PANEL_ID}-plush" 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-miss">MIS</div><div class="col-name">Plushie</div></div>
            <div id="${PANEL_ID}-plush-rows"></div>
          </div>

          <div class="section" id="${PANEL_ID}-drugs" 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-miss"> </div><div class="col-name">Drugs</div></div>
            <div id="${PANEL_ID}-drugs-rows"></div>
          </div>

          <div id="${PANEL_ID}-fly" class="note"></div>
          <div id="${PANEL_ID}-meta" class="note"></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`)) 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';
    GM_setValue(`${PANEL_ID}-collapsed`, !open);
  }

  // ----- network -----
  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 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 public endpoint -----
  async function fetchYata() {
    try {
      const res = await gmGetJson(YATA_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 (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 thresholds -----
  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';
  }

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

  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 codePart = bestCountryFor(name, yataMap).code ? ` ${bestCountryFor(name, yataMap).code.toUpperCase()}` : '';
      html += `<div class="tbl-row"><div class="dot ${dotClass}"></div><div class="col-av">${av}</div><div class="col-st">${stk}${codePart? ' |'+codePart.trim():''}</div><div class="col-miss">${miss>0?miss:'—'}</div><div class="col-name">${escapeHtml(meta.short||name)} ${meta.flag||''}</div></div>`;
    }
    el.innerHTML = html;
  }

  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: ${flowers.sets} | Plush: ${plush.sets}`;

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

    // drugs - Xanax (SOU)
    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="dot ${xanDot}"></div><div class="col-av">${xanInv}</div><div class="col-st">${xanStk} | SOU</div><div class="col-miss">—</div><div class="col-name">${escapeHtml(SPECIAL_DRUG)} 🇿🇦</div></div>`;

    // flight suggestion: choose first missing flower, then first missing plush
    const lowFlower = firstMissingWithBest(FLOWERS_ORDER, flowers.missing, yataMap);
    const lowPlush = firstMissingWithBest(PLUSHIES_ORDER, plush.missing, yataMap);
    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 += ' | ';
      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`).textContent = flyHtml || '';

    document.getElementById(`${PANEL_ID}-meta`).textContent = `Refresh: ${Math.round(REFRESH_MS/1000)}s | Points per set: ${POINTS_PER_SET}`;
  }

  function firstMissingWithBest(order, missingMap, yataMap) {
    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`);
      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 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); }
  }

  // ----- boot -----
  createPanel();
  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 top for dynamic PDA nav height (on load and resize)
  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 observer = new MutationObserver(reposition);
  observer.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });

})();