// ==UserScript==
// @name 🌺🧸 Unified Display & Points (Above PDA) v3.4.2-merged
// @namespace http://tampermonkey.net/
// @version 3.4.2.1
// @description Slim top-center toggle + fixed merged YATA+Prometheus stock. Display+inventory + abroad stk (flowers, plushies, Xanax). Refresh button, color-coded stk, merge by averaging. 45s refresh. Collapsed shows only "🌺 🧸 Exporter".
// @match https://www.torn.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @connect yata.yt
// @connect api.prombot.co.uk
// @run-at document-end
// ==/UserScript==
(function(){
'use strict';
const PANEL_ID = 'unified_points_merged_v3_4_2';
const REFRESH_MS = 45 * 1000;
const POINTS_PER_SET = 10;
const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
const PROM_URL = 'https://api.prombot.co.uk/api/travel';
// Items: [FullName, {code, flag, short}]
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:'BB',flag:'🏪',short:'Sheep'}],
["Teddy Bear Plushie",{code:'BB',flag:'🏪',short:'Teddy'}],
["Kitten Plushie",{code:'BB',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';
// mapping many common country names -> 3/abbr codes used in YATA
const COUNTRY_NAME_TO_CODE = {
'JAPAN':'JAP','JAP':'JAP','JPN':'JAP',
'MEXICO':'MEX','MEX':'MEX',
'CANADA':'CAN','CAN':'CAN',
'CHINA':'CHI','CHN':'CHI','CHI':'CHI',
'UNITED KINGDOM':'UNI','UK':'UNI','GBR':'UNI','BRITAIN':'UNI',
'ARGENTINA':'ARG','ARG':'ARG',
'SWITZERLAND':'SWI','SWI':'SWI',
'HAWAII':'HAW','HAW':'HAW',
'UAE':'UAE','UNITED ARAB EMIRATES':'UAE',
'CAYMAN ISLANDS':'CAY','CAY':'CAY',
'SOUTH AFRICA':'SOU','S.A':'SOU','SA':'SOU','SOU':'SOU',
'TORN':'BB','B.B':'BB','TOWN':'BB'
};
// helper to compute PDA top
function getPDANavHeight(){
const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
return nav ? nav.offsetHeight : 40;
}
// styles: center top, width ~1/3 screen, squeezed left
GM_addStyle(`
#${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 50%; transform: translateX(-50%); z-index: 999999; pointer-events:auto; font-family:"DejaVu Sans Mono",monospace; font-size:12px; }
.${PANEL_ID}-toggle { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; color:#dfe7ff; user-select:none; border-radius:4px; transition:background .12s; }
.${PANEL_ID}-toggle:hover { background: rgba(255,255,255,0.02); }
.${PANEL_ID}-card { margin-top:6px; width:33vw; min-width:300px; max-width:640px; background: rgba(8,8,8,0.78); color:#e9eef8; border-radius:6px; box-shadow:0 10px 30px rgba(0,0,0,0.6); border:1px solid rgba(255,255,255,0.04); overflow:hidden; }
.${PANEL_ID}.collapsed .${PANEL_ID}-card { display:none; }
.${PANEL_ID}-header { display:flex; align-items:center; padding:6px 8px; gap:8px; }
.${PANEL_ID}-refresh { margin-left:auto; background:transparent; color:#dfe7ff; border:1px solid rgba(255,255,255,0.04); padding:4px 7px; border-radius:4px; cursor:pointer; }
.${PANEL_ID}-body { padding:8px 6px; font-size:12px; line-height:1.06; max-height:68vh; overflow:auto; }
.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); margin-bottom:6px; }
.tbl-row { display:flex; gap:6px; align-items:center; padding:4px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
.col-dot { flex:0 0 18px; display:flex; align-items:center; justify-content:flex-start; padding-left:2px; }
.col-av { flex:0 0 44px; text-align:right; color:#cfe8c6; padding-left:2px; }
.col-st { flex:0 0 72px; text-align:right; color:#f7b3b3; padding-left:2px; }
.col-mis { flex:0 0 40px; text-align:right; color:#f0d08a; padding-left:2px; }
.col-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; color:#e9eef8; padding-left:8px; text-align:left; }
.dot { width:10px; height:10px; border-radius:50%; margin-right:6px; flex:0 0 10px; }
.stock-green{ background:#00c853; } .stock-orange{ background:#ff9800; } .stock-red{ background:#ff1744; } .stock-gray{ background:#9ea6b3; }
.${PANEL_ID}-bottom { padding:8px; border-top:1px solid rgba(255,255,255,0.03); color:#bfc9d6; font-size:12px; display:flex; flex-direction:column; gap:4px; }
.${PANEL_ID}-source { color:#9ea6b3; font-size:11px; margin-top:4px; }
@media (max-width:900px){ #${PANEL_ID}{ left:6px; transform:none; } .${PANEL_ID}-card { width:92vw; } .col-st{ flex:0 0 56px; } .col-av{ flex:0 0 36px; } .col-mis{ flex:0 0 34px; } }
`);
// build UI
function buildUI(){
let root = document.getElementById(PANEL_ID);
if (root) return root;
root = document.createElement('div');
root.id = PANEL_ID;
const collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
if (collapsed) root.classList.add(PANEL_ID, 'collapsed');
else root.classList.add(PANEL_ID);
root.innerHTML = `
<div class="${PANEL_ID}-toggle" id="${PANEL_ID}-toggle">🌺 🧸 Exporter</div>
<div class="${PANEL_ID}-card" role="region" aria-label="Unified Display & Points">
<div class="${PANEL_ID}-header">
<div style="font-weight:700;color:#dfe7ff">Unified Display & Points</div>
<button class="${PANEL_ID}-refresh" id="${PANEL_ID}-refresh">Refresh</button>
</div>
<div class="${PANEL_ID}-body">
<div id="${PANEL_ID}-status" style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Waiting...</div>
<div 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-mis">MIS</div><div class="col-name">Flower</div></div>
<div id="${PANEL_ID}-flowers-rows"></div>
</div>
<div 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-mis">MIS</div><div class="col-name">Plushie</div></div>
<div id="${PANEL_ID}-plush-rows"></div>
</div>
<div 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-mis"></div><div class="col-name">Drugs</div></div>
<div id="${PANEL_ID}-drugs-rows"></div>
</div>
</div>
<div class="${PANEL_ID}-bottom" id="${PANEL_ID}-bottom">
<div id="${PANEL_ID}-flylines"></div>
<div class="${PANEL_ID}-source" id="${PANEL_ID}-source">Data source: —</div>
</div>
</div>
`;
document.body.appendChild(root);
// toggle
const toggle = document.getElementById(`${PANEL_ID}-toggle`);
toggle.addEventListener('click', () => {
const root = document.getElementById(PANEL_ID);
const collapsedNow = root.classList.toggle('collapsed');
GM_setValue(`${PANEL_ID}-collapsed`, collapsedNow);
// ensure collapsed state only leaves toggle visible (card display toggled by CSS)
});
toggle.addEventListener('dblclick', (e) => { e.stopPropagation(); refreshAll(true); });
// refresh button
const refreshBtn = document.getElementById(`${PANEL_ID}-refresh`);
refreshBtn.addEventListener('click', (e) => { e.stopPropagation(); refreshAll(true); });
return root;
}
// --- Networking helper (GM_xmlhttpRequest wrapper)
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'))
});
});
}
// --- Parse YATA (country codes are keys)
function parseYata(yataData){
const map = {}; // countryCode -> {itemName: qty}
if (!yataData || !yataData.stocks) return map;
for (const [code, obj] of Object.entries(yataData.stocks)){
const countryCode = String(code).toUpperCase();
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 ?? it.qty ?? 0) || 0;
map[countryCode] = m;
}
return map;
}
// --- Parse Prometheus: try to normalize to same country-code keyed map
function parseProm(promData){
const map = {}; // countryCode -> {itemName: qty}
if (!promData) return map;
// Prometheus may return an object keyed by country names (or codes). We'll handle both.
for (const [countryKey, countryVal] of Object.entries(promData)){
if (!countryVal) continue;
// normalize countryKey to a code if possible
const up = String(countryKey).trim().toUpperCase();
let code = up;
// if it's a full name, try mapping
if (COUNTRY_NAME_TO_CODE[up]) code = COUNTRY_NAME_TO_CODE[up];
// prepare item map
const m = {};
// countryVal might be {stocks: [...] } or { "Camel Plushie": {quantity: 123} } or similar
if (Array.isArray(countryVal.stocks)){
// handle array-of-stocks format
for (const it of countryVal.stocks){
if (!it || !it.name) continue;
m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
}
} else {
// object map style
for (const [k,v] of Object.entries(countryVal)){
// v might be {quantity: X} or a raw number
if (v == null) continue;
if (typeof v === 'object' && ('quantity' in v || 'qty' in v || 'amount' in v)){
m[k] = Number(v.quantity ?? v.qty ?? v.amount ?? 0) || 0;
} else if (typeof v === 'number' || !isNaN(Number(v))){
m[k] = Number(v) || 0;
} else if (typeof v === 'object' && v.stocks && Array.isArray(v.stocks)){
// sometimes nested
for (const it of v.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
} else {
// best-effort: skip
}
}
}
map[String(code).toUpperCase()] = m;
}
return map;
}
// --- Merge maps by averaging when both present for same country+item
function mergeMaps(yataMap, promMap){
const merged = {};
const countries = new Set([...Object.keys(yataMap || {}), ...Object.keys(promMap || {})]);
for (const c of countries){
const yItems = yataMap[c] || {};
const pItems = promMap[c] || {};
const itemNames = new Set([...Object.keys(yItems), ...Object.keys(pItems)]);
const m = {};
for (const item of itemNames){
const yv = Number(yItems[item] ?? NaN);
const pv = Number(pItems[item] ?? NaN);
const hasY = !Number.isNaN(yv);
const hasP = !Number.isNaN(pv);
if (hasY && hasP){
// average
m[item] = Math.round((yv + pv) / 2);
} else if (hasY){
m[item] = Math.round(yv);
} else if (hasP){
m[item] = Math.round(pv);
}
}
merged[c] = m;
}
return merged;
}
// helper: sum across countries for item
function sumMergedFor(itemName, mergedMap){
let total = 0;
for (const c of Object.keys(mergedMap || {})){
total += Number(mergedMap[c][itemName] || 0);
}
return total;
}
// best country for item (largest quantity) from merged map
function bestCountryForMerged(itemName, mergedMap){
let best = { code: null, qty: 0 };
for (const [code, m] of Object.entries(mergedMap || {})){
const q = Number(m[itemName] || 0);
if (q > best.qty){ best = { code, qty: q }; }
}
return best;
}
// compute sets & missing (same logic as before)
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 };
}
// color thresholds
function stockClassByQty(q){
q = Number(q || 0);
if (q === 0) return 'stock-red';
if (q >= 1500) return 'stock-green';
if (q >= 321 && q <= 749) return 'stock-orange';
return 'stock-gray';
}
function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
// render helpers
function renderGroupRows(containerId, order, countsMap, mergedMap, 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 = sumMergedFor(name, mergedMap);
const miss = Number(missingMap[name] || 0);
const dotClass = stockClassByQty(stk);
const best = bestCountryForMerged(name, mergedMap);
const bestCode = best.code ? String(best.code).toUpperCase() : '';
const codeInfo = bestCode ? ` | ${bestCode}` : '';
html += `<div class="tbl-row"><div class="col-dot"><div class="dot ${dotClass}"></div></div><div class="col-av">${av}</div><div class="col-st">${stk}${codeInfo}</div><div class="col-mis">${miss>0?miss:'—'}</div><div class="col-name">${escapeHtml(meta.short||name)} ${meta.flag||''} ${meta.code?(' | '+meta.code):''}</div></div>`;
}
el.innerHTML = html;
}
// build concise fly lines: up to 4 lines, "Fly to <ABBR> for <Short>"
function buildFlyLines(flowersMissing, plushMissing, mergedMap){
const lines = [];
const collect = (order, missing) => {
for (const [name, meta] of order){
if (lines.length >= 4) break;
const miss = Number(missing[name] || 0);
if (miss <= 0) continue;
const best = bestCountryForMerged(name, mergedMap);
const code = best.code ? String(best.code).toUpperCase() : (meta.code || '');
const displayCode = (code === 'SOU') ? 'S.A' : (code || meta.code || '');
lines.push(`Fly to ${displayCode} for ${meta.short || name}`);
}
};
collect(FLOWERS_ORDER, flowersMissing);
collect(PLUSHIES_ORDER, plushMissing);
return lines.length ? lines : ['Fly to —'];
}
// show merged data & UI
function renderUI(displayMap, yataMapRaw, promMapRaw, sourcesUsed){
// parse maps
const yataMap = parseYata(yataMapRaw);
const promMap = parseProm(promMapRaw);
const mergedMap = mergeMaps(yataMap, promMap);
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`);
if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets:${totalSets} Points:${totalPoints}`;
renderGroupRows(`${PANEL_ID}-flowers-rows`, FLOWERS_ORDER, flowers.countsMap, mergedMap, flowers.missing);
renderGroupRows(`${PANEL_ID}-plush-rows`, PLUSHIES_ORDER, plush.countsMap, mergedMap, plush.missing);
// Xanax row: count from displayMap, stk from mergedMap (sum across countries)
const drugsEl = document.getElementById(`${PANEL_ID}-drugs-rows`);
const xanInv = Number(displayMap[SPECIAL_DRUG] || 0);
const xanStk = sumMergedFor(SPECIAL_DRUG, mergedMap) || 0;
const xanDot = stockClassByQty(xanStk);
// find best country for xanax
const xanBest = bestCountryForMerged(SPECIAL_DRUG, mergedMap);
const xanBestCode = xanBest.code ? xanBest.code.toUpperCase() : 'SOU';
drugsEl.innerHTML = `<div class="tbl-row"><div class="col-dot"><div class="dot ${xanDot}"></div></div><div class="col-av">${xanInv}</div><div class="col-st">${xanStk} | ${xanBestCode}</div><div class="col-mis">—</div><div class="col-name">${escapeHtml(SPECIAL_DRUG)} 🇿🇦</div></div>`;
// fly lines
const flyLines = buildFlyLines(flowers.missing, plush.missing, mergedMap);
const flyContainer = document.getElementById(`${PANEL_ID}-flylines`);
flyContainer.innerHTML = flyLines.map(l => `<div>${escapeHtml(l)}</div>`).join('');
const srcEl = document.getElementById(`${PANEL_ID}-source`);
if (srcEl) srcEl.textContent = `Data source: ${sourcesUsed.length ? sourcesUsed.join(' + ') : '—'}`;
}
// fetch flow: get display data then both sources in parallel
async function refreshAll(force=false){
try {
const statusEl = document.getElementById(`${PANEL_ID}-status`);
if (statusEl) statusEl.textContent = 'Fetching...';
const tornPromise = fetchTornDisplayInventory();
const yataPromise = gmGetJson(YATA_URL).catch(()=>null);
const promPromise = gmGetJson(PROM_URL).catch(()=>null);
const [displayFromApi, yataRaw, promRaw] = await Promise.all([tornPromise, yataPromise, promPromise]);
let displayMap = {};
if (displayFromApi && Object.keys(displayFromApi).length > 0) displayMap = displayFromApi;
else {
const dom = fetchDisplayViaDOM();
displayMap = dom || displayFromApi || {};
}
// determine sources used
const sourcesUsed = [];
if (yataRaw && Object.keys(yataRaw).length) sourcesUsed.push('YATA');
if (promRaw && Object.keys(promRaw).length) sourcesUsed.push('Prometheus');
renderUI(displayMap, yataRaw, promRaw, sourcesUsed);
if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets and points above.`;
} catch (e){
console.warn('refreshAll err', e);
const statusEl = document.getElementById(`${PANEL_ID}-status`);
if (statusEl) statusEl.textContent = 'Update failed';
}
}
// Torn display fetch helpers
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 push = (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;
}
};
push(data.display);
push(data.inventory);
return items;
}
function fetchDisplayViaDOM(){
const map = {};
const els = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
if (els && els.length){
els.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;
}
// small utilities
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'))
});
});
}
// boot
buildUI();
// restore collapse
const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
if (wasCollapsed) document.getElementById(PANEL_ID).classList.add('collapsed');
refreshAll(true);
let timer = setInterval(()=>refreshAll(false), REFRESH_MS);
window.addEventListener('beforeunload', ()=>{ if (timer) clearInterval(timer); });
// reposition on header changes
function reposition(){
const r = document.getElementById(PANEL_ID);
if (!r) return;
const top = getPDANavHeight();
r.style.top = top + 'px';
}
reposition();
window.addEventListener('resize', reposition);
const obs = new MutationObserver(reposition);
obs.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });
})();