🌺 🐫 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.

Ekde 2025/10/11. Vidu La ĝisdata versio.

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

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 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.

(I already have a user script manager, let me install it!)

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         🌺 🐫 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);
  });

})();