🌺🧸 Unified Stock (v4.1) - One-line + Fly Suggestion + Points

One-line per item: display-case inv + foreign stk + flag/code. Shows Sets & Points, and single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.

2025-10-11 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         🌺🧸 Unified Stock (v4.1) - One-line + Fly Suggestion + Points
// @namespace    http://tampermonkey.net/
// @version      4.1.1.4
// @description  One-line per item: display-case inv + foreign stk + flag/code. Shows Sets & Points, and single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.
// @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)
  const POINTS_PER_SET = 10;

  // ===== TRACKED ITEMS =====
  // Flowers and Plushies with their home country code and a short loc with flag
  const FLOWERS_ORDER = [
    ["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_ORDER = [
    ["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"; // show only for 'sou'

  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_LIST = [
    ...FLOWERS_ORDER.map(x=>x[0]),
    ...PLUSHIES_ORDER.map(x=>x[0]),
    SPECIAL_DRUG
  ];

  // ===== UI styling =====
  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; }
    #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; }
    #uniHeaderLeft { display:flex; flex-direction:column; gap:2px; }
    #titleRow { font-weight:700; font-size:13px; }
    #pointsRow { color:#bfc9d6; font-size:11px; }
    .uni-row { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:4px 0; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .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:150px; text-align:right; font-size:11px; flex:0 0 150px; }
    #uni_status { color:#9ea6b3; margin:6px 0; }
    .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; }
  `);

  // ===== DOM build =====
  const panel = document.createElement('div');
  panel.id = 'uniStockPanel';
  panel.innerHTML = `
    <div id="uniHeader">
      <div id="uniHeaderLeft">
        <div id="titleRow">▶ 🌺🧸 Unified Stock</div>
        <div id="pointsRow">Sets: - | Points: -</div>
      </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 to: CODE 🇿🇦"</div>
    </div>
  `;
  document.body.appendChild(panel);

  const titleRow = panel.querySelector('#titleRow');
  const pointsRow = panel.querySelector('#pointsRow');
  const statusEl = panel.querySelector('#uni_status');
  const listEl = 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';
    titleRow.textContent = (collapsed ? '▶' : '▼') + ' 🌺🧸 Unified Stock';
    GM_setValue('uni_collapsed', collapsed);
  }
  titleRow.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
  updateCollapse();

  // API key save/load
  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: (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 =====
  async function fetchYataSafe() {
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (e) {
      console.warn('YATA fetch failed', e);
      return null;
    }
  }

  // ===== compute sets & points =====
  function computeSets(displayMap) {
    // For flowers, a set requires one of each flower in FLOWERS_ORDER
    const flowerCounts = FLOWERS_ORDER.map(([name]) => Number(displayMap[name] || 0));
    const plushCounts  = PLUSHIES_ORDER.map(([name]) => Number(displayMap[name] || 0));
    const fSets = flowerCounts.length ? Math.min(...flowerCounts) : 0;
    const pSets = plushCounts.length ? Math.min(...plushCounts) : 0;
    const totalSets = (isFinite(fSets) ? fSets : 0) + (isFinite(pSets) ? pSets : 0);
    const points = totalSets * POINTS_PER_SET;
    return { totalSets, points, fSets, pSets };
  }

  // ===== dot color =====
  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 & fly suggestion =====
  function buildUnified(displayMap, yataData) {
    // build map countryCode -> {itemName: stk}
    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 totalStk (sum across all countries) and bestCountry (highest stk for that item)
    const itemsInfo = [];

    for (const name of TRACKED_LIST) {
      if (name === SPECIAL_DRUG) {
        const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
        const inv = Number(displayMap[name] ?? 0) || 0;
        itemsInfo.push({ name, inv, totalStk: stk, bestCode: stk > 0 ? 'sou' : null, bestStk: stk, loc: 'SA 🇿🇦' });
        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; }
      }
      // determine loc from our maps
      let loc = '';
      const f = FLOWERS_ORDER.find(x => x[0] === name);
      const p = PLUSHIES_ORDER.find(x => x[0] === name);
      if (f) loc = f[1].loc;
      else if (p) loc = p[1].loc;
      const inv = Number(displayMap[name] ?? 0) || 0;
      itemsInfo.push({ name, inv, totalStk: total, bestCode, bestStk, loc });
    }

    // find lowest by display count (inv). Prefer items with inv==0. Tie-breaker: smaller totalStk then lexicographic.
    let lowest = null;
    for (const it of itemsInfo) {
      if (!lowest) { lowest = it; continue; }
      if ((it.inv || 0) < (lowest.inv || 0)) lowest = it;
      else if ((it.inv || 0) === (lowest.inv || 0)) {
        if ((it.totalStk || 0) < (lowest.totalStk || 0)) lowest = it;
      }
    }

    return { itemsInfo, lowest, yataStocks };
  }

  // ===== render one-line layout =====
  function renderAll(displayMap, yataData) {
    // compute points
    const { totalSets, points } = computeSets(displayMap);
    pointsRow.textContent = `Sets: ${totalSets} | Points: ${points}`;

    const { itemsInfo, lowest } = buildUnified(displayMap, yataData);

    // prepare ordered lines: flowers in FLOWERS_ORDER, then plushies in PLUSHIES_ORDER, then Xanax
    const order = [];
    for (const [name] of FLOWERS_ORDER) order.push(name);
    for (const [name] of PLUSHIES_ORDER) order.push(name);
    order.push(SPECIAL_DRUG);

    const infoByName = {};
    for (const it of itemsInfo) infoByName[it.name] = it;

    let html = '';
    for (const name of order) {
      const it = infoByName[name];
      if (!it) continue;
      const inv = Number(it.inv || 0);
      const stk = Number(it.totalStk || 0);
      const cls = dotClass(inv, stk);
      const locText = it.loc || (it.bestCode ? (COUNTRY_NAMES[it.bestCode] ? `${COUNTRY_NAMES[it.bestCode]} ${it.bestCode.toUpperCase()}` : it.bestCode.toUpperCase()) : '');
      const codeText = it.bestCode ? ` ${it.bestCode.toUpperCase()}` : '';
      const flagPart = it.loc ? ` ${it.loc}` : (it.bestCode ? ` ${it.bestCode.toUpperCase()}` : '');
      // fly suggestion only if this is the lowest item AND there's a best country with stock
      const flyNote = (lowest && name === lowest.name && lowest.bestCode && (lowest.bestStk > 0))
        ? ` <span class="fly">✈ Fly to: ${ (COUNTRY_NAMES[lowest.bestCode] || lowest.bestCode.toUpperCase()) } ${ getFlagForCode(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(name)}</div></div><div class="meta">${meta}${flyNote}${flagPart ? ' ' + escapeHtml(flagPart) : ''}</div></div>`;
    }

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

  // helper: simple flag for known codes (fallback to empty)
  function getFlagForCode(code) {
    if (!code) return '';
    const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
    return map[code] || '';
  }

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

  // ===== safe wrappers (GM_xmlhttpRequest) =====
  async function fetchDisplayCaseSafe() {
    if (!apiKey) return {}; // no key -> empty map
    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 (err) {
      console.warn('display fetch error', err);
      return {};
    }
  }

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

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

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

})();