A comprehensive Torn.com travel farming companion for TornPDA & Tampermonkey. Tracks plushie, flower, and prehistoric point sets, meteorite & fossil counts, live overseas stock via YATA, Xanax counter with faction totals, points value estimation, and Bits 'n' Bobs shop shortcuts. Features section toggles, draggable panel, colour-coded stock warnings, and an interactive hint carousel. Fork of "Points Museum" by SuperNovae [2637223] — extended and rebuilt by Phillip_J_Fry / OSDevscape.
ของเมื่อวันที่
// ==UserScript==
// @name ✈️ Loot Tracker
// @namespace https://greatest.deepsurf.us/users/OSMays8338
// @version 4.0.2
// @description A comprehensive Torn.com travel farming companion for TornPDA & Tampermonkey. Tracks plushie, flower, and prehistoric point sets, meteorite & fossil counts, live overseas stock via YATA, Xanax counter with faction totals, points value estimation, and Bits 'n' Bobs shop shortcuts. Features section toggles, draggable panel, colour-coded stock warnings, and an interactive hint carousel. Fork of "Points Museum" by SuperNovae [2637223] — extended and rebuilt by Phillip_J_Fry / OSDevscape.
// @author Phillip_J_Fry (OSMays8338 on Greasyfork) — OSDevscape
// @license MIT — Original base "Points Museum" by SuperNovae [2637223] (Victor the great on Greasyfork). All extensions, modifications, and new features by OSDevscape / Phillip_J_Fry.
// @match https://www.torn.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect yata.yt
// @connect api.torn.com
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// =============================================================================
// ✈️ PJFry's Loot Tracker — v4.0.0
// =============================================================================
//
// Author : Phillip_J_Fry | Torn: Phillip_J_Fry | GF: OSMays8338
// Company : OSDevscape
// Repo : https://greatest.deepsurf.us/users/OSMays8338
//
// Based on "✈️ Points Museum" (v3.5.0)
// Original author : SuperNovae [2637223] (Victor the great on Greasyfork)
// Original thread : https://www.torn.com/forums.php#/p=threads&f=67&t=16530836
// Original script : https://greatest.deepsurf.us/en/scripts/559534-points-museum
//
// What's new in this fork (v4.0.0):
// - Full TornPDA compatibility — GM shim layer, localStorage persistence
// - Draggable floating toggle with snap-to-edge behaviour
// - Prehistoric Points section (Quartz, Chalcedony, Basalt, Quartzite,
// Chert, Obsidian) — 25 pts/set
// - Special items section (Meteorite Fragment, Patagonian Fossil)
// - Xanax tracker with personal count, carry limit & faction armoury total
// - Bits 'n' Bobs shop shortcut for Sheep, Teddy Bear & Kitten plushies
// - Live abroad stock via YATA with colour-coded thresholds
// - Section visibility toggles in settings menu
// - Manual refresh button in panel header
// - Interactive 5-slide hint carousel (retrigger from settings)
// - Column header tooltips explaining Own / Abroad columns
// - Points market value in summary bar
// - Robust timeout handling — no more stuck "UPDATING..."
// - Gold & Green theme
//
// License : MIT
// Original base "Points Museum" copyright SuperNovae [2637223]
// All extensions and modifications copyright OSDevscape / Phillip_J_Fry
//
// =============================================================================
/* ================= STORAGE (localStorage fallback — never touches window.GM) ================= */
// TornPDA seals window.GM as non-configurable. We NEVER assign to window.GM_*.
// Instead use localStorage directly for persistence, and route XHR through
// TornPDA's native GM_xmlhttpRequest (already in scope via @grant) with fetch fallback.
const _store = {
get(key, def) {
try { const v = localStorage.getItem('pjfry_' + key); return v === null ? def : JSON.parse(v); }
catch { return def; }
},
set(key, val) {
try { localStorage.setItem('pjfry_' + key, JSON.stringify(val)); } catch {}
}
};
// Cross-platform GM storage — works on TornPDA and Tampermonkey
// Neither platform allows redeclaring GM_getValue/GM_setValue, so we
// wrap them once here and use _gmGet/_gmSet throughout.
const _gmGet = typeof GM_getValue === 'function' ? GM_getValue : (k, d) => _store.get(k, d);
const _gmSet = typeof GM_setValue === 'function' ? GM_setValue : (k, v) => _store.set(k, v);
// Style injection — plain DOM, no GM_addStyle
function GM_addStyle(css) {
const s = document.createElement('style');
s.textContent = css;
(document.head || document.documentElement).appendChild(s);
}
function withTimeout(promise, ms, fallback) {
return Promise.race([
promise,
new Promise(resolve => setTimeout(() => resolve(fallback), ms))
]);
}
// External JSON fetch — uses TornPDA's GM_xmlhttpRequest if available, else fetch
function gmJSON(url, timeoutMs = 10000) {
const req = new Promise(resolve => {
// GM_xmlhttpRequest is injected by TornPDA into scope (not on window)
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET', url,
timeout: timeoutMs,
onload: r => { try { resolve(JSON.parse(r.responseText)); } catch { resolve({}); } },
onerror: () => resolve({}),
ontimeout: () => resolve({})
});
} else {
fetch(url).then(r => r.json()).then(resolve).catch(() => resolve({}));
}
});
return withTimeout(req, timeoutMs, {});
}
/* ================= CONFIG ================= */
const PANEL_ID = 'travel_mini_hud';
const TOGGLE_ID = 'travel_mini_toggle';
const API_PANEL_ID = 'travel_api_panel';
const POLL = 45000;
const PRE_PTS = 25, FLO_PTS = 10, PLU_PTS = 10, MET_PTS = 15, FOS_PTS = 20;
const POINTS_ENDPOINT = 'https://api.torn.com/v2/market/pointsmarket';
let currentPointsPrice = 0;
let pointsPriceCache = { time: 0, price: 0, history: [] };
const POINTS_CACHE_DURATION = 300000;
const POINTS_HISTORY_SIZE = 5;
const PLUSHIE_THRESHOLD = 2000;
const FLOWER_THRESHOLD = 5000;
const XANAX_ITEM_ID = 206;
const XANAX_NAME = 'Xanax';
const XANAX_STORAGE_KEY = 'xanax_personal_count';
const XANAX_CARRY_KEY = 'xanax_carry_limit';
// Hint bubble storage key
const HINTS_SHOWN_KEY = 'hints_shown';
// Section visibility settings
const SECTION_KEYS = {
prehistoric: 'show_prehistoric',
plushies: 'show_plushies',
flowers: 'show_flowers',
special: 'show_special',
xanax: 'show_xanax',
};
const itemImg = id => `https://www.torn.com/images/items/${id}/large.png`;
// BoB shop item IDs — stock scraped from shops page (no API available)
const BOB_ITEM_IDS = new Set([186, 187, 215]); // Sheep, Teddy Bear, Kitten
/* ================= LOCATION MAP ================= */
const LOCATIONS = {
"Mexico": { flag: "🇲🇽", label: "Mexico" },
"Hawaii": { flag: "🏝️", label: "Hawaii" },
"South Africa": { flag: "🇿🇦", label: "South Africa" },
"Japan": { flag: "🇯🇵", label: "Japan" },
"China": { flag: "🇨🇳", label: "China" },
"Argentina": { flag: "🇦🇷", label: "Argentina" },
"Switzerland": { flag: "🇨🇭", label: "Switzerland" },
"Canada": { flag: "🇨🇦", label: "Canada" },
"UK": { flag: "🇬🇧", label: "United Kingdom" },
"UAE": { flag: "🇦🇪", label: "UAE" },
"Cayman Islands": { flag: "🇰🇾", label: "Cayman Islands" },
"BoB": { flag: "🏪", label: "Big Al's Bag of Bits (BoB)" },
};
/* ================= DATA ================= */
const SPECIAL_ITEMS = {
"Meteorite Fragment": { id: 512, s: "Meteor", loc: "Argentina" },
"Patagonian Fossil": { id: 513, s: "Fossil", loc: "Argentina" }
};
const GROUPS = {
Prehistoric: {
pts: PRE_PTS,
items: {
"Quartz Point": { id: 619, s: "Quartz", loc: "Canada" },
"Chalcedony Point":{ id: 620, s: "Chalced", loc: "Argentina" },
"Basalt Point": { id: 621, s: "Basalt", loc: "Hawaii" },
"Quartzite Point": { id: 622, s: "Quartzit",loc: "South Africa" },
"Chert Point": { id: 623, s: "Chert", loc: "UK" },
"Obsidian Point": { id: 624, s: "Obsidian",loc: "Mexico" }
}
},
Plushies: {
pts: PLU_PTS,
items: {
"Sheep Plushie": { id: 186, s: "Sheep", loc: "BoB" },
"Teddy Bear Plushie": { id: 187, s: "Teddy", loc: "BoB" },
"Kitten Plushie": { id: 215, s: "Kitten", loc: "BoB" },
"Jaguar Plushie": { id: 258, s: "Jaguar", loc: "Mexico" },
"Wolverine Plushie": { id: 261, s: "Wolverine", loc: "Canada" },
"Nessie Plushie": { id: 266, s: "Nessie", loc: "UK" },
"Red Fox Plushie": { id: 268, s: "Fox", loc: "UK" },
"Monkey Plushie": { id: 269, s: "Monkey", loc: "Argentina" },
"Chamois Plushie": { id: 273, s: "Chamois", loc: "Switzerland" },
"Panda Plushie": { id: 274, s: "Panda", loc: "China" },
"Lion Plushie": { id: 281, s: "Lion", loc: "South Africa" },
"Camel Plushie": { id: 384, s: "Camel", loc: "UAE" },
"Stingray Plushie": { id: 618, s: "Stingray", loc: "Cayman Islands" }
}
},
Flowers: {
pts: FLO_PTS,
items: {
"Dahlia": { id: 260, s: "Dahlia", loc: "Mexico" },
"Orchid": { id: 264, s: "Orchid", loc: "Hawaii" },
"African Violet": { id: 282, s: "Violet", loc: "South Africa" },
"Cherry Blossom": { id: 277, s: "Blossoms", loc: "Japan" },
"Peony": { id: 276, s: "Peony", loc: "China" },
"Ceibo Flower": { id: 271, s: "Ceibo", loc: "Argentina" },
"Edelweiss": { id: 272, s: "Edelweiss", loc: "Switzerland" },
"Crocus": { id: 263, s: "Crocus", loc: "Canada" },
"Heather": { id: 267, s: "Heather", loc: "UK" },
"Tribulus Omanense": { id: 385, s: "Tribulus", loc: "UAE" },
"Banana Orchid": { id: 617, s: "B. Orchid", loc: "Cayman Islands" }
}
}
};
/* ================= STYLES ================= */
GM_addStyle(`
@keyframes fadeSlide {
from { opacity:0; transform:translateX(20px) scale(0.95); }
to { opacity:1; transform:translateX(0) scale(1); }
}
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes gentlePulse { 0%,100%{opacity:.8;} 50%{opacity:1;} }
@keyframes subtleFloat { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-2px);} }
@keyframes highlightPulse { 0%,100%{box-shadow:0 0 0 0 rgba(0,255,0,.4);} 50%{box-shadow:0 0 0 3px rgba(0,255,0,0);} }
@keyframes warningPulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,165,0,.4);} 50%{box-shadow:0 0 0 3px rgba(255,165,0,0);} }
@keyframes dangerPulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,0,0,.4);} 50%{box-shadow:0 0 0 3px rgba(255,0,0,0);} }
@keyframes xanPopIn {
from { opacity:0; transform:scale(0.88) translateY(6px); }
to { opacity:1; transform:scale(1) translateY(0); }
}
/* ── API Panel ── */
#${API_PANEL_ID} {
position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
width:320px;
background:linear-gradient(145deg,rgba(0,0,0,0.98),rgba(10,15,25,0.97));
color:#e0f0ff; font:12px 'Segoe UI',sans-serif;
border:1px solid rgba(76,175,80,0.35); border-radius:12px;
z-index:1000001;
box-shadow:inset 0 0 30px rgba(255,215,0,0.1),0 10px 40px rgba(0,0,0,0.7),0 0 50px rgba(76,175,80,0.35);
backdrop-filter:blur(10px); overflow:hidden; animation:fadeSlide 0.4s ease-out;
}
#${API_PANEL_ID} .api-header {
padding:16px; font-weight:600;
background:linear-gradient(90deg,rgba(255,215,0,0.2),transparent);
color:#FFD700; border-bottom:1px solid rgba(76,175,80,0.35);
font-size:13px; letter-spacing:0.5px; text-transform:uppercase;
display:flex; align-items:center; gap:8px;
}
#${API_PANEL_ID} .api-content { padding:16px; }
#${API_PANEL_ID} .api-input-group { margin-bottom:16px; }
#${API_PANEL_ID} .api-label { display:block; margin-bottom:6px; color:#8BC34A; font-weight:600; font-size:11px; }
#${API_PANEL_ID} .api-input {
width:100%; padding:10px 12px; box-sizing:border-box;
background:rgba(30,40,55,0.8); border:1px solid rgba(255,215,0,0.3);
border-radius:6px; color:#fff; font-size:12px; font-family:'Consolas',monospace;
transition:all 0.2s ease;
}
#${API_PANEL_ID} .api-input:focus {
outline:none; border-color:#FFD700;
box-shadow:0 0 0 2px rgba(255,215,0,0.2); background:rgba(40,50,65,0.9);
}
#${API_PANEL_ID} .api-note {
background:rgba(255,165,0,0.1); border-left:3px solid rgba(255,165,0,0.6);
padding:10px 12px; margin:12px 0; border-radius:4px;
font-size:10.5px; color:#ffcc88; line-height:1.4;
}
#${API_PANEL_ID} .api-note strong { color:#ffaa00; font-weight:700; }
#${API_PANEL_ID} .api-buttons { display:flex; gap:10px; margin-top:20px; }
#${API_PANEL_ID} .api-button {
flex:1; padding:10px;
background:linear-gradient(145deg,#3a5233,#253822);
border:1px solid rgba(76,175,80,0.4); border-radius:6px;
color:#8BC34A; font-weight:600; font-size:11px;
cursor:pointer; text-align:center;
}
#${API_PANEL_ID} .api-button.primary {
background:linear-gradient(145deg,#b39c1a,#8a7a0d);
border-color:rgba(255,215,0,0.6); color:#fff;
}
.api-backdrop {
position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.7); backdrop-filter:blur(3px);
z-index:1000000; animation:fadeIn 0.3s ease;
}
/* ── Toggle ── */
#${TOGGLE_ID} {
position:fixed; width:44px; height:44px;
background:linear-gradient(145deg,#3d3b1e,#252a0d);
border:1px solid rgba(255,215,0,0.3); border-radius:8px;
color:#FFD700; font-size:22px;
display:flex; align-items:center; justify-content:center;
cursor:grab; z-index:1000000;
box-shadow:0 2px 8px rgba(0,0,0,0.3),inset 0 1px 0 rgba(255,255,255,0.1);
user-select:none; backdrop-filter:blur(4px);
font-weight:600; text-shadow:0 0 6px rgba(255,215,0,0.5);
touch-action:none;
}
#${TOGGLE_ID}:active { cursor:grabbing; }
/* ── Panel ── */
#${PANEL_ID} {
position:fixed; width:0; height:auto; max-height:55vh;
background:linear-gradient(145deg,rgba(15,20,30,0.98),rgba(8,12,20,0.97));
color:#e0f0ff; font:11px 'Segoe UI',sans-serif;
border:1px solid rgba(255,215,0,0.2); border-radius:8px;
z-index:999999; display:flex; flex-direction:column;
box-shadow:inset 0 0 20px rgba(255,215,0,0.05),0 4px 20px rgba(0,0,0,0.5);
backdrop-filter:blur(8px); overflow:hidden;
transition:width 0.25s ease,opacity 0.2s ease;
opacity:0; border-right:none;
}
#${PANEL_ID}.open {
width:260px; opacity:1;
border-right:1px solid rgba(255,215,0,0.2); border-radius:8px;
}
#${PANEL_ID} .h {
padding:7px 10px; font-weight:600;
background:linear-gradient(90deg,rgba(255,215,0,0.15),transparent);
color:#FFD700; border-bottom:1px solid rgba(255,215,0,0.15);
display:flex; align-items:center; gap:5px;
font-size:10px; letter-spacing:0.5px; text-transform:uppercase;
}
#${PANEL_ID} .h::before {
content:'✈'; font-size:10px; opacity:0.8;
animation:subtleFloat 3s ease-in-out infinite;
}
#${PANEL_ID} .h-refresh {
margin-left:auto; background:none; border:none;
color:rgba(255,215,0,0.5); font-size:14px;
cursor:pointer; padding:0 2px; line-height:1;
}
#${PANEL_ID} .h-refresh:hover { color:#FFD700; }
#${PANEL_ID} .s {
padding:6px 10px; background:rgba(46,125,50,0.2);
font-weight:700; color:#8BC34A; text-align:center;
border-bottom:1px solid rgba(76,175,80,0.1); font-size:10px;
}
#${PANEL_ID} .b {
overflow-y:auto; overflow-x:hidden; flex:1;
scrollbar-width:thin;
scrollbar-color:rgba(255,215,0,0.5) rgba(25,35,50,0.2);
}
#${PANEL_ID} .b::-webkit-scrollbar { width:3px; }
#${PANEL_ID} .b::-webkit-scrollbar-track { background:rgba(25,35,50,0.2); }
#${PANEL_ID} .b::-webkit-scrollbar-thumb { background:rgba(255,215,0,0.5); border-radius:2px; }
#${PANEL_ID} .a {
background:rgba(255,75,75,0.1); border-left:2px solid rgba(255,100,100,0.6);
margin:3px 8px; padding:5px 8px 5px 20px;
font-weight:600; border-radius:3px; color:#ff8888;
position:relative; font-size:9.5px; line-height:1.3;
}
#${PANEL_ID} .a::before {
content:'!'; position:absolute; left:5px; top:50%;
transform:translateY(-50%); font-size:9px; font-weight:900; opacity:0.8;
}
#${PANEL_ID} .t {
padding:5px 10px; background:rgba(76,175,80,0.08); color:#8BC34A;
font-weight:600; border-top:1px solid rgba(76,175,80,0.1);
border-bottom:1px solid rgba(76,175,80,0.05);
font-size:9.5px; letter-spacing:0.3px; text-transform:uppercase;
display:flex; align-items:center; justify-content:space-between;
}
#${PANEL_ID} .t-sets { color:#FFD700; font-weight:700; font-size:9px; opacity:0.9; }
/* ── Rows ── */
#${PANEL_ID} .r {
padding:3px 8px;
display:grid; grid-template-columns:32px 32px 34px 1fr;
gap:6px; align-items:center;
min-height:36px;
border-bottom:1px solid rgba(255,255,255,0.02);
transition:background 0.15s ease;
}
#${PANEL_ID} .r:hover { background:rgba(255,215,0,0.05) !important; }
#${PANEL_ID} .r:nth-child(even) { background:rgba(30,40,55,0.1); }
#${PANEL_ID} .r .item-img {
width:30px; height:30px; object-fit:contain;
border-radius:2px; background:rgba(255,255,255,0.05);
border:1px solid rgba(255,255,255,0.1);
display:block; transition:transform 0.2s ease;
}
#${PANEL_ID} .r:hover .item-img { transform:scale(1.15); }
#${PANEL_ID} .r .item-fallback {
display:none; width:30px; height:30px;
font-size:7px; font-weight:700; text-align:center; line-height:30px;
border-radius:2px; border:1px solid rgba(255,255,255,0.1);
background:rgba(255,255,255,0.05); color:#c0e0ff;
font-family:'Consolas',monospace;
}
#${PANEL_ID} .r .col-local {
color:#7fff7f; background:rgba(127,255,127,0.08);
font-weight:700; text-align:center;
border:1px solid rgba(127,255,127,0.1);
font-family:'Consolas',monospace;
padding:2px 4px; border-radius:2px; font-size:10px;
}
#${PANEL_ID} .r .col-abroad {
font-family:'Consolas',monospace; text-align:center; font-weight:700;
padding:2px 4px; border-radius:2px; border:1px solid; font-size:10px;
transition:all 0.3s ease;
}
#${PANEL_ID} .r .col-abroad.status-green {
color:#00ff00 !important; background:rgba(0,255,0,0.12) !important;
border-color:rgba(0,255,0,0.3) !important;
animation:highlightPulse 2s ease-in-out infinite;
text-shadow:0 0 4px rgba(0,255,0,0.5);
}
#${PANEL_ID} .r .col-abroad.status-orange {
color:#ffa500 !important; background:rgba(255,165,0,0.12) !important;
border-color:rgba(255,165,0,0.3) !important;
animation:warningPulse 2s ease-in-out infinite;
text-shadow:0 0 4px rgba(255,165,0,0.5);
}
#${PANEL_ID} .r .col-abroad.status-red {
color:#ff0000 !important; background:rgba(255,0,0,0.12) !important;
border-color:rgba(255,0,0,0.3) !important;
animation:dangerPulse 2s ease-in-out infinite;
text-shadow:0 0 4px rgba(255,0,0,0.5);
}
#${PANEL_ID} .r .col-abroad:not([class*="status-"]) {
color:#ffa0a0; background:rgba(255,160,160,0.08);
border-color:rgba(255,160,160,0.12);
}
#${PANEL_ID} .r .col-flag {
display:flex; align-items:center; justify-content:center;
font-size:15px; line-height:1;
}
#${PANEL_ID} .r .col-bob-btn {
display:flex; align-items:center; justify-content:center;
font-size:8.5px; font-weight:700; text-decoration:none;
color:#FFD700; background:rgba(255,215,0,0.1);
border:1px solid rgba(255,215,0,0.35); border-radius:4px;
padding:2px 4px; cursor:pointer; white-space:nowrap;
transition:all 0.15s ease;
}
#${PANEL_ID} .r .col-bob-btn:hover {
background:rgba(255,215,0,0.25); border-color:rgba(255,215,0,0.7);
color:#fff; transform:scale(1.05);
}
#${PANEL_ID} .loading { animation:gentlePulse 1.5s ease-in-out infinite; }
#${PANEL_ID} .xan-count-display {
color:#7fff7f; background:rgba(127,255,127,0.08);
font-weight:700; text-align:center;
border:1px solid rgba(127,255,127,0.1);
font-family:'Consolas',monospace;
padding:2px 4px; border-radius:2px; font-size:10px;
cursor:pointer; user-select:none; display:block; line-height:1.6;
}
#${PANEL_ID} .r#xan-row { cursor:pointer; }
#${PANEL_ID} .col-price {
font-family:'Consolas',monospace; text-align:center; font-weight:700; font-size:9px;
padding:2px 4px; border-radius:2px;
color:#FFD700; background:rgba(255,215,0,0.08);
border:1px solid rgba(255,215,0,0.2);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* ── Xanax Popup ── */
.xan-popup {
position:fixed;
background:linear-gradient(145deg,rgba(12,17,28,0.99),rgba(6,10,18,0.98));
border:1px solid rgba(255,215,0,0.35); border-radius:10px;
z-index:1000005; overflow:hidden;
box-shadow:0 10px 40px rgba(0,0,0,0.8),inset 0 0 20px rgba(255,215,0,0.04);
backdrop-filter:blur(14px);
animation:xanPopIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
min-width:248px; color:#e0f0ff; font:11px 'Segoe UI',sans-serif;
}
.xan-popup-header {
padding:11px 14px; font-weight:700;
background:linear-gradient(90deg,rgba(255,215,0,0.18),transparent);
color:#FFD700; border-bottom:1px solid rgba(255,215,0,0.18);
font-size:12px; letter-spacing:0.6px; text-transform:uppercase;
display:flex; align-items:center; gap:7px;
}
.xan-popup-body { padding:14px 14px 12px; }
.xan-popup-row { display:flex; align-items:center; gap:8px; margin-bottom:11px; }
.xan-popup-label {
color:#8BC34A; font-weight:600; font-size:10px;
width:44px; flex-shrink:0; text-align:right; letter-spacing:0.3px;
}
.xan-popup-num {
font-family:'Consolas',monospace; font-size:13px; font-weight:700;
text-align:center; width:66px; padding:5px 4px; box-sizing:border-box;
background:rgba(22,30,45,0.95); border:1px solid rgba(255,215,0,0.28);
border-radius:5px; color:#fff; outline:none; -moz-appearance:textfield;
}
.xan-popup-num::-webkit-inner-spin-button,
.xan-popup-num::-webkit-outer-spin-button { -webkit-appearance:none; }
.xan-popup-num:focus {
border-color:#FFD700; box-shadow:0 0 0 2px rgba(255,215,0,0.18);
background:rgba(32,44,62,0.98);
}
.xan-stepper {
padding:5px 10px; border-radius:5px; flex-shrink:0;
border:1px solid rgba(255,215,0,0.35);
background:linear-gradient(145deg,#36360f,#222208);
color:#FFD700; font-weight:800; font-size:15px;
cursor:pointer; line-height:1; user-select:none;
}
.xan-stepper:active { transform:scale(0.9); }
.xan-section-divider { height:1px; background:rgba(255,215,0,0.1); margin:2px 0 12px; border:none; }
.xan-add-btn {
flex-shrink:0; padding:6px 10px; border-radius:5px;
background:linear-gradient(145deg,#3a5233,#253822);
border:1px solid rgba(76,175,80,0.4);
color:#8BC34A; font-weight:700; font-size:10px;
cursor:pointer; white-space:nowrap;
}
.xan-add-btn:active { transform:scale(0.95); }
.xan-popup-save {
width:100%; padding:9px 0; margin-top:4px; display:block;
background:linear-gradient(145deg,rgba(255,215,0,0.14),rgba(255,215,0,0.07));
border:1px solid rgba(255,215,0,0.3); border-radius:6px;
color:#FFD700; font-weight:700; font-size:11px; letter-spacing:0.5px; cursor:pointer;
}
.xan-light-backdrop {
position:fixed; top:0; left:0; right:0; bottom:0;
z-index:1000004; background:transparent;
}
/* ── Settings popup ── */
.settings-row {
display:flex; align-items:center; gap:10px;
padding:6px 2px; cursor:pointer;
border-bottom:1px solid rgba(255,215,0,0.06);
}
.settings-row:last-of-type { border-bottom:none; }
.settings-chk {
width:16px; height:16px; cursor:pointer; accent-color:#FFD700;
flex-shrink:0;
}
.settings-label {
font-size:10px; color:#e0f0ff; font-weight:600;
}
.settings-row:hover .settings-label { color:#FFD700; }
/* ── Menu items ── */
.pjfry-menu-item {
padding:8px 12px; font-size:11px; color:#c0e0ff; cursor:pointer;
}
.pjfry-menu-item:hover { background:rgba(255,215,0,0.15); color:#fff; }
/* ── Hint carousel bubble ── */
.tt-hint {
position:fixed; z-index:1000010; pointer-events:auto;
background:linear-gradient(145deg,rgba(10,14,24,0.99),rgba(6,9,16,0.98));
border:1px solid rgba(255,215,0,0.45); border-radius:10px;
width:240px; max-width:calc(100vw - 20px);
font:11px 'Segoe UI',sans-serif; color:#e0f0ff;
box-shadow:0 8px 32px rgba(0,0,0,0.8),0 0 16px rgba(255,215,0,0.12);
}
.hint-header {
display:flex; align-items:center; justify-content:space-between;
padding:10px 12px 6px;
border-bottom:1px solid rgba(255,215,0,0.12);
}
.tt-title {
font-size:11px; font-weight:700; color:#FFD700; flex:1;
}
.hint-close {
background:none; border:none; color:rgba(255,215,0,0.5);
cursor:pointer; font-size:12px; padding:0 0 0 8px; line-height:1;
}
.hint-close:hover { color:#FFD700; }
.hint-body {
padding:10px 12px; font-size:10px; line-height:1.6;
color:#c8e0f8; white-space:pre-wrap;
min-height:44px;
}
.hint-footer {
display:flex; align-items:center; justify-content:space-between;
padding:6px 10px 10px;
border-top:1px solid rgba(255,215,0,0.08);
}
.hint-dots { display:flex; gap:5px; align-items:center; }
.hint-dot {
width:6px; height:6px; border-radius:50%;
background:rgba(255,215,0,0.2); cursor:pointer;
transition:background 0.2s, transform 0.2s;
}
.hint-dot.active { background:#FFD700; transform:scale(1.3); }
.hint-dot:hover { background:rgba(255,215,0,0.6); }
.hint-nav { display:flex; align-items:center; gap:6px; }
.hint-btn {
background:rgba(255,215,0,0.1); border:1px solid rgba(255,215,0,0.3);
border-radius:5px; color:#FFD700; cursor:pointer;
font-size:14px; font-weight:700; padding:1px 8px; line-height:1.4;
transition:all 0.15s;
}
.hint-btn:hover:not([disabled]) { background:rgba(255,215,0,0.25); }
.hint-btn[disabled] { opacity:0.25; cursor:default; }
.hint-count { font-size:9px; color:rgba(255,215,0,0.5); min-width:28px; text-align:center; }
@keyframes hintIn {
from { opacity:0; transform:scale(0.9) translateY(6px); }
to { opacity:1; transform:scale(1) translateY(0); }
}
/* ── Column header row ── */
#${PANEL_ID} .col-header {
display:grid; grid-template-columns:32px 32px 34px 1fr;
gap:6px; padding:3px 8px;
background:rgba(255,215,0,0.04);
border-bottom:1px solid rgba(255,215,0,0.08);
}
#${PANEL_ID} .col-header span {
font:600 8px 'Segoe UI',sans-serif;
color:rgba(255,215,0,0.5); text-align:center;
text-transform:uppercase; letter-spacing:0.4px;
cursor:help; user-select:none;
position:relative;
}
/* column header tooltips handled via JS carousel */
/* ── Tooltip ── */
.points-tooltip { position:relative; cursor:help; }
.points-tooltip::after {
content:attr(data-tooltip);
position:absolute; bottom:100%; left:50%; transform:translateX(-50%);
background:rgba(20,25,40,0.95); color:#8BC34A;
padding:8px 12px; border-radius:6px; font-size:10px; white-space:pre-line;
border:1px solid rgba(255,215,0,0.4); opacity:0; visibility:hidden;
transition:opacity 0.2s; z-index:100000; pointer-events:none;
min-width:200px; text-align:center;
}
.points-tooltip:hover::after { opacity:1; visibility:visible; }
`);
/* ================= API PANEL ================= */
function createApiPanel() {
if (_gmGet('tornAPIKey', '')) return;
const backdrop = document.createElement('div');
backdrop.className = 'api-backdrop';
const apiPanel = document.createElement('div');
apiPanel.id = API_PANEL_ID;
apiPanel.innerHTML = `
<div class="api-header">🔑 API KEY REQUIRED</div>
<div class="api-content">
<div class="api-input-group">
<label class="api-label" for="torn-api-key">Torn API Key:</label>
<input type="text" id="torn-api-key" class="api-input"
placeholder="Enter your limited API key..." maxlength="16">
</div>
<div class="api-note">
<strong>⚠️ SECURITY NOTE:</strong><br>
Use a <strong>LIMITED API KEY</strong> with only <strong>DISPLAY</strong> access.<br>
This script only needs to read your displayed items.<br>
<em>Create at: <strong>Torn.com → Settings → API</strong></em>
</div>
<div class="api-buttons">
<button class="api-button" id="save-api-key">Save API Key</button>
<button class="api-button primary" id="skip-api">Skip (Limited)</button>
</div>
</div>`;
document.body.appendChild(backdrop);
document.body.appendChild(apiPanel);
const saveBtn = apiPanel.querySelector('#save-api-key');
const skipBtn = apiPanel.querySelector('#skip-api');
const apiInput = apiPanel.querySelector('#torn-api-key');
setTimeout(() => apiInput.focus(), 100);
saveBtn.onclick = () => {
const k = apiInput.value.trim();
if (!k) { apiInput.style.borderColor = '#ff4444'; setTimeout(() => { apiInput.style.borderColor = ''; }, 1000); return; }
if (k.length !== 16) { alert('Torn API keys are exactly 16 characters.'); return; }
_gmSet('tornAPIKey', k);
backdrop.remove(); apiPanel.remove();
showNotification('API key saved! Starting tracker...');
setTimeout(initializeTracker, 1000);
};
skipBtn.onclick = () => {
backdrop.remove(); apiPanel.remove();
showNotification('Limited mode. Long-press toggle to add key later.');
initializeTracker();
};
apiInput.addEventListener('keypress', e => { if (e.key === 'Enter') saveBtn.click(); });
backdrop.onclick = e => { if (e.target === backdrop) { backdrop.remove(); apiPanel.remove(); initializeTracker(); } };
}
/* ================= NOTIFICATION ================= */
function showNotification(msg) {
const n = document.createElement('div');
n.style.cssText = `position:fixed;top:20px;left:50%;transform:translateX(-50%);
background:linear-gradient(145deg,#3a5233,#253822);color:#8BC34A;
padding:12px 20px;border-radius:8px;border:1px solid rgba(255,215,0,0.4);
box-shadow:0 4px 20px rgba(0,0,0,0.6);z-index:1000002;
font:600 12px 'Segoe UI',sans-serif;backdrop-filter:blur(8px);
text-align:center;max-width:300px;`;
n.textContent = msg;
document.body.appendChild(n);
setTimeout(() => {
n.style.opacity = '0'; n.style.transition = 'opacity 0.3s';
setTimeout(() => n.remove(), 300);
}, 3000);
}
/* ================= CREATE ELEMENTS ================= */
function createMainElements() {
const toggle = document.createElement('div');
toggle.id = TOGGLE_ID;
toggle.innerHTML = '✈';
toggle.title = 'Travel Tracker';
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="h">TRAVEL TRACKER <button class="h-refresh" id="tt-refresh" title="Refresh now">⟳</button></div>
<div class="s">LOADING...</div>
<div class="b"></div>`;
document.body.appendChild(toggle);
document.body.appendChild(panel);
return { toggle, panel };
}
/* ================= API MENU ================= */
function showApiManagementMenu() {
document.getElementById('api-management-menu')?.remove();
const menu = document.createElement('div');
menu.id = 'api-management-menu';
menu.style.cssText = `position:fixed;
background:linear-gradient(145deg,rgba(20,25,40,0.98),rgba(10,15,25,0.97));
border:1px solid rgba(255,215,0,0.3);border-radius:8px;padding:8px 0;
min-width:180px;z-index:1000003;backdrop-filter:blur(10px);
box-shadow:0 4px 20px rgba(0,0,0,0.7);font:11px 'Segoe UI',sans-serif;`;
const currentKey = _gmGet('tornAPIKey', '');
menu.innerHTML = `
<div style="padding:8px 12px;font-size:11px;color:#8BC34A;border-bottom:1px solid rgba(76,175,80,0.2);">
API Key Management
</div>
${currentKey
? `<div class="pjfry-menu-item" data-action="view">View Current Key</div>
<div class="pjfry-menu-item" data-action="change">Change API Key</div>
<div class="pjfry-menu-item" data-action="remove">Remove API Key</div>`
: `<div class="pjfry-menu-item" data-action="add">Add API Key</div>`}
<div class="pjfry-menu-item" data-action="help">API Key Help</div>
<div class="pjfry-menu-item" data-action="settings">⚙️ Section Settings</div>`;
const rect = document.getElementById(TOGGLE_ID).getBoundingClientRect();
menu.style.top = (rect.bottom + 5) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
document.body.appendChild(menu);
menu.addEventListener('click', e => {
const item = e.target.closest('.pjfry-menu-item');
if (!item) return;
menu.remove();
switch (item.dataset.action) {
case 'view': alert(`API Key: ${currentKey}\nEnds with: ...${currentKey.slice(-4)}`); break;
case 'change':
case 'add': _gmSet('tornAPIKey', ''); createApiPanel(); break;
case 'remove':
if (confirm('Remove API key?')) { _gmSet('tornAPIKey', ''); showNotification('Key removed. Refresh page.'); }
break;
case 'settings': showSettingsPopup(); break;
case 'help':
alert('API KEY SETUP:\n1. Torn.com → Settings → API\n2. Create New Key\n3. Select ONLY "Display"\n4. Copy & paste 16-char key here');
break;
}
});
setTimeout(() => {
const close = e => {
if (!menu.contains(e.target) && e.target.id !== TOGGLE_ID) { menu.remove(); document.removeEventListener('click', close); }
};
document.addEventListener('click', close);
}, 100);
}
/* ================= GLOBALS ================= */
let toggle, panel, sum, body, isPanelOpen = false, pollTimer = null;
/* ================= INIT ================= */
function initializeTracker() {
const els = createMainElements();
toggle = els.toggle; panel = els.panel;
sum = panel.querySelector('.s'); body = panel.querySelector('.b');
setupToggleFunctionality();
setupDrag();
// Refresh button
panel.querySelector('#tt-refresh').addEventListener('click', () => {
if (pollTimer) clearTimeout(pollTimer);
mainLoop();
});
// Show hint bubbles on first load
if (!_gmGet(HINTS_SHOWN_KEY, false)) {
setTimeout(showHints, 1800);
}
const apiKey = _gmGet('tornAPIKey', '');
if (apiKey) {
mainLoop();
} else {
sum.textContent = 'API KEY REQUIRED';
body.innerHTML = `<div style="padding:20px 12px;text-align:center;color:#ff8888;font-size:10px;">
⚠️ No API key.<br><br>
<span style="color:#8BC34A;font-size:9px;">Long-press the toggle<br>to add your API key.</span>
</div>`;
}
}
/* ================= TOGGLE ================= */
function positionPanel() {
const tr = toggle.getBoundingClientRect();
panel.style.top = tr.top + 'px'; panel.style.bottom = 'auto';
if (toggle._snapSide === 'left') {
panel.style.left = (tr.right + 6) + 'px'; panel.style.right = 'auto';
} else {
panel.style.right = (window.innerWidth - tr.left + 6) + 'px'; panel.style.left = 'auto';
}
}
function setupToggleFunctionality() {
toggle.addEventListener('click', () => {
if (toggle._dragged) { toggle._dragged = false; return; }
isPanelOpen = !isPanelOpen;
panel.classList.toggle('open', isPanelOpen);
toggle.style.color = isPanelOpen ? '#FFEB3B' : '#FFD700';
toggle.innerHTML = isPanelOpen ? '×' : '✈';
positionPanel();
});
let longPressTimer = null;
toggle.addEventListener('touchstart', e => {
longPressTimer = setTimeout(() => { longPressTimer = null; showApiManagementMenu(); }, 600);
}, { passive: true });
toggle.addEventListener('touchend', () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } });
toggle.addEventListener('touchmove', () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } });
toggle.addEventListener('contextmenu', e => { e.preventDefault(); showApiManagementMenu(); });
}
/* ================= DRAG ================= */
function setupDrag() {
const SZ = 36, GAP = 6;
function snapTo(side, top) {
toggle._snapSide = side;
toggle.style.top = top + 'px'; toggle.style.bottom = 'auto';
toggle.style.left = side === 'left' ? GAP + 'px' : 'auto';
toggle.style.right = side === 'right' ? GAP + 'px' : 'auto';
positionPanel();
}
try {
const saved = _gmGet('togglePos', null);
if (saved) {
const { top, side } = saved;
snapTo(side || 'right', Math.max(GAP, Math.min(top, window.innerHeight - SZ - GAP)));
} else { snapTo('right', 350); }
} catch { snapTo('right', 350); }
let active = false, startX, startY, startLeft, startTop, dragReady = false;
function beginDrag(cx, cy) {
dragReady = true; toggle._dragged = false;
startX = cx; startY = cy;
const r = toggle.getBoundingClientRect();
startLeft = r.left; startTop = r.top;
toggle.style.opacity = '0.75'; toggle.style.transform = 'scale(1.1)';
}
function doMove(cx, cy) {
if (!dragReady) return;
const dx = cx - startX, dy = cy - startY;
if (!toggle._dragged && Math.hypot(dx, dy) < 4) return;
toggle._dragged = true; active = true;
toggle.style.left = Math.max(GAP, Math.min(startLeft + dx, window.innerWidth - SZ - GAP)) + 'px';
toggle.style.right = 'auto';
toggle.style.top = Math.max(GAP, Math.min(startTop + dy, window.innerHeight - SZ - GAP)) + 'px';
toggle.style.bottom = 'auto';
positionPanel();
}
function endDrag() {
toggle.style.opacity = ''; toggle.style.transform = '';
dragReady = false;
if (!active) return; active = false;
const r = toggle.getBoundingClientRect();
const side = (r.left + SZ / 2) < window.innerWidth / 2 ? 'left' : 'right';
snapTo(side, r.top);
_gmSet('togglePos', { top: r.top, side });
}
toggle.addEventListener('touchstart', e => beginDrag(e.touches[0].clientX, e.touches[0].clientY), { passive: true });
toggle.addEventListener('touchmove', e => { e.preventDefault(); doMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false });
toggle.addEventListener('touchend', () => endDrag());
toggle.addEventListener('touchcancel', () => { dragReady = false; active = false; toggle.style.opacity = ''; toggle.style.transform = ''; });
toggle.addEventListener('mousedown', e => { if (e.button === 0) beginDrag(e.clientX, e.clientY); });
document.addEventListener('mousemove', e => doMove(e.clientX, e.clientY));
document.addEventListener('mouseup', () => endDrag());
}
/* ================= DATA FETCHING ================= */
async function localItems() {
const key = _gmGet('tornAPIKey', '');
if (!key) throw new Error('No API key configured');
const r = await fetch(`https://api.torn.com/user/?selections=display&key=${key}`);
const data = await r.json();
if (data.error) throw new Error(data.error.error || 'API Error');
const items = {};
(data.display || []).forEach(item => { items[item.name] = (items[item.name] || 0) + item.quantity; });
return items;
}
// Build reverse lookup: item ID -> item name
function buildIdToNameMap() {
const map = {};
for (const group of Object.values(GROUPS)) {
for (const [name, data] of Object.entries(group.items)) {
map[data.id] = name;
}
}
map[XANAX_ITEM_ID] = XANAX_NAME;
return map;
}
async function abroadItems() {
const data = await gmJSON('https://yata.yt/api/v1/travel/export/');
const idToName = buildIdToNameMap();
const map = {};
if (!data) return map;
// YATA v1: array of country objects [ { country, items: [{id,quantity,cost}] }, ... ]
// YATA alt: object keyed by country { MX: { items: [...] }, ... }
// YATA alt2: { stocks: { MX: { items: [...] } } }
let entries = [];
if (Array.isArray(data)) {
entries = data;
} else if (data.stocks && typeof data.stocks === 'object') {
entries = Object.values(data.stocks);
} else {
// Try treating top-level values as country objects
entries = Object.values(data).filter(v => v && typeof v === 'object' && v.items);
}
for (const country of entries) {
const items = Array.isArray(country?.items) ? country.items
: Array.isArray(country?.stocks) ? country.stocks
: [];
for (const item of items) {
const name = idToName[Number(item.id)];
if (name) map[name] = (map[name] || 0) + Number(item.quantity || 0);
}
}
return map;
}
async function factionXanax(apiKey) {
try {
const data = await gmJSON(`https://api.torn.com/v2/faction/items?key=${apiKey}`);
if (!data.error) {
const items = data.items || {};
const direct = items[String(XANAX_ITEM_ID)];
if (direct !== undefined) return direct.quantity || 0;
let total = 0;
Object.values(items).forEach(i => { if (i.name === XANAX_NAME || Number(i.id) === XANAX_ITEM_ID) total += (i.quantity || 0); });
return total;
}
const d2 = await gmJSON(`https://api.torn.com/v1/faction/?selections=armoury&key=${apiKey}`);
if (d2.error) return null;
let total = 0;
Object.values(d2.armoury || {}).forEach(i => {
if (i.name === XANAX_NAME || Number(i.ID) === XANAX_ITEM_ID || Number(i.id) === XANAX_ITEM_ID)
total += (i.quantity || i.qty || 0);
});
return total;
} catch { return null; }
}
async function xanaxAbroadSA() {
try {
const data = await gmJSON('https://yata.yt/api/v1/travel/export/');
const SA_COUNTRY_KEYS = ['sou', 'saf', 'zaf', 'za', 'south_africa', 'South Africa'];
// Handle both array and object response formats
const entries = Array.isArray(data) ? data
: Array.isArray(data?.exports) ? data.exports
: Object.entries(data?.stocks || {}).map(([k, v]) => ({ ...v, _key: k }));
for (const country of entries) {
const key = country._key || country.country || '';
const isSA = SA_COUNTRY_KEYS.some(k => key.toLowerCase().includes(k.toLowerCase()));
if (!isSA) continue;
const items = country?.items || country?.stocks || [];
const hit = items.find(i => Number(i.id) === XANAX_ITEM_ID);
if (hit) return { qty: hit.quantity || 0, price: hit.cost || hit.price || 0 };
}
} catch(e) { console.error('xanaxAbroadSA error:', e); }
return { qty: 0, price: 0 };
}
async function fetchPointsPrice(apiKey) {
const now = Date.now();
if (pointsPriceCache.time && now - pointsPriceCache.time < POINTS_CACHE_DURATION) return pointsPriceCache.price;
try {
const data = await gmJSON(`${POINTS_ENDPOINT}?key=${apiKey}`);
if (data.pointsmarket) {
const listings = Object.values(data.pointsmarket).filter(l => l.quantity > 0).map(l => l.cost).sort((a,b) => a-b);
if (listings.length) {
const top = listings.slice(0, Math.min(5, listings.length));
const avg = Math.round(top.reduce((s,p) => s+p, 0) / top.length);
pointsPriceCache.history.push(avg);
if (pointsPriceCache.history.length > POINTS_HISTORY_SIZE) pointsPriceCache.history.shift();
const stable = Math.round(pointsPriceCache.history.reduce((s,p) => s+p, 0) / pointsPriceCache.history.length);
pointsPriceCache = { time: now, price: stable, history: pointsPriceCache.history };
currentPointsPrice = stable;
return stable;
}
}
} catch(e) { console.error('Points price error:', e); }
return currentPointsPrice || 0;
}
/* ================= LOGIC ================= */
function calcSet(inv, items) { const v = Object.keys(items).map(k => inv[k] || 0); return v.length ? Math.min(...v) : 0; }
function lowestTwo(inv, items, sets) {
const counts = Object.entries(items).map(([name, data]) => ({
code: data.s, location: LOCATIONS[data.loc]?.label || data.loc, count: (inv[name] || 0) - sets
})).sort((a,b) => a.count - b.count).slice(0,2).filter(i => i.count < 5);
if (!counts.length) return null;
const parts = counts.map(i => `${i.code} → ${i.location}`);
return parts.length === 2 ? `Need ${parts[0]} & ${parts[1]}` : `Need ${parts[0]}`;
}
function getSortedItems(inv, items, sets) {
return Object.entries(items).map(([name, data]) => ({ name, data, remaining: (inv[name] || 0) - sets }))
.sort((a, b) => a.remaining - b.remaining);
}
function getStatusClass(name, abroad) {
if (abroad === 0) return 'status-red';
if (name.includes('Plushie')) return abroad >= PLUSHIE_THRESHOLD ? 'status-green' : 'status-orange';
if (Object.keys(GROUPS.Flowers.items).includes(name)) return abroad >= FLOWER_THRESHOLD ? 'status-green' : 'status-orange';
return '';
}
/* ================= HINT CAROUSEL ================= */
const HINT_SLIDES = [
{
title: '✈️ Travel Tracker',
body: 'Tap the ✈ button to open or close the tracker panel.',
anchor: () => document.getElementById(TOGGLE_ID)
},
{
title: '⚙️ Settings & API',
body: 'Long-press the button (or right-click on desktop) to open the settings menu — manage your API key, toggle sections, and more.',
anchor: () => document.getElementById(TOGGLE_ID)
},
{
title: '🟩 Own column',
body: 'Shows how many of each item you have after subtracting completed sets. The lowest number is your current bottleneck.',
anchor: () => document.querySelector('.col-header')
},
{
title: '🌍 Abroad column',
body: '🌍 Overseas items → live YATA data\n🟢 Plushie \u22652000 / Flower \u22655000\n🟠 Below threshold 🔴 Zero\n\n🏪 Sheep, Teddy Bear, Kitten →\nTap the 🏪 BoB button to go\ndirectly to Bits n Bobs shop.',
anchor: () => document.querySelector('.col-header')
},
{
title: '🧪 Xanax row',
body: 'Tap the Xanax row to open the counter. Set your personal count and carry limit, or tap "+ Add Limit" to add a carry run worth.',
anchor: () => document.getElementById('xan-row')
},
];
let _hintIndex = 0;
let _hintEl = null;
let _hintBackdrop = null;
function showHints() {
_hintIndex = 0;
_gmSet(HINTS_SHOWN_KEY, true);
_renderHint();
}
function _renderHint() {
dismissHints();
const slide = HINT_SLIDES[_hintIndex];
if (!slide) return;
// Backdrop (click outside to dismiss)
_hintBackdrop = document.createElement('div');
_hintBackdrop.id = 'hint-backdrop';
_hintBackdrop.style.cssText = 'position:fixed;inset:0;z-index:1000008;';
_hintBackdrop.addEventListener('click', dismissHints);
// Bubble
_hintEl = document.createElement('div');
_hintEl.className = 'tt-hint';
_hintEl.id = 'hint-bubble';
const total = HINT_SLIDES.length;
const dots = Array.from({length: total}, (_, i) =>
`<span class="hint-dot${i === _hintIndex ? ' active' : ''}" data-i="${i}"></span>`
).join('');
_hintEl.innerHTML = `
<div class="hint-header">
<span class="tt-title">${slide.title}</span>
<button class="hint-close" title="Dismiss">✕</button>
</div>
<div class="hint-body">${slide.body.replace(/\n/g, '<br>')}</div>
<div class="hint-footer">
<div class="hint-dots">${dots}</div>
<div class="hint-nav">
<button class="hint-btn" id="hint-prev" ${_hintIndex === 0 ? 'disabled' : ''}>‹</button>
<span class="hint-count">${_hintIndex + 1} / ${total}</span>
<button class="hint-btn" id="hint-next">${_hintIndex === total - 1 ? '✓ Done' : '›'}</button>
</div>
</div>`;
document.body.appendChild(_hintBackdrop);
document.body.appendChild(_hintEl);
// Position bubble near anchor or toggle
const anchor = (slide.anchor && slide.anchor()) || document.getElementById(TOGGLE_ID);
_positionHint(_hintEl, anchor);
// Wire controls
_hintEl.querySelector('.hint-close').addEventListener('click', e => { e.stopPropagation(); dismissHints(); });
_hintEl.querySelector('#hint-next').addEventListener('click', e => {
e.stopPropagation();
if (_hintIndex >= HINT_SLIDES.length - 1) { dismissHints(); return; }
_hintIndex++; _renderHint();
});
_hintEl.querySelector('#hint-prev').addEventListener('click', e => {
e.stopPropagation();
if (_hintIndex <= 0) return;
_hintIndex--; _renderHint();
});
_hintEl.querySelectorAll('.hint-dot').forEach(dot => {
dot.addEventListener('click', e => { e.stopPropagation(); _hintIndex = parseInt(dot.dataset.i); _renderHint(); });
});
_hintEl.addEventListener('click', e => e.stopPropagation());
// Fade in
_hintEl.style.opacity = '0';
requestAnimationFrame(() => { _hintEl.style.transition = 'opacity 0.25s'; _hintEl.style.opacity = '1'; });
}
function _positionHint(el, anchor) {
el.style.visibility = 'hidden';
requestAnimationFrame(() => {
const W = el.offsetWidth || 240;
const H = el.offsetHeight || 140;
const vw = window.innerWidth, vh = window.innerHeight;
let top, left;
// Fall back to toggle if anchor missing or has zero size (panel closed)
const toggleEl = document.getElementById(TOGGLE_ID);
const a = (anchor && anchor.offsetWidth > 0) ? anchor : toggleEl;
if (a) {
const r = a.getBoundingClientRect();
top = r.bottom + 10;
left = r.left + r.width / 2 - W / 2;
if (top + H > vh - 10) top = r.top - H - 10;
if (left + W > vw - 10) left = vw - W - 10;
} else {
top = vh / 2 - H / 2;
left = vw / 2 - W / 2;
}
el.style.top = Math.max(8, top) + 'px';
el.style.left = Math.max(8, left) + 'px';
el.style.visibility = 'visible';
});
}
function dismissHints() {
_hintEl?._remove?.call(_hintEl) || _hintEl?.remove();
_hintBackdrop?.remove();
_hintEl = null; _hintBackdrop = null;
}
/* ================= SETTINGS POPUP ================= */
function showSettingsPopup() {
document.getElementById('settings-popup')?.remove();
document.getElementById('settings-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.id = 'settings-backdrop';
backdrop.className = 'xan-light-backdrop';
const sections = [
{ key: 'prehistoric', label: '🪨 Prehistoric Points' },
{ key: 'plushies', label: '🧸 Plushies' },
{ key: 'flowers', label: '🌸 Flowers' },
{ key: 'special', label: '☄️ Special (Meteor/Fossil)' },
{ key: 'xanax', label: '🧪 Xanax' },
];
const checksHtml = sections.map(s => {
const checked = _gmGet(SECTION_KEYS[s.key], true) !== false ? 'checked' : '';
return `
<label class="settings-row">
<input type="checkbox" class="settings-chk" data-key="${s.key}" ${checked}>
<span class="settings-label">${s.label}</span>
</label>`;
}).join('');
const popup = document.createElement('div');
popup.id = 'settings-popup';
popup.className = 'xan-popup';
popup.style.minWidth = '230px';
popup.innerHTML = `
<div class="xan-popup-header">⚙️ Section Visibility</div>
<div class="xan-popup-body" style="padding:12px 14px 10px;">
<div style="font-size:9px;color:#8BC34A;margin-bottom:10px;opacity:0.8;">
Toggle sections to hide/show them
</div>
${checksHtml}
<button class="xan-popup-save" id="settings-close" style="margin-top:12px;">✓ Save & Close</button>
<button class="xan-popup-save" id="settings-hints" style="margin-top:6px;background:linear-gradient(145deg,rgba(255,215,0,0.1),rgba(255,215,0,0.05));font-size:10px;">💡 Show Hints Again</button>
</div>`;
document.body.appendChild(backdrop);
document.body.appendChild(popup);
// Position near toggle
const toggleEl = document.getElementById(TOGGLE_ID);
const tr = toggleEl.getBoundingClientRect();
const PW = 244, PH = 260;
const top = Math.max(6, Math.min(tr.top, window.innerHeight - PH - 6));
const left = toggle._snapSide === 'left'
? tr.right + 8
: Math.max(6, tr.left - PW - 8);
popup.style.top = top + 'px';
popup.style.left = left + 'px';
function saveAndClose() {
popup.querySelectorAll('.settings-chk').forEach(chk => {
_gmSet(SECTION_KEYS[chk.dataset.key], chk.checked);
});
backdrop.remove(); popup.remove();
render(); // re-render immediately
}
popup.querySelector('#settings-close').addEventListener('click', saveAndClose);
popup.querySelector('#settings-hints').addEventListener('click', () => {
saveAndClose();
_gmSet(HINTS_SHOWN_KEY, false);
setTimeout(showHints, 400);
});
backdrop.addEventListener('click', saveAndClose);
popup.addEventListener('click', e => e.stopPropagation());
popup.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === 'Escape') saveAndClose(); });
}
/* ================= XANAX POPUP ================= */
function showXanaxPopup(anchorEl) {
document.getElementById('xan-popup')?.remove();
document.getElementById('xan-popup-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.id = 'xan-popup-backdrop'; backdrop.className = 'xan-light-backdrop';
const popup = document.createElement('div');
popup.id = 'xan-popup'; popup.className = 'xan-popup';
popup.innerHTML = `
<div class="xan-popup-header">🧪 Xanax Count</div>
<div class="xan-popup-body">
<div class="xan-popup-row">
<span class="xan-popup-label">Count:</span>
<button class="xan-stepper" id="xan-dec">−</button>
<input type="number" class="xan-popup-num" id="xan-count-val" value="${_gmGet(XANAX_STORAGE_KEY, 0)}" min="0">
<button class="xan-stepper" id="xan-inc">+</button>
</div>
<div class="xan-section-divider"></div>
<div class="xan-popup-row">
<span class="xan-popup-label">Carry:</span>
<input type="number" class="xan-popup-num" id="xan-carry-val" value="${_gmGet(XANAX_CARRY_KEY, 0)}" min="0">
<button class="xan-add-btn" id="xan-add-carry">+ Add Limit</button>
</div>
<button class="xan-popup-save" id="xan-save">✓ Save & Close</button>
</div>`;
document.body.appendChild(backdrop); document.body.appendChild(popup);
const panelEl = document.getElementById(PANEL_ID);
const pr = panelEl ? panelEl.getBoundingClientRect() : anchorEl.getBoundingClientRect();
const ar = anchorEl.getBoundingClientRect();
const PW = 252, PH = 182;
const top = Math.max(6, Math.min(ar.top - 8, window.innerHeight - PH - 6));
const tryL = pr.left - PW - 8, tryR = pr.right + 8;
const left = tryL >= 6 ? tryL : (tryR + PW <= window.innerWidth - 6 ? tryR : Math.max(6, tryL));
popup.style.top = top + 'px'; popup.style.left = left + 'px';
const ci = popup.querySelector('#xan-count-val'), cri = popup.querySelector('#xan-carry-val');
function saveAndClose() {
_gmSet(XANAX_STORAGE_KEY, Math.max(0, parseInt(ci.value) || 0));
_gmSet(XANAX_CARRY_KEY, Math.max(0, parseInt(cri.value) || 0));
const badge = document.getElementById('xan-count-display');
if (badge) badge.textContent = _gmGet(XANAX_STORAGE_KEY, 0);
backdrop.remove(); popup.remove();
}
popup.querySelector('#xan-inc').onclick = () => { ci.value = (parseInt(ci.value) || 0) + 1; };
popup.querySelector('#xan-dec').onclick = () => { ci.value = Math.max(0, (parseInt(ci.value) || 0) - 1); };
popup.querySelector('#xan-add-carry').onclick = () => {
ci.value = (parseInt(ci.value) || 0) + (parseInt(cri.value) || 0);
ci.style.borderColor = '#8BC34A'; setTimeout(() => { ci.style.borderColor = ''; }, 600);
};
popup.querySelector('#xan-save').onclick = saveAndClose;
backdrop.onclick = saveAndClose;
popup.addEventListener('click', e => e.stopPropagation());
ci.addEventListener('focus', () => ci.select());
cri.addEventListener('focus', () => cri.select());
popup.addEventListener('keydown', e => { if (e.key === 'Enter') saveAndClose(); });
setTimeout(() => ci.select(), 60);
}
/* ================= RENDER ================= */
async function render() {
try {
sum.classList.add('loading'); sum.textContent = 'UPDATING...';
const apiKey = _gmGet('tornAPIKey', '');
console.log('[TT] Starting fetch...');
const t0 = Date.now();
const [inventory, abroad, xanFaction, xanSA] = await Promise.all([
withTimeout(localItems(), 12000, {}).then(r => { console.log('[TT] localItems done', Date.now()-t0, 'ms', r); return r; }),
withTimeout(abroadItems(), 12000, {}).then(r => { console.log('[TT] abroadItems done', Date.now()-t0, 'ms', Object.keys(r).length, 'items'); return r; }),
apiKey ? withTimeout(factionXanax(apiKey).catch(() => null), 10000, null).then(r => { console.log('[TT] factionXanax done', Date.now()-t0, 'ms', r); return r; }) : Promise.resolve(null),
withTimeout(xanaxAbroadSA(), 10000, { qty: 0, price: 0 }).then(r => { console.log('[TT] xanaxAbroadSA done', Date.now()-t0, 'ms', r); return r; })
]);
console.log('[TT] All fetches complete', Date.now()-t0, 'ms');
const xanPersonal = _gmGet(XANAX_STORAGE_KEY, 0);
let totalSets = 0, totalPoints = 0, html = '';
console.log('[TT] Building HTML, inventory keys:', Object.keys(inventory).length, 'abroad keys:', Object.keys(abroad).length);
// Column header row with tooltips
html += `<div class="col-header">
<span></span>
<span data-tip="Your display items minus\ncompleted sets.\nLowest item = your bottleneck.">Own</span>
<span data-tip="Live overseas stock from YATA.\n🟢 Plushie ≥2000 / Flower ≥5000\n🟠 Below threshold\n🔴 Zero stock\n\n🏪 Sheep/Teddy/Kitten show a\nshortcut to Bits 'n' Bobs shop.">Abroad</span>
<span></span>
</div>`;
for (const [groupName, group] of Object.entries(GROUPS)) {
const sectionKey = groupName.toLowerCase();
if (_gmGet(SECTION_KEYS[sectionKey], true) === false) continue;
const sets = calcSet(inventory, group.items);
const warn = lowestTwo(inventory, group.items, sets);
totalSets += sets; totalPoints += sets * group.pts;
html += `<div class="t">${groupName.toUpperCase()} <span class="t-sets">• ${sets} Sets</span></div>`;
if (warn) html += `<div class="a">${warn}</div>`;
for (const { name, data, remaining } of getSortedItems(inventory, group.items, sets)) {
const isBob = BOB_ITEM_IDS.has(data.id);
const loc = LOCATIONS[data.loc] || { flag: '❓', label: data.loc };
if (isBob) {
html += `
<div class="r">
<img class="item-img" src="${itemImg(data.id)}" alt="${data.s}" title="${name}"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<span class="item-fallback" title="${name}">${data.s}</span>
<span class="col-local" title="You have ${remaining} extra after completing ${sets} sets">${remaining}</span>
<a class="col-bob-btn" href="https://www.torn.com/shops.php?step=bitsnbobs" title="Open Bits 'n' Bobs shop">🏪 BoB</a>
<span class="col-flag" title="Bits 'n' Bobs shop">🏪</span>
</div>`;
} else {
const ac = abroad[name] || 0;
html += `
<div class="r">
<img class="item-img" src="${itemImg(data.id)}" alt="${data.s}" title="${name}"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<span class="item-fallback" title="${name}">${data.s}</span>
<span class="col-local" title="You have ${remaining} extra after completing ${sets} sets">${remaining}</span>
<span class="col-abroad ${getStatusClass(name, ac)}" title="Overseas stock (YATA): ${ac}">${ac}</span>
<span class="col-flag" title="${loc.label}">${loc.flag}</span>
</div>`;
}
}
}
/* ── Special items: Meteorite + Fossil ── */
if (_gmGet(SECTION_KEYS.special, true) !== false) {
const meteorite = inventory["Meteorite Fragment"] || 0;
const fossil = inventory["Patagonian Fossil"] || 0;
totalPoints += meteorite * MET_PTS + fossil * FOS_PTS;
const metAbroad = abroad["Meteorite Fragment"] || 0;
const fosAbroad = abroad["Patagonian Fossil"] || 0;
html += `<div class="t">SPECIAL <span class="t-sets">• ${meteorite + fossil} Items</span></div>`;
html += `
<div class="r">
<img class="item-img" src="${itemImg(512)}" alt="Meteor" title="Meteorite Fragment"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<span class="item-fallback">MTR</span>
<span class="col-local">${meteorite}</span>
<span class="col-abroad ${getStatusClass('Meteorite Fragment', metAbroad)}">${metAbroad}</span>
<span class="col-flag" title="Argentina">🇦🇷</span>
</div>
<div class="r">
<img class="item-img" src="${itemImg(513)}" alt="Fossil" title="Patagonian Fossil"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<span class="item-fallback">FSL</span>
<span class="col-local">${fossil}</span>
<span class="col-abroad ${getStatusClass('Patagonian Fossil', fosAbroad)}">${fosAbroad}</span>
<span class="col-flag" title="Argentina">🇦🇷</span>
</div>`;
} // end special section
if (_gmGet(SECTION_KEYS.xanax, true) !== false) {
const fSuffix = xanFaction === null ? '' : ` <span class="t-sets">• F = ${xanFaction.toLocaleString()}</span>`;
html += `<div class="t">XANAX${fSuffix}</div>
<div class="r" id="xan-row" title="Click to edit Xanax count">
<img class="item-img" src="${itemImg(XANAX_ITEM_ID)}" alt="XAN" title="Xanax"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<span class="item-fallback">XAN</span>
<span class="xan-count-display" id="xan-count-display">${xanPersonal}</span>
<span class="col-abroad">${xanSA.qty}</span>
<span class="col-price">${xanSA.price > 0 ? '$' + xanSA.price.toLocaleString() : '—'}</span>
</div>`;
} // end xanax section
// ── Points value & summary bar — always runs ──
let pvFmt = '';
if (apiKey && totalPoints > 0) {
const pp = await fetchPointsPrice(apiKey);
if (pp > 0) {
const pv = totalPoints * pp;
pvFmt = pv >= 1000000 ? `$${(pv/1000000).toFixed(1)}M` : pv >= 1000 ? `$${Math.round(pv/1000)}k` : `$${pv}`;
sum.classList.add('points-tooltip');
sum.setAttribute('data-tooltip', `Points: ${totalPoints}\nPer point: $${pp.toLocaleString()}\nTotal: $${pv.toLocaleString()}`);
}
}
sum.classList.remove('loading');
sum.innerHTML = pvFmt
? `✈ ${totalSets} SETS • ${totalPoints} PTS • <span style="color:#7fff7f">${pvFmt}</span>`
: `✈ ${totalSets} SETS • ${totalPoints} PTS`;
if (!pvFmt) { sum.classList.remove('points-tooltip'); sum.removeAttribute('data-tooltip'); }
body.innerHTML = html;
body.scrollTop = 0;
// Wire column header tooltip popovers
body.querySelectorAll('.col-header span[data-tip]').forEach(el => {
let tipEl = null;
el.addEventListener('mouseenter', () => {
tipEl = document.createElement('div');
tipEl.style.cssText = `position:fixed;z-index:1000020;background:linear-gradient(145deg,rgba(10,14,24,0.99),rgba(6,9,16,0.98));border:1px solid rgba(255,215,0,0.45);border-radius:7px;padding:8px 11px;font:10px 'Segoe UI',sans-serif;color:#e0f0ff;white-space:pre-line;line-height:1.6;box-shadow:0 6px 20px rgba(0,0,0,0.7);pointer-events:none;max-width:200px;`;
tipEl.textContent = el.dataset.tip;
document.body.appendChild(tipEl);
const r = el.getBoundingClientRect();
const tw = tipEl.offsetWidth, th = tipEl.offsetHeight;
let left = r.left + r.width/2 - tw/2;
let top = r.bottom + 6;
if (top + th > window.innerHeight - 6) top = r.top - th - 6;
if (left + tw > window.innerWidth - 6) left = window.innerWidth - tw - 6;
tipEl.style.left = Math.max(6,left) + 'px';
tipEl.style.top = Math.max(6,top) + 'px';
});
el.addEventListener('mouseleave', () => { tipEl?.remove(); tipEl = null; });
});
body.querySelector('#xan-row')?.addEventListener('click', () => showXanaxPopup(body.querySelector('#xan-row')));
} catch (err) {
sum.classList.remove('loading'); sum.textContent = 'ERROR';
body.innerHTML = `<div style="padding:20px 12px;text-align:center;color:#ff8888;font-size:10px;">
⚠️ ${err.message}<br><br>
<span style="color:#8BC34A;font-size:9px;">Check API key.<br>Long-press toggle to manage.</span>
</div>`;
}
}
/* ================= MAIN LOOP ================= */
async function mainLoop() {
const apiKey = _gmGet('tornAPIKey', '');
if (apiKey) fetchPointsPrice(apiKey).catch(() => {});
await render();
pollTimer = setTimeout(mainLoop, POLL);
}
/* ================= CLEANUP + START ================= */
function cleanupExistingInstance() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
[PANEL_ID, TOGGLE_ID, API_PANEL_ID, 'api-management-menu', 'xan-popup', 'xan-popup-backdrop']
.forEach(id => document.getElementById(id)?.remove());
document.querySelector('.api-backdrop')?.remove();
document.querySelector('.xan-light-backdrop')?.remove();
}
if (document.getElementById(TOGGLE_ID)) return;
cleanupExistingInstance();
if (!_gmGet('tornAPIKey', '')) {
setTimeout(createApiPanel, 1000);
} else {
setTimeout(initializeTracker, 500);
}
})();