// ==UserScript==
// @name 🌸🐻 Unified Stock Compact v4.4
// @namespace http://tampermonkey.net/
// @version 4.4
// @description Compact header: display-case (inv) + YATA stock (stk). Flowers / Plushies split, Xanax (SA). Color-coded stk, sets & missing to next set, flight suggestions. Refresh 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 PANEL_ID = 'tm_uni_stock_compact_v4_4';
const POINTS_PER_SET = 10;
// color thresholds
const STK_ZERO = 'stock-zero'; // orange
const STK_LOW = 'stock-low'; // red (<600)
const STK_HIGH = 'stock-high'; // green (>1000)
const STK_MID = 'stock-mid'; // neutral
/* ---------- ITEMS, SHORT NAMES, ORDER & FLAGS ---------- */
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"
};
// flowers (name, code, flag)
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:'🇰🇾'}]
];
// plushies (name, code, 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' };
/* ---------- STYLES (compact, narrow & sliding) ---------- */
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-radius:6px; font-family:"DejaVu Sans Mono",monospace;
font-size:12px; padding:5px 8px; display:flex; align-items:center; gap:8px; cursor:pointer; min-width:110px; max-width:360px; 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; }
/* dropdown (narrow) */
#${PANEL_ID} .dropdown { position:absolute; left:0; top:calc(100% + 8px); width:320px; max-width:34vw; min-width:260px;
background: rgba(11,11,11,0.96); color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 12px 30px rgba(0,0,0,0.6);
overflow:hidden; max-height:0; transition: max-height 320ms cubic-bezier(.2,.9,.3,1), opacity 200ms ease, transform 260ms cubic-bezier(.2,.9,.3,1);
opacity:0; transform: translateY(-8px); 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; font-size:12px; }
#${PANEL_ID} .list { max-height:calc(40vh - 110px); overflow:auto; padding:6px 8px; display:block; }
#${PANEL_ID} .section-title { font-weight:700; font-size:12px; margin:6px 0 4px; color:#dfe7ff; }
#${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:9px; height:9px; border-radius:50%; flex:0 0 9px; }
.${STK_HIGH} { background:#00c853; } .${STK_LOW} { background:#ff1744; }
.${STK_ZERO} { background:#ff9800; } .${STK_MID} { background:#9ea6b3; }
#${PANEL_ID} .name { min-width:0; overflow:hidden; text-overflow:ellipsis; font-size:11px; }
#${PANEL_ID} .meta { color:#bfc9d6; width:150px; text-align:right; flex:0 0 150px; font-size:11px; }
#${PANEL_ID} .fly { color:#9ad0ff; font-weight:700; margin-top:8px; text-align:right; padding:6px 8px; }
#${PANEL_ID} .small { font-size:11px; color:#9ea6b3; margin-top:6px; padding:0 8px 8px; }
@media (max-width:720px) {
#${PANEL_ID} .dropdown { width:92vw; left:4vw; right:4vw; max-width:92vw; }
#${PANEL_ID} .meta { width:110px; flex:0 0 110px; }
}
`);
/* ---------- Build panel DOM ---------- */
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> 🌸🐻 Unified Stock</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">
<!-- sections inserted here -->
</div>
<div class="fly" id="${PANEL_ID}-fly"></div>
<div class="small">Format: ShortName — (inv: X | stk: Y | CODE)</div>
</div>
`;
// events
root.querySelector('.compact').addEventListener('click', (e) => {
if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.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;
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 panel between header left and right groups ---------- */
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();
const old = document.getElementById(PANEL_ID);
if (old) old.remove();
if (left && right && left.parentNode === right.parentNode){
left.parentNode.insertBefore(panelNode, right);
} else {
const header = document.querySelector('header, #header, .topbar, .header') || document.body;
header.appendChild(panelNode);
}
// restore collapsed state
const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
toggleDropdown(!wasCollapsed);
}
// reinsert if header gets re-rendered
const headerObserver = new MutationObserver(() => {
if (!document.body.contains(panelNode)) {
panelNode = createPanelNode();
insertPanel();
}
});
headerObserver.observe(document.documentElement || document.body, { childList:true, subtree:true });
insertPanel();
/* ---------- Networking (GM_xmlhttpRequest 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'))
});
});
}
/* ---------- Torn display + YATA fetchers ---------- */
async function askApiKey(){
const current = GM_getValue('tornAPIKey','');
const k = prompt('Enter your Torn user API key (needs display permission):', current || '');
if (k !== null) {
GM_setValue('tornAPIKey', String(k).trim());
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', err);
return {};
}
}
async function fetchYata(){
try {
const data = await gmGetJson(YATA_URL);
return data || null;
} catch (err) {
console.warn('fetchYata', err);
return null;
}
}
/* ---------- Logic: sets, missing-to-next, unify YATA stocks ---------- */
function computeSetsAndMissing(displayMap, groupOrder){
// groupOrder: array of [name, meta]
const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
const sets = counts.length ? Math.min(...counts) : 0;
// missing to complete next set = sum max(0, (sets+1)-count)
const need = counts.reduce((sum, c) => sum + Math.max(0, (sets + 1) - (c || 0)), 0);
return { sets, need };
}
function buildYataMap(yataData){
// returns { code: { itemName: qty, ... }, ... }
const map = {};
if (!yataData || !yataData.stocks) return map;
for (const [code, obj] of Object.entries(yataData.stocks)) {
const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
const m = {};
for (const it of arr) {
if (!it || !it.name) continue;
m[it.name] = Number(it.quantity ?? 0) || 0;
}
map[code] = m;
}
return map;
}
function sumTotalStockForItem(yataMap, itemName){
let total = 0;
for (const code of Object.keys(yataMap)) {
total += Number(yataMap[code][itemName] || 0);
}
return total;
}
function bestCountryForItem(yataMap, itemName){
let best = { code: null, qty: 0 };
for (const [code, m] of Object.entries(yataMap)) {
const q = Number(m[itemName] || 0);
if (q > best.qty) { best = { code, qty: q }; }
}
return best;
}
/* ---------- Color class for stk ---------- */
function stkClassForQty(q){
if (q === 0) return STK_ZERO;
if (q > 1000) return STK_HIGH;
if (q < 600) return STK_LOW;
return STK_MID;
}
function getFlagByCode(code){
if (!code) return '';
const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
return map[code] || '';
}
/* ---------- Render function (compact & narrow) ---------- */
function renderAll(displayMap, yataData){
const yataMap = buildYataMap(yataData);
// compute sets & missing
const flowersInfo = computeSetsAndMissing(displayMap, FLOWERS_ORDER);
const plushiesInfo = computeSetsAndMissing(displayMap, PLUSHIES_ORDER);
const totalSets = (flowersInfo.sets || 0) + (plushiesInfo.sets || 0);
const totalPoints = totalSets * POINTS_PER_SET;
// update top points (visible only when expanded)
const pointsEl = panelNode.querySelector(`#${PANEL_ID}-points`);
pointsEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints}`;
// prepare HTML for list: Flowers section, Plushies section, Xanax
let html = '';
// Flowers section title + missing line
html += `<div class="section-title">Flowers — Missing to next set: ${flowersInfo.need}</div>`;
for (const [name, meta] of FLOWERS_ORDER) {
const inv = Number(displayMap[name] || 0);
const stk = sumTotalStockForItem(yataMap, name);
const best = bestCountryForItem(yataMap, name);
const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
const club = stkClassForQty(stk);
const short = SHORT_MAP[name] || name;
html += `<div class="row"><div class="left"><div class="dot ${club}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag || ''}</div></div>`;
}
// Plushies section title + missing line
html += `<div class="section-title" style="margin-top:8px;">Plushies — Missing to next set: ${plushiesInfo.need}</div>`;
for (const [name, meta] of PLUSHIES_ORDER) {
const inv = Number(displayMap[name] || 0);
const stk = sumTotalStockForItem(yataMap, name);
const best = bestCountryForItem(yataMap, name);
const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
const club = stkClassForQty(stk);
const short = SHORT_MAP[name] || name;
html += `<div class="row"><div class="left"><div class="dot ${club}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag || ''}</div></div>`;
}
// Xanax (SA) at bottom
{
const name = SPECIAL_DRUG;
const inv = Number(displayMap[name] || 0);
const stk = Number(yataMap['sou']?.[name] || 0);
const club = stkClassForQty(stk);
const codePart = stk > 0 ? ` | SOU` : '';
html += `<div class="section-title" style="margin-top:8px;">Drugs</div>`;
html += `<div class="row"><div class="left"><div class="dot ${club}"></div><div class="name">${escapeHtml(SHORT_MAP[name]||name)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) 🇿🇦</div></div>`;
}
// set the list
const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
listEl.innerHTML = html;
// flight suggestions: for flowers and plushies independently
const bestFlower = findLowestAndBest(displayMap, yataMap, FLOWERS_ORDER);
const bestPlush = findLowestAndBest(displayMap, yataMap, PLUSHIES_ORDER);
const flyEl = panelNode.querySelector(`#${PANEL_ID}-fly`);
let flyHtml = '';
if (bestFlower && bestFlower.bestCode && bestFlower.bestQty > 0) {
flyHtml += `✈ Fly (flowers): ${(COUNTRY_NAMES[bestFlower.bestCode] || bestFlower.bestCode.toUpperCase())} ${getFlagByCode(bestFlower.bestCode)} — ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
} else if (bestFlower) {
flyHtml += `✈ Fly (flowers): No stock abroad for ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
}
if (bestPlush) {
if (flyHtml) flyHtml += '<br>';
if (bestPlush.bestCode && bestPlush.bestQty > 0) flyHtml += `✈ Fly (plushies): ${(COUNTRY_NAMES[bestPlush.bestCode] || bestPlush.bestCode.toUpperCase())} ${getFlagByCode(bestPlush.bestCode)} — ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
else flyHtml += `✈ Fly (plushies): No stock abroad for ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
}
flyEl.innerHTML = flyHtml;
// timestamp
const upEl = panelNode.querySelector(`#${PANEL_ID}-updated`);
upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
}
// helper: find lowest-count item in group and best country
function findLowestAndBest(displayMap, yataMap, groupOrder){
let lowest = null;
for (const [name] of groupOrder) {
const inv = Number(displayMap[name] || 0);
if (!lowest || inv < lowest.inv || (inv === lowest.inv && sumTotalStockForItem(yataMap,name) < lowest.totalStk)) {
lowest = { name, inv, totalStk: sumTotalStockForItem(yataMap,name) };
}
}
if (!lowest) return null;
const best = bestCountryForItem(yataMap, lowest.name);
return { name: lowest.name, inv: lowest.inv, totalStk: lowest.totalStk, bestCode: best.code, bestQty: best.qty };
}
/* ---------- Utility ---------- */
function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
/* ---------- Master refresh ---------- */
let timer = 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() ]);
renderAll(displayMap||{}, yataData||null);
} catch (e) {
console.warn('refreshAll', e);
const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
if (listEl) listEl.innerHTML = `<div style="color:#f88;padding:8px">Update failed</div>`;
}
}
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;
}
}
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: insert panel, restore state, start polling ---------- */
// ensure panel present and inserted
if (!panelNode) panelNode = createPanelNode();
insertPanel();
// restore collapse state
const storedCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
toggleDropdown(!storedCollapsed);
// initial refresh + polling
refreshAll(true);
if (timer) clearInterval(timer);
timer = setInterval(() => refreshAll(false), REFRESH_MS);
// cleanup
window.addEventListener('beforeunload', () => {
if (timer) clearInterval(timer);
headerObserver.disconnect();
});
// hint set-key if 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);
}
})();