// ==UserScript==
// @name 🌺 🐫 Points & Stock Tracker (Unified PDA Panel) v4.0
// @namespace http://tampermonkey.net/
// @version 4.0.0
// @description Unified panel: your flower/plush totals + foreign shop stock (YATA). Single refresh every 45s. Works while flying. Xanax shown for South Africa at bottom.
// @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 POLL_MS = 45 * 1000; // unified 45s refresh
const PANEL_WIDTH = 320;
// Items of interest
const FLOWERS_MAP = {
"Dahlia": { short: "Dahlia", loc: "MX 🇲🇽", country: "Mexico" },
"Orchid": { short: "Orchid", loc: "HW 🏝️", country: "Hawaii" },
"African Violet": { short: "Violet", loc: "SA 🇿🇦", country: "South Africa" },
"Cherry Blossom": { short: "Cherry", loc: "JP 🇯🇵", country: "Japan" },
"Peony": { short: "Peony", loc: "CN 🇨🇳", country: "China" },
"Ceibo Flower": { short: "Ceibo", loc: "AR 🇦🇷", country: "Argentina" },
"Edelweiss": { short: "Edelweiss", loc: "CH 🇨🇭", country: "Switzerland" },
"Crocus": { short: "Crocus", loc: "CA 🇨🇦", country: "Canada" },
"Heather": { short: "Heather", loc: "UK 🇬🇧", country: "United Kingdom" },
"Tribulus Omanense": { short: "Tribulus", loc: "AE 🇦🇪", country: "UAE" },
"Banana Orchid": { short: "Banana", loc: "KY 🇰🇾", country: "Cayman Islands" }
};
const PLUSHIES_MAP = {
"Sheep Plushie": { short: "Sheep", loc: "B.B 🏪", country: "Torn City" },
"Teddy Bear Plushie": { short: "Teddy", loc: "B.B 🏪", country: "Torn City" },
"Kitten Plushie": { short: "Kitten", loc: "B.B 🏪", country: "Torn City" },
"Jaguar Plushie": { short: "Jaguar", loc: "MX 🇲🇽", country: "Mexico" },
"Wolverine Plushie": { short: "Wolverine", loc: "CA 🇨🇦", country: "Canada" },
"Nessie Plushie": { short: "Nessie", loc: "UK 🇬🇧", country: "United Kingdom" },
"Red Fox Plushie": { short: "Fox", loc: "UK 🇬🇧", country: "United Kingdom" },
"Monkey Plushie": { short: "Monkey", loc: "AR 🇦🇷", country: "Argentina" },
"Chamois Plushie": { short: "Chamois", loc: "CH 🇨🇭", country: "Switzerland" },
"Panda Plushie": { short: "Panda", loc: "CN 🇨🇳", country: "China" },
"Lion Plushie": { short: "Lion", loc: "SA 🇿🇦", country: "South Africa" },
"Camel Plushie": { short: "Camel", loc: "AE 🇦🇪", country: "UAE" },
"Stingray Plushie": { short: "Stingray", loc: "KY 🇰🇾", country: "Cayman Islands" }
};
// tracked set for quick lookup
const TRACKED_SET = new Set([
...Object.keys(FLOWERS_MAP),
...Object.keys(PLUSHIES_MAP),
"Xanax"
]);
// map country codes to readable name (YATA codes)
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'
};
// UI styles
function getPDANavHeight() {
const nav = document.querySelector('#pda-nav') || document.querySelector('.pda');
return nav ? nav.offsetHeight : 40;
}
GM_addStyle(`
#ptsStockPanel {
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;
}
#ptsHeader {
background: #121212;
padding: 6px 8px;
cursor: pointer;
font-weight:700;
font-size:13px;
border-bottom:1px solid #333;
user-select:none;
display:flex;
justify-content:space-between;
align-items:center;
gap:8px;
}
#ptsContent { padding:8px; display:block; }
.controls { margin-bottom:8px; display:flex; gap:6px; flex-wrap:wrap; }
.controls button {
font-size:11px;
padding:4px 8px;
background:#171717;
color:#eaeaea;
border:1px solid #333;
border-radius:4px;
cursor:pointer;
}
.summary-line { font-weight:700; margin-bottom:6px; font-size:12px; color:#dfe7ff; }
.section-title { font-weight:700; margin-top:6px; margin-bottom:6px; font-size:11px; border-bottom:1px dashed #222; padding-bottom:4px; }
.row { display:flex; align-items:center; gap:8px; padding:2px 0; white-space:nowrap; }
.name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
.count { width:48px; text-align:right; font-weight:700; }
.avail { width:80px; text-align:right; color:#bfc9d6; font-size:11px; }
.dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; vertical-align:middle; }
.g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; }
.country-block { margin-bottom:6px; padding-top:6px; border-top:1px dashed #222; }
.country-title { display:flex; justify-content:space-between; font-weight:700; font-size:11px; margin-bottom:4px; }
.small-note { font-size:11px; color:#9ea6b3; margin-top:6px; }
`);
// build DOM
const panel = document.createElement('div');
panel.id = 'ptsStockPanel';
panel.innerHTML = `
<div id="ptsHeader">
<div id="ptsHeaderLeft">▶ 🌺 🐫 Points & Stock</div>
<div id="ptsHeaderRight">
<button id="pts_refresh">Refresh</button>
<button id="pts_setkey">Set API Key</button>
<button id="pts_toggle">Collapse</button>
</div>
</div>
<div id="ptsContent">
<div class="controls">
<div id="pts_status" style="flex:1 1 auto; color:#bdbdbd;">Initializing...</div>
</div>
<div id="myInventorySection">
<div class="section-title">My Inventory & Display</div>
<div id="mySummary" class="summary-line"></div>
<div id="myList"></div>
</div>
<div id="foreignSection">
<div class="section-title">Foreign Stock (YATA)</div>
<div id="foreignList"></div>
</div>
<div class="small-note">Data sync: Torn (your items) + YATA (shop stock). Updates every 45s.</div>
</div>
`;
document.body.appendChild(panel);
// elements
const headerLeft = panel.querySelector('#ptsHeaderLeft');
const headerRight = panel.querySelector('#ptsHeaderRight');
const btnRefresh = panel.querySelector('#pts_refresh');
const btnSetKey = panel.querySelector('#pts_setkey');
const btnToggle = panel.querySelector('#pts_toggle');
const ptsContent = panel.querySelector('#ptsContent');
const statusEl = panel.querySelector('#pts_status');
const mySummary = panel.querySelector('#mySummary');
const myList = panel.querySelector('#myList');
const foreignList = panel.querySelector('#foreignList');
// collapse
let collapsed = GM_getValue('pts_collapsed', false);
function updateCollapse() {
ptsContent.style.display = collapsed ? 'none' : 'block';
headerLeft.textContent = (collapsed ? '▶' : '▼') + ' 🌺 🐫 Points & Stock';
btnToggle.textContent = collapsed ? 'Expand' : 'Collapse';
GM_setValue('pts_collapsed', collapsed);
}
updateCollapse();
headerLeft.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
btnToggle.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
// API key handling (user key)
let apiKey = GM_getValue('tornAPIKey', null);
btnSetKey.addEventListener('click', async () => {
const k = prompt('Enter your Torn API key (user key; items permission recommended):', apiKey || '');
if (k) {
apiKey = k.trim();
GM_setValue('tornAPIKey', apiKey);
statusEl.textContent = 'API key saved.';
await refreshAll(true);
}
});
btnRefresh.addEventListener('click', () => refreshAll(true));
// helper: safe fetch via GM_xmlhttpRequest
function gmGetJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'json',
onload: (res) => {
let data = res.response;
if (!data && res.responseText) {
try { data = JSON.parse(res.responseText); } catch(e) {}
}
if (!data) return reject(new Error('No JSON'));
resolve(data);
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// Torn fetch: try items (works while flying), fallback to display+inventory if present
async function fetchTornItems() {
if (!apiKey) {
throw new Error('No API key');
}
// prefer items selection (flight-safe)
const urlItems = `https://api.torn.com/user/?selections=items,display,inventory&key=${encodeURIComponent(apiKey)}`;
// requesting items plus display+inventory - server will return available selections; we handle presence.
const data = await gmGetJson(urlItems);
if (data.error) throw new Error(`Torn API error: ${data.error.error} (${data.error.code})`);
// combine sources robustly
const itemsAgg = {};
// helper to add entries (handles array or object)
function addFrom(src) {
if (!src) return;
const entries = Array.isArray(src) ? src : Object.values(src);
for (const e of entries) {
if (!e) continue;
// some item objects in /items contain 'name' and 'quantity'; display/inventory entries vary
const name = e.name || e.item_name || e.title || e.item || null;
const qty = Number(e.quantity ?? e.qty ?? e.amount ?? e.count ?? 0) || 0;
if (!name) continue;
itemsAgg[name] = (itemsAgg[name] || 0) + qty;
}
}
// items selection often appears under data.items
addFrom(data.items);
addFrom(data.display);
addFrom(data.inventory);
return itemsAgg; // map: name -> total qty
}
// YATA fetch
async function fetchYataExport() {
const data = await gmGetJson(YATA_URL);
// structure: { stocks: { mex: {update, stocks: [ {id,name,quantity,cost}, ... ] }, ... }, timestamp }
return data;
}
// color helper
function dotClass(q) {
if (q <= 0) return 'r';
if (q <= 10) return 'y';
return 'g';
}
// Render: My inventory (flowers & plushies)
function renderMyInventory(itemsAgg) {
// build totals for tracked items (flowers + plushies)
const flowerShorts = Object.values(FLOWERS_MAP).map(o => o.short);
const plushShorts = Object.values(PLUSHIES_MAP).map(o => o.short);
// produce counts keyed by short name as per original UI
const flowerTotals = {};
const plushTotals = {};
// initialize
flowerShorts.forEach(s => flowerTotals[s] = 0);
plushShorts.forEach(s => plushTotals[s] = 0);
// map full names to short names
for (const full of Object.keys(FLOWERS_MAP)) {
const short = FLOWERS_MAP[full].short;
const q = itemsAgg[full] || 0;
flowerTotals[short] = (flowerTotals[short] || 0) + q;
}
for (const full of Object.keys(PLUSHIES_MAP)) {
const short = PLUSHIES_MAP[full].short;
const q = itemsAgg[full] || 0;
plushTotals[short] = (plushTotals[short] || 0) + q;
}
// compute sets and remainder
const fCountsArr = Object.values(flowerTotals);
const pCountsArr = Object.values(plushTotals);
const fSets = fCountsArr.length ? Math.min(...fCountsArr) : 0;
const pSets = pCountsArr.length ? Math.min(...pCountsArr) : 0;
const totalSets = fSets + pSets;
const totalPoints = totalSets * 10;
// summary
mySummary.textContent = `Total sets: ${totalSets} | Points: ${totalPoints}`;
// render lists
let html = '';
html += `<div style="font-weight:700; margin-bottom:6px;">Flowers — sets: ${fSets}</div>`;
Object.keys(flowerTotals).forEach(name => {
const total = flowerTotals[name] || 0;
const dclass = dotClass(total);
html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(name)}</div><div class="count">${total}</div><div class="avail">${getFlowerLoc(name)}</div></div>`;
});
html += `<div style="font-weight:700; margin:8px 0 6px;">Plushies — sets: ${pSets}</div>`;
Object.keys(plushTotals).forEach(name => {
const total = plushTotals[name] || 0;
const dclass = dotClass(total);
html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(name)}</div><div class="count">${total}</div><div class="avail">${getPlushLoc(name)}</div></div>`;
});
myList.innerHTML = html;
}
function getFlowerLoc(short) {
// find by short in FLOWERS_MAP
for (const full in FLOWERS_MAP) {
if (FLOWERS_MAP[full].short === short) return FLOWERS_MAP[full].loc;
}
return '';
}
function getPlushLoc(short) {
for (const full in PLUSHIES_MAP) {
if (PLUSHIES_MAP[full].short === short) return PLUSHIES_MAP[full].loc;
}
return '';
}
// Render foreign stock: only show tracked items (flowers, plushies, and Xanax only in South Africa)
function renderForeignStock(yataData) {
if (!yataData || !yataData.stocks) {
foreignList.innerHTML = `<div style="color:#999;">No foreign stock data.</div>`;
return;
}
const stocks = yataData.stocks;
const countryCodes = Object.keys(stocks).sort();
let html = '';
for (const code of countryCodes) {
const c = stocks[code];
if (!c) continue;
const items = Array.isArray(c.stocks) ? c.stocks : [];
// filter tracked items; Xanax only for 'sou'
const filtered = items.filter(it => {
if (!it || !it.name) return false;
if (!TRACKED_SET.has(it.name)) return false;
if (it.name === 'Xanax' && code !== 'sou') return false;
return true;
});
if (!filtered.length) continue;
const cname = COUNTRY_NAMES[code] || code.toUpperCase();
const upd = c.update ? new Date(c.update * 1000).toUTCString() : '';
html += `<div class="country-block"><div class="country-title"><div>${escapeHtml(cname)}</div><div style="font-size:11px;color:#9ea6b3">${upd}</div></div>`;
// show flowers then plushies (keep grouping readable)
// sort by tracked order: flowers first then plushies then Xanax
filtered.sort((a,b) => {
const aIsFlower = !!FLOWERS_MAP[a.name];
const bIsFlower = !!FLOWERS_MAP[b.name];
if (aIsFlower !== bIsFlower) return aIsFlower ? -1 : 1;
if (a.name === 'Xanax') return 1;
if (b.name === 'Xanax') return -1;
return a.name.localeCompare(b.name);
});
for (const it of filtered) {
const q = Number(it.quantity ?? 0);
const dclass = dotClass(q);
const availText = q <= 0 ? 'Out' : (q <= 10 ? `${q} low` : `${q} available`);
html += `<div class="row"><div class="name"><span class="dot ${dclass}"></span>${escapeHtml(it.name)}</div><div class="count">${availText}</div><div class="avail">${code}</div></div>`;
}
html += `</div>`;
}
// ensure Xanax for SA appears at bottom if present in data but not included above
// already included by filter for code === 'sou' so no extra step needed.
foreignList.innerHTML = html || `<div style="color:#999;">No tracked items found in foreign shops.</div>`;
}
// utility escape
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// master refresh: fetch Torn and YATA, then render both
let pollHandle = null;
let lastYataTs = 0;
async function refreshAll(force=false) {
statusEl.textContent = 'Updating...';
try {
// fetch both in parallel
const tornPromise = apiKey ? fetchTornItems().catch(e => { throw new Error('Torn:' + e.message); }) : Promise.reject(new Error('No Torn API key set'));
const yataPromise = fetchYataExport().catch(e => { throw new Error('YATA:' + (e.message || e)); });
const [itemsAgg, yataData] = await Promise.allSettled([tornPromise, yataPromise])
.then(results => {
// results[0] for Torn, results[1] for YATA
const tornRes = results[0];
const yataRes = results[1];
if (tornRes.status === 'rejected') {
// show note but continue with empty inventory
statusEl.textContent = `Torn fetch failed: ${tornRes.reason.message || tornRes.reason}`;
}
if (yataRes.status === 'rejected') {
statusEl.textContent = (statusEl.textContent ? statusEl.textContent + ' | ' : '') + `YATA fetch failed`;
}
return [
tornRes.status === 'fulfilled' ? tornRes.value : {},
yataRes.status === 'fulfilled' ? yataRes.value : null
];
});
// render my inventory
renderMyInventory(itemsAgg || {});
// only re-render foreign stock if new payload or force
if (yataData) {
if (force || !yataData.timestamp || yataData.timestamp !== lastYataTs) {
renderForeignStock(yataData);
lastYataTs = yataData.timestamp || lastYataTs;
}
}
// status
const tNow = new Date().toLocaleTimeString();
statusEl.textContent = `Updated: ${tNow}`;
} catch (err) {
statusEl.textContent = `Update error: ${err.message || err}`;
}
}
function startPolling() {
if (pollHandle) return;
refreshAll(true);
pollHandle = setInterval(() => refreshAll(false), POLL_MS);
}
function stopPolling() {
if (!pollHandle) return;
clearInterval(pollHandle);
pollHandle = null;
}
// init: if api key stored, start
if (apiKey) {
startPolling();
} else {
statusEl.textContent = 'No Torn API key set. Click Set API Key.';
// still fetch YATA so foreign stock visible without key
fetchYataExport().then(data => renderForeignStock(data)).catch(()=>{/*ignore*/});
}
// store cleanly when unloading
window.addEventListener('beforeunload', () => stopPolling());
})();