// ==UserScript==
// @name 🌺🧸 Display Case Unified Compact v2.8
// @namespace http://tampermonkey.net/
// @version 2.8
// @description Compact display-case + YATA stock: flowers, plushies, Xanax (SA). inv | stk | CODE one-line rows, sets, missing, flight hints. 45s refresh, 210px width.
// @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 = 'dc_unified_compact_v2_8';
const POINTS_PER_SET = 10;
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 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 (narrow 210px, compact) ---------- */
GM_addStyle(`
#${PANEL_ID} { position:relative; display:inline-block; vertical-align:middle; margin:0 8px; z-index:999999; }
#${PANEL_ID} .compact { background: rgba(8,8,8,0.64); color:#e9eef8; border-radius:6px; font-family:"DejaVu Sans Mono",monospace; font-size:11px; padding:6px 8px; display:flex; align-items:center; gap:8px; cursor:pointer; min-width:110px; max-width:210px; box-sizing:border-box; border:1px solid #333; }
#${PANEL_ID} .compact .title { font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#${PANEL_ID} .compact .btns{ margin-left:auto; display:flex; gap:6px; align-items:center; }
#${PANEL_ID} .btn { background:transparent; border:1px solid rgba(255,255,255,0.06); color:#eaeaea; padding:2px 6px; border-radius:4px; cursor:pointer; font-size:11px; }
#${PANEL_ID} .dropdown { position:absolute; left:0; top:calc(100% + 6px); width:210px; min-width:210px; max-width:210px; background: rgba(10,10,10,0.94); border:1px solid #222; border-radius:6px; box-shadow: 0 10px 22px rgba(0,0,0,0.6); overflow:hidden; opacity:0; transform:translateY(-6px); transition:opacity .18s, transform .22s, max-height .28s; max-height:0; }
#${PANEL_ID} .dropdown.open { opacity:1; transform:translateY(0); max-height:60vh; }
#${PANEL_ID} .head { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.03); font-size:11px; color:#bfc9d6; }
#${PANEL_ID} .list { padding:6px 6px 8px 6px; font-size:11px; max-height:calc(60vh - 110px); overflow:auto; }
.section-title { font-weight:700; color:#dfe7ff; margin:6px 0 4px; font-size:12px; }
.row { display:flex; justify-content:space-between; gap:6px; padding:3px 0; align-items:center; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
.left { display:flex; align-items:center; gap:6px; min-width:0; overflow:hidden; }
.dot { width:9px; height:9px; border-radius:50%; flex:0 0 9px; }
.stock-high { background:#00c853; } .stock-low { background:#ff1744; } .stock-zero { background:#ff9800; } .stock-mid { background:#9ea6b3; }
.name { overflow:hidden; text-overflow:ellipsis; min-width:0; font-size:11px; }
.meta { color:#bfc9d6; flex:0 0 135px; text-align:right; font-size:11px; }
.fly { padding:6px 8px; color:#9ad0ff; font-weight:700; font-size:11px; text-align:right; line-height:1.1; }
.small { padding:6px 8px 8px; color:#9ea6b3; font-size:11px; }
@media (max-width:740px){ #${PANEL_ID} .dropdown{ width:92vw; left:4vw; min-width:unset; } .meta{ flex:0 0 110px; } }
`);
/* ---------- Build DOM ---------- */
function createPanel() {
const root = document.createElement('div');
root.id = PANEL_ID;
root.innerHTML = `
<div class="compact" title="Click to expand">
<div class="title">🌺🧸 Display Unified</div>
<div class="btns">
<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 id="${PANEL_ID}-points">Sets: - | Points: -</div><div id="${PANEL_ID}-updated" style="color:#9ea6b3;font-size:11px">Updated: -</div></div>
<div class="list" id="${PANEL_ID}-list"></div>
<div class="fly" id="${PANEL_ID}-fly"></div>
<div class="small">Format: Short — (inv: X | stk: Y | CODE)</div>
</div>
`;
// events:
const compact = root.querySelector('.compact');
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 panel = createPanel();
document.body.appendChild(panel);
function toggleDropdown(force) {
const dd = panel.querySelector(`#${PANEL_ID}-dropdown`);
const icon = panel.querySelector(`#${PANEL_ID} .title`);
const open = (typeof force === 'boolean') ? force : !dd.classList.contains('open');
if (open) { dd.classList.add('open'); dd.setAttribute('aria-hidden','false'); } else { dd.classList.remove('open'); dd.setAttribute('aria-hidden','true'); }
GM_setValue(`${PANEL_ID}-collapsed`, !open);
}
// Preserve position between header left & right if available (attempt)
function insertBetweenHeader() {
const left = document.querySelector('.header-links-left, .headerLeft, .leftLinks, #nav-left');
const right = document.querySelector('.header-links-right, .headerRight, .rightLinks, #topbar-right');
try {
if (left && right && left.parentNode === right.parentNode) left.parentNode.insertBefore(panel, right);
} catch (e) { /* fallback already appended */ }
}
insertBetweenHeader();
/* ---------- Networking wrappers ---------- */
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'))
});
});
}
/* ---------- Fetchers ---------- */
async function askApiKey() {
const current = GM_getValue('tornAPIKey', '');
const v = prompt('Enter your Torn user API key (display permission):', current || '');
if (v !== null) {
GM_setValue('tornAPIKey', String(v).trim());
await refreshAll(true);
}
}
async function fetchDisplayViaApi() {
const key = GM_getValue('tornAPIKey', null);
if (!key) return null;
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
try {
const data = await gmGetJson(url);
if (!data || data.error) return null;
const map = {};
const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
for (const e of entries) {
const name = e.name || e.item_name || e.title || e.item;
const q = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
if (!name) continue;
map[name] = (map[name] || 0) + q;
}
return map;
} catch (err) { console.warn('fetchDisplayViaApi', err); return null; }
}
function fetchDisplayViaDOM() {
// fallback: parse DOM display case if on displaycase page
const map = {};
// common Torn display case layout variations: search for elements containing item names + qty
// Attempt several selectors
const itemEls = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item');
if (itemEls && itemEls.length) {
itemEls.forEach(el => {
// try to identify name and quantity
let name = '';
let qty = 0;
const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a');
if (nameEl) name = nameEl.innerText.trim();
const qtyEl = el.querySelector('.item-amount, .count, .qty, .quantity') || el.querySelector('.item-qty');
if (qtyEl) qty = parseInt(qtyEl.innerText.replace(/\D/g,'')) || 0;
if (name) map[name] = (map[name] || 0) + qty;
});
} else {
// as last resort, try to parse any text nodes with known names
const allText = document.body.innerText;
// skip heavy parsing; return empty to avoid wrong counts
}
return map;
}
async function fetchYata() {
try {
const data = await gmGetJson(YATA_URL);
return data || null;
} catch (err) { console.warn('fetchYata', err); return null; }
}
/* ---------- Helpers: YATA map, sums ---------- */
function buildYataMap(yataData) {
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) m[it.name] = Number(it.quantity ?? 0) || 0;
map[code] = m;
}
return map;
}
function sumYataFor(itemName, yataMap) {
let total = 0;
for (const c of Object.keys(yataMap || {})) total += Number(yataMap[c][itemName] || 0);
return total;
}
function bestCountryFor(itemName, yataMap) {
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;
}
/* ---------- Computation: sets, missing ---------- */
function computeSetInfo(displayMap, groupOrder) {
const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
const sets = counts.length ? Math.min(...counts) : 0;
// missing total to reach next set (for user-friendly display)
const need = counts.reduce((s,c) => s + Math.max(0, (sets+1) - (c || 0)), 0);
return { sets, need, countsMap: groupOrder.reduce((acc,[name])=>{acc[name]=Number(displayMap[name]||0);return acc;},{}) };
}
function stkClass(q) {
if (q === 0) return 'stock-zero';
if (q > 1000) return 'stock-high';
if (q < 600) return 'stock-low';
return 'stock-mid';
}
/* ---------- Render ---------- */
function renderDisplayAndStock(displayMap, yataData) {
const yataMap = buildYataMap(yataData);
const flowers = computeSetInfo(displayMap, FLOWERS_ORDER);
const plush = computeSetInfo(displayMap, PLUSHIES_ORDER);
const totalSets = (flowers.sets || 0) + (plush.sets || 0);
const totalPoints = totalSets * POINTS_PER_SET;
panel.querySelector(`#${PANEL_ID}-points`).textContent = `Sets: ${totalSets} | Points: ${totalPoints}`;
let html = '';
// Flowers
html += `<div class="section-title">Flowers — Missing: ${flowers.need}</div>`;
for (const [name, meta] of FLOWERS_ORDER) {
const inv = Number(displayMap[name] || 0);
const stk = sumYataFor(name, yataMap);
const best = bestCountryFor(name, yataMap);
const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
const cls = stkClass(stk);
const short = SHORT_MAP[name] || name;
html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag||''}</div></div>`;
}
// Plushies
html += `<div class="section-title" style="margin-top:6px">Plushies — Missing: ${plush.need}</div>`;
for (const [name, meta] of PLUSHIES_ORDER) {
const inv = Number(displayMap[name] || 0);
const stk = sumYataFor(name, yataMap);
const best = bestCountryFor(name, yataMap);
const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
const cls = stkClass(stk);
const short = SHORT_MAP[name] || name;
html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag||''}</div></div>`;
}
// Xanax (SA)
{
const name = SPECIAL_DRUG;
const inv = Number(displayMap[name] || 0);
const stk = Number(yataMap['sou']?.[name] || 0);
const cls = stkClass(stk);
const codePart = stk>0? ' | SOU' : '';
html += `<div class="section-title" style="margin-top:6px">Drugs</div>`;
html += `<div class="row"><div class="left"><span class="dot ${cls}"></span><div class="name">${escapeHtml(SHORT_MAP[name]||name)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) 🇿🇦</div></div>`;
}
panel.querySelector(`#${PANEL_ID}-list`).innerHTML = html;
// Flight suggestions: choose lowest inv in each group, then give best country with stock
const bestFlower = lowestWithBest(FLOWERS_ORDER, displayMap, yataMap);
const bestPlush = lowestWithBest(PLUSHIES_ORDER, displayMap, yataMap);
let flyHtml = '';
if (bestFlower) {
if (bestFlower.bestCode && bestFlower.bestQty>0) flyHtml += `✈ Fly (flowers): ${(COUNTRY_NAMES[bestFlower.bestCode]||bestFlower.bestCode.toUpperCase())} ${flag(bestFlower.bestCode)} — ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
else 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())} ${flag(bestPlush.bestCode)} — ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
else flyHtml += `✈ Fly (plushies): No stock abroad for ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
}
panel.querySelector(`#${PANEL_ID}-fly`).innerHTML = flyHtml;
panel.querySelector(`#${PANEL_ID}-updated`).textContent = `Updated: ${new Date().toLocaleTimeString()}`;
}
function lowestWithBest(groupOrder, displayMap, yataMap) {
let lowest = null;
for (const [name] of groupOrder) {
const inv = Number(displayMap[name]||0);
const totalStk = sumYataFor(name, yataMap);
if (!lowest || inv < lowest.inv || (inv === lowest.inv && totalStk < lowest.totalStk)) lowest = { name, inv, totalStk };
}
if (!lowest) return null;
const best = bestCountryFor(lowest.name, yataMap);
return { name: lowest.name, inv: lowest.inv, bestCode: best.code, bestQty: best.qty };
}
function flag(code){
const m = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
return m[code]||'';
}
function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
/* ---------- Master refresh flow ---------- */
let timer = null;
async function refreshAll(force=false) {
try {
// Try API first (if key present), else DOM fallback for display
const apiKey = GM_getValue('tornAPIKey', null);
const displayPromise = apiKey ? fetchDisplayViaApi().then(m=>m||{}) : Promise.resolve({});
const domFallbackPromise = Promise.resolve(fetchDisplayViaDOM());
const yataPromise = fetchYata();
// fetch yata always, combine with whichever display data is available (API if present else DOM)
const [displayFromApi, yataData] = await Promise.all([displayPromise, yataPromise]);
let displayMap = {};
// If API returned a non-empty map, use it; else try DOM fallback
if (displayFromApi && Object.keys(displayFromApi).length>0) displayMap = displayFromApi;
else {
// try DOM
const domMap = fetchDisplayViaDOM();
displayMap = domMap || displayFromApi || {};
}
renderDisplayAndStock(displayMap, yataData || null);
} catch (e) {
console.warn('refreshAll err', e);
const listEl = panel.querySelector(`#${PANEL_ID}-list`);
if (listEl) listEl.innerHTML = `<div style="color:#f88;padding:8px">Update failed</div>`;
}
}
// start polling
refreshAll(true);
if (timer) clearInterval(timer);
timer = setInterval(()=>refreshAll(false), REFRESH_MS);
// cleanup on unload
window.addEventListener('beforeunload', ()=>{ if (timer) clearInterval(timer); });
// helper: prompt for key if not set (visual hint)
if (!GM_getValue('tornAPIKey', null)) {
setTimeout(()=> {
const btn = panel.querySelector(`#${PANEL_ID}-setkey`);
if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.14)'; }
},1200);
}
})();