🌺 🐫 Points & Stock Tracker (Unified PDA Panel) v4.0

Unified panel: your flower/plush totals + foreign shop stock (YATA). Single refresh every 45s. Works while flying. Xanax shown for South Africa at bottom.

От 11.10.2025. Виж последната версия.

// ==UserScript==
// @name         🌺 🐫 Points & Stock Tracker (Unified PDA Panel) v4.0
// @namespace    http://tampermonkey.net/
// @version      4.0.0
// @description  Unified panel: your flower/plush totals + foreign shop stock (YATA). Single refresh every 45s. Works while flying. Xanax shown for South Africa at bottom.
// @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 POLL_MS = 45 * 1000; // unified 45s refresh
  const PANEL_WIDTH = 320;

  // Items of interest
  const FLOWERS_MAP = {
    "Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico" },
    "Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii" },
    "African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa" },
    "Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan" },
    "Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China" },
    "Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina" },
    "Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland" },
    "Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada" },
    "Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom" },
    "Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE" },
    "Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands" }
  };

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

  // tracked set for quick lookup
  const TRACKED_SET = new Set([
    ...Object.keys(FLOWERS_MAP),
    ...Object.keys(PLUSHIES_MAP),
    "Xanax"
  ]);

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

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

  GM_addStyle(`
    #ptsStockPanel {
      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;
    }
    #ptsHeader {
      background: #121212;
      padding: 6px 8px;
      cursor: pointer;
      font-weight:700;
      font-size:13px;
      border-bottom:1px solid #333;
      user-select:none;
      display:flex;
      justify-content:space-between;
      align-items:center;
      gap:8px;
    }
    #ptsContent { padding:8px; display:block; }
    .controls { margin-bottom:8px; display:flex; gap:6px; flex-wrap:wrap; }
    .controls button {
      font-size:11px;
      padding:4px 8px;
      background:#171717;
      color:#eaeaea;
      border:1px solid #333;
      border-radius:4px;
      cursor:pointer;
    }
    .summary-line { font-weight:700; margin-bottom:6px; font-size:12px; color:#dfe7ff; }
    .section-title { font-weight:700; margin-top:6px; margin-bottom:6px; font-size:11px; border-bottom:1px dashed #222; padding-bottom:4px; }
    .row { display:flex; align-items:center; gap:8px; padding:2px 0; white-space:nowrap; }
    .name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .count { width:48px; text-align:right; font-weight:700; }
    .avail { width:80px; text-align:right; color:#bfc9d6; font-size:11px; }
    .dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; vertical-align:middle; }
    .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
    .country-block { margin-bottom:6px; padding-top:6px; border-top:1px dashed #222; }
    .country-title { display:flex; justify-content:space-between; font-weight:700; font-size:11px; margin-bottom:4px; }
    .small-note { font-size:11px; color:#9ea6b3; margin-top:6px; }
  `);

  // build DOM
  const panel = document.createElement('div');
  panel.id = 'ptsStockPanel';
  panel.innerHTML = `
    <div id="ptsHeader">
      <div id="ptsHeaderLeft">▶ 🌺 🐫 Points & Stock</div>
      <div id="ptsHeaderRight">
        <button id="pts_refresh">Refresh</button>
        <button id="pts_setkey">Set API Key</button>
        <button id="pts_toggle">Collapse</button>
      </div>
    </div>
    <div id="ptsContent">
      <div class="controls">
        <div id="pts_status" style="flex:1 1 auto; color:#bdbdbd;">Initializing...</div>
      </div>

      <div id="myInventorySection">
        <div class="section-title">My Inventory & Display</div>
        <div id="mySummary" class="summary-line"></div>
        <div id="myList"></div>
      </div>

      <div id="foreignSection">
        <div class="section-title">Foreign Stock (YATA)</div>
        <div id="foreignList"></div>
      </div>

      <div class="small-note">Data sync: Torn (your items) + YATA (shop stock). Updates every 45s.</div>
    </div>
  `;
  document.body.appendChild(panel);

  // elements
  const headerLeft = panel.querySelector('#ptsHeaderLeft');
  const headerRight = panel.querySelector('#ptsHeaderRight');
  const btnRefresh = panel.querySelector('#pts_refresh');
  const btnSetKey = panel.querySelector('#pts_setkey');
  const btnToggle = panel.querySelector('#pts_toggle');
  const ptsContent = panel.querySelector('#ptsContent');
  const statusEl = panel.querySelector('#pts_status');
  const mySummary = panel.querySelector('#mySummary');
  const myList = panel.querySelector('#myList');
  const foreignList = panel.querySelector('#foreignList');

  // collapse
  let collapsed = GM_getValue('pts_collapsed', false);
  function updateCollapse() {
    ptsContent.style.display = collapsed ? 'none' : 'block';
    headerLeft.textContent = (collapsed ? '▶' : '▼') + ' 🌺 🐫 Points & Stock';
    btnToggle.textContent = collapsed ? 'Expand' : 'Collapse';
    GM_setValue('pts_collapsed', collapsed);
  }
  updateCollapse();
  headerLeft.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
  btnToggle.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });

  // API key handling (user key)
  let apiKey = GM_getValue('tornAPIKey', null);
  btnSetKey.addEventListener('click', async () => {
    const k = prompt('Enter your Torn API key (user key; items permission recommended):', apiKey || '');
    if (k) {
      apiKey = k.trim();
      GM_setValue('tornAPIKey', apiKey);
      statusEl.textContent = 'API key saved.';
      await refreshAll(true);
    }
  });

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

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

  // Torn fetch: try items (works while flying), fallback to display+inventory if present
  async function fetchTornItems() {
    if (!apiKey) {
      throw new Error('No API key');
    }
    // prefer items selection (flight-safe)
    const urlItems = `https://api.torn.com/user/?selections=items,display,inventory&key=${encodeURIComponent(apiKey)}`;
    // requesting items plus display+inventory - server will return available selections; we handle presence.
    const data = await gmGetJson(urlItems);
    if (data.error) throw new Error(`Torn API error: ${data.error.error} (${data.error.code})`);

    // combine sources robustly
    const itemsAgg = {};

    // helper to add entries (handles array or object)
    function addFrom(src) {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries) {
        if (!e) continue;
        // some item objects in /items contain 'name' and 'quantity'; display/inventory entries vary
        const name = e.name || e.item_name || e.title || e.item || null;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? e.count ?? 0) || 0;
        if (!name) continue;
        itemsAgg[name] = (itemsAgg[name] || 0) + qty;
      }
    }

    // items selection often appears under data.items
    addFrom(data.items);
    addFrom(data.display);
    addFrom(data.inventory);

    return itemsAgg; // map: name -> total qty
  }

  // YATA fetch
  async function fetchYataExport() {
    const data = await gmGetJson(YATA_URL);
    // structure: { stocks: { mex: {update, stocks: [ {id,name,quantity,cost}, ... ] }, ... }, timestamp }
    return data;
  }

  // color helper
  function dotClass(q) {
    if (q <= 0) return 'r';
    if (q <= 10) return 'y';
    return 'g';
  }

  // Render: My inventory (flowers & plushies)
  function renderMyInventory(itemsAgg) {
    // build totals for tracked items (flowers + plushies)
    const flowerShorts = Object.values(FLOWERS_MAP).map(o => o.short);
    const plushShorts = Object.values(PLUSHIES_MAP).map(o => o.short);

    // produce counts keyed by short name as per original UI
    const flowerTotals = {};
    const plushTotals = {};

    // initialize
    flowerShorts.forEach(s => flowerTotals[s] = 0);
    plushShorts.forEach(s => plushTotals[s] = 0);

    // map full names to short names
    for (const full of Object.keys(FLOWERS_MAP)) {
      const short = FLOWERS_MAP[full].short;
      const q = itemsAgg[full] || 0;
      flowerTotals[short] = (flowerTotals[short] || 0) + q;
    }
    for (const full of Object.keys(PLUSHIES_MAP)) {
      const short = PLUSHIES_MAP[full].short;
      const q = itemsAgg[full] || 0;
      plushTotals[short] = (plushTotals[short] || 0) + q;
    }

    // compute sets and remainder
    const fCountsArr = Object.values(flowerTotals);
    const pCountsArr = Object.values(plushTotals);
    const fSets = fCountsArr.length ? Math.min(...fCountsArr) : 0;
    const pSets = pCountsArr.length ? Math.min(...pCountsArr) : 0;
    const totalSets = fSets + pSets;
    const totalPoints = totalSets * 10;

    // summary
    mySummary.textContent = `Total sets: ${totalSets} | Points: ${totalPoints}`;

    // render lists
    let html = '';
    html += `<div style="font-weight:700; margin-bottom:6px;">Flowers — sets: ${fSets}</div>`;
    Object.keys(flowerTotals).forEach(name => {
      const total = flowerTotals[name] || 0;
      const dclass = dotClass(total);
      html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(name)}</div><div class="count">${total}</div><div class="avail">${getFlowerLoc(name)}</div></div>`;
    });

    html += `<div style="font-weight:700; margin:8px 0 6px;">Plushies — sets: ${pSets}</div>`;
    Object.keys(plushTotals).forEach(name => {
      const total = plushTotals[name] || 0;
      const dclass = dotClass(total);
      html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(name)}</div><div class="count">${total}</div><div class="avail">${getPlushLoc(name)}</div></div>`;
    });

    myList.innerHTML = html;
  }

  function getFlowerLoc(short) {
    // find by short in FLOWERS_MAP
    for (const full in FLOWERS_MAP) {
      if (FLOWERS_MAP[full].short === short) return FLOWERS_MAP[full].loc;
    }
    return '';
  }
  function getPlushLoc(short) {
    for (const full in PLUSHIES_MAP) {
      if (PLUSHIES_MAP[full].short === short) return PLUSHIES_MAP[full].loc;
    }
    return '';
  }

  // Render foreign stock: only show tracked items (flowers, plushies, and Xanax only in South Africa)
  function renderForeignStock(yataData) {
    if (!yataData || !yataData.stocks) {
      foreignList.innerHTML = `<div style="color:#999;">No foreign stock data.</div>`;
      return;
    }
    const stocks = yataData.stocks;
    const countryCodes = Object.keys(stocks).sort();

    let html = '';
    for (const code of countryCodes) {
      const c = stocks[code];
      if (!c) continue;
      const items = Array.isArray(c.stocks) ? c.stocks : [];
      // filter tracked items; Xanax only for 'sou'
      const filtered = items.filter(it => {
        if (!it || !it.name) return false;
        if (!TRACKED_SET.has(it.name)) return false;
        if (it.name === 'Xanax' && code !== 'sou') return false;
        return true;
      });
      if (!filtered.length) continue;

      const cname = COUNTRY_NAMES[code] || code.toUpperCase();
      const upd = c.update ? new Date(c.update * 1000).toUTCString() : '';
      html += `<div class="country-block"><div class="country-title"><div>${escapeHtml(cname)}</div><div style="font-size:11px;color:#9ea6b3">${upd}</div></div>`;

      // show flowers then plushies (keep grouping readable)
      // sort by tracked order: flowers first then plushies then Xanax
      filtered.sort((a,b) => {
        const aIsFlower = !!FLOWERS_MAP[a.name];
        const bIsFlower = !!FLOWERS_MAP[b.name];
        if (aIsFlower !== bIsFlower) return aIsFlower ? -1 : 1;
        if (a.name === 'Xanax') return 1;
        if (b.name === 'Xanax') return -1;
        return a.name.localeCompare(b.name);
      });

      for (const it of filtered) {
        const q = Number(it.quantity ?? 0);
        const dclass = dotClass(q);
        const availText = q <= 0 ? 'Out' : (q <= 10 ? `${q} low` : `${q} available`);
        html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(it.name)}</div><div class="count">${availText}</div><div class="avail">${code}</div></div>`;
      }

      html += `</div>`;
    }

    // ensure Xanax for SA appears at bottom if present in data but not included above
    // already included by filter for code === 'sou' so no extra step needed.

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

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

  // master refresh: fetch Torn and YATA, then render both
  let pollHandle = null;
  let lastYataTs = 0;
  async function refreshAll(force=false) {
    statusEl.textContent = 'Updating...';
    try {
      // fetch both in parallel
      const tornPromise = apiKey ? fetchTornItems().catch(e => { throw new Error('Torn:' + e.message); }) : Promise.reject(new Error('No Torn API key set'));
      const yataPromise = fetchYataExport().catch(e => { throw new Error('YATA:' + (e.message || e)); });

      const [itemsAgg, yataData] = await Promise.allSettled([tornPromise, yataPromise])
        .then(results => {
          // results[0] for Torn, results[1] for YATA
          const tornRes = results[0];
          const yataRes = results[1];

          if (tornRes.status === 'rejected') {
            // show note but continue with empty inventory
            statusEl.textContent = `Torn fetch failed: ${tornRes.reason.message || tornRes.reason}`;
          }
          if (yataRes.status === 'rejected') {
            statusEl.textContent = (statusEl.textContent ? statusEl.textContent + ' | ' : '') + `YATA fetch failed`;
          }
          return [
            tornRes.status === 'fulfilled' ? tornRes.value : {},
            yataRes.status === 'fulfilled' ? yataRes.value : null
          ];
        });

      // render my inventory
      renderMyInventory(itemsAgg || {});

      // only re-render foreign stock if new payload or force
      if (yataData) {
        if (force || !yataData.timestamp || yataData.timestamp !== lastYataTs) {
          renderForeignStock(yataData);
          lastYataTs = yataData.timestamp || lastYataTs;
        }
      }

      // status
      const tNow = new Date().toLocaleTimeString();
      statusEl.textContent = `Updated: ${tNow}`;
    } catch (err) {
      statusEl.textContent = `Update error: ${err.message || err}`;
    }
  }

  function startPolling() {
    if (pollHandle) return;
    refreshAll(true);
    pollHandle = setInterval(() => refreshAll(false), POLL_MS);
  }
  function stopPolling() {
    if (!pollHandle) return;
    clearInterval(pollHandle);
    pollHandle = null;
  }

  // init: if api key stored, start
  if (apiKey) {
    startPolling();
  } else {
    statusEl.textContent = 'No Torn API key set. Click Set API Key.';
    // still fetch YATA so foreign stock visible without key
    fetchYataExport().then(data => renderForeignStock(data)).catch(()=>{/*ignore*/});
  }

  // store cleanly when unloading
  window.addEventListener('beforeunload', () => stopPolling());

})();