// ==UserScript==
// @name 🌺🧸 Unified Display & Points (Above PDA) v3.4.1
// @namespace http://tampermonkey.net/
// @version 3.4.1
// @description Slim unified panel above PDA: display + inventory, YATA stock (public endpoint), sets, points, missing, travel suggestions. Compact single-line rows. Xanax shown at bottom. 45s refresh.
// @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 PANEL_ID = 'unified_points_display_v3_4_1';
const REFRESH_MS = 45 * 1000;
const POINTS_PER_SET = 10;
const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
const FLOWERS_ORDER = [
["Dahlia",{code:'mex', flag:'🇲🇽', short:'Dahlia'}],
["Orchid",{code:'haw', flag:'🏝️', short:'Orchid'}],
["African Violet",{code:'sou', flag:'🇿🇦', short:'A.Violet'}],
["Cherry Blossom",{code:'jap', flag:'🇯🇵', short:'C.Blossom'}],
["Peony",{code:'chi', flag:'🇨🇳', short:'Peony'}],
["Ceibo Flower",{code:'arg', flag:'🇦🇷', short:'Ceibo'}],
["Edelweiss",{code:'swi', flag:'🇨🇭', short:'Edelweiss'}],
["Crocus",{code:'can', flag:'🇨🇦', short:'Crocus'}],
["Heather",{code:'uni', flag:'🇬🇧', short:'Heather'}],
["Tribulus Omanense",{code:'uae', flag:'🇦🇪', short:'Tribulus'}],
["Banana Orchid",{code:'cay', flag:'🇰🇾', short:'Banana'}]
];
const PLUSHIES_ORDER = [
["Sheep Plushie",{code:null, flag:'🏪', short:'Sheep'}],
["Teddy Bear Plushie",{code:null, flag:'🏪', short:'Teddy'}],
["Kitten Plushie",{code:null, flag:'🏪', short:'Kitten'}],
["Jaguar Plushie",{code:'mex', flag:'🇲🇽', short:'Jaguar'}],
["Wolverine Plushie",{code:'can', flag:'🇨🇦', short:'Wolverine'}],
["Nessie Plushie",{code:'uni', flag:'🇬🇧', short:'Nessie'}],
["Red Fox Plushie",{code:'uni', flag:'🇬🇧', short:'R.Fox'}],
["Monkey Plushie",{code:'arg', flag:'🇦🇷', short:'Monkey'}],
["Chamois Plushie",{code:'swi', flag:'🇨🇭', short:'Chamois'}],
["Panda Plushie",{code:'chi', flag:'🇨🇳', short:'Panda'}],
["Lion Plushie",{code:'sou', flag:'🇿🇦', short:'Lion'}],
["Camel Plushie",{code:'uae', flag:'🇦🇪', short:'Camel'}],
["Stingray Plushie",{code:'cay', flag:'🇰🇾', short:'Stingray'}]
];
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' };
// ----- styling: slim header & narrow width so it doesn't hide navigation -----
function getPDANavHeight() {
const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
return nav ? nav.offsetHeight : 40;
}
GM_addStyle(`
#${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 18px; width: 210px; z-index: 999999; font-family: "DejaVu Sans Mono", monospace; font-size:11px; pointer-events:auto; }
#${PANEL_ID} .panel { background:#0b0b0b; color:#eaeaea; border:1px solid #333; border-radius:6px; box-shadow:0 8px 20px rgba(0,0,0,0.6); overflow:hidden; max-height:80vh; }
#${PANEL_ID} .header { display:flex; align-items:center; gap:8px; padding:6px 8px; background:#121212; cursor:pointer; user-select:none; font-weight:700; height:30px; box-sizing:border-box; }
#${PANEL_ID} .title { font-size:12px; line-height:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
#${PANEL_ID} .controls { margin-left:auto; display:flex; gap:6px; align-items:center; }
#${PANEL_ID} button { background:#171717; color:#eaeaea; border:1px solid #333; padding:3px 6px; border-radius:4px; cursor:pointer; font-size:11px; }
#${PANEL_ID} .body { padding:6px 6px 8px; display:none; font-size:11px; line-height:1.05; }
#${PANEL_ID} .summary { font-weight:700; margin-bottom:6px; color:#dfe7ff; font-size:12px; }
.tbl-head { display:flex; gap:6px; padding:4px 2px; color:#bfc9d6; font-weight:700; font-size:11px; border-bottom:1px solid rgba(255,255,255,0.03); }
.tbl-row { display:flex; gap:6px; align-items:center; padding:3px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
.col-av { flex:0 0 42px; text-align:right; color:#cfe8c6; }
.col-st { flex:0 0 56px; text-align:right; color:#f7b3b3; }
.col-miss { flex:0 0 46px; text-align:right; color:#f0d08a; }
.col-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; color:#e9eef8; }
.dot { width:10px; height:10px; border-radius:50%; flex:0 0 10px; margin-right:6px; }
.stock-green { background:#00c853; } .stock-orange { background:#ff9800; } .stock-red { background:#ff1744; } .stock-gray { background:#9ea6b3; }
.note { margin-top:6px; color:#9ea6b3; font-size:11px; }
@media (max-width:740px){ #${PANEL_ID}{ left:6px; right:6px; width:auto; } .col-st{ flex:0 0 44px; } .col-av{ flex:0 0 36px; } .col-miss{ flex:0 0 40px; } }
`);
// ----- DOM build -----
function createPanel() {
let root = document.getElementById(PANEL_ID);
if (root) return root;
root = document.createElement('div');
root.id = PANEL_ID;
root.innerHTML = `
<div class="panel">
<div class="header" title="Click to expand/collapse">
<div class="title">▶ 🌺🧸 Unified Display & Points</div>
<div class="controls">
<button id="${PANEL_ID}-refresh">Refresh</button>
<button id="${PANEL_ID}-set-torn">Set Torn Key</button>
</div>
</div>
<div class="body">
<div id="${PANEL_ID}-status" class="summary">Waiting...</div>
<div class="section" id="${PANEL_ID}-flowers">
<div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-miss">MIS</div><div class="col-name">Flower</div></div>
<div id="${PANEL_ID}-flowers-rows"></div>
</div>
<div class="section" id="${PANEL_ID}-plush" style="margin-top:8px;">
<div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-miss">MIS</div><div class="col-name">Plushie</div></div>
<div id="${PANEL_ID}-plush-rows"></div>
</div>
<div class="section" id="${PANEL_ID}-drugs" style="margin-top:8px;">
<div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-miss"> </div><div class="col-name">Drugs</div></div>
<div id="${PANEL_ID}-drugs-rows"></div>
</div>
<div id="${PANEL_ID}-fly" class="note"></div>
<div id="${PANEL_ID}-meta" class="note"></div>
</div>
</div>
`;
document.body.appendChild(root);
// events
const header = root.querySelector('.header');
header.addEventListener('click', (e) => {
if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.target.id === `${PANEL_ID}-set-torn`)) return;
toggleBody();
});
root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
root.querySelector(`#${PANEL_ID}-set-torn`).addEventListener('click', (ev) => { ev.stopPropagation(); askTornKey(); });
// highlight set-torn if missing
setTimeout(()=> {
const torn = GM_getValue('tornAPIKey', null);
if (!torn) {
const b = root.querySelector(`#${PANEL_ID}-set-torn`);
if (b) b.style.boxShadow = '0 0 8px rgba(137,183,255,0.14)';
}
},1200);
return root;
}
function toggleBody(force) {
const body = document.querySelector(`#${PANEL_ID} .body`);
const title = document.querySelector(`#${PANEL_ID} .title`);
const open = (typeof force === 'boolean') ? force : (body.style.display !== 'block');
body.style.display = open ? 'block' : 'none';
title.textContent = (open ? '▼' : '▶') + ' 🌺🧸 Unified Display & Points';
GM_setValue(`${PANEL_ID}-collapsed`, !open);
}
// ----- network -----
function gmGetJson(url, timeout = 14000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout,
onload: res => {
try {
const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
resolve(parsed);
} catch (e) { reject(e); }
},
onerror: err => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// ----- Torn display + inventory -----
async function fetchTornDisplayInventory() {
const key = GM_getValue('tornAPIKey', null);
if (!key) return null;
const url = `https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;
try {
const data = await gmGetJson(url);
if (!data || data.error) return null;
return aggregateFromApiResponse(data);
} catch (e) { console.warn('fetchTornDisplayInventory', e); return null; }
}
function aggregateFromApiResponse(data) {
const items = {};
const pushSrc = (src) => {
if (!src) return;
const entries = Array.isArray(src) ? src : Object.values(src);
for (const e of entries) {
if (!e) continue;
const name = e.name || e.item_name || e.title || e.item || null;
if (!name) continue;
const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 1) || 0;
items[name] = (items[name] || 0) + qty;
}
};
pushSrc(data.display);
pushSrc(data.inventory);
return items;
}
function fetchDisplayViaDOM() {
const map = {};
const itemEls = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
if (itemEls && itemEls.length) {
itemEls.forEach(el => {
let name = '';
let qty = 0;
const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a') || el;
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;
});
}
return map;
}
// ----- YATA public endpoint -----
async function fetchYata() {
try {
const res = await gmGetJson(YATA_URL, 14000);
return res || null;
} catch (e) { console.warn('fetchYata', e); return null; }
}
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;
}
// ----- compute sets & missing (deduct completed sets, show missing to next set) -----
function computeForGroup(displayMap, groupOrder) {
const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
const sets = counts.length ? Math.min(...counts) : 0;
const missing = groupOrder.reduce((acc, [name]) => {
const c = Number(displayMap[name] || 0);
acc[name] = Math.max(0, (sets + 1) - c);
return acc;
}, {});
const countsMap = groupOrder.reduce((acc,[name])=>{ acc[name]=Number(displayMap[name]||0); return acc; }, {});
return { sets, countsMap, missing };
}
// ----- stock color thresholds -----
function stockClassByQty(q) {
q = Number(q || 0);
if (q === 0) return 'stock-gray';
if (q > 1000) return 'stock-green';
if (q >= 600) return 'stock-orange';
return 'stock-red';
}
// ----- render UI -----
function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
function renderGroupRows(containerId, order, countsMap, yataMap, missingMap) {
const el = document.getElementById(containerId);
if (!el) return;
let html = '';
for (const [name, meta] of order) {
const av = Number(countsMap[name] || 0);
const stk = sumYataFor(name, yataMap);
const miss = Number(missingMap[name] || 0);
const dotClass = stockClassByQty(stk);
const codePart = bestCountryFor(name, yataMap).code ? ` ${bestCountryFor(name, yataMap).code.toUpperCase()}` : '';
html += `<div class="tbl-row"><div class="dot ${dotClass}"></div><div class="col-av">${av}</div><div class="col-st">${stk}${codePart? ' |'+codePart.trim():''}</div><div class="col-miss">${miss>0?miss:'—'}</div><div class="col-name">${escapeHtml(meta.short||name)} ${meta.flag||''}</div></div>`;
}
el.innerHTML = html;
}
function renderUI(displayMap, yataData) {
const yataMap = buildYataMap(yataData);
const flowers = computeForGroup(displayMap, FLOWERS_ORDER);
const plush = computeForGroup(displayMap, PLUSHIES_ORDER);
const totalSets = (flowers.sets || 0) + (plush.sets || 0);
const totalPoints = totalSets * POINTS_PER_SET;
const statusEl = document.getElementById(`${PANEL_ID}-status`);
statusEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints} | Flowers: ${flowers.sets} | Plush: ${plush.sets}`;
renderGroupRows(`${PANEL_ID}-flowers-rows`, FLOWERS_ORDER, flowers.countsMap, yataMap, flowers.missing);
renderGroupRows(`${PANEL_ID}-plush-rows`, PLUSHIES_ORDER, plush.countsMap, yataMap, plush.missing);
// drugs - Xanax (SOU)
const drugsEl = document.getElementById(`${PANEL_ID}-drugs-rows`);
const xanInv = Number(displayMap[SPECIAL_DRUG] || 0);
const xanStk = Number(yataMap['sou']?.[SPECIAL_DRUG] || 0);
const xanDot = stockClassByQty(xanStk);
drugsEl.innerHTML = `<div class="tbl-row"><div class="dot ${xanDot}"></div><div class="col-av">${xanInv}</div><div class="col-st">${xanStk} | SOU</div><div class="col-miss">—</div><div class="col-name">${escapeHtml(SPECIAL_DRUG)} 🇿🇦</div></div>`;
// flight suggestion: choose first missing flower, then first missing plush
const lowFlower = firstMissingWithBest(FLOWERS_ORDER, flowers.missing, yataMap);
const lowPlush = firstMissingWithBest(PLUSHIES_ORDER, plush.missing, yataMap);
let flyHtml = '';
if (lowFlower) {
if (lowFlower.bestQty > 0) flyHtml += `✈ Fly (flower): ${COUNTRY_NAMES[lowFlower.bestCode]||lowFlower.bestCode} ${flag(lowFlower.bestCode)} — ${escapeHtml(lowFlower.short)} (stk ${lowFlower.bestQty})`;
else flyHtml += `✈ Fly (flower): No stock abroad for ${escapeHtml(lowFlower.short)}`;
}
if (lowPlush) {
if (flyHtml) flyHtml += ' | ';
if (lowPlush.bestQty > 0) flyHtml += `✈ Fly (plush): ${COUNTRY_NAMES[lowPlush.bestCode]||lowPlush.bestCode} ${flag(lowPlush.bestCode)} — ${escapeHtml(lowPlush.short)} (stk ${lowPlush.bestQty})`;
else flyHtml += `✈ Fly (plush): No stock abroad for ${escapeHtml(lowPlush.short)}`;
}
document.getElementById(`${PANEL_ID}-fly`).textContent = flyHtml || '';
document.getElementById(`${PANEL_ID}-meta`).textContent = `Refresh: ${Math.round(REFRESH_MS/1000)}s | Points per set: ${POINTS_PER_SET}`;
}
function firstMissingWithBest(order, missingMap, yataMap) {
for (const [name, meta] of order) {
const miss = Number(missingMap[name] || 0);
if (miss > 0) {
const best = bestCountryFor(name, yataMap);
return { name, short: meta.short || name, miss, bestCode: best.code, bestQty: best.qty };
}
}
return null;
}
function flag(code){ const m = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' }; return m[code]||''; }
// ----- refresh flow -----
let timer = null;
async function refreshAll(force=false) {
try {
const statusEl = document.getElementById(`${PANEL_ID}-status`);
if (statusEl) statusEl.textContent = 'Fetching...';
const tornPromise = fetchTornDisplayInventory();
const yataPromise = fetchYata();
const [displayFromApi, yataData] = await Promise.all([tornPromise, yataPromise]);
let displayMap = {};
if (displayFromApi && Object.keys(displayFromApi).length > 0) displayMap = displayFromApi;
else {
const dom = fetchDisplayViaDOM();
displayMap = dom || displayFromApi || {};
}
renderUI(displayMap, yataData || null);
if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
} catch (e) {
console.warn('refreshAll err', e);
const statusEl = document.getElementById(`${PANEL_ID}-status`);
if (statusEl) statusEl.textContent = 'Update failed';
}
}
// ----- Torn key prompt -----
function askTornKey() {
const current = GM_getValue('tornAPIKey', '');
const k = prompt('Enter Torn API key (display + inventory permissions):', current || '');
if (k !== null) { GM_setValue('tornAPIKey', String(k).trim()); refreshAll(true); }
}
// ----- boot -----
createPanel();
const collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
toggleBody(!collapsed);
refreshAll(true);
if (timer) clearInterval(timer);
timer = setInterval(()=>refreshAll(false), REFRESH_MS);
window.addEventListener('beforeunload', ()=>{ if (timer) clearInterval(timer); });
// reposition top for dynamic PDA nav height (on load and resize)
function reposition() {
const root = document.getElementById(PANEL_ID);
if (!root) return;
const top = getPDANavHeight();
root.style.top = top + 'px';
}
reposition();
window.addEventListener('resize', reposition);
const observer = new MutationObserver(reposition);
observer.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });
})();