// ==UserScript==
// @name 🌸🐻 Unified Stock (Compact Header v4.3)
// @namespace http://tampermonkey.net/
// @version 4.3
// @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';
/* ====== CONFIG ====== */
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_3';
/* ====== SHORT NAMES & ORDER ====== */
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"
};
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', cay:'Cayman Islands', can:'Canada', haw:'Hawaii', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', jap:'Japan', chi:'China', uae:'UAE', sou:'South Africa' };
/* ====== STYLES (compact + slide) ====== */
GM_addStyle(`
#${PANEL_ID} { display:inline-block; vertical-align:middle; margin:0 6px; position:relative; z-index:999999; }
#${PANEL_ID} .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; min-width:120px; max-width:520px; box-sizing:border-box; }
#${PANEL_ID} .compact .title { font-weight:700; display:flex; gap:6px; align-items:center; }
#${PANEL_ID} .compact .controls { display:flex; gap:6px; align-items:center; }
#${PANEL_ID} .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} .dropdown { position:absolute; left:0; top:calc(100% + 8px); width:360px; max-width:40vw; min-width:240px;
background: rgba(11,11,11,0.95); color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 10px 26px rgba(0,0,0,0.6);
overflow:hidden; max-height:0; transition: max-height 280ms cubic-bezier(.2,.9,.3,1), opacity 220ms ease, transform 260ms cubic-bezier(.2,.9,.3,1);
opacity:0; transform:translateY(-6px); font-size:11px; }
#${PANEL_ID} .dropdown.open { max-height:40vh; opacity:1; transform:translateY(0); }
#${PANEL_ID} .dropdown .head { display:flex; justify-content:space-between; align-items:center; padding:8px; gap:8px; border-bottom:1px solid rgba(255,255,255,0.03); }
#${PANEL_ID} .points { color:#bfc9d6; font-weight:700; }
#${PANEL_ID} .list { max-height:calc(40vh - 80px); overflow:auto; padding:6px 8px; }
#${PANEL_ID} .row { display:flex; justify-content:space-between; gap:8px; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.02); align-items:center; white-space:nowrap; }
#${PANEL_ID} .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
#${PANEL_ID} .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} .name { min-width:0; overflow:hidden; text-overflow:ellipsis; }
#${PANEL_ID} .meta { color:#bfc9d6; width:170px; text-align:right; flex:0 0 170px; font-size:11px; }
#${PANEL_ID} .fly { color:#9ad0ff; font-weight:700; margin-top:6px; text-align:right; padding:6px 8px; }
@media (max-width:720px) {
#${PANEL_ID} .dropdown { width:92vw; left:4vw; right:4vw; max-width:92vw; }
#${PANEL_ID} .meta { width:120px; flex:0 0 120px; }
}
`);
/* ====== DOM: build panel ====== */
function createPanelNode() {
const root = document.createElement('div');
root.id = PANEL_ID;
root.innerHTML = `
<div class="compact" title="Click to expand">
<div class="title"><span id="${PANEL_ID}-icon">▼</span> <span style="margin-left:4px">🌸🐻 Unified Stock</span></div>
<div class="controls">
<button class="btn" id="${PANEL_ID}-refresh">Refresh</button>
<button class="btn" id="${PANEL_ID}-setkey">Set Key</button>
</div>
</div>
<div class="dropdown" id="${PANEL_ID}-dropdown" aria-hidden="true">
<div class="head">
<div class="points" id="${PANEL_ID}-points">Sets: - | Points: -</div>
<div style="color:#9ea6b3;font-size:11px" id="${PANEL_ID}-updated">Updated: -</div>
</div>
<div class="list" id="${PANEL_ID}-list"></div>
<div class="fly" id="${PANEL_ID}-fly"></div>
</div>
`;
// events
root.querySelector('.compact').addEventListener('click', (ev) => {
if (ev.target && (ev.target.id === `${PANEL_ID}-refresh` || ev.target.id === `${PANEL_ID}-setkey`)) return;
toggleDropdown();
});
root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
root.querySelector(`#${PANEL_ID}-setkey`).addEventListener('click', (ev) => { ev.stopPropagation(); askApiKey(); });
return root;
}
let panelNode = createPanelNode();
let dropdownOpen = false;
let collapsedStored = GM_getValue(`${PANEL_ID}-collapsed`, false);
function toggleDropdown(force) {
const dd = panelNode.querySelector(`#${PANEL_ID}-dropdown`);
const icon = panelNode.querySelector(`#${PANEL_ID}-icon`);
dropdownOpen = (typeof force === 'boolean') ? force : !dropdownOpen;
if (dropdownOpen) {
dd.classList.add('open'); dd.setAttribute('aria-hidden','false'); icon.textContent = '▲';
} else {
dd.classList.remove('open'); dd.setAttribute('aria-hidden','true'); icon.textContent = '▼';
}
GM_setValue(`${PANEL_ID}-collapsed`, !dropdownOpen);
}
/* ====== Insert in header (between left & right groups) & observe ====== */
function findHeaderGroups() {
const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud, #topbar-right');
return { left, right };
}
function insertPanel() {
const { left, right } = findHeaderGroups();
// remove existing
const existing = document.getElementById(PANEL_ID);
if (existing) existing.remove();
// insert
if (left && right && left.parentNode === right.parentNode) {
left.parentNode.insertBefore(panelNode, right);
} else {
// fallback to top header or body
const header = document.querySelector('header, #header, .topbar, .header') || document.body;
header.appendChild(panelNode);
}
// restore collapse state
const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
toggleDropdown(!wasCollapsed);
// ensure dropdown doesn't overlap too much: position dropdown under panel with absolute already set relative to panel
}
// reinsert if header mutated
const headerObserver = new MutationObserver(() => {
if (!document.body.contains(panelNode)) {
panelNode = createPanelNode();
insertPanel();
}
});
headerObserver.observe(document.documentElement || document.body, { childList:true, subtree:true });
insertPanel();
/* ====== Network helper using GM_xmlhttpRequest ====== */
function gmGetJson(url, timeout = 12000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout,
onload: (res) => {
try {
let d = res.response;
if (!d && res.responseText) d = JSON.parse(res.responseText);
resolve(d);
} catch (e) { reject(e); }
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
/* ====== Torn display & YATA fetches ====== */
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());
// immediate refresh
await refreshAll(true);
}
}
async function fetchDisplayCase() {
const key = GM_getValue('tornAPIKey', null);
if (!key) return {};
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
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('fetchDisplayCase error', err);
return {};
}
}
async function fetchYata() {
try {
const data = await gmGetJson(YATA_URL);
return data || null;
} catch (err) {
console.warn('fetchYata error', err);
return null;
}
}
/* ====== Logic: build unified list, sets, lowest item ====== */
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);
return { totalSets, points: totalSets * POINTS_PER_SET, 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 buildUnifiedData(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; }
}
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 });
}
// 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 getFlagByCode(code) {
if (!code) return '';
const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
return map[code] || '';
}
/* ====== Render dropdown content (compact lines) ====== */
function renderDropdown(displayMap, yataData) {
const { totalSets, points } = computeSets(displayMap);
const pointsEl = panelNode.querySelector(`#${PANEL_ID}-points`);
pointsEl.textContent = `Sets: ${totalSets} | Points: ${points}`;
const { items, lowest } = buildUnifiedData(displayMap, yataData);
const listEl = panelNode.querySelector(`#${PANEL_ID}-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 meta = `(inv: ${inv} | stk: ${stk}${it.bestCode ? ` | ${it.bestCode.toUpperCase()}` : ''})`;
html += `<div class="row"><div class="left"><div class="dot ${cls}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">${escapeHtml(meta)} ${it.locFlag ? escapeHtml(it.locFlag) : ''}</div></div>`;
}
listEl.innerHTML = html || '<div style="color:#999">No tracked items</div>';
// fly hint bottom
const flyEl = panelNode.querySelector(`#${PANEL_ID}-fly`);
if (lowest && lowest.bestCode && lowest.bestStk > 0) {
flyEl.innerHTML = `✈ Fly: ${ (COUNTRY_NAMES[lowest.bestCode] || lowest.bestCode.toUpperCase()) } ${ getFlagByCode(lowest.bestCode) } — ${escapeHtml(SHORT_MAP[lowest.name] || lowest.name)}`;
} else {
flyEl.innerHTML = '';
}
// updated time
const upEl = panelNode.querySelector(`#${PANEL_ID}-updated`);
if (upEl) upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
}
function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
/* ====== Master refresh ====== */
let refreshTimer = null;
async function refreshAll(force=false) {
const pointsNode = panelNode.querySelector(`#${PANEL_ID}-points`);
if (pointsNode) pointsNode.textContent = 'Updating...';
try {
const [displayMap, yataData] = await Promise.all([ fetchDisplayCase(), fetchYata() ]);
renderDropdown(displayMap||{}, yataData||null);
} catch (e) {
console.warn('refreshAll err', e);
const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
if (listEl) listEl.innerHTML = `<div style="color:#f88">Update failed</div>`;
}
}
/* ====== Fetch wrappers ====== */
async function fetchDisplayCase() {
const key = GM_getValue('tornAPIKey', null);
if (!key) return {};
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
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('fetchDisplayCase err', err);
return {};
}
}
async function fetchYata() {
try {
const data = await gmGetJson(YATA_URL);
return data || null;
} catch (err) {
console.warn('fetchYata err', err);
return null;
}
}
// gmGetJson wrapper
function gmGetJson(url, timeout=14000) {
return new Promise((resolve,reject) => {
GM_xmlhttpRequest({
method:'GET', url, timeout,
onload: res => {
try {
let d = res.response;
if (!d && res.responseText) d = JSON.parse(res.responseText);
resolve(d);
} catch (e) { reject(e); }
},
onerror: err => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
/* ====== Init ====== */
// ensure panel exists & inserted
if (!panelNode) panelNode = createPanelNode();
insertPanel();
// restore collapse flag and set dropdown according to stored state
const storedCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
toggleDropdown(!storedCollapsed);
// initial fetch + polling
refreshAll(true);
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => refreshAll(false), REFRESH_MS);
// cleanup
window.addEventListener('beforeunload', () => {
if (refreshTimer) clearInterval(refreshTimer);
headerObserver.disconnect();
});
// highlight set key button if key missing
if (!GM_getValue('tornAPIKey', null)) {
setTimeout(() => {
const btn = panelNode.querySelector(`#${PANEL_ID}-setkey`);
if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.18)'; }
}, 1500);
}
})();