// ==UserScript==
// @name 🌺 🐫 Points & Stock Unified (Display + YATA) v3.0
// @namespace http://tampermonkey.net/
// @version 3.1.1
// @description Unified PDA panel: Display-case counts (Inv) + foreign shop stock (Stk) from YATA. One line per country/item: "Mexico Jaguar Plushie (Inv 52 | Stk 2)". Refresh every 45s. Xanax shown only for South Africa.
// @author Nova
// @match https://www.torn.com/page.php?sid=travel*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect yata.yt
// @connect api.torn.com
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
if (!/page\.php\?sid=travel/.test(location.href)) return;
// ---------- CONFIG ----------
const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
const REFRESH_MS = 45 * 1000;
const PANEL_WIDTH = 320;
const LOW_STOCK = 500; // threshold that marks "low/restocking"
// Flowers & Plushies (source -> home country readable label)
const FLOWERS_MAP = {
"Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico", code: "mex" },
"Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii", code: "haw" },
"African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa", code: "sou" },
"Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan", code: "jap" },
"Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China", code: "chi" },
"Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina", code: "arg" },
"Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland", code: "swi" },
"Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada", code: "can" },
"Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom", code: "uni" },
"Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE", code: "uae" },
"Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands", code: "cay" }
};
const PLUSHIES_MAP = {
"Sheep Plushie": { short: "Sheep", loc: "B.B 🏪", country: "Torn City", code: null },
"Teddy Bear Plushie": { short: "Teddy", loc: "B.B 🏪", country: "Torn City", code: null },
"Kitten Plushie": { short: "Kitten", loc: "B.B 🏪", country: "Torn City", code: null },
"Jaguar Plushie": { short: "Jaguar", loc: "MX 🇲🇽", country: "Mexico", code: "mex" },
"Wolverine Plushie": { short: "Wolverine", loc: "CA 🇨🇦", country: "Canada", code: "can" },
"Nessie Plushie": { short: "Nessie", loc: "UK 🇬🇧", country: "United Kingdom", code: "uni" },
"Red Fox Plushie": { short: "Fox", loc: "UK 🇬🇧", country: "United Kingdom", code: "uni" },
"Monkey Plushie": { short: "Monkey", loc: "AR 🇦🇷", country: "Argentina", code: "arg" },
"Chamois Plushie": { short: "Chamois", loc: "CH 🇨🇭", country: "Switzerland", code: "swi" },
"Panda Plushie": { short: "Panda", loc: "CN 🇨🇳", country: "China", code: "chi" },
"Lion Plushie": { short: "Lion", loc: "SA 🇿🇦", country: "South Africa", code: "sou" },
"Camel Plushie": { short: "Camel", loc: "AE 🇦🇪", country: "UAE", code: "uae" },
"Stingray Plushie": { short: "Stingray", loc: "KY 🇰🇾", country: "Cayman Islands", code: "cay" }
};
// Xanax will be shown only for 'sou' (South Africa)
const SPECIAL_DRUG = "Xanax";
// map YATA country codes -> readable name
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'
};
// Build a set of tracked item names for quick filtering
const TRACKED_ITEMS = new Set([
...Object.keys(FLOWERS_MAP),
...Object.keys(PLUSHIES_MAP),
SPECIAL_DRUG
]);
// ---------- UI ----------
function getPDANavHeight() {
const nav = document.querySelector('#pda-nav') || document.querySelector('.pda');
return nav ? nav.offsetHeight : 40;
}
GM_addStyle(`
#ptsUnifiedPanel {
position: fixed;
top: ${getPDANavHeight()}px;
left: 18px;
width: ${PANEL_WIDTH}px;
background: #0b0b0b;
color: #eaeaea;
font-family: "DejaVu Sans Mono", monospace;
font-size: 11px;
border: 1px solid #444;
border-radius: 6px;
z-index: 999999;
box-shadow: 0 6px 16px rgba(0,0,0,0.5);
max-height: 70vh;
overflow-y: auto;
line-height: 1.15;
}
#ptsUnifiedHeader {
background: #121212;
padding: 6px 8px;
display:flex;
justify-content:space-between;
align-items:center;
border-bottom:1px solid #333;
user-select:none;
}
#ptsUnifiedContent { padding:8px; }
.section-title { font-weight:700; border-bottom:1px dashed #222; margin:6px 0; padding-bottom:4px; }
.country-block { margin:6px 0 4px 0; padding-top:6px; border-top:1px dashed #222; }
.country-title { display:flex; justify-content:space-between; align-items:center; font-weight:700; margin-bottom:6px; }
.row { display:flex; justify-content:space-between; align-items:center; padding:2px 0; }
.row .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
.dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
.g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
.itemname { min-width:0; overflow:hidden; text-overflow:ellipsis; }
.meta { color:#bfc9d6; width:120px; text-align:right; font-size:11px; }
#pts_status { color:#9ea6b3; margin-bottom:6px; }
.small { font-size:11px; color:#9ea6b3; margin-top:6px; }
button.pts-btn { background:#171717; color:#eaeaea; border:1px solid #333; padding:4px 8px; border-radius:4px; cursor:pointer; }
`);
const panel = document.createElement('div');
panel.id = 'ptsUnifiedPanel';
panel.innerHTML = `
<div id="ptsUnifiedHeader">
<div id="ptsTitle">▶ 🌺 🐫 Points & Stock</div>
<div style="display:flex;gap:6px;align-items:center">
<button id="ptsRefresh" class="pts-btn">Refresh</button>
<button id="ptsSetKey" class="pts-btn">Set Key</button>
</div>
</div>
<div id="ptsUnifiedContent">
<div id="pts_status">Initializing...</div>
<div class="section-title">Unified Country List</div>
<div id="ptsCountryList"></div>
<div class="small">Format: Item — (Inv X | Stk Y) · Inv = display case count · Stk = YATA shop stock</div>
</div>
`;
document.body.appendChild(panel);
const titleEl = panel.querySelector('#ptsTitle');
const statusEl = panel.querySelector('#pts_status');
const countryListEl = panel.querySelector('#ptsCountryList');
const btnRefresh = panel.querySelector('#ptsRefresh');
const btnSetKey = panel.querySelector('#ptsSetKey');
// collapse toggle
let collapsed = GM_getValue('pts_unified_collapsed', false);
function updateCollapseUI() {
const content = panel.querySelector('#ptsUnifiedContent');
content.style.display = collapsed ? 'none' : 'block';
titleEl.textContent = (collapsed ? '▶' : '▼') + ' 🌺 🐫 Points & Stock';
GM_setValue('pts_unified_collapsed', collapsed);
}
titleEl.addEventListener('click', () => { collapsed = !collapsed; updateCollapseUI(); });
updateCollapseUI();
// ---------- storage for API key ----------
let apiKey = GM_getValue('tornAPIKey', null);
btnSetKey.addEventListener('click', () => {
const k = prompt('Enter Torn user API key (needs "display" permission):', apiKey || '');
if (k) {
apiKey = k.trim();
GM_setValue('tornAPIKey', apiKey);
statusEl.textContent = 'API key saved.';
refreshAll(true);
}
});
btnRefresh.addEventListener('click', () => refreshAll(true));
// ---------- helper: GM XHR GET JSON ----------
function gmGetJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'json',
onload: function(res) {
let data = res.response;
if (!data && res.responseText) {
try { data = JSON.parse(res.responseText); } catch(e) { return reject(new Error('Invalid JSON')); }
}
resolve(data);
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// ---------- fetch display case (only) ----------
async function fetchDisplayCase() {
if (!apiKey) throw new Error('No Torn API key set');
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
const data = await gmGetJson(url);
if (!data) throw new Error('Torn API no response');
if (data.error) throw new Error(`Torn API: ${data.error.error || 'error'}`);
// Data.display is usually object keyed by id; values have name and quantity
const out = {};
const displaySrc = data.display || {};
const entries = Array.isArray(displaySrc) ? displaySrc : Object.values(displaySrc);
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; // map name -> qty
}
// ---------- fetch YATA export ----------
async function fetchYataExport() {
const data = await gmGetJson(YATA_URL);
if (!data) throw new Error('YATA no response');
return data; // shape: { stocks: { mex: { update, stocks: [ {id,name,quantity,cost}, ... ] }, ... }, timestamp }
}
// ---------- merge & render logic ----------
// dot color rules:
// - yellow if stock >0 and stock < LOW_STOCK OR (Inv>0 && stock>0 && stock<LOW_STOCK)
// - green otherwise if stock>0 or Inv>0
// - red if stock==0 and Inv==0
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 renderUnified(displayMap, yataData) {
// Build a map countryCode => { itemName -> stockQtyFromYata }
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;
}
}
}
// Build list of country codes we will show:
// - All codes that appear in YATA stocks
// - plus home country codes from FLOWERS/PLUSHIES where code not null
const codesSet = new Set(Object.keys(yataStocks));
for (const [name, v] of Object.entries(FLOWERS_MAP)) if (v.code) codesSet.add(v.code);
for (const [name, v] of Object.entries(PLUSHIES_MAP)) if (v.code) codesSet.add(v.code);
const codes = Array.from(codesSet).sort();
// For each country code, build an ordered list of tracked items to show
let html = '';
for (const code of codes) {
const countryLabel = COUNTRY_NAMES[code] || code.toUpperCase();
// collect items in this country:
// - from YATA stock (if present) filtered to tracked items
// - plus any tracked items whose home code === code (ensures your home items appear even if YATA doesn't list them)
const seen = new Set();
const items = [];
// from YATA
const ymap = yataStocks[code] || {};
for (const name of Object.keys(ymap)) {
if (!TRACKED_ITEMS.has(name)) continue;
seen.add(name);
items.push({ name, stk: ymap[name] });
}
// from our home maps (ensure presence even if YATA doesn't list)
for (const [name, v] of Object.entries(FLOWERS_MAP)) {
if (v.code === code && !seen.has(name)) {
seen.add(name);
items.push({ name, stk: yataStocks[code]?.[name] ?? 0 });
}
}
for (const [name, v] of Object.entries(PLUSHIES_MAP)) {
if (v.code === code && !seen.has(name)) {
seen.add(name);
items.push({ name, stk: yataStocks[code]?.[name] ?? 0 });
}
}
// special: include Xanax only for 'sou'
if (code === 'sou') {
if (!seen.has(SPECIAL_DRUG)) {
const stk = yataStocks['sou']?.[SPECIAL_DRUG] ?? 0;
items.push({ name: SPECIAL_DRUG, stk });
seen.add(SPECIAL_DRUG);
}
}
if (!items.length) continue; // skip countries with no tracked items
// sort items: flowers (by FLOWERS_MAP order), then plushies, then Xanax
items.sort((a,b) => {
if (a.name === SPECIAL_DRUG) return 1;
if (b.name === SPECIAL_DRUG) return -1;
const aIsFlower = !!FLOWERS_MAP[a.name];
const bIsFlower = !!FLOWERS_MAP[b.name];
if (aIsFlower !== bIsFlower) return aIsFlower ? -1 : 1;
return a.name.localeCompare(b.name);
});
// render country block
html += `<div class="country-block"><div class="country-title"><div>${escapeHtml(countryLabel)}</div><div style="font-size:11px;color:#9ea6b3">${code}</div></div>`;
for (const it of items) {
const inv = Number(displayMap[it.name] ?? 0) || 0;
const stk = Number(it.stk ?? 0) || 0;
const dot = `<span class="dot ${dotClass(inv, stk)}"></span>`;
const meta = `(Inv ${inv} | Stk ${stk})`;
html += `<div class="row"><div class="left">${dot}<div class="itemname">${escapeHtml(it.name)}</div></div><div class="meta">${meta}</div></div>`;
}
html += `</div>`; // country-block
}
countryListEl.innerHTML = html || `<div style="color:#999;">No tracked items found.</div>`;
}
// ---------- utilities ----------
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// ---------- master refresh ----------
let timerHandle = null;
let lastYataTs = 0;
async function refreshAll(force=false) {
statusEl.textContent = 'Updating...';
try {
// fetch display and yata in parallel (display may fail if no key)
const displayPromise = apiKey ? fetchDisplayCaseSafe() : Promise.resolve({});
const yataPromise = fetchYataSafe();
const [displayMap, yataData] = await Promise.all([displayPromise, yataPromise]);
renderUnified(displayMap, yataData);
statusEl.textContent = `Updated ${new Date().toLocaleTimeString()}`;
} catch (err) {
statusEl.textContent = 'Error: ' + (err && err.message ? err.message : err);
}
}
// safe wrappers
function fetchDisplayCaseSafe() {
return new Promise((resolve, reject) => {
if (!apiKey) return resolve({});
const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(apiKey)}`;
GM_xmlhttpRequest({
method: 'GET', url, responseType: 'json',
onload: res => {
try {
const data = res.response || JSON.parse(res.responseText || '{}');
if (data && data.error) {
console.warn('Torn API error', data.error);
return resolve({}); // don't fail entire refresh
}
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;
}
resolve(out);
} catch (e) {
console.warn('Torn parse failed', e);
resolve({});
}
},
onerror: () => resolve({}),
ontimeout: () => resolve({})
});
});
}
function fetchYataSafe() {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: YATA_URL,
responseType: 'json',
onload: res => {
try {
const data = res.response || JSON.parse(res.responseText || '{}');
resolve(data);
} catch (e) {
console.warn('YATA parse failed', e);
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null)
});
});
}
// ---------- init polling ----------
refreshAll(true);
timerHandle = setInterval(() => refreshAll(false), REFRESH_MS);
// cleanup
window.addEventListener('beforeunload', () => {
if (timerHandle) clearInterval(timerHandle);
});
})();