🌺🧸 Unified Stock (v4.1)

Unified one-line per item: Display-case (inv) + YATA shop stock (stk). Shows single "fly" suggestion for the lowest-stock item. Refresh 45s. Works while flying (display selection used).

As of 11.10.2025. See ბოლო ვერსია.

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         🌺🧸 Unified Stock (v4.1)
// @namespace    http://tampermonkey.net/
// @version      4.1
// @description  Unified one-line per item: Display-case (inv) + YATA shop stock (stk). Shows single "fly" suggestion for the lowest-stock item. Refresh 45s. Works while flying (display selection used).
// @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 for "low abroad" (yellow)

  // tracked items (flowers + plushies) and their YATA country code where they belong (if applicable)
  const FLOWERS_MAP = {
    "Dahlia": { code: 'mex', loc: 'MX 🇲🇽' },
    "Orchid": { code: 'haw', loc: 'HW 🏝️' },
    "African Violet": { code: 'sou', loc: 'SA 🇿🇦' },
    "Cherry Blossom": { code: 'jap', loc: 'JP 🇯🇵' },
    "Peony": { code: 'chi', loc: 'CN 🇨🇳' },
    "Ceibo Flower": { code: 'arg', loc: 'AR 🇦🇷' },
    "Edelweiss": { code: 'swi', loc: 'CH 🇨🇭' },
    "Crocus": { code: 'can', loc: 'CA 🇨🇦' },
    "Heather": { code: 'uni', loc: 'UK 🇬🇧' },
    "Tribulus Omanense": { code: 'uae', loc: 'AE 🇦🇪' },
    "Banana Orchid": { code: 'cay', loc: 'KY 🇰🇾' }
  };

  const PLUSHIES_MAP = {
    "Sheep Plushie": { code: null, loc: 'B.B 🏪' },
    "Teddy Bear Plushie": { code: null, loc: 'B.B 🏪' },
    "Kitten Plushie": { code: null, loc: 'B.B 🏪' },
    "Jaguar Plushie": { code: 'mex', loc: 'MX 🇲🇽' },
    "Wolverine Plushie": { code: 'can', loc: 'CA 🇨🇦' },
    "Nessie Plushie": { code: 'uni', loc: 'UK 🇬🇧' },
    "Red Fox Plushie": { code: 'uni', loc: 'UK 🇬🇧' },
    "Monkey Plushie": { code: 'arg', loc: 'AR 🇦🇷' },
    "Chamois Plushie": { code: 'swi', loc: 'CH 🇨🇭' },
    "Panda Plushie": { code: 'chi', loc: 'CN 🇨🇳' },
    "Lion Plushie": { code: 'sou', loc: 'SA 🇿🇦' },
    "Camel Plushie": { code: 'uae', loc: 'AE 🇦🇪' },
    "Stingray Plushie": { code: 'cay', loc: 'KY 🇰🇾' }
  };

  const SPECIAL_DRUG = "Xanax"; // only show for 'sou' (South Africa)

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

  const TRACKED_ITEMS = [
    ...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(`
    #uniStockPanel { 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; padding-bottom:8px; }
    #uniHeader { background:#121212; padding:6px 8px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #333; user-select:none; }
    #uniContent { padding:8px; }
    .uni-row { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:2px 0; white-space:nowrap; }
    .uni-left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    .dot { width:10px; height:10px; border-radius:50%; display:inline-block; flex:0 0 10px; }
    .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
    .itemname { min-width:0; overflow:hidden; text-overflow:ellipsis; }
    .meta { color:#bfc9d6; width:140px; text-align:right; font-size:11px; flex:0 0 140px; }
    #uni_status { color:#9ea6b3; margin-bottom:8px; }
    .fly { color:#9ad0ff; font-weight:700; margin-left:8px; }
    .small { font-size:11px; color:#9ea6b3; margin-top:6px; }
    .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 = 'uniStockPanel';
  panel.innerHTML = `
    <div id="uniHeader">
      <div id="uniTitle">▶ 🌺🧸 Unified Stock</div>
      <div style="display:flex;gap:6px;align-items:center">
        <button id="uniRefresh" class="pts-btn">Refresh</button>
        <button id="uniSetKey" class="pts-btn">Set Key</button>
      </div>
    </div>
    <div id="uniContent">
      <div id="uni_status">Initializing...</div>
      <div id="uniList"></div>
      <div class="small">Format: Item — (inv: X | stk: Y) · inv = display case count · stk = foreign shop stock · lowest item shows "✈ Fly: CODE"</div>
    </div>`;
  document.body.appendChild(panel);

  const titleEl = panel.querySelector('#uniTitle');
  const statusEl = panel.querySelector('#uni_status');
  const uniListEl = panel.querySelector('#uniList');
  const btnRefresh = panel.querySelector('#uniRefresh');
  const btnSetKey = panel.querySelector('#uniSetKey');

  // collapse toggle
  let collapsed = GM_getValue('uni_collapsed', false);
  function updateCollapse(){
    const content = panel.querySelector('#uniContent');
    content.style.display = collapsed ? 'none' : 'block';
    titleEl.textContent = (collapsed ? '▶' : '▼') + ' 🌺🧸 Unified Stock';
    GM_setValue('uni_collapsed', collapsed);
  }
  titleEl.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
  updateCollapse();

  // API key storage
  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 JSON
  function gmGetJson(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url, responseType: 'json',
        onload: (res) => {
          let d = res.response;
          if (!d && res.responseText) {
            try { d = JSON.parse(res.responseText); } catch(e) { return reject(new Error('Invalid JSON')); }
          }
          resolve(d);
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // fetch display-case (only)
  async function fetchDisplayCaseSafe() {
    if (!apiKey) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return {};
      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;
      }
      return out;
    } catch (e) {
      console.warn('display fetch failed', e);
      return {};
    }
  }

  // fetch YATA export
  async function fetchYataSafe() {
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (e) {
      console.warn('YATA fetch failed', e);
      return null;
    }
  }

  // compute dot class: green if inv>0 or stk high; yellow if stk>0 && stk < LOW_STOCK; red otherwise
  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';
  }

  // build unified list, find lowest foreign-stock item and best country to fly to for it
  function buildUnifiedList(displayMap, yataData) {
    // create map: countryCode -> { itemName -> stkQty }
    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;
        }
      }
    }

    // For each tracked item, compute total foreign stock (sum across countries)
    const itemInfo = []; // { name, inv, totalStk, bestCountryForThisItem (code), bestCountryStock }
    for (const name of TRACKED_ITEMS) {
      if (name === SPECIAL_DRUG) {
        // show only for sou
        const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
        const inv = Number(displayMap[name] ?? 0) || 0;
        itemInfo.push({ name, inv, totalStk: stk, bestCode: stk > 0 ? 'sou' : null, bestStk: stk });
        continue;
      }
      let total = 0;
      let bestCode = null;
      let bestStk = 0;
      for (const [code, map] of Object.entries(yataStocks)) {
        const q = Number(map[name] ?? 0) || 0;
        total += q;
        if (q > bestStk) { bestStk = q; bestCode = code; }
      }
      const inv = Number(displayMap[name] ?? 0) || 0;
      itemInfo.push({ name, inv, totalStk: total, bestCode, bestStk });
    }

    // find the lowest foreign-stock item among tracked (tie-breaker: prefer items you don't own)
    // exclude items whose totalStk is undefined (when YATA missing) — treat as 0
    let lowest = null;
    for (const it of itemInfo) {
      const stk = Number(it.totalStk ?? 0);
      if (!lowest) { lowest = it; continue; }
      const lowestStk = Number(lowest.totalStk ?? 0);
      if (stk < lowestStk) lowest = it;
      else if (stk === lowestStk) {
        // if tie, prefer item with smaller inv (you own less)
        if ((it.inv || 0) < (lowest.inv || 0)) lowest = it;
      }
    }

    return { itemInfo, lowest };
  }

  // render one-line per item
  function renderUnified(displayMap, yataData) {
    const { itemInfo, lowest } = buildUnifiedList(displayMap, yataData);

    // build per-country grouping for visual grouping (we will simply print items in a sensible order)
    // The user asked "one line for every single flower or plushie" — we'll list items grouped by their home country code (if any), then any remaining.
    // build an ordered list: for each country code that appears in our maps, list the items whose home code === that code; then add remaining items (no home code).
    const countryOrder = [];
    const seenItems = new Set();
    // collect codes from maps
    const addIf = (code) => { if (code && !countryOrder.includes(code)) countryOrder.push(code); };
    for (const [n, v] of Object.entries(FLOWERS_MAP)) addIf(v.code);
    for (const [n, v] of Object.entries(PLUSHIES_MAP)) addIf(v.code);
    // ensure 'sou' near end so Xanax shows last; we'll still include sou earlier too — but final display will place Xanax at bottom by special handling
    // build mapping name->info
    const infoByName = {};
    for (const it of itemInfo) infoByName[it.name] = it;

    // prepare final lines array
    const lines = [];

    // helper to push items for a code
    function pushForCode(code) {
      // flowers with that code
      for (const [name, v] of Object.entries(FLOWERS_MAP)) {
        if (v.code === code) {
          const it = infoByName[name];
          if (it) { lines.push({ code, ...it }); seenItems.add(name); }
        }
      }
      // plushies with that code
      for (const [name, v] of Object.entries(PLUSHIES_MAP)) {
        if (v.code === code) {
          const it = infoByName[name];
          if (it) { lines.push({ code, ...it }); seenItems.add(name); }
        }
      }
    }

    // push by country order
    for (const code of countryOrder) pushForCode(code);

    // push any tracked items not pushed yet (Sheep/Teddy/Kitten or others without code)
    for (const name of TRACKED_ITEMS) {
      if (seenItems.has(name)) continue;
      if (name === SPECIAL_DRUG) continue; // skip, will add at bottom
      const it = infoByName[name];
      if (it) { lines.push({ code: null, ...it }); seenItems.add(name); }
    }

    // finally add Xanax at bottom if present
    const xan = infoByName[SPECIAL_DRUG];
    if (xan) lines.push({ code: 'sou', ...xan });

    // render lines
    let html = '';
    for (const line of lines) {
      const inv = Number(line.inv || 0);
      const stk = Number(line.totalStk || 0);
      const cls = dotClass(inv, stk);
      const flyNote = (lowest && line.name === lowest.name && lowest.bestCode) ? ` <span class="fly">✈ Fly: ${ (COUNTRY_NAMES[lowest.bestCode] ? lowest.bestCode.toUpperCase() : lowest.bestCode) }</span>` : '';
      const meta = `(inv: ${inv} | stk: ${stk})`;
      html += `<div class="uni-row"><div class="uni-left"><span class="dot ${cls}"></span><div class="itemname">${escapeHtml(line.name)}</div></div><div class="meta">${meta}${flyNote}</div></div>`;
    }

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

  // ---------- master refresh ----------
  let timer = null;
  async function refreshAll(force=false) {
    statusEl.textContent = 'Updating...';
    try {
      const [displayMap, yata] = await Promise.all([ fetchDisplayCaseSafe(), fetchYataSafe() ]);
      renderUnified(displayMap || {}, yata || null);
      statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
    } catch (e) {
      console.warn('refreshAll error', e);
      statusEl.textContent = 'Update error';
    }
  }

  // safe fetch wrappers already defined above

  // init
  refreshAll(true);
  timer = setInterval(() => refreshAll(false), REFRESH_MS);
  window.addEventListener('beforeunload', () => { if (timer) clearInterval(timer); });

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

})();