// ==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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
})();