您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
当前为
// ==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); }); })();