// ==UserScript==
// @name 💫 Points Maker
// @namespace http://tampermonkey.net/
// @version 1.1
// @description Points-style PDA panel showing Torn display + abroad stock (YATA preferred, Prometheus fallback). Collapsible, Set API Key, Refresh, auto-poll every 45s. Fixed between menu (☰) and search (🔍) on top bar.
// @match https://www.torn.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'points_maker_pda';
const POLL_INTERVAL_MS = 45 * 1000;
const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
const PROM_URL = 'https://api.prombot.co.uk/api/travel';
const SPECIAL_DRUG = 'Xanax';
const FLOWERS = {
"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 = {
"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" }
};
const COUNTRY_NAME_TO_CODE = {
'JAPAN': 'JAP', 'MEXICO': 'MEX', 'CANADA': 'CAN', 'CHINA': 'CHI', 'UNITED KINGDOM': 'UNI',
'ARGENTINA': 'ARG', 'SWITZERLAND': 'SWI', 'HAWAII': 'HAW', 'UAE': 'UAE', 'CAYMAN ISLANDS': 'CAY',
'SOUTH AFRICA': 'SOU', 'S.A': 'SOU', 'SA': 'SOU', 'TORN': 'BB', 'B.B': 'BB'
};
function getPDANavHeight() {
const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
return nav ? nav.offsetHeight : 40;
}
function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); }
function colorForPercent(value, max) {
if (!max || max === 0) return '#bdbdbd';
const pct = (value / max) * 100;
if (pct >= 75) return '#00c853';
if (pct >= 40) return '#3399ff';
return '#ff1744';
}
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';
}
// styles (Points Exporter look)
GM_addStyle(`
#${PANEL_ID} {
position: fixed;
top: ${getPDANavHeight()}px;
left: 18px;
width: 250px;
background: #0b0b0b;
color: #eaeaea;
font-family: "DejaVu Sans Mono", monospace;
font-size: 9px;
border: 1px solid #444;
border-radius: 6px;
z-index: 999999;
box-shadow: 0 6px 16px rgba(0,0,0,0.5);
max-height: 65vh;
overflow-y: auto;
line-height: 1.1;
}
#${PANEL_ID} .header {
background: #121212;
padding: 4px 6px;
cursor: pointer;
font-weight:700;
font-size:10px;
border-bottom:1px solid #333;
user-select:none;
display:flex;
align-items:center;
gap:6px;
}
#${PANEL_ID} .controls { padding:6px; display:flex; gap:6px; }
#${PANEL_ID} .controls button { font-size:9px; padding:2px 6px; background:#171717; color:#eaeaea; border:1px solid #333; border-radius:3px; cursor:pointer; }
#${PANEL_ID} .controls button:hover { background:#222; }
#${PANEL_ID} .summary-line { font-weight:700; margin:6px; font-size:10px; color:#dfe7ff; }
#${PANEL_ID} .low-line { color:#ff4d4d; font-weight:700; margin:6px; font-size:10px; }
#${PANEL_ID} .group-title { font-weight:700; margin:6px 6px 2px 6px; font-size:9.5px; }
#${PANEL_ID} ul.item-list { margin:4px 6px 8px 12px; padding:0; list-style:none; }
#${PANEL_ID} li.item-row { display:flex; align-items:center; gap:6px; padding:2px 0; white-space:nowrap; }
#${PANEL_ID} .item-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
#${PANEL_ID} .item-total { flex:0 0 36px; text-align:right; color:#cfe8c6; }
#${PANEL_ID} .item-av { flex:0 0 60px; text-align:right; color:#f7b3b3; }
#${PANEL_ID} .item-loc { flex:0 0 36px; text-align:right; color:#bcbcbc; font-size:8.5px; }
#${PANEL_ID} #tc_status { font-size:9px; color:#bdbdbd; margin:6px; }
.stock-green{ color:#00c853 !important; } .stock-orange{ color:#ff9800 !important; } .stock-red{ color:#ff1744 !important; } .stock-gray{ color:#9ea6b3 !important; }
`);
// build DOM (Points Exporter structure) with header-position fixing between menu and search
function buildUI() {
let root = document.getElementById(PANEL_ID);
if (root) return root;
root = document.createElement('div');
root.id = PANEL_ID;
root.innerHTML = `
<div id="tc_header" class="header">▶ 💫 Points Maker</div>
<div id="tc_content_wrapper">
<div id="tc_controls" class="controls">
<button id="tc_refresh">Refresh</button>
<button id="tc_setkey">Set API Key</button>
<button id="tc_resetkey">Reset Key</button>
</div>
<div id="tc_status">Waiting for API key...</div>
<div id="tc_summary"></div>
<div id="tc_content"></div>
</div>
`;
// append to body as baseline
document.body.appendChild(root);
// collapse handling exactly like first script
const headerEl = root.querySelector('#tc_header');
const contentWrapper = root.querySelector('#tc_content_wrapper');
let collapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
function updateCollapse() {
headerEl.textContent = (collapsed ? '▶ ' : '▼ ') + '💫 Points Maker';
contentWrapper.style.display = collapsed ? 'none' : 'block';
}
updateCollapse();
headerEl.addEventListener('click', () => {
collapsed = !collapsed;
GM_setValue(`${PANEL_ID}-collapsed`, collapsed);
updateCollapse();
});
// buttons
root.querySelector('#tc_refresh').addEventListener('click', () => refreshAll(true));
root.querySelector('#tc_setkey').addEventListener('click', () => askKey(true));
root.querySelector('#tc_resetkey').addEventListener('click', () => {
GM_setValue('tornAPIKey', null);
apiKey = null;
const status = document.getElementById('tc_status');
if (status) status.textContent = 'Key cleared. Click Set API Key.';
const summ = document.getElementById('tc_summary'); if (summ) summ.innerHTML = '';
const content = document.getElementById('tc_content'); if (content) content.innerHTML = '';
stopPolling();
});
// Positioning: try to place the panel fixed between menu (☰) and search (🔍)
// We'll attempt multiple selectors to find likely elements, then compute the left coordinate.
function findMenuElement() {
const candidates = [
'button.menu', '.menu', '.hamburger', '.icon-menu', '.fa-bars', '[title*="menu"]',
'.nav-toggle', '.sidebar-toggle', '.mobile-toggle', '#menu-toggle', '.nav-button'
];
for (const sel of candidates) {
const el = document.querySelector(sel);
if (el) return el;
}
// look for visible element with innerText containing ☰
const all = Array.from(document.querySelectorAll('button, a, div, span'));
for (const el of all) {
if (!el.innerText) continue;
if (el.innerText.trim().includes('☰') || el.innerText.trim().includes('≡')) return el;
}
return null;
}
function findSearchElement() {
const candidates = [
'input[type="search"]', '.search', '.search-box', '.icon-search', '.fa-search',
'[title*="Search"]', '.search-toggle', '#search', '.search-input'
];
for (const sel of candidates) {
const el = document.querySelector(sel);
if (el) return el;
}
// fallback: find element that includes "🔍" in text
const all = Array.from(document.querySelectorAll('button, a, div, span'));
for (const el of all) {
if (!el.innerText) continue;
if (el.innerText.trim().includes('🔍')) return el;
}
return null;
}
function placeBetweenMenuAndSearch() {
const menuEl = findMenuElement();
const searchEl = findSearchElement();
const panelEl = document.getElementById(PANEL_ID);
if (!panelEl) return false;
// if both exist, compute center between them, else fallback to near menu or default left:18px
if (menuEl && searchEl) {
const mRect = menuEl.getBoundingClientRect();
const sRect = searchEl.getBoundingClientRect();
// compute left as menu right + small gap, but ensure not to overlap search
const gap = 8;
let left = Math.round(mRect.right + gap);
// if panel would overlap search, clamp it to search left - panel width - gap
const panelWidth = panelEl.offsetWidth || 250;
if (left + panelWidth + gap > sRect.left) {
left = Math.max(mRect.right + gap, sRect.left - panelWidth - gap);
}
// compute top to align vertically with header/menu
const top = Math.max(6, Math.round(mRect.top + (mRect.height - panelEl.offsetHeight) / 2));
// make fixed and assign
panelEl.style.position = 'fixed';
panelEl.style.left = left + 'px';
panelEl.style.top = (mRect.top + window.scrollY) + 'px';
panelEl.style.transform = 'none';
panelEl.style.zIndex = '2147483647';
return true;
}
// if only menu exists, place to its right
if (menuEl) {
const mRect = menuEl.getBoundingClientRect();
panelEl.style.position = 'fixed';
panelEl.style.left = (Math.round(mRect.right + 8)) + 'px';
panelEl.style.top = (mRect.top + window.scrollY) + 'px';
panelEl.style.transform = 'none';
panelEl.style.zIndex = '2147483647';
return true;
}
// fallback: keep original left:18px and fixed top near PDA nav
const navTop = getPDANavHeight();
panelEl.style.position = 'fixed';
panelEl.style.left = '18px';
panelEl.style.top = navTop + 'px';
panelEl.style.transform = 'none';
panelEl.style.zIndex = '999999';
return false;
}
// Try placing immediately and then keep attempting until header area settles
placeBetweenMenuAndSearch();
// try again on resize and after DOM changes
window.addEventListener('resize', () => placeBetweenMenuAndSearch());
const mo = new MutationObserver(() => placeBetweenMenuAndSearch());
mo.observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true });
return root;
}
buildUI();
const statusEl = document.getElementById('tc_status');
const summaryEl = document.getElementById('tc_summary');
const contentEl = document.getElementById('tc_content');
// API key storage & polling
let apiKey = GM_getValue('tornAPIKey', null);
let pollHandle = null;
function startPolling() { if (pollHandle) return; pollHandle = setInterval(() => refreshAll(false), POLL_INTERVAL_MS); }
function stopPolling() { if (!pollHandle) return; clearInterval(pollHandle); pollHandle = null; }
async function askKey(force) {
if (!apiKey || force) {
const k = prompt('Enter your Torn API key (needs display + inventory permissions):', apiKey || '');
if (k) { apiKey = k.trim(); GM_setValue('tornAPIKey', apiKey); }
}
if (apiKey) { startPolling(); await refreshAll(true); }
}
// network helper
function gmGetJson(url, timeout = 14000) {
return new Promise((resolve, reject) => {
try {
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'))
});
} catch (e) { reject(e); }
});
}
// parse YATA format
function parseYata(yataData) {
const map = {};
if (!yataData || !yataData.stocks) return map;
for (const [code, obj] of Object.entries(yataData.stocks)) {
const c = 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[c] = m;
}
return map;
}
// parse Prom format
function parseProm(promData) {
const map = {};
if (!promData) return map;
for (const [countryKey, countryVal] of Object.entries(promData)) {
if (!countryVal) continue;
const up = String(countryKey).trim().toUpperCase();
let code = up;
if (COUNTRY_NAME_TO_CODE[up]) code = COUNTRY_NAME_TO_CODE[up];
const m = {};
if (Array.isArray(countryVal.stocks)) {
for (const it of countryVal.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
} else {
for (const [k, v] of Object.entries(countryVal)) {
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)) {
for (const it of v.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
}
}
}
map[String(code).toUpperCase()] = m;
}
return map;
}
// sum across countries for a given item from a parsed map
function sumAcrossCountriesFor(itemName, parsedMap) {
if (!parsedMap) return 0;
let total = 0;
for (const c of Object.keys(parsedMap)) total += Number(parsedMap[c][itemName] || 0);
return total;
}
// Torn display parsing helpers
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;
}
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 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;
}
// build required lists for rendering
function buildRequiredList(mapObj) {
const fullNames = Object.keys(mapObj);
const shortNames = fullNames.map(fn => mapObj[fn].short);
const locByShort = {};
const countryByShort = {};
fullNames.forEach(fn => {
const s = mapObj[fn].short;
locByShort[s] = mapObj[fn].loc;
countryByShort[s] = mapObj[fn].country;
});
return { fullNames, shortNames, locByShort, countryByShort };
}
const flowersReq = buildRequiredList(FLOWERS);
const plushReq = buildRequiredList(PLUSHIES);
function countsForReq(itemsAgg, req, mapObj) {
const counts = {};
req.shortNames.forEach(s => counts[s] = 0);
req.fullNames.forEach(fn => {
const short = mapObj[fn].short;
const q = itemsAgg[fn] || 0;
counts[short] = (counts[short] || 0) + q;
});
return counts;
}
function calcSetsAndRemainderFromCounts(counts, shortNames) {
const countsArr = shortNames.map(n => counts[n] || 0);
const sets = countsArr.length ? Math.min(...countsArr) : 0;
const remainder = {};
shortNames.forEach(n => remainder[n] = Math.max(0, (counts[n] || 0) - sets));
return { sets, remainder };
}
function findLowest(remainder, locMap, countryMap) {
const keys = Object.keys(remainder);
if (!keys.length) return null;
let min = Infinity;
keys.forEach(k => { if (remainder[k] < min) min = remainder[k]; });
const allEqual = keys.every(k => remainder[k] === min);
if (allEqual) return null;
const key = keys.find(k => remainder[k] === min);
return { short: key, rem: min, loc: locMap[key] || '', country: countryMap[key] || '' };
}
// rendering: Points Exporter style, Av replaced with YATA priority fallback
function renderUI(itemsAgg, yataRaw, promRaw, sourcesUsed) {
const flowerTotals = countsForReq(itemsAgg, flowersReq, FLOWERS);
const plushTotals = countsForReq(itemsAgg, plushReq, PLUSHIES);
const fCalc = calcSetsAndRemainderFromCounts(flowerTotals, flowersReq.shortNames);
const pCalc = calcSetsAndRemainderFromCounts(plushTotals, plushReq.shortNames);
const totalSets = fCalc.sets + pCalc.sets;
const totalPoints = totalSets * 10;
const yataMap = parseYata(yataRaw);
const promMap = parseProm(promRaw);
// decide Av source: YATA primary; if YATA has zero for that item across all countries and Prom has value, use Prom.
function pickAvFor(fullName) {
const yataSum = sumAcrossCountriesFor(fullName, yataMap);
if (yataSum > 0) return { val: yataSum, src: 'Y' };
const promSum = sumAcrossCountriesFor(fullName, promMap);
if (promSum > 0) return { val: promSum, src: 'P' };
const yataResponded = yataRaw && Object.keys(yataRaw).length > 0;
const promResponded = promRaw && Object.keys(promRaw).length > 0;
if (yataResponded) return { val: yataSum, src: 'Y' };
if (promResponded) return { val: promSum, src: 'P' };
return { val: null, src: null };
}
// status & summary
if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets F:${fCalc.sets} P:${pCalc.sets}`;
if (summaryEl) summaryEl.innerHTML = `<div class="summary-line">Total sets: ${totalSets} | Points: ${totalPoints}</div>`;
const lowFlower = findLowest(fCalc.remainder, flowersReq.locByShort, flowersReq.countryByShort);
const lowPlush = findLowest(pCalc.remainder, plushReq.locByShort, plushReq.countryByShort);
let html = '';
if (lowFlower) {
html += `<div class="low-line">🛫 Low on ${escapeHtml(lowFlower.short)} — travel to ${escapeHtml(lowFlower.country)} ${escapeHtml(lowFlower.loc)} and import 🛬</div>`;
}
html += `<div class="group-title">Flowers — sets: ${fCalc.sets} | pts: ${fCalc.sets * 10}</div>`;
html += `<ul class="item-list">`;
flowersReq.fullNames.forEach(full => {
const short = FLOWERS[full].short;
const total = flowerTotals[short] ?? 0;
const picked = pickAvFor(full);
const avText = (picked && picked.val != null && picked.src) ? `${picked.val} Av [${picked.src}]` : '—';
const col = colorForPercent(total, Math.max(...Object.values(flowerTotals),1));
html += `<li class="item-row" style="color:${col}">
<span class="item-name">${escapeHtml(short)}</span>
<span class="item-total">${total}</span>
<span class="item-av">(${avText})</span>
<span class="item-loc">${FLOWERS[full].loc || ''}</span>
</li>`;
});
html += `</ul>`;
if (lowPlush) {
html += `<div class="low-line">🛫 Low on ${escapeHtml(lowPlush.short)} — travel to ${escapeHtml(lowPlush.country)} ${escapeHtml(lowPlush.loc)} and import 🛬</div>`;
}
html += `<div class="group-title">Plushies — sets: ${pCalc.sets} | pts: ${pCalc.sets * 10}</div>`;
html += `<ul class="item-list">`;
plushReq.fullNames.forEach(full => {
const short = PLUSHIES[full].short;
const total = plushTotals[short] ?? 0;
const picked = pickAvFor(full);
const avText = (picked && picked.val != null && picked.src) ? `${picked.val} Av [${picked.src}]` : '—';
const col = colorForPercent(total, Math.max(...Object.values(plushTotals),1));
html += `<li class="item-row" style="color:${col}">
<span class="item-name">${escapeHtml(short)}</span>
<span class="item-total">${total}</span>
<span class="item-av">(${avText})</span>
<span class="item-loc">${PLUSHIES[full].loc || ''}</span>
</li>`;
});
html += `</ul>`;
// Drugs (Xanax)
const xanInv = Number(itemsAgg[SPECIAL_DRUG] || 0);
const yataX = sumAcrossCountriesFor(SPECIAL_DRUG, yataMap);
const promX = sumAcrossCountriesFor(SPECIAL_DRUG, promMap);
let xanPicked = { val: null, src: null };
if (yataX > 0) xanPicked = { val: yataX, src: 'Y' };
else if (promX > 0) xanPicked = { val: promX, src: 'P' };
else if (yataRaw && Object.keys(yataRaw).length) xanPicked = { val: yataX, src: 'Y' };
else if (promRaw && Object.keys(promRaw).length) xanPicked = { val: promX, src: 'P' };
const xanAvText = xanPicked.val != null && xanPicked.src ? `${xanPicked.val} Av [${xanPicked.src}]` : '—';
html += `<div class="group-title">Drugs</div>`;
html += `<ul class="item-list"><li class="item-row" style="color:#dfe7ff">
<span class="item-name">${escapeHtml(SPECIAL_DRUG)}</span>
<span class="item-total">${xanInv}</span>
<span class="item-av">(${xanAvText})</span>
<span class="item-loc">🇿🇦</span>
</li></ul>`;
contentEl.innerHTML = html;
}
// refresh flow: fetch Torn display/inventory + YATA + Prometheus in parallel
async function refreshAll(force = false) {
if (statusEl) statusEl.textContent = 'Fetching...';
try {
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 itemsAgg = {};
if (displayFromApi && Object.keys(displayFromApi).length > 0) itemsAgg = displayFromApi;
else {
const dom = fetchDisplayViaDOM();
itemsAgg = dom || displayFromApi || {};
}
renderUI(itemsAgg, yataRaw, promRaw);
if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
} catch (err) {
console.warn('refreshAll err', err);
if (statusEl) statusEl.textContent = 'Update failed';
}
}
// Torn helpers
function gmGetJson(url, timeout = 14000) {
return new Promise((resolve, reject) => {
try {
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'))
});
} catch (e) { reject(e); }
});
}
// start
if (apiKey) { startPolling(); refreshAll(true); }
else { setTimeout(() => askKey(false), 300); }
window.addEventListener('beforeunload', () => stopPolling());
// keep aligned with PDA nav (fallback)
function repositionFallback() {
const el = document.getElementById(PANEL_ID);
if (!el) return;
el.style.top = getPDANavHeight() + 'px';
}
repositionFallback();
window.addEventListener('resize', repositionFallback);
new MutationObserver(repositionFallback).observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true });
})();