// ==UserScript==
// @name 🌸🐻 Unified Stock (Compact Header v4.2-final)
// @namespace http://tampermonkey.net/
// @version 4.2
// @description Compact one-line display-case (inv) + YATA stock (stk) embedded between Torn header left & right. Short names, sets/points shown on expand, single ✈ fly suggestion. Refresh every 45s.
// @match https://www.torn.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @connect yata.yt
// @run-at document-end
// ==/UserScript==
(function(){
'use strict';
const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
const REFRESH_MS = 45 * 1000;
const LOW_STOCK = 500;
const POINTS_PER_SET = 10;
const PANEL_ID = 'tm_uni_stock_compact_v4_2';
// ----- Short name map (what you requested) -----
const SHORT_MAP = {
"Dahlia": "Dahlia",
"Orchid": "Orchid",
"African Violet": "A.Violet",
"Cherry Blossom": "C.Blossom",
"Peony": "Peony",
"Ceibo Flower": "Ceibo",
"Edelweiss": "Edelweiss",
"Crocus": "Crocus",
"Heather": "Heather",
"Tribulus Omanense": "Tribulus",
"Banana Orchid": "Banana",
"Sheep Plushie": "Sheep",
"Teddy Bear Plushie": "Teddy",
"Kitten Plushie": "Kitten",
"Jaguar Plushie": "Jaguar",
"Wolverine Plushie": "Wolverine",
"Nessie Plushie": "Nessie",
"Red Fox Plushie": "R.Fox",
"Monkey Plushie": "Monkey",
"Chamois Plushie": "Chamois",
"Panda Plushie": "Panda",
"Lion Plushie": "Lion",
"Camel Plushie": "Camel",
"Stingray Plushie": "Stingray",
"Xanax": "Xanax"
};
// define item order (flowers then plushies then xanax)
const FLOWERS_ORDER = [
["Dahlia",{code:'mex',flag:'🇲🇽'}],
["Orchid",{code:'haw',flag:'🏝️'}],
["African Violet",{code:'sou',flag:'🇿🇦'}],
["Cherry Blossom",{code:'jap',flag:'🇯🇵'}],
["Peony",{code:'chi',flag:'🇨🇳'}],
["Ceibo Flower",{code:'arg',flag:'🇦🇷'}],
["Edelweiss",{code:'swi',flag:'🇨🇭'}],
["Crocus",{code:'can',flag:'🇨🇦'}],
["Heather",{code:'uni',flag:'🇬🇧'}],
["Tribulus Omanense",{code:'uae',flag:'🇦🇪'}],
["Banana Orchid",{code:'cay',flag:'🇰🇾'}]
];
const PLUSHIES_ORDER = [
["Sheep Plushie",{code:null,flag:'🏪'}],
["Teddy Bear Plushie",{code:null,flag:'🏪'}],
["Kitten Plushie",{code:null,flag:'🏪'}],
["Jaguar Plushie",{code:'mex',flag:'🇲🇽'}],
["Wolverine Plushie",{code:'can',flag:'🇨🇦'}],
["Nessie Plushie",{code:'uni',flag:'🇬🇧'}],
["Red Fox Plushie",{code:'uni',flag:'🇬🇧'}],
["Monkey Plushie",{code:'arg',flag:'🇦🇷'}],
["Chamois Plushie",{code:'swi',flag:'🇨🇭'}],
["Panda Plushie",{code:'chi',flag:'🇨🇳'}],
["Lion Plushie",{code:'sou',flag:'🇿🇦'}],
["Camel Plushie",{code:'uae',flag:'🇦🇪'}],
["Stingray Plushie",{code:'cay',flag:'🇰🇾'}]
];
const SPECIAL_DRUG = "Xanax";
const TRACKED_ORDER = [...FLOWERS_ORDER.map(x=>x[0]), ...PLUSHIES_ORDER.map(x=>x[0]), SPECIAL_DRUG];
const COUNTRY_NAMES = { mex:'Mexico', can:'Canada', jap:'Japan', chi:'China', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', haw:'Hawaii', uae:'UAE', cay:'Cayman Islands', sou:'South Africa' };
// ----- end maps -----
// CSS - semi-transparent compact header, dropdown below
GM_addStyle(`
/* main container we'll insert between left & right header groups */
#${PANEL_ID} { display:inline-block; vertical-align:middle; margin:0 6px; }
#${PANEL_ID} .tm-compact { background: rgba(10,10,10,0.62); color:#eee; border:1px solid rgba(255,255,255,0.06); border-radius:6px; font-family:"DejaVu Sans Mono",monospace; font-size:12px; padding:6px 8px; display:flex; align-items:center; gap:10px; cursor:pointer; }
#${PANEL_ID} .tm-compact .title { font-weight:700; display:flex; gap:6px; align-items:center; }
#${PANEL_ID} .tm-compact .controls { display:flex; gap:6px; align-items:center; }
#${PANEL_ID} .tm-compact .btn { background:transparent; color:#eaeaea; border:1px solid rgba(255,255,255,0.06); padding:2px 6px; border-radius:4px; cursor:pointer; font-size:12px; }
#${PANEL_ID} .tm-dropdown { position:absolute; z-index:999999; margin-top:6px; background:#0b0b0b; color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 8px 20px rgba(0,0,0,0.6); width:360px; max-height:60vh; overflow:auto; padding:8px; display:none; }
#${PANEL_ID} .tm-header-row { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:6px; }
#${PANEL_ID} .tm-points { color:#bfc9d6; font-weight:700; font-size:12px; }
#${PANEL_ID} .tm-list { display:block; }
#${PANEL_ID} .tm-row { display:flex; justify-content:space-between; gap:8px; padding:6px 4px; border-bottom:1px solid rgba(255,255,255,0.02); align-items:center; white-space:nowrap; }
#${PANEL_ID} .tm-left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
#${PANEL_ID} .tm-dot { width:10px; height:10px; border-radius:50%; flex:0 0 10px; }
#${PANEL_ID} .g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
#${PANEL_ID} .tm-name { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#${PANEL_ID} .tm-meta { color:#bfc9d6; width:160px; text-align:right; flex:0 0 160px; font-size:12px; }
#${PANEL_ID} .tm-fly { color:#9ad0ff; font-weight:700; margin-top:6px; display:block; text-align:right; }
/* small screens adjust */
@media (max-width:720px) {
#${PANEL_ID} .tm-dropdown { width: 92vw; left:4vw; right:4vw; }
#${PANEL_ID} .tm-meta { width:120px; flex:0 0 120px; }
}
`);
// create DOM elements
function makePanel() {
const wrap = document.createElement('div');
wrap.id = PANEL_ID;
// compact header (visible in header)
const compact = document.createElement('div');
compact.className = 'tm-compact';
compact.innerHTML = `
<div class="title"><span id="tm_toggle_icon">▼</span> <span style="margin-left:2px">🌸🐻 Unified Stock</span></div>
<div class="controls">
<button class="btn" id="tm_btn_refresh">Refresh</button>
<button class="btn" id="tm_btn_setkey">Set Key</button>
</div>`;
wrap.appendChild(compact);
// dropdown (absolute positioned below the compact header)
const dropdown = document.createElement('div');
dropdown.className = 'tm-dropdown';
dropdown.innerHTML = `
<div class="tm-header-row">
<div class="tm-points" id="tm_points">Sets: - | Points: -</div>
<div id="tm_updated" style="color:#9ea6b3;font-size:12px">Updated: -</div>
</div>
<div class="tm-list" id="tm_list"></div>
<div class="tm-fly" id="tm_fly_hint"></div>
<div style="font-size:11px;color:#9ea6b3;margin-top:6px">Format: ShortName — (inv: X | stk: Y | CODE)</div>
`;
wrap.appendChild(dropdown);
// action wiring
compact.addEventListener('click', (e) => {
// don't trigger when clicking buttons
if (e.target && (e.target.id === 'tm_btn_refresh' || e.target.id === 'tm_btn_setkey' || e.target.classList.contains('btn'))) return;
toggleDropdown();
});
wrap.querySelector('#tm_btn_refresh').addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
wrap.querySelector('#tm_btn_setkey').addEventListener('click', (ev) => { ev.stopPropagation(); askApiKey(); });
return wrap;
}
let panelNode = makePanel();
let dropdownVisible = false;
let collapsed = GM_getValue('tm_uni_collapsed_v4_2', false);
function toggleDropdown(show) {
const dd = panelNode.querySelector('.tm-dropdown');
const icon = panelNode.querySelector('#tm_toggle_icon');
dropdownVisible = (typeof show === 'boolean') ? show : !dropdownVisible;
dd.style.display = dropdownVisible ? 'block' : 'none';
icon.textContent = dropdownVisible ? '▲' : '▼';
// update collapsed storage: collapsed = !visible
collapsed = !dropdownVisible;
GM_setValue('tm_uni_collapsed_v4_2', collapsed);
// update points visibility: show points only when expanded
const pts = panelNode.querySelector('#tm_points');
if (pts) pts.style.display = dropdownVisible ? 'inline-block' : 'none';
}
// compute available width between left & right header groups and set compact width
function sizeCompactToGap() {
const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud');
const compact = panelNode.querySelector('.tm-compact');
// fallback: place compact width minimal
if (!left || !right) {
compact.style.maxWidth = '280px';
return;
}
try {
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
const available = rightRect.left - leftRect.right - 20; // 20px padding
if (available > 140) {
compact.style.maxWidth = Math.min(available, 420) + 'px';
} else {
compact.style.maxWidth = '140px';
}
} catch (e) {
compact.style.maxWidth = '280px';
}
}
// try to find insertion point (between left and right)
function insertPanel() {
// remove existing if any
const old = document.getElementById(PANEL_ID);
if (old) old.remove();
// find left and right groups
const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud');
if (left && right && left.parentNode === right.parentNode) {
// insert between them
left.parentNode.insertBefore(panelNode, right);
} else {
// fallback: try top header or body
const header = document.querySelector('header, #header, .topbar, .header') || document.body;
header.appendChild(panelNode);
}
sizeCompactToGap();
if (!collapsed) toggleDropdown(true);
}
// watch for DOM changes (Torn often updates header) and re-insert if removed
const observer = new MutationObserver((muts) => {
if (!document.body.contains(panelNode)) {
// rebuild & insert
panelNode = makePanel();
insertPanel();
} else {
// resize if header layout changed
sizeCompactToGap();
}
});
observer.observe(document.documentElement || document.body, { childList:true, subtree:true });
// network helper using GM_xmlhttpRequest
function gmGetJson(url, timeout = 10000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout,
onload: res => {
let data = res.response;
if (!data && res.responseText) {
try { data = JSON.parse(res.responseText); } catch(e) { return reject(new Error('Invalid JSON')); }
}
resolve(data);
},
onerror: err => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// Torn display fetch (safe)
async function askApiKey() {
const current = GM_getValue('tornAPIKey','');
const k = prompt('Enter Torn user API key (needs display permission):', current || '');
if (k !== null) {
GM_setValue('tornAPIKey', String(k).trim());
// re-refresh
await refreshAll(true);
}
}
async function fetchDisplayCase() {
const key = GM_getValue('tornAPIKey', null);
if (!key) return {};
try {
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
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('fetchDisplayCase error', e);
return {};
}
}
// YATA fetch
async function fetchYata() {
try {
const data = await gmGetJson(YATA_URL);
return data || null;
} catch (e) {
console.warn('fetchYata error', 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 };
}
// merge and compute best country & totals
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 items = [];
for (const name of TRACKED_ORDER) {
if (name === SPECIAL_DRUG) {
const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
const inv = Number(displayMap[name] ?? 0) || 0;
items.push({ name, inv, totalStk: stk, bestCode: stk>0 ? 'sou' : null, bestStk: stk, locFlag: '🇿🇦' });
continue;
}
let total = 0, bestCode = null, 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; }
}
// find flag from lists
let locFlag = '';
const f = FLOWERS_ORDER.find(x=>x[0]===name); if (f) locFlag = f[1].flag;
const p = PLUSHIES_ORDER.find(x=>x[0]===name); if (p) locFlag = p[1].flag;
const inv = Number(displayMap[name] ?? 0) || 0;
items.push({ name, inv, totalStk: total, bestCode, bestStk, locFlag });
}
// find lowest by inv then by totalStk
let lowest = null;
for (const it of items) {
if (!lowest) { lowest = it; continue; }
if ((it.inv||0) < (lowest.inv||0)) lowest = it;
else if ((it.inv||0) === (lowest.inv||0) && (it.totalStk||0) < (lowest.totalStk||0)) lowest = it;
}
return { items, lowest };
}
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 getFlagByCode(code) {
if (!code) return '';
const m = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
return m[code] || '';
}
// render dropdown content
function renderDropdown(displayMap, yataData) {
const points = computeSets(displayMap);
const pointsEl = panelNode.querySelector('#tm_points');
pointsEl.textContent = `Sets: ${points.totalSets} | Points: ${points.points}`;
const { items, lowest } = buildUnified(displayMap, yataData);
const listEl = panelNode.querySelector('#tm_list');
let html = '';
for (const name of TRACKED_ORDER) {
const it = items.find(x=>x.name===name);
if (!it) continue;
const inv = Number(it.inv||0), stk = Number(it.totalStk||0);
const cls = dotClass(inv, stk);
const short = SHORT_MAP[name] || name;
const codeTxt = it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : '';
const meta = `(inv: ${inv} | stk: ${stk}${it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : ''})`;
html += `<div class="tm-row"><div class="tm-left"><div class="tm-dot ${cls}"></div><div class="tm-name">${escapeHtml(short)}</div></div><div class="tm-meta">${escapeHtml(meta)} ${it.locFlag ? escapeHtml(it.locFlag) : ''}</div></div>`;
}
listEl.innerHTML = html || '<div style="color:#999">No tracked items</div>';
// fly hint below
const flyEl = panelNode.querySelector('#tm_fly_hint');
if (lowest && lowest.bestCode && lowest.bestStk > 0) {
flyEl.innerHTML = `✈ Fly: ${ (COUNTRY_NAMES[lowest.bestCode]||lowest.bestCode.toUpperCase()) } ${ escapeHtml(getFlagByCode(lowest.bestCode)) } — ${escapeHtml(SHORT_MAP[lowest.name]||lowest.name)}`;
} else {
flyEl.innerHTML = '';
}
// updated timestamp
const upEl = panelNode.querySelector('#tm_updated');
upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
}
function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
// master refresh
let timer = null;
async function refreshAll(force=false) {
// show quick spinner in points while updating
const pts = panelNode.querySelector('#tm_points');
if (pts) pts.textContent = 'Updating...';
try {
const [displayMap, yataData] = await Promise.all([ fetchDisplayCase(), fetchYata() ]);
renderDropdown(displayMap||{}, yataData||null);
} catch (e) {
console.warn('refreshAll error', e);
const listEl = panelNode.querySelector('#tm_list');
if (listEl) listEl.innerHTML = `<div style="color:#f88">Update failed</div>`;
}
}
// fetch helpers wrappers
function fetchDisplayCase() {
const key = GM_getValue('tornAPIKey', null);
if (!key) return Promise.resolve({});
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
return gmGetJson(url).then(data => {
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 err', err); return {}; });
}
function fetchYata() {
return gmGetJson(YATA_URL).catch(err => { console.warn('YATA err', err); return null; });
}
// GM XHR wrapper
function gmGetJson(url, timeout=12000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method:'GET', url, timeout,
onload: (res) => {
try {
let data = res.response;
if (!data && res.responseText) data = JSON.parse(res.responseText);
resolve(data);
} catch (e) { reject(e); }
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// initial insertion & periodic refresh
insertPanel();
// ensure collapsed state from storage
if (!collapsed) toggleDropdown(true); else toggleDropdown(false);
refreshAll(true);
timer = setInterval(() => refreshAll(false), REFRESH_MS);
// cleanup on unload
window.addEventListener('beforeunload', () => {
if (timer) clearInterval(timer);
observer.disconnect();
});
// subtle auto-prompt highlight if no key
if (!GM_getValue('tornAPIKey', null)) {
setTimeout(()=> {
const btn = panelNode.querySelector('#tm_btn_setkey');
if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.25)'; }
}, 1500);
}
})();