// ==UserScript==
// @name 🌺🧸 Unified Stock (v4.1) - One-line + Fly Suggestion + Points (Inline Nav)
// @namespace http://tampermonkey.net/
// @version 4.1.2
// @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. Inline between left and right nav.
// @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 LOW_STOCK = 500; // threshold for "low abroad" (yellow)
const POINTS_PER_SET = 10;
// ===== TRACKED ITEMS =====
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";
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 =====
GM_addStyle(`
#uniStockPanel { display:inline-block; margin:0 10px; vertical-align:middle; background:#0b0b0b; color:#eaeaea; font-family:"DejaVu Sans Mono",monospace; font-size:11px; border:1px solid #444; border-radius:6px; 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; }
#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; }
.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 leftNav = document.querySelector('#nav-left'); // replace with correct left selector
const rightNav = document.querySelector('#nav-right'); // replace with correct right selector
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>
`;
if (leftNav && rightNav && leftNav.parentNode === rightNav.parentNode) {
leftNav.parentNode.insertBefore(panel, rightNav);
} else {
document.body.appendChild(panel); // fallback
}
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'))
});
});
}
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 (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;
}
}
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 };
}
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';
}
function buildUnified(displayMap, yataData) {
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;
}
}
}
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; }
}
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 });
}
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 };
}
function renderAll(displayMap, yataData) {
const { totalSets, points } = computeSets(displayMap);
pointsRow.textContent = `Sets: ${totalSets} | Points: ${points}`;
const { itemsInfo, lowest } = buildUnified(displayMap, yataData);
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 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})${flyNote}`;
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}</div></div>`;
}
listEl.innerHTML = html || `<div style="color:#999;">No tracked items found.</div>`;
}
function getFlagForCode(code) {
if (!code) return '';
const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
return map[code] || '';
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
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';
}
}
refreshAll(true);
const timer = setInterval(() => refreshAll(false), REFRESH_MS);
window.addEventListener('beforeunload', () => { clearInterval(timer); });
})();