🌺 🐫 Points & Stock Unified (Display + YATA) v3.0

Unified PDA panel: Display-case counts (Inv) + foreign shop stock (Stk) from YATA. One line per country/item: "Mexico Jaguar Plushie (Inv 52 | Stk 2)". Refresh every 45s. Xanax shown only for South Africa.

La data de 11-10-2025. Vezi ultima versiune.

// ==UserScript==
// @name         🌺 🐫 Points & Stock Unified (Display + YATA) v3.0
// @namespace    http://tampermonkey.net/
// @version      3.1.1
// @description  Unified PDA panel: Display-case counts (Inv) + foreign shop stock (Stk) from YATA. One line per country/item: "Mexico  Jaguar Plushie  (Inv 52 | Stk 2)". Refresh every 45s. Xanax shown only for South Africa.
// @author       Nova
// @match        https://www.torn.com/page.php?sid=travel*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      yata.yt
// @connect      api.torn.com
// @run-at       document-end
// ==/UserScript==

(function() {
  'use strict';

  if (!/page\.php\?sid=travel/.test(location.href)) return;

  // ---------- CONFIG ----------
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const REFRESH_MS = 45 * 1000;
  const PANEL_WIDTH = 320;
  const LOW_STOCK = 500; // threshold that marks "low/restocking"

  // Flowers & Plushies (source -> home country readable label)
  const FLOWERS_MAP = {
    "Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico", code: "mex" },
    "Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii", code: "haw" },
    "African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa", code: "sou" },
    "Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan", code: "jap" },
    "Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China", code: "chi" },
    "Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina", code: "arg" },
    "Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland", code: "swi" },
    "Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada", code: "can" },
    "Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom", code: "uni" },
    "Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE", code: "uae" },
    "Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands", code: "cay" }
  };

  const PLUSHIES_MAP = {
    "Sheep Plushie": { short: "Sheep", loc: "B.B 🏪", country: "Torn City", code: null },
    "Teddy Bear Plushie": { short: "Teddy", loc: "B.B 🏪", country: "Torn City", code: null },
    "Kitten Plushie": { short: "Kitten", loc: "B.B 🏪", country: "Torn City", code: null },
    "Jaguar Plushie": { short: "Jaguar", loc: "MX 🇲🇽", country: "Mexico", code: "mex" },
    "Wolverine Plushie": { short: "Wolverine", loc: "CA 🇨🇦", country: "Canada", code: "can" },
    "Nessie Plushie": { short: "Nessie", loc: "UK 🇬🇧", country: "United Kingdom", code: "uni" },
    "Red Fox Plushie": { short: "Fox", loc: "UK 🇬🇧", country: "United Kingdom", code: "uni" },
    "Monkey Plushie": { short: "Monkey", loc: "AR 🇦🇷", country: "Argentina", code: "arg" },
    "Chamois Plushie": { short: "Chamois", loc: "CH 🇨🇭", country: "Switzerland", code: "swi" },
    "Panda Plushie": { short: "Panda", loc: "CN 🇨🇳", country: "China", code: "chi" },
    "Lion Plushie": { short: "Lion", loc: "SA 🇿🇦", country: "South Africa", code: "sou" },
    "Camel Plushie": { short: "Camel", loc: "AE 🇦🇪", country: "UAE", code: "uae" },
    "Stingray Plushie": { short: "Stingray", loc: "KY 🇰🇾", country: "Cayman Islands", code: "cay" }
  };

  // Xanax will be shown only for 'sou' (South Africa)
  const SPECIAL_DRUG = "Xanax";

  // map YATA country codes -> readable name
  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'
  };

  // Build a set of tracked item names for quick filtering
  const TRACKED_ITEMS = new Set([
    ...Object.keys(FLOWERS_MAP),
    ...Object.keys(PLUSHIES_MAP),
    SPECIAL_DRUG
  ]);

  // ---------- UI ----------
  function getPDANavHeight() {
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda');
    return nav ? nav.offsetHeight : 40;
  }

  GM_addStyle(`
    #ptsUnifiedPanel {
      position: fixed;
      top: ${getPDANavHeight()}px;
      left: 18px;
      width: ${PANEL_WIDTH}px;
      background: #0b0b0b;
      color: #eaeaea;
      font-family: "DejaVu Sans Mono", monospace;
      font-size: 11px;
      border: 1px solid #444;
      border-radius: 6px;
      z-index: 999999;
      box-shadow: 0 6px 16px rgba(0,0,0,0.5);
      max-height: 70vh;
      overflow-y: auto;
      line-height: 1.15;
    }
    #ptsUnifiedHeader {
      background: #121212;
      padding: 6px 8px;
      display:flex;
      justify-content:space-between;
      align-items:center;
      border-bottom:1px solid #333;
      user-select:none;
    }
    #ptsUnifiedContent { padding:8px; }
    .section-title { font-weight:700; border-bottom:1px dashed #222; margin:6px 0; padding-bottom:4px; }
    .country-block { margin:6px 0 4px 0; padding-top:6px; border-top:1px dashed #222; }
    .country-title { display:flex; justify-content:space-between; align-items:center; font-weight:700; margin-bottom:6px; }
    .row { display:flex; justify-content:space-between; align-items:center; padding:2px 0; }
    .row .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
    .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
    .itemname { min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .meta { color:#bfc9d6; width:120px; text-align:right; font-size:11px; }
    #pts_status { color:#9ea6b3; margin-bottom:6px; }
    .small { font-size:11px; color:#9ea6b3; margin-top:6px; }
    button.pts-btn { background:#171717; color:#eaeaea; border:1px solid #333; padding:4px 8px; border-radius:4px; cursor:pointer; }
  `);

  const panel = document.createElement('div');
  panel.id = 'ptsUnifiedPanel';
  panel.innerHTML = `
    <div id="ptsUnifiedHeader">
      <div id="ptsTitle">▶ 🌺 🐫 Points & Stock</div>
      <div style="display:flex;gap:6px;align-items:center">
        <button id="ptsRefresh" class="pts-btn">Refresh</button>
        <button id="ptsSetKey" class="pts-btn">Set Key</button>
      </div>
    </div>
    <div id="ptsUnifiedContent">
      <div id="pts_status">Initializing...</div>
      <div class="section-title">Unified Country List</div>
      <div id="ptsCountryList"></div>
      <div class="small">Format: Item — (Inv X | Stk Y) · Inv = display case count · Stk = YATA shop stock</div>
    </div>
  `;
  document.body.appendChild(panel);

  const titleEl = panel.querySelector('#ptsTitle');
  const statusEl = panel.querySelector('#pts_status');
  const countryListEl = panel.querySelector('#ptsCountryList');
  const btnRefresh = panel.querySelector('#ptsRefresh');
  const btnSetKey = panel.querySelector('#ptsSetKey');

  // collapse toggle
  let collapsed = GM_getValue('pts_unified_collapsed', false);
  function updateCollapseUI() {
    const content = panel.querySelector('#ptsUnifiedContent');
    content.style.display = collapsed ? 'none' : 'block';
    titleEl.textContent = (collapsed ? '▶' : '▼') + ' 🌺 🐫 Points & Stock';
    GM_setValue('pts_unified_collapsed', collapsed);
  }
  titleEl.addEventListener('click', () => { collapsed = !collapsed; updateCollapseUI(); });
  updateCollapseUI();

  // ---------- storage for API key ----------
  let apiKey = GM_getValue('tornAPIKey', null);
  btnSetKey.addEventListener('click', () => {
    const k = prompt('Enter Torn user API key (needs "display" permission):', apiKey || '');
    if (k) {
      apiKey = k.trim();
      GM_setValue('tornAPIKey', apiKey);
      statusEl.textContent = 'API key saved.';
      refreshAll(true);
    }
  });

  btnRefresh.addEventListener('click', () => refreshAll(true));

  // ---------- helper: GM XHR GET JSON ----------
  function gmGetJson(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'json',
        onload: function(res) {
          let data = res.response;
          if (!data && res.responseText) {
            try { data = JSON.parse(res.responseText); } catch(e) { return reject(new Error('Invalid JSON')); }
          }
          resolve(data);
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // ---------- fetch display case (only) ----------
  async function fetchDisplayCase() {
    if (!apiKey) throw new Error('No Torn API key set');
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
    const data = await gmGetJson(url);
    if (!data) throw new Error('Torn API no response');
    if (data.error) throw new Error(`Torn API: ${data.error.error || 'error'}`);
    // Data.display is usually object keyed by id; values have name and quantity
    const out = {};
    const displaySrc = data.display || {};
    const entries = Array.isArray(displaySrc) ? displaySrc : Object.values(displaySrc);
    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; // map name -> qty
  }

  // ---------- fetch YATA export ----------
  async function fetchYataExport() {
    const data = await gmGetJson(YATA_URL);
    if (!data) throw new Error('YATA no response');
    return data; // shape: { stocks: { mex: { update, stocks: [ {id,name,quantity,cost}, ... ] }, ... }, timestamp }
  }

  // ---------- merge & render logic ----------
  // dot color rules:
  // - yellow if stock >0 and stock < LOW_STOCK OR (Inv>0 && stock>0 && stock<LOW_STOCK)
  // - green otherwise if stock>0 or Inv>0
  // - red if stock==0 and Inv==0
  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 renderUnified(displayMap, yataData) {
    // Build a map countryCode => { itemName -> stockQtyFromYata }
    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;
        }
      }
    }

    // Build list of country codes we will show:
    // - All codes that appear in YATA stocks
    // - plus home country codes from FLOWERS/PLUSHIES where code not null
    const codesSet = new Set(Object.keys(yataStocks));
    for (const [name, v] of Object.entries(FLOWERS_MAP)) if (v.code) codesSet.add(v.code);
    for (const [name, v] of Object.entries(PLUSHIES_MAP)) if (v.code) codesSet.add(v.code);
    const codes = Array.from(codesSet).sort();

    // For each country code, build an ordered list of tracked items to show
    let html = '';
    for (const code of codes) {
      const countryLabel = COUNTRY_NAMES[code] || code.toUpperCase();
      // collect items in this country:
      // - from YATA stock (if present) filtered to tracked items
      // - plus any tracked items whose home code === code (ensures your home items appear even if YATA doesn't list them)
      const seen = new Set();
      const items = [];

      // from YATA
      const ymap = yataStocks[code] || {};
      for (const name of Object.keys(ymap)) {
        if (!TRACKED_ITEMS.has(name)) continue;
        seen.add(name);
        items.push({ name, stk: ymap[name] });
      }

      // from our home maps (ensure presence even if YATA doesn't list)
      for (const [name, v] of Object.entries(FLOWERS_MAP)) {
        if (v.code === code && !seen.has(name)) {
          seen.add(name);
          items.push({ name, stk: yataStocks[code]?.[name] ?? 0 });
        }
      }
      for (const [name, v] of Object.entries(PLUSHIES_MAP)) {
        if (v.code === code && !seen.has(name)) {
          seen.add(name);
          items.push({ name, stk: yataStocks[code]?.[name] ?? 0 });
        }
      }

      // special: include Xanax only for 'sou'
      if (code === 'sou') {
        if (!seen.has(SPECIAL_DRUG)) {
          const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
          items.push({ name: SPECIAL_DRUG, stk });
          seen.add(SPECIAL_DRUG);
        }
      }

      if (!items.length) continue; // skip countries with no tracked items

      // sort items: flowers (by FLOWERS_MAP order), then plushies, then Xanax
      items.sort((a,b) => {
        if (a.name === SPECIAL_DRUG) return 1;
        if (b.name === SPECIAL_DRUG) return -1;
        const aIsFlower = !!FLOWERS_MAP[a.name];
        const bIsFlower = !!FLOWERS_MAP[b.name];
        if (aIsFlower !== bIsFlower) return aIsFlower ? -1 : 1;
        return a.name.localeCompare(b.name);
      });

      // render country block
      html += `<div class="country-block"><div class="country-title"><div>${escapeHtml(countryLabel)}</div><div style="font-size:11px;color:#9ea6b3">${code}</div></div>`;
      for (const it of items) {
        const inv = Number(displayMap[it.name] ?? 0) || 0;
        const stk = Number(it.stk ?? 0) || 0;
        const dot = `<span class="dot ${dotClass(inv, stk)}"></span>`;
        const meta = `(Inv ${inv} | Stk ${stk})`;
        html += `<div class="row"><div class="left">${dot}<div class="itemname">${escapeHtml(it.name)}</div></div><div class="meta">${meta}</div></div>`;
      }
      html += `</div>`; // country-block
    }

    countryListEl.innerHTML = html || `<div style="color:#999;">No tracked items found.</div>`;
  }

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

  // ---------- master refresh ----------
  let timerHandle = null;
  let lastYataTs = 0;

  async function refreshAll(force=false) {
    statusEl.textContent = 'Updating...';
    try {
      // fetch display and yata in parallel (display may fail if no key)
      const displayPromise = apiKey ? fetchDisplayCaseSafe() : Promise.resolve({});
      const yataPromise = fetchYataSafe();

      const [displayMap, yataData] = await Promise.all([displayPromise, yataPromise]);

      renderUnified(displayMap, yataData);

      statusEl.textContent = `Updated ${new Date().toLocaleTimeString()}`;
    } catch (err) {
      statusEl.textContent = 'Error: ' + (err && err.message ? err.message : err);
    }
  }

  // safe wrappers
  function fetchDisplayCaseSafe() {
    return new Promise((resolve, reject) => {
      if (!apiKey) return resolve({});
      const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
      GM_xmlhttpRequest({
        method: 'GET', url, responseType: 'json',
        onload: res => {
          try {
            const data = res.response || JSON.parse(res.responseText || '{}');
            if (data && data.error) {
              console.warn('Torn API error', data.error);
              return resolve({}); // don't fail entire refresh
            }
            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;
            }
            resolve(out);
          } catch (e) {
            console.warn('Torn parse failed', e);
            resolve({});
          }
        },
        onerror: () => resolve({}),
        ontimeout: () => resolve({})
      });
    });
  }

  function fetchYataSafe() {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: YATA_URL,
        responseType: 'json',
        onload: res => {
          try {
            const data = res.response || JSON.parse(res.responseText || '{}');
            resolve(data);
          } catch (e) {
            console.warn('YATA parse failed', e);
            resolve(null);
          }
        },
        onerror: () => resolve(null),
        ontimeout: () => resolve(null)
      });
    });
  }

  // ---------- init polling ----------
  refreshAll(true);
  timerHandle = setInterval(() => refreshAll(false), REFRESH_MS);

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

})();