🌺🧸 Unified Stock (v5.0) - Predictive Fly Suggestion + Points

One-line per item: display-case inv + foreign stk + flag/code. Predicts availability on arrival using YATA travel durations and depletion/restock times. Shows Sets & Points, and single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.

2025-10-11 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

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 (v5.0) - Predictive Fly Suggestion + Points
// @namespace    http://tampermonkey.net/
// @version      5.0.0
// @description  One-line per item: display-case inv + foreign stk + flag/code. Predicts availability on arrival using YATA travel durations and depletion/restock times. Shows Sets & Points, and single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.
// @author       Nova (merged & enhanced)
// @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_STOCK_URL = 'https://yata.yt/api/v1/travel/export/'; // stocks per country (stocks array, may have depletion/restock)
  const YATA_TRAVEL_URL = 'https://yata.yt/api/v1/travel/';       // travel durations per country
  const REFRESH_MS = 45 * 1000;
  const PANEL_WIDTH = 320;
  const LOW_STOCK = 500; // threshold for "low abroad" (yellow fallback)
  const POINTS_PER_SET = 10;

  // ===== TRACKED ITEMS (as in original) =====
  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; cursor:pointer; }
    #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; } .o { background:#ff8f00; }
    .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 =====
  async function fetchDisplayCase() {
    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 stock export =====
  async function fetchYataStocks() {
    try {
      const data = await gmGetJson(YATA_STOCK_URL);
      return data || null;
    } catch (e) {
      console.warn('YATA stock fetch failed', e);
      return null;
    }
  }

  // ===== fetch YATA travel durations =====
  async function fetchYataTravel() {
    try {
      const data = await gmGetJson(YATA_TRAVEL_URL);
      return data || null;
    } catch (e) {
      console.warn('YATA travel fetch failed', e);
      return null;
    }
  }

  // ===== compute sets & points =====
  function computeSets(displayMap) {
    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 };
  }

  // ===== Prediction logic =====
  // For a given yata item object (may contain quantity, depletion, restock) and a given arrival timestamp,
  // returns status string: 'green' | 'red' | 'orange'
  function predictStatusForArrival(itemObj, arrivalTs) {
    // itemObj: { name, quantity (or stock), depletion (timestamp string), restock (timestamp string) }
    const stock = Number(itemObj.quantity ?? itemObj.stock ?? 0) || 0;
    const depTs = parsePossibleTimestamp(itemObj.depletion);
    const restTs = parsePossibleTimestamp(itemObj.restock);

    // If currently stocked and either no depletion time provided or depletion after arrival -> green
    if (stock > 0 && (!depTs || depTs > arrivalTs)) return 'green';
    // If currently stocked but will deplete before or at arrival -> red
    if (stock > 0 && depTs && depTs <= arrivalTs) return 'red';
    // If currently empty (stock == 0) but restock before or at arrival -> orange
    if (stock === 0 && restTs && restTs <= arrivalTs) return 'orange';
    // Otherwise treat as red (unavailable)
    return 'red';
  }

  function parsePossibleTimestamp(val) {
    if (!val) return null;
    // YATA might return ISO strings or numeric ms; try to parse robustly
    if (typeof val === 'number') return val;
    const n = Number(val);
    if (!Number.isNaN(n) && n > 1000000000) return n; // likely ms/epoch
    const d = Date.parse(val);
    if (!Number.isNaN(d)) return d;
    return null;
  }

  // ===== get flight duration for a country code (ms) from yataTravel map =====
  function getFlightDurationMs(yataTravelMap, code) {
    if (!yataTravelMap || !code) return 45*60*1000; // fallback 45 min
    const c = yataTravelMap[code] || yataTravelMap[code.toUpperCase()] || yataTravelMap[code.toLowerCase()];
    if (!c) return 45*60*1000;
    // many YATA travel endpoints return duration in seconds or milliseconds depending on implementation
    const d = c.duration ?? c.time ?? c.ms ?? c.seconds;
    if (typeof d === 'number') {
      // if value looks like seconds (less than 20000), convert to ms
      if (d > 0 && d < 20000) return d * 1000;
      return d;
    }
    // try parse if string
    const n = Number(d);
    if (!Number.isNaN(n)) {
      if (n > 0 && n < 20000) return n * 1000;
      return n;
    }
    return 45*60*1000;
  }

  // ===== build unified list & fly suggestion (predictive) =====
  function buildUnified(displayMap, yataData, yataTravelMap) {
    // build map countryCode -> {itemName -> {quantity, depletion, restock}}
    const yataStocks = {}; // code -> { name -> { quantity, depletion, restock } }
    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] = {
            quantity: Number(it.quantity ?? it.qty ?? it.stock ?? 0) || 0,
            depletion: it.depletion ?? it.deplete ?? it.depletionTime ?? null,
            restock: it.restock ?? it.restock_time ?? it.restockTime ?? null
          };
        }
      }
    }

    // For each tracked item compute per-country statuses and pick bestCode according to rules:
    // Prefer code that yields 'green' at arrival, then 'orange', then 'red'.
    // Tie-breaker for same status: higher available stock at current time; then lower flight duration
    const itemsInfo = [];

    for (const name of TRACKED_LIST) {
      if (name === SPECIAL_DRUG) {
        // Only consider 'sou' for Xanax
        const code = 'sou';
        const stockObj = yataStocks[code]?.[SPECIAL_DRUG] ?? { quantity:0, depletion:null, restock:null };
        const inv = Number(displayMap[name] ?? 0) || 0;
        itemsInfo.push({
          name,
          inv,
          perCountry: { [code]: stockObj },
        });
        continue;
      }

      const perCountry = {};
      for (const [code, map] of Object.entries(yataStocks)) {
        const qObj = map[name];
        if (qObj) perCountry[code] = qObj;
      }
      // Include known home loc code info even if yata missing (so UI shows loc)
      const f = FLOWERS_ORDER.find(x => x[0] === name);
      const p = PLUSHIES_ORDER.find(x => x[0] === name);
      const knownLoc = f ? f[1].loc : (p ? p[1].loc : '');
      const inv = Number(displayMap[name] ?? 0) || 0;
      itemsInfo.push({ name, inv, perCountry, loc: knownLoc });
    }

    // Now for each item compute best country with prediction status (based on flight durations)
    const now = Date.now();
    for (const it of itemsInfo) {
      const candidates = [];
      for (const [code, obj] of Object.entries(it.perCountry || {})) {
        const flightMs = getFlightDurationMs(yataTravelMap, code);
        const arrival = now + flightMs;
        const predicted = predictStatusForArrival({ quantity: obj.quantity, depletion: obj.depletion, restock: obj.restock }, arrival);
        const stockNow = Number(obj.quantity || 0);
        candidates.push({ code, predicted, stockNow, flightMs, obj });
      }

      // If no candidates found, leave bestCode null (will show no foreign stock)
      if (candidates.length === 0) {
        it.bestCode = null;
        it.bestStk = 0;
        it.bestPredicted = null;
        continue;
      }

      // sort by predicted preference: green > orange > red, then by stockNow desc, then flightMs asc
      const rank = s => (s === 'green' ? 3 : (s === 'orange' ? 2 : (s === 'red' ? 1 : 0)));
      candidates.sort((a,b) => {
        const ra = rank(a.predicted), rb = rank(b.predicted);
        if (ra !== rb) return rb - ra; // higher rank first
        if (a.stockNow !== b.stockNow) return b.stockNow - a.stockNow; // larger stock first
        return a.flightMs - b.flightMs; // shorter flight first
      });

      it.bestCode = candidates[0].code;
      it.bestStk = candidates[0].stockNow;
      it.bestPredicted = candidates[0].predicted;
      it.bestObj = candidates[0].obj;
    }

    // find lowest by display count (inv). Prefer items with inv==0. Tie-breaker: smaller bestStk 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.bestStk || 0) < (lowest.bestStk || 0)) lowest = it;
        else if ((it.bestStk || 0) === (lowest.bestStk || 0)) {
          if ((it.name || '') < (lowest.name || '')) lowest = it;
        }
      }
    }

    return { itemsInfo, lowest, yataStocks };
  }

  // ===== dot class determination using predictive status =====
  function dotClassFromPred(pred) {
    if (pred === 'green') return 'g';
    if (pred === 'orange') return 'o';
    if (pred === 'red') return 'r';
    // fallback
    return 'r';
  }

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

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

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

    // 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.bestStk || 0);
      const predicted = it.bestPredicted || null;
      const cls = dotClassFromPred(predicted);
      const locText = it.loc || (it.bestCode ? (COUNTRY_NAMES[it.bestCode] ? `${COUNTRY_NAMES[it.bestCode]} ${it.bestCode.toUpperCase()}` : 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 or restock predicted
      const flyNote = (lowest && name === lowest.name && it.bestCode && it.bestPredicted)
        ? ` <span class="fly">✈ Fly to: ${ (COUNTRY_NAMES[it.bestCode] || it.bestCode.toUpperCase()) } ${ getFlagForCode(it.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>`;
  }

  // ===== master refresh =====
  let timer = null;
  async function refreshAll(force=false) {
    statusEl.textContent = 'Updating...';
    try {
      const [displayMap, yataData, yataTravel] = await Promise.all([ fetchDisplayCase(), fetchYataStocks(), fetchYataTravel() ]);
      renderAll(displayMap || {}, yataData || null, yataTravel || {});
      // display helpful status: last updated and whether YATA data available
      const t = new Date();
      const yOk = yataData && yataData.stocks ? true : false;
      const trOk = yataTravel ? true : false;
      statusEl.textContent = `Updated: ${t.toLocaleTimeString()} · YATA stocks: ${yOk ? 'OK' : 'ERR'} · Travel: ${trOk ? 'OK' : 'ERR'}`;
    } catch (e) {
      console.warn('refreshAll error', e);
      statusEl.textContent = 'Update error';
    }
  }

  // ===== 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); });

})();