// ==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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// ===== init =====
refreshAll(true);
timer = setInterval(() => refreshAll(false), REFRESH_MS);
window.addEventListener('beforeunload', () => { if (timer) clearInterval(timer); });
})();