// ==UserScript==
// @name 🌺🧸 Unified Stock (v5.1) - Predictive Fly Suggestion + Points (Torn travel-aware)
// @namespace http://tampermonkey.net/
// @version 5.1.0
// @description One-line per item: display-case inv + foreign stk + flag/code. Predicts availability on arrival using YATA + Torn travel timings. Shows Sets & Points, single "✈ Fly to" suggestion for the lowest item. Refresh 45s. Uses display selection so works while flying.
// @author Nova (enhanced)
// @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_STOCK_URL = 'https://yata.yt/api/v1/travel/export/'; // original export with stocks
const YATA_TRAVEL_URL = 'https://yata.yt/api/v1/travel/export/'; // durations (same export endpoint often contains travel meta)
const REFRESH_MS = 45 * 1000;
const PANEL_WIDTH = 320;
const LOW_STOCK = 500; // threshold for "low abroad" (yellow fallback)
const POINTS_PER_SET = 10;
const TURNAROUND_MS = 2 * 60 * 1000; // time on ground to refuel/reselect, 2 minutes by default
const FALLBACK_FLIGHT_MS = 45 * 60 * 1000; // fallback flight time if none available
// ===== TRACKED ITEMS =====
const FLOWERS_ORDER = [
["Dahlia", {code:'mex', loc:'MX 🇲🇽'}],
["Orchid", {code:'haw', loc:'HW 🏝️'}],
["African Violet", {code:'sou', loc:'SA 🇿🇦'}],
["Cherry Blossom", {code:'jap', loc:'JP 🇯🇵'}],
["Peony", {code:'chi', loc:'CN 🇨🇳'}],
["Ceibo Flower", {code:'arg', loc:'AR 🇦🇷'}],
["Edelweiss", {code:'swi', loc:'CH 🇨🇭'}],
["Crocus", {code:'can', loc:'CA 🇨🇦'}],
["Heather", {code:'uni', loc:'UK 🇬🇧'}],
["Tribulus Omanense", {code:'uae', loc:'AE 🇦🇪'}],
["Banana Orchid", {code:'cay', loc:'KY 🇰🇾'}]
];
const PLUSHIES_ORDER = [
["Sheep Plushie", {code:null, loc:'B.B 🏪'}],
["Teddy Bear Plushie", {code:null, loc:'B.B 🏪'}],
["Kitten Plushie", {code:null, loc:'B.B 🏪'}],
["Jaguar Plushie", {code:'mex', loc:'MX 🇲🇽'}],
["Wolverine Plushie", {code:'can', loc:'CA 🇨🇦'}],
["Nessie Plushie", {code:'uni', loc:'UK 🇬🇧'}],
["Red Fox Plushie", {code:'uni', loc:'UK 🇬🇧'}],
["Monkey Plushie", {code:'arg', loc:'AR 🇦🇷'}],
["Chamois Plushie", {code:'swi', loc:'CH 🇨🇭'}],
["Panda Plushie", {code:'chi', loc:'CN 🇨🇳'}],
["Lion Plushie", {code:'sou', loc:'SA 🇿🇦'}],
["Camel Plushie", {code:'uae', loc:'AE 🇦🇪'}],
["Stingray Plushie", {code:'cay', loc:'KY 🇰🇾'}]
];
const SPECIAL_DRUG = "Xanax"; // show only for 'sou'
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'
};
const TRACKED_LIST = [
...FLOWERS_ORDER.map(x=>x[0]),
...PLUSHIES_ORDER.map(x=>x[0]),
SPECIAL_DRUG
];
// ===== UI styling (keeps original styling, adds orange class) =====
function getPDANavHeight() {
const nav = document.querySelector('#pda-nav') || document.querySelector('.pda');
return nav ? nav.offsetHeight : 40;
}
GM_addStyle(`
#uniStockPanel { 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; }
#uniHeader { background:#121212; padding:6px 8px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #333; user-select:none; }
#uniContent { padding:8px; }
#uniHeaderLeft { display:flex; flex-direction:column; gap:2px; }
#titleRow { font-weight:700; font-size:13px; cursor:pointer; }
#pointsRow { color:#bfc9d6; font-size:11px; }
.uni-row { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:4px 0; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
.uni-left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
.dot { width:10px; height:10px; border-radius:50%; display:inline-block; flex:0 0 10px; }
.g { background:#00c853; } .y { background:#ffb300; } .r { background:#ff1744; } .o { background:#ff8f00; }
.itemname { min-width:0; overflow:hidden; text-overflow:ellipsis; }
.meta { color:#bfc9d6; width:150px; text-align:right; font-size:11px; flex:0 0 150px; }
#uni_status { color:#9ea6b3; margin:6px 0; }
.fly { color:#9ad0ff; font-weight:700; margin-left:8px; }
.small { font-size:11px; color:#9ea6b3; margin-top:6px; }
.pts-btn { background:#171717; color:#eaeaea; border:1px solid #333; padding:4px 8px; border-radius:4px; cursor:pointer; }
`);
// ===== DOM build (unchanged) =====
const panel = document.createElement('div');
panel.id = 'uniStockPanel';
panel.innerHTML = `
<div id="uniHeader">
<div id="uniHeaderLeft">
<div id="titleRow">▶ 🌺🧸 Unified Stock</div>
<div id="pointsRow">Sets: - | Points: -</div>
</div>
<div style="display:flex;gap:6px;align-items:center">
<button id="uniRefresh" class="pts-btn">Refresh</button>
<button id="uniSetKey" class="pts-btn">Set Key</button>
</div>
</div>
<div id="uniContent">
<div id="uni_status">Initializing...</div>
<div id="uniList"></div>
<div class="small">Format: Item — (inv: X | stk: Y) · inv = display case count · stk = foreign shop stock · lowest item shows "✈ Fly to: CODE 🇿🇦"</div>
</div>
`;
document.body.appendChild(panel);
const titleRow = panel.querySelector('#titleRow');
const pointsRow = panel.querySelector('#pointsRow');
const statusEl = panel.querySelector('#uni_status');
const listEl = panel.querySelector('#uniList');
const btnRefresh = panel.querySelector('#uniRefresh');
const btnSetKey = panel.querySelector('#uniSetKey');
// collapse toggle (unchanged)
let collapsed = GM_getValue('uni_collapsed', false);
function updateCollapse(){
const content = panel.querySelector('#uniContent');
content.style.display = collapsed ? 'none' : 'block';
titleRow.textContent = (collapsed ? '▶' : '▼') + ' 🌺🧸 Unified Stock';
GM_setValue('uni_collapsed', collapsed);
}
titleRow.addEventListener('click', () => { collapsed = !collapsed; updateCollapse(); });
updateCollapse();
// API key save/load (unchanged)
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 (unchanged) =====
function gmGetJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'json',
onload: (res) => {
let d = res.response;
if (!d && res.responseText) {
try { d = JSON.parse(res.responseText); } catch(e) { return reject(new Error('Invalid JSON')); }
}
resolve(d);
},
onerror: (err) => reject(err),
ontimeout: () => reject(new Error('timeout'))
});
});
}
// ===== fetch display-case only (unchanged) =====
async function fetchDisplayCaseSafe() {
if (!apiKey) return {};
const url = `https://api.torn.com/user/?selections=display,travel&key=${encodeURIComponent(apiKey)}`;
// Note: we ask for travel selection too so we can get travel info from Torn API if available
try {
const data = await gmGetJson(url);
if (!data || data.error) return {};
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;
}
return { display: out, travelSelection: data.travel ?? data.selections?.travel ?? null };
} catch (e) {
console.warn('display fetch failed', e);
return { display: {}, travelSelection: null };
}
}
// ===== fetch YATA (unchanged endpoint variable) =====
async function fetchYataSafe() {
try {
const data = await gmGetJson(YATA_STOCK_URL);
return data || null;
} catch (e) {
console.warn('YATA fetch failed', e);
return null;
}
}
// ===== compute sets & points (unchanged) =====
function computeSets(displayMap) {
const flowerCounts = FLOWERS_ORDER.map(([name]) => Number(displayMap[name] || 0));
const plushCounts = PLUSHIES_ORDER.map(([name]) => Number(displayMap[name] || 0));
const fSets = flowerCounts.length ? Math.min(...flowerCounts) : 0;
const pSets = plushCounts.length ? Math.min(...plushCounts) : 0;
const totalSets = (isFinite(fSets) ? fSets : 0) + (isFinite(pSets) ? pSets : 0);
const points = totalSets * POINTS_PER_SET;
return { totalSets, points, fSets, pSets };
}
// ===== utilities for timestamps/parsing =====
function parsePossibleTimestamp(val) {
if (!val) return null;
if (typeof val === 'number') return val;
const n = Number(val);
if (!Number.isNaN(n) && n > 1000000000) return n; // likely ms/epoch
const d = Date.parse(val);
if (!Number.isNaN(d)) return d;
return null;
}
// ===== get remaining flight time from Torn sources =====
// Try multiple sources (Torn API travel selection if present, window.tornTravel, DOM timer)
function parseRemainingFromTravelSelection(travelSelection) {
// travelSelection may be in different shapes; look for 'return' or 'return_in' or 'arrival'
if (!travelSelection) return 0;
try {
// If Torn API returns a 'travel' object like {destination: ..., arrival: timestamp}
if (travelSelection.arrival) {
const arr = parsePossibleTimestamp(travelSelection.arrival);
if (arr) return Math.max(0, arr - Date.now());
}
// Some variants: travelSelection.return_in (ms), travelSelection.return (timestamp)
if (travelSelection.return_in) return Number(travelSelection.return_in) || 0;
if (travelSelection.return) {
const r = parsePossibleTimestamp(travelSelection.return);
if (r) return Math.max(0, r - Date.now());
}
// older shapes might have 'travel_time_remaining' in seconds
if (travelSelection.travel_time_remaining) {
const s = Number(travelSelection.travel_time_remaining);
if (!Number.isNaN(s)) return s * 1000;
}
} catch (e) { /* ignore and fallback */ }
return 0;
}
function parseRemainingFromDOM() {
// Torn shows timers in a few DOM places; be permissive
const possible = [
document.querySelector('.travelCountdown'), // potential class
document.querySelector('.travelTimer'), // potential class
document.querySelector('.timer'), // generic timer
document.querySelector('#travel-timer'), // other
document.querySelector('.countdown') // other
].filter(Boolean);
for (const el of possible) {
const txt = el.textContent.trim();
if (!txt) continue;
// formats: "00:31:20" or "31m 20s" or "31m"
// try HH:MM:SS or MM:SS:
const hhmmss = txt.match(/^(\d+):(\d{2}):(\d{2})$/);
if (hhmmss) {
const ms = (Number(hhmmss[1])*3600 + Number(hhmmss[2])*60 + Number(hhmmss[3])) * 1000;
return ms;
}
const mmss = txt.match(/^(\d+):(\d{2})$/);
if (mmss) {
const ms = (Number(mmss[1])*60 + Number(mmss[2])) * 1000;
return ms;
}
const mms = txt.match(/(\d+)\s*h/);
const mms2 = txt.match(/(\d+)\s*m/);
const mms3 = txt.match(/(\d+)\s*s/);
const h = mms ? Number(mms[1]) : 0;
const m = mms2 ? Number(mms2[1]) : 0;
const s = mms3 ? Number(mms3[1]) : 0;
if (h || m || s) return ((h*3600)+(m*60)+s)*1000;
// fallback parse numbers like "31m 20s"
const alt = txt.match(/(\d+)\s*m/);
if (alt) return Number(alt[1]) * 60 * 1000;
}
return 0;
}
function getRemainingFlightMs(travelSelection) {
// priority: travelSelection -> window.tornTravel / global -> DOM
let ms = 0;
if (travelSelection) ms = parseRemainingFromTravelSelection(travelSelection) || 0;
if (!ms && (window.tornTravel || window.travel_data || window.torn)) {
try {
const t = window.tornTravel || window.travel_data || (window.torn && window.torn.travel);
if (t && t.arrival) {
const arr = parsePossibleTimestamp(t.arrival);
if (arr) ms = Math.max(0, arr - Date.now());
} else if (t && t.return_in) ms = Number(t.return_in) || 0;
} catch (e) { /* ignore */ }
}
if (!ms) ms = parseRemainingFromDOM() || 0;
return ms;
}
// ===== flight duration lookup helpers (Torn travel info / YATA fallback / hardcoded) =====
// Torn travel durations: we will attempt to read from Torn API travel selection (if it includes a travel table),
// else try DOM travel table (page.php?sid=travel), else fallback to YATA travel export, else hardcoded map.
function parseTravelDurationsFromSelection(travelSelection) {
// travelSelection may include an object listing durations per destination; try common shapes
if (!travelSelection) return null;
try {
// possibility: travelSelection.destinations { code: { time: ms } }
if (travelSelection.destinations && typeof travelSelection.destinations === 'object') {
const out = {};
for (const [k,v] of Object.entries(travelSelection.destinations)) {
const d = v.duration ?? v.time ?? v.ms ?? v.seconds ?? v.travel_time;
if (d === undefined) continue;
if (typeof d === 'number') {
out[k.toLowerCase()] = (d > 0 && d < 20000) ? d * 1000 : d;
} else {
const n = Number(d);
if (!Number.isNaN(n)) out[k.toLowerCase()] = (n > 0 && n < 20000) ? n * 1000 : n;
}
}
if (Object.keys(out).length) return out;
}
// other shapes: travelSelection.table with rows that include 'code' and 'duration'
if (Array.isArray(travelSelection)) {
const out = {};
for (const r of travelSelection) {
const code = (r.code || r.country || r.destination || r.id);
const d = r.duration ?? r.time ?? r.ms ?? r.seconds;
if (!code || !d) continue;
const num = Number(d);
out[String(code).toLowerCase()] = (num > 0 && num < 20000) ? num * 1000 : num;
}
if (Object.keys(out).length) return out;
}
} catch (e) { /* ignore */ }
return null;
}
function parseTravelDurationsFromDOM() {
// parse travel table on page.php?sid=travel - look for time cells
const rows = document.querySelectorAll('.travels-table tr, .travel-row, .destination-row');
if (!rows || rows.length === 0) return null;
const out = {};
rows.forEach(row => {
try {
const text = row.textContent || '';
// try to find a country code in a known list
for (const [code,name] of Object.entries(COUNTRY_NAMES)) {
if (text.includes(name) || text.includes(code.toUpperCase())) {
// find a time token like "1h 20m" or "80m" or "1:20:00" or "75m"
const match = text.match(/(\d+\s*h(?:ours?)?\s*)?(\d+\s*m)?(\s*\d+\s*s)?/i);
let ms = null;
// prefer hh:mm:ss
const hm = text.match(/(\d+):(\d{2}):(\d{2})/);
if (hm) ms = (Number(hm[1])*3600 + Number(hm[2])*60 + Number(hm[3]))*1000;
else {
const mh = text.match(/(\d+)\s*h/);
const mm = text.match(/(\d+)\s*m/);
const msn = text.match(/(\d+)\s*s/);
const h = mh ? Number(mh[1]) : 0;
const m = mm ? Number(mm[1]) : 0;
const s = msn ? Number(msn[1]) : 0;
if (h || m || s) ms = (h*3600 + m*60 + s) * 1000;
}
if (ms) out[code] = ms;
}
}
} catch (e) { /* ignore */ }
});
if (Object.keys(out).length) return out;
return null;
}
async function fetchYataTravelDurations() {
try {
const data = await gmGetJson(YATA_TRAVEL_URL);
// YATA sometimes includes travel meta at root; try common shapes
if (!data) return null;
const out = {};
if (data.travel && typeof data.travel === 'object') {
for (const [k,v] of Object.entries(data.travel)) {
const dur = v.duration ?? v.time ?? v.ms ?? v.seconds;
if (!dur) continue;
if (typeof dur === 'number') out[k.toLowerCase()] = (dur > 0 && dur < 20000) ? dur * 1000 : dur;
else {
const n = Number(dur);
if (!Number.isNaN(n)) out[k.toLowerCase()] = (n > 0 && n < 20000) ? n * 1000 : n;
}
}
if (Object.keys(out).length) return out;
}
// sometimes YATA returns top-level with codes
for (const [k,v] of Object.entries(data)) {
if (v && (v.duration || v.time || v.ms || v.seconds)) {
const dur = v.duration ?? v.time ?? v.ms ?? v.seconds;
const val = typeof dur === 'number' ? dur : Number(dur);
if (!Number.isNaN(val)) out[k.toLowerCase()] = (val > 0 && val < 20000) ? val * 1000 : val;
}
}
if (Object.keys(out).length) return out;
return null;
} catch (e) {
console.warn('YATA travel durations fetch failed', e);
return null;
}
}
// fallback hardcoded (keeps reasonable defaults)
const HARDCODE_FLIGHT_MS = {
mex: 30*60*1000, can: 40*60*1000, haw: 60*60*1000, uni: 40*60*1000, arg: 80*60*1000,
swi: 70*60*1000, jap: 90*60*1000, chi: 100*60*1000, uae: 110*60*1000, cay: 50*60*1000, sou: 75*60*1000
};
function getFlightDurationMs(travelDurations, code) {
if (!code) return FALLBACK_FLIGHT_MS;
const lc = code.toLowerCase();
if (travelDurations && travelDurations[lc]) return travelDurations[lc];
if (travelDurations && travelDurations[code]) return travelDurations[code];
if (HARDCODE_FLIGHT_MS[lc]) return HARDCODE_FLIGHT_MS[lc];
return FALLBACK_FLIGHT_MS;
}
// ===== Prediction logic =====
// For a given yata item object and a computed arrival timestamp, returns status 'green'|'orange'|'red'
function predictStatusForArrival(itemObj, arrivalTs) {
const stock = Number(itemObj.quantity ?? itemObj.stock ?? 0) || 0;
const depTs = parsePossibleTimestamp(itemObj.depletion);
const restTs = parsePossibleTimestamp(itemObj.restock);
// currently stocked and deplete unknown or after arrival -> green
if (stock > 0 && (!depTs || depTs > arrivalTs)) return 'green';
// currently stocked but depletes before arrival -> red
if (stock > 0 && depTs && depTs <= arrivalTs) return 'red';
// currently empty but will restock before arrival -> orange
if (stock === 0 && restTs && restTs <= arrivalTs) return 'orange';
// otherwise treat as red/unavailable
return 'red';
}
// ===== build unified list & fly suggestion (now predictive) =====
// This function returns itemsInfo list, lowest item, and yataStocks map (as original)
function buildUnified(displayMap, yataData, travelDurationsFromSources, travelSelection) {
// build map countryCode -> {itemName: { quantity, depletion, restock } }
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] = {
quantity: Number(it.quantity ?? it.qty ?? it.stock ?? 0) || 0,
depletion: it.depletion ?? it.deplete ?? it.depletionTime ?? null,
restock: it.restock ?? it.restock_time ?? it.restockTime ?? null
};
}
}
}
// Precompute remaining flight back to Torn (if any)
const remainingMs = getRemainingFlightMs(travelSelection) || 0;
const now = Date.now();
// Construct itemsInfo for tracked list
const itemsInfo = [];
for (const name of TRACKED_LIST) {
if (name === SPECIAL_DRUG) {
// Only sou for Xanax
const code = 'sou';
const stockObj = yataStocks[code]?.[SPECIAL_DRUG] ?? { quantity:0, depletion:null, restock:null };
const inv = Number(displayMap[name] ?? 0) || 0;
itemsInfo.push({ name, inv, perCountry: { [code]: stockObj }, loc: 'SA 🇿🇦' });
continue;
}
const perCountry = {};
for (const [code, map] of Object.entries(yataStocks)) {
const obj = map[name];
if (obj) perCountry[code] = obj;
}
// include known home loc text (for UI)
const f = FLOWERS_ORDER.find(x => x[0] === name);
const p = PLUSHIES_ORDER.find(x => x[0] === name);
const knownLoc = f ? f[1].loc : (p ? p[1].loc : '');
const inv = Number(displayMap[name] ?? 0) || 0;
itemsInfo.push({ name, inv, perCountry, loc: knownLoc });
}
// For each item, evaluate candidate countries with predicted status at arrival
for (const it of itemsInfo) {
const candidates = [];
for (const [code, obj] of Object.entries(it.perCountry || {})) {
// compute nextFlight duration from the best available source
const nextFlightMs = getFlightDurationMs(travelDurationsFromSources, code);
const arrivalTs = now + remainingMs + TURNAROUND_MS + nextFlightMs;
const predicted = predictStatusForArrival({ quantity: obj.quantity, depletion: obj.depletion, restock: obj.restock }, arrivalTs);
const stockNow = Number(obj.quantity || 0);
candidates.push({ code, predicted, stockNow, nextFlightMs, obj, arrivalTs });
}
if (candidates.length === 0) {
it.bestCode = null;
it.bestStk = 0;
it.bestPredicted = null;
continue;
}
// rank: green > orange > red ; tie-breaker: stockNow desc ; then flightMs asc
const rank = s => (s === 'green' ? 3 : (s === 'orange' ? 2 : (s === 'red' ? 1 : 0)));
candidates.sort((A,B) => {
const rA = rank(A.predicted), rB = rank(B.predicted);
if (rA !== rB) return rB - rA;
if (A.stockNow !== B.stockNow) return B.stockNow - A.stockNow;
return A.nextFlightMs - B.nextFlightMs;
});
it.bestCode = candidates[0].code;
it.bestStk = candidates[0].stockNow;
it.bestPredicted = candidates[0].predicted;
it.bestObj = candidates[0].obj;
it.bestArrival = candidates[0].arrivalTs;
}
// find lowest by display count (inv). Prefer items with inv==0. Tie-breaker: smaller bestStk then lexicographic.
let lowest = null;
for (const it of itemsInfo) {
if (!lowest) { lowest = it; continue; }
if ((it.inv || 0) < (lowest.inv || 0)) lowest = it;
else if ((it.inv || 0) === (lowest.inv || 0)) {
if ((it.bestStk || 0) < (lowest.bestStk || 0)) lowest = it;
else if ((it.bestStk || 0) === (lowest.bestStk || 0)) {
if ((it.name || '') < (lowest.name || '')) lowest = it;
}
}
}
return { itemsInfo, lowest, yataStocks };
}
// ===== dot class from predictive status =====
function dotClassFromPred(pred) {
if (pred === 'green') return 'g';
if (pred === 'orange') return 'o';
if (pred === 'red') return 'r';
return 'r';
}
// ===== flag helper (unchanged) =====
function getFlagForCode(code) {
if (!code) return '';
const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
return map[code] || '';
}
// ===== render one-line layout (keeps original look, uses predictive dot) =====
function renderAll(displayMap, yataData, travelDurationsFromSources, travelSelection) {
const { totalSets, points } = computeSets(displayMap);
pointsRow.textContent = `Sets: ${totalSets} | Points: ${points}`;
const { itemsInfo, lowest } = buildUnified(displayMap, yataData, travelDurationsFromSources, travelSelection);
const order = [];
for (const [name] of FLOWERS_ORDER) order.push(name);
for (const [name] of PLUSHIES_ORDER) order.push(name);
order.push(SPECIAL_DRUG);
const infoByName = {};
for (const it of itemsInfo) infoByName[it.name] = it;
let html = '';
for (const name of order) {
const it = infoByName[name];
if (!it) continue;
const inv = Number(it.inv || 0);
const stk = Number(it.bestStk || 0);
const predicted = it.bestPredicted || null;
const cls = dotClassFromPred(predicted);
const locText = it.loc || (it.bestCode ? (COUNTRY_NAMES[it.bestCode] ? `${COUNTRY_NAMES[it.bestCode]} ${it.bestCode.toUpperCase()}` : it.bestCode.toUpperCase()) : '');
const flagPart = it.loc ? ` ${it.loc}` : (it.bestCode ? ` ${it.bestCode.toUpperCase()}` : '');
// fly suggestion only if this is the lowest item AND there's a best country with predicted availability of any kind
const flyNote = (lowest && name === lowest.name && it.bestCode && it.bestPredicted)
? ` <span class="fly">✈ Fly to: ${ (COUNTRY_NAMES[it.bestCode] || it.bestCode.toUpperCase()) } ${ getFlagForCode(it.bestCode) }</span>`
: '';
const meta = `(inv: ${inv} | stk: ${stk})`;
html += `<div class="uni-row"><div class="uni-left"><span class="dot ${cls}"></span><div class="itemname">${escapeHtml(name)}</div></div><div class="meta">${meta}${flyNote}${flagPart ? ' ' + escapeHtml(flagPart) : ''}</div></div>`;
}
listEl.innerHTML = html || `<div style="color:#999;">No tracked items found.</div>`;
}
// ===== master refresh (now fetches travel durations from Torn API if possible, else fallback to DOM/YATA/hardcoded) =====
let timer = null;
// last-known caches (preserve on failure)
let lastYataData = null;
let lastTravelDurations = null;
let lastDisplayMap = {};
let lastTravelSelection = null;
async function refreshAll(force=false) {
statusEl.textContent = 'Updating...';
try {
// fetch display + travel selection together (display includes user.display; travel selection may be present)
const dispRes = await fetchDisplayCaseSafe();
const displayMap = dispRes.display || {};
const travelSelection = dispRes.travelSelection || null;
// fetch YATA stocks (prefer fresh)
let yataData = null;
try {
yataData = await fetchYataSafe();
if (yataData) lastYataData = yataData;
} catch (e) {
yataData = lastYataData;
}
// try to get travel durations from Torn API travelSelection, then DOM, then YATA, then fallback map
let travelDurations = null;
try {
travelDurations = parseTravelDurationsFromSelection(travelSelection) || parseTravelDurationsFromDOM() || await fetchYataTravelDurations() || lastTravelDurations || HARDCODE_FLIGHT_MS;
if (travelDurations && Object.keys(travelDurations).length) lastTravelDurations = travelDurations;
} catch (e) {
travelDurations = lastTravelDurations || HARDCODE_FLIGHT_MS;
}
// render everything
renderAll(displayMap, yataData || lastYataData, travelDurations, travelSelection);
// update status
const t = new Date();
const yOk = yataData && yataData.stocks ? 'OK' : (lastYataData && lastYataData.stocks ? 'STALE' : 'ERR');
const trOk = travelDurations && Object.keys(travelDurations).length ? 'OK' : 'ERR';
statusEl.textContent = `Updated: ${t.toLocaleTimeString()} · YATA stocks: ${yOk} · Travel: ${trOk}`;
// save last maps for fallback
lastDisplayMap = displayMap;
lastTravelSelection = travelSelection;
} catch (e) {
console.warn('refreshAll error', e);
statusEl.textContent = 'Update error (see console)';
// show last known data if any
renderAll(lastDisplayMap || {}, lastYataData || {stocks:{}}, lastTravelDurations || HARDCODE_FLIGHT_MS, lastTravelSelection || null);
}
}
// ===== fetchYataTravelDurations helper (used in refreshAll) =====
async function fetchYataTravelDurations() {
try {
const data = await gmGetJson(YATA_TRAVEL_URL);
if (!data) return null;
// Try to extract durations out of data similar to fetchYataTravelDurations earlier
const out = {};
// YATA structure may contain codes at top-level or under 'travel'
const tryAdd = (k,v) => {
const dur = v.duration ?? v.time ?? v.ms ?? v.seconds;
if (dur === undefined || dur === null) return;
const val = typeof dur === 'number' ? dur : Number(dur);
if (!Number.isNaN(val)) out[k.toLowerCase()] = (val > 0 && val < 20000) ? val*1000 : val;
};
if (data.travel && typeof data.travel === 'object') {
for (const [k,v] of Object.entries(data.travel)) tryAdd(k,v);
}
for (const [k,v] of Object.entries(data)) {
if (k === 'travel') continue;
if (v && typeof v === 'object') tryAdd(k,v);
}
if (Object.keys(out).length) return out;
return null;
} catch (e) {
console.warn('fetchYataTravelDurations error', e);
return null;
}
}
// ===== helpers reused earlier =====
async function fetchYataSafe() {
try {
const data = await gmGetJson(YATA_STOCK_URL);
return data || null;
} catch (e) {
console.warn('YATA fetch error', e);
return null;
}
}
async function fetchDisplayCaseSafe() {
if (!apiKey) return { display: {}, travelSelection: null };
const url = `https://api.torn.com/user/?selections=display,travel&key=${encodeURIComponent(apiKey)}`;
try {
const data = await gmGetJson(url);
if (!data || data.error) return { display: {}, travelSelection: null };
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;
}
// travel selection may be in different spots depending on Torn API format
const travelSelection = data.travel ?? data.selections?.travel ?? data;
return { display: out, travelSelection };
} catch (e) {
console.warn('display fetch error', e);
return { display: {}, travelSelection: null };
}
}
// ===== utilities (escapeHtml) =====
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// ===== init =====
refreshAll(true);
timer = setInterval(() => refreshAll(false), REFRESH_MS);
window.addEventListener('beforeunload', () => { if (timer) clearInterval(timer); });
})();