Points Museum with item images in suggestions - Fixed memory leaks and refresh issues
// ==UserScript==
// @name ✨ Points Museum - Fixed
// @namespace http://tampermonkey.net/
// @version 14.5.0
// @description Points Museum with item images in suggestions - Fixed memory leaks and refresh issues
// @match https://www.torn.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// Check if already initialized
if (window.__pointsMuseumFixedInitialized) {
console.log('Points Museum already running, skipping duplicate');
return;
}
window.__pointsMuseumFixedInitialized = true;
const UID = 'pm_' + Math.random().toString(36).substr(2, 8);
const PREFIX = `pts_museum_${UID}`;
function id(name) {
return `${PREFIX}_${name}`;
}
// ================= CONFIG =================
const THEME_KEY = "pts_museum_theme_preference";
const POLL = 120000;
const PRE_PTS = 25,
FLO_PTS = 10,
PLU_PTS = 10;
const CATEGORY_ORDER_KEY = 'pts_museum_category_order';
const COLLAPSED_CATEGORIES_KEY = 'pts_museum_collapsed_categories';
const MUSEUM_DAY_MONTH = 5;
const MUSEUM_DAY_DATE = 18;
const POINTS_PRICE_CACHE_DURATION = 600000;
const DEFAULT_CATEGORY_ORDER = ['Prehistoric', 'Flowers', 'Plushies', 'Special'];
let currentPointsPrice = 0;
let pointsPriceCache = {
time: 0,
price: 0
};
let isMuseumDay = false;
let daysToMuseumDay = 0;
let isPanelOpen = false;
let refreshTimer = null;
let isLoading = false;
let isDestroyed = false;
// Store event listeners for cleanup
const eventListeners = new Map();
let urlObserver = null;
// ================= DATA =================
const GROUPS = {
Prehistoric: {
name: "Prehistoric",
emoji: "🦕",
pts: PRE_PTS,
bonusPts: PRE_PTS * 0.10,
items: {
"Quartz Point": {
s: "Q",
flag: "🇨🇦",
id: 1504
},
"Chalcedony Point": {
s: "CH",
flag: "🇦🇷",
id: 1503
},
"Basalt Point": {
s: "B",
flag: "🏝️",
id: 1502
},
"Quartzite Point": {
s: "QZ",
flag: "🇿🇦",
id: 1500
},
"Chert Point": {
s: "CT",
flag: "🇬🇧",
id: 1501
},
"Obsidian Point": {
s: "O",
flag: "🇲🇽",
id: 1499
}
}
},
Flowers: {
name: "Flowers",
emoji: "🌸",
pts: FLO_PTS,
bonusPts: FLO_PTS * 0.10,
items: {
"Dahlia": {
s: "DH",
flag: "🇲🇽",
id: 260
},
"Orchid": {
s: "OR",
flag: "🏝️",
id: 264
},
"African Violet": {
s: "V",
flag: "🇿🇦",
id: 282
},
"Cherry Blossom": {
s: "CB",
flag: "🇯🇵",
id: 277
},
"Peony": {
s: "P",
flag: "🇨🇳",
id: 276
},
"Ceibo Flower": {
s: "CE",
flag: "🇦🇷",
id: 271
},
"Edelweiss": {
s: "E",
flag: "🇨🇭",
id: 272
},
"Crocus": {
s: "CR",
flag: "🇨🇦",
id: 263
},
"Heather": {
s: "H",
flag: "🇬🇧",
id: 267
},
"Tribulus Omanense": {
s: "T",
flag: "🇦🇪",
id: 385
},
"Banana Orchid": {
s: "BO",
flag: "🇰🇾",
id: 617
}
}
},
Plushies: {
name: "Plushies",
emoji: "🧸",
pts: PLU_PTS,
bonusPts: PLU_PTS * 0.10,
items: {
"Sheep Plushie": {
s: "SH",
flag: "🏝️",
id: 186
},
"Teddy Bear Plushie": {
s: "TB",
flag: "🏝️",
id: 187
},
"Kitten Plushie": {
s: "KT",
flag: "🏝️",
id: 215
},
"Jaguar Plushie": {
s: "J",
flag: "🇲🇽",
id: 258
},
"Wolverine Plushie": {
s: "W",
flag: "🇨🇦",
id: 261
},
"Nessie Plushie": {
s: "N",
flag: "🇬🇧",
id: 266
},
"Red Fox Plushie": {
s: "F",
flag: "🇬🇧",
id: 268
},
"Monkey Plushie": {
s: "M",
flag: "🇦🇷",
id: 269
},
"Chamois Plushie": {
s: "CM",
flag: "🇨🇭",
id: 273
},
"Panda Plushie": {
s: "PD",
flag: "🇨🇳",
id: 274
},
"Lion Plushie": {
s: "L",
flag: "🇿🇦",
id: 281
},
"Camel Plushie": {
s: "CA",
flag: "🇦🇪",
id: 384
},
"Stingray Plushie": {
s: "SR",
flag: "🇰🇾",
id: 618
}
}
}
};
// Safe event listener addition
function addSafeEventListener(element, event, handler) {
if (!element) return;
const key = `${event}_${Date.now()}_${Math.random()}`;
element.addEventListener(event, handler);
if (!eventListeners.has(element)) {
eventListeners.set(element, []);
}
eventListeners.get(element).push({
event,
handler,
key
});
}
// Cleanup function
function destroy() {
if (isDestroyed) return;
isDestroyed = true;
// Clear timers
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
// Remove all event listeners
for (const [element, listeners] of eventListeners) {
if (element && element.removeEventListener) {
listeners.forEach(({
event,
handler
}) => {
element.removeEventListener(event, handler);
});
}
}
eventListeners.clear();
// Disconnect observers
if (urlObserver) {
urlObserver.disconnect();
urlObserver = null;
}
// Remove DOM elements
const container = document.getElementById(id('edge_container'));
if (container) container.remove();
// Remove toasts
document.querySelectorAll(`.${PREFIX}_toast`).forEach(toast => toast.remove());
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
destroy();
});
// Watch for navigation changes (SPA)
let lastUrl = location.href;
urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
// Don't destroy, just reset state
isLoading = false;
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
// Re-initialize for new page
setTimeout(() => {
if (!isDestroyed) {
mainLoop();
}
}, 1000);
}
});
urlObserver.observe(document, {
subtree: true,
childList: true
});
function getItemImageUrl(itemId) {
return `https://www.torn.com/images/items/${itemId}/small.png`;
}
function formatMoney(value) {
if (value === undefined || value === null) return '$0';
return '$' + Math.round(value).toLocaleString();
}
function calculateDaysToMuseumDay() {
const today = new Date();
const currentYear = today.getFullYear();
let museumDay = new Date(currentYear, MUSEUM_DAY_MONTH - 1, MUSEUM_DAY_DATE);
if (today > museumDay) {
museumDay = new Date(currentYear + 1, MUSEUM_DAY_MONTH - 1, MUSEUM_DAY_DATE);
}
const diffTime = museumDay - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
function checkMuseumDay() {
const now = new Date();
isMuseumDay = (now.getMonth() + 1 === MUSEUM_DAY_MONTH && now.getDate() === MUSEUM_DAY_DATE);
daysToMuseumDay = calculateDaysToMuseumDay();
return isMuseumDay;
}
// ================= Get limiting item for each category =================
function getLimitingItem(inventory, group) {
const quantities = [];
for (const [name, data] of Object.entries(group.items)) {
quantities.push({
name: name,
id: data.id,
quantity: inventory[name] || 0
});
}
const minQty = Math.min(...quantities.map(q => q.quantity));
const limitingItems = quantities.filter(q => q.quantity === minQty);
return limitingItems[0];
}
// ================= UI =================
function getCategoryOrder() {
const saved = GM_getValue(CATEGORY_ORDER_KEY, DEFAULT_CATEGORY_ORDER);
const validOrder = saved.filter(cat => cat === 'Prehistoric' || cat === 'Flowers' || cat === 'Plushies' || cat === 'Special');
DEFAULT_CATEGORY_ORDER.forEach(cat => {
if (!validOrder.includes(cat)) validOrder.push(cat);
});
return validOrder;
}
function saveCategoryOrder(order) {
GM_setValue(CATEGORY_ORDER_KEY, order);
}
function getCollapsedCategories() {
return GM_getValue(COLLAPSED_CATEGORIES_KEY, []);
}
function saveCollapsedCategory(category, isCollapsed) {
let collapsed = getCollapsedCategories();
if (isCollapsed && !collapsed.includes(category)) collapsed.push(category);
else if (!isCollapsed && collapsed.includes(category)) collapsed = collapsed.filter(c => c !== category);
GM_setValue(COLLAPSED_CATEGORIES_KEY, collapsed);
}
function toggleCategory(category) {
saveCollapsedCategory(category, !getCollapsedCategories().includes(category));
render();
}
function moveCategoryUp(category) {
const order = getCategoryOrder();
const index = order.indexOf(category);
if (index > 0) {
[order[index - 1], order[index]] = [order[index], order[index - 1]];
saveCategoryOrder(order);
render();
}
}
function moveCategoryDown(category) {
const order = getCategoryOrder();
const index = order.indexOf(category);
if (index < order.length - 1) {
[order[index], order[index + 1]] = [order[index + 1], order[index]];
saveCategoryOrder(order);
render();
}
}
function applyTheme(theme) {
const container = document.getElementById(id('edge_container'));
if (!container) return;
container.classList.remove(`${PREFIX}_dark_edge`);
container.classList.remove(`${PREFIX}_light_edge`);
if (theme === 'light') {
container.classList.add(`${PREFIX}_light_edge`);
} else {
container.classList.add(`${PREFIX}_dark_edge`);
}
GM_setValue(THEME_KEY, theme);
}
function toggleTheme() {
const container = document.getElementById(id('edge_container'));
if (!container) return;
const isDark = container.classList.contains(`${PREFIX}_dark_edge`);
const newTheme = isDark ? 'light' : 'dark';
applyTheme(newTheme);
showToast(isDark ? "🎨 Light theme" : "🎨 Dark theme");
}
function loadSavedTheme() {
const savedTheme = GM_getValue(THEME_KEY, 'dark');
applyTheme(savedTheme);
}
function showToast(message, duration = 2000) {
const existing = document.querySelector(`.${PREFIX}_toast`);
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `${PREFIX}_toast`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
function updateLastUpdateTime() {
const el = document.getElementById(id('last_update'));
if (el) {
const now = new Date();
el.textContent = now.toLocaleTimeString();
}
}
function initEvents() {
const panel = document.getElementById(id('edge_panel'));
const pullTab = document.getElementById(id('pull_tab'));
const modalOverlay = document.getElementById(id('modal_overlay'));
const closePanelBtn = document.getElementById(id('close_panel'));
if (closePanelBtn) {
addSafeEventListener(closePanelBtn, 'click', () => {
isPanelOpen = false;
if (panel) panel.classList.remove(`${PREFIX}_open`);
if (pullTab) {
const icon = pullTab.querySelector(`.${PREFIX}_tab_icon`);
if (icon) icon.textContent = '▶';
}
});
}
if (pullTab) {
addSafeEventListener(pullTab, 'click', () => {
isPanelOpen = !isPanelOpen;
if (panel) {
if (isPanelOpen) {
panel.classList.add(`${PREFIX}_open`);
const icon = pullTab.querySelector(`.${PREFIX}_tab_icon`);
if (icon) icon.textContent = '◀';
} else {
panel.classList.remove(`${PREFIX}_open`);
const icon = pullTab.querySelector(`.${PREFIX}_tab_icon`);
if (icon) icon.textContent = '▶';
}
}
});
}
const refreshBtn = document.getElementById(id('refresh_panel_btn'));
if (refreshBtn) {
addSafeEventListener(refreshBtn, 'click', () => {
showToast("⟳ Refreshing...");
forceRefresh();
});
}
const settingsBtn = document.getElementById(id('settings_btn'));
if (settingsBtn && modalOverlay) {
addSafeEventListener(settingsBtn, 'click', () => {
modalOverlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
const input = document.getElementById(id('api_input'));
if (input) input.value = GM_getValue('tornAPIKey', '');
});
}
const closeModal = () => {
if (modalOverlay) modalOverlay.style.display = 'none';
document.body.style.overflow = '';
};
const modalCloseBtn = document.getElementById(id('modal_close_btn'));
if (modalCloseBtn) addSafeEventListener(modalCloseBtn, 'click', closeModal);
if (modalOverlay) {
addSafeEventListener(modalOverlay, 'click', (e) => {
if (e.target === modalOverlay) closeModal();
});
}
addSafeEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape' && modalOverlay && modalOverlay.style.display === 'flex') closeModal();
});
const themeToggle = document.getElementById(id('theme_toggle'));
if (themeToggle) {
addSafeEventListener(themeToggle, 'click', () => {
toggleTheme();
closeModal();
});
}
const resetBtn = document.getElementById(id('reset_btn'));
if (resetBtn) {
addSafeEventListener(resetBtn, 'click', () => {
if (confirm('Delete ALL data?')) {
GM_setValue('tornAPIKey', '');
GM_setValue(THEME_KEY, '');
GM_setValue(CATEGORY_ORDER_KEY, '');
GM_setValue(COLLAPSED_CATEGORIES_KEY, '');
showToast('All data reset. Refresh page.');
setTimeout(() => location.reload(), 1500);
}
closeModal();
});
}
const apiSaveBtn = document.getElementById(id('api_save'));
if (apiSaveBtn) {
addSafeEventListener(apiSaveBtn, 'click', () => {
const input = document.getElementById(id('api_input'));
const key = input ? input.value.trim() : '';
if (key && key.length === 16) {
GM_setValue('tornAPIKey', key);
showToast("🔑 API Key saved!");
closeModal();
forceRefresh();
} else {
showToast("❌ API key must be 16 characters");
}
});
}
const inventoryToggle = document.getElementById(id('inventory_toggle'));
const inventoryContent = document.getElementById(id('inventory_content'));
if (inventoryToggle && inventoryContent) {
addSafeEventListener(inventoryToggle, 'click', () => {
const isVisible = inventoryContent.style.display !== 'none';
inventoryContent.style.display = isVisible ? 'none' : 'block';
const toggleIcon = inventoryToggle.querySelector(`.${PREFIX}_toggle_icon`);
if (toggleIcon) toggleIcon.textContent = isVisible ? '▶' : '▼';
});
}
}
function createHaloUI() {
if (document.getElementById(id('edge_container'))) return;
const container = document.createElement("div");
container.id = id('edge_container');
container.className = `${PREFIX}_edge_container ${PREFIX}_dark_edge`;
container.innerHTML = `
<div class="${PREFIX}_pull_tab ${PREFIX}_pull_tab_right" id="${id('pull_tab')}">
<div class="${PREFIX}_tab_icon">▶</div>
<div class="${PREFIX}_tab_text">PTS</div>
<div class="${PREFIX}_tab_stats" id="${id('tab_sets')}">0</div>
</div>
<div class="${PREFIX}_edge_panel ${PREFIX}_edge_panel_right" id="${id('edge_panel')}">
<div class="${PREFIX}_panel_header">
<div class="${PREFIX}_brand"><span class="${PREFIX}_star">✨</span><span>POINTS MUSEUM</span></div>
<div class="${PREFIX}_header_buttons">
<button class="${PREFIX}_refresh_btn_panel" id="${id('refresh_panel_btn')}" title="Refresh">⟳</button>
<button class="${PREFIX}_settings_btn" id="${id('settings_btn')}" title="Settings">⚙</button>
<button class="${PREFIX}_close_panel" id="${id('close_panel')}" title="Close">✕</button>
</div>
</div>
<div class="${PREFIX}_panel_inner">
<div class="${PREFIX}_quick_stats">
<div class="${PREFIX}_stat_item"><div class="${PREFIX}_stat_number" id="${id('stat_sets')}">0</div><div class="${PREFIX}_stat_label">Sets</div></div>
<div class="${PREFIX}_stat_item"><div class="${PREFIX}_stat_number" id="${id('stat_points')}">0</div><div class="${PREFIX}_stat_label">Points</div></div>
<div class="${PREFIX}_stat_item"><div class="${PREFIX}_stat_number" id="${id('stat_value')}">$0</div><div class="${PREFIX}_stat_label">Value</div></div>
</div>
<div class="${PREFIX}_highlight_bar" id="${id('highlight_bar')}">
<div class="${PREFIX}_highlight_text">-- days 🏛️ 10% -- points @ --</div>
</div>
<div class="${PREFIX}_suggestions_section">
<div class="${PREFIX}_suggestions_content" id="${id('suggestions_content')}">
<div class="${PREFIX}_loading">Loading...</div>
</div>
</div>
<div class="${PREFIX}_inventory_section">
<div class="${PREFIX}_section_header clickable" id="${id('inventory_toggle')}">
<span>📦 INVENTORY</span>
<span class="${PREFIX}_toggle_icon">▼</span>
</div>
<div class="${PREFIX}_inventory_content" id="${id('inventory_content')}">
<div id="${id('items_container')}"><div class="${PREFIX}_loading">Loading...</div></div>
</div>
</div>
<div class="${PREFIX}_footer">
<span class="${PREFIX}_update_time" id="${id('last_update')}">--:--:--</span>
<a href="https://www.torn.com/profiles.php?XID=2637223" target="_blank">❤️</a>
</div>
</div>
</div>
<div class="${PREFIX}_modal_overlay" id="${id('modal_overlay')}" style="display: none;">
<div class="${PREFIX}_modal">
<div class="${PREFIX}_modal_header"><span>⚙ Settings</span><button class="${PREFIX}_modal_close" id="${id('modal_close_btn')}">✕</button></div>
<div class="${PREFIX}_modal_body">
<div class="${PREFIX}_settings_field"><label>API Key (16 chars)</label><input type="password" id="${id('api_input')}" placeholder="Enter Torn API key" class="${PREFIX}_input"><button id="${id('api_save')}" class="${PREFIX}_btn_primary">Save</button></div>
<div class="${PREFIX}_settings_field"><label>Appearance</label><button id="${id('theme_toggle')}" class="${PREFIX}_btn_secondary">🌓 Toggle Theme</button></div>
<div class="${PREFIX}_settings_field"><label style="color: var(--pts-danger);">Danger</label><button id="${id('reset_btn')}" class="${PREFIX}_btn_danger">⚠ Reset All</button></div>
</div>
</div>
</div>
`;
document.body.appendChild(container);
}
async function localItems() {
const key = GM_getValue('tornAPIKey');
if (!key) throw new Error('No API key');
const response = await fetch(`https://api.torn.com/user/?selections=display&key=${key}`).then(r => r.json());
if (response.error) throw new Error(response.error.error || 'API Error');
const items = {},
prices = {};
if (response.display && Array.isArray(response.display)) {
response.display.forEach(item => {
items[item.name] = (items[item.name] || 0) + item.quantity;
prices[item.name] = item.market_price || 0;
});
}
return {
items,
prices
};
}
function gmJSON(url) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: r => {
try {
resolve(JSON.parse(r.responseText));
} catch {
resolve({});
}
},
onerror: () => resolve({})
});
});
}
async function abroadItems() {
const [yataData] = await Promise.all([gmJSON('https://yata.yt/api/v1/travel/export/')]);
const abroadMap = {};
if (yataData && yataData.stocks) {
Object.values(yataData.stocks).forEach(country => {
if (country && country.stocks) {
country.stocks.forEach(item => {
abroadMap[item.name] = (abroadMap[item.name] || 0) + (item.quantity || 0);
});
}
});
}
return abroadMap;
}
async function fetchPointsPrice(apiKey) {
const now = Date.now();
if (pointsPriceCache.time && (now - pointsPriceCache.time) < POINTS_PRICE_CACHE_DURATION) return pointsPriceCache.price;
try {
const response = await fetch(`https://api.torn.com/v2/market/pointsmarket?key=${apiKey}`);
const data = await response.json();
if (data && 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 > 0) {
const avgPrice = Math.round(listings.slice(0, 5).reduce((s, p) => s + p, 0) / Math.min(5, listings.length));
pointsPriceCache = {
time: now,
price: avgPrice
};
return avgPrice;
}
}
} catch (error) {}
return currentPointsPrice || 0;
}
function calcSet(inventory, items) {
const values = Object.keys(items).map(key => inventory[key] || 0);
return {
sets: values.length ? Math.min(...values) : 0
};
}
function getStatusClass(abroadCount) {
if (abroadCount === 0) return `${PREFIX}_status_red`;
if (abroadCount > 1000) return `${PREFIX}_status_green`;
return `${PREFIX}_status_orange`;
}
async function forceRefresh() {
if (isLoading) {
showToast("Already refreshing...");
return;
}
pointsPriceCache = {
time: 0,
price: 0
};
await render(true);
}
async function render(force = false) {
if (isLoading && !force) return;
if (isDestroyed) return;
isLoading = true;
const container = document.getElementById(id('items_container'));
if (!container) {
isLoading = false;
return;
}
const apiKey = GM_getValue('tornAPIKey');
if (!apiKey) {
if (container) container.innerHTML = `<div class="${PREFIX}_edge_empty">🔑 No API key - Click ⚙</div>`;
const suggestionsContent = document.getElementById(id('suggestions_content'));
if (suggestionsContent) suggestionsContent.innerHTML = `<div class="${PREFIX}_suggest_note">🔑 Set API key first</div>`;
updateStats(0, 0, 0);
updateLastUpdateTime();
isLoading = false;
return;
}
try {
const {
items: inventory,
prices: itemPrices
} = await localItems();
const abroad = await abroadItems();
let pointsPrice = await fetchPointsPrice(apiKey);
checkMuseumDay();
let totalSets = 0,
totalPoints = 0,
totalItemValue = 0,
totalRemainingValue = 0;
let categoryHtml = {},
categoryRemainingValue = {};
const processCat = (catName, group) => {
const {
sets
} = calcSet(inventory, group.items);
let html = `<div class="${PREFIX}_category_t" data-category="${catName}">
<div class="${PREFIX}_category_title"><span>${catName === 'Prehistoric' ? '🦕' : catName === 'Flowers' ? '🌸' : '🧸'}</span> ${catName} <span class="${PREFIX}_category_sets">${sets.toLocaleString()}</span></div>
<div class="${PREFIX}_category_controls"><span class="${PREFIX}_category_btn up" data-category="${catName}">▲</span><span class="${PREFIX}_category_btn down" data-category="${catName}">▼</span></div>
</div><div class="${PREFIX}_category_content" data-category="${catName}">`;
let totalVal = 0,
remainVal = 0;
const sorted = Object.entries(group.items).map(([name, data]) => ({
name,
data,
remaining: (inventory[name] || 0) - sets
})).sort((a, b) => a.remaining - b.remaining);
for (const {
name,
data,
remaining
} of sorted) {
const price = itemPrices[name] || 0;
const abroadCount = abroad[name] || 0;
totalVal += (inventory[name] || 0) * price;
remainVal += remaining * price;
html += `<div class="${PREFIX}_item_row">
<img src="${getItemImageUrl(data.id)}" class="${PREFIX}_item_img" alt="${name}">
<span class="${PREFIX}_item_local">${remaining.toLocaleString()}</span>
<span class="${PREFIX}_item_abroad ${getStatusClass(abroadCount)}">${abroadCount.toLocaleString()}</span>
<span class="${PREFIX}_item_flag">${data.flag}</span>
</div>`;
}
html += `</div>`;
return {
sets,
points: sets * group.pts,
html,
totalValue: totalVal,
remainingValue: remainVal
};
};
const prehistoric = processCat('Prehistoric', GROUPS.Prehistoric);
const flowers = processCat('Flowers', GROUPS.Flowers);
const plushies = processCat('Plushies', GROUPS.Plushies);
categoryHtml['Prehistoric'] = prehistoric.html;
categoryHtml['Flowers'] = flowers.html;
categoryHtml['Plushies'] = plushies.html;
categoryRemainingValue['Prehistoric'] = prehistoric.remainingValue;
categoryRemainingValue['Flowers'] = flowers.remainingValue;
categoryRemainingValue['Plushies'] = plushies.remainingValue;
totalSets = prehistoric.sets + flowers.sets + plushies.sets;
totalPoints = prehistoric.points + flowers.points + plushies.points;
totalItemValue = prehistoric.totalValue + flowers.totalValue + plushies.totalValue;
totalRemainingValue = prehistoric.remainingValue + flowers.remainingValue + plushies.remainingValue;
const meteorite = inventory["Meteorite Fragment"] || 0;
const fossil = inventory["Patagonian Fossil"] || 0;
const meteoritePrice = itemPrices["Meteorite Fragment"] || 0;
const fossilPrice = itemPrices["Patagonian Fossil"] || 0;
totalItemValue += (meteorite * meteoritePrice) + (fossil * fossilPrice);
totalPoints += (meteorite * 15) + (fossil * 20);
categoryHtml['Special'] = `<div class="${PREFIX}_category_t" data-category="Special"><div class="${PREFIX}_category_title"><span>⭐</span> Special <span class="${PREFIX}_category_sets">${(meteorite + fossil).toLocaleString()}</span></div><div class="${PREFIX}_category_controls"><span class="${PREFIX}_category_btn up" data-category="Special">▲</span><span class="${PREFIX}_category_btn down" data-category="Special">▼</span></div></div><div class="${PREFIX}_category_content" data-category="Special"><div class="${PREFIX}_item_row"><img src="${getItemImageUrl(1488)}" class="${PREFIX}_item_img"><span class="${PREFIX}_item_local">${meteorite.toLocaleString()}</span><span class="${PREFIX}_item_abroad ${getStatusClass(abroad["Meteorite Fragment"] || 0)}">${(abroad["Meteorite Fragment"] || 0).toLocaleString()}</span><span class="${PREFIX}_item_flag">🇦🇷</span></div><div class="${PREFIX}_item_row"><img src="${getItemImageUrl(1487)}" class="${PREFIX}_item_img"><span class="${PREFIX}_item_local">${fossil.toLocaleString()}</span><span class="${PREFIX}_item_abroad ${getStatusClass(abroad["Patagonian Fossil"] || 0)}">${(abroad["Patagonian Fossil"] || 0).toLocaleString()}</span><span class="${PREFIX}_item_flag">🇦🇷</span></div></div>`;
const pointsValue = totalPoints * pointsPrice;
const bonusPoints = Math.round(totalPoints * 0.10);
const bonusValue = Math.round(bonusPoints * pointsPrice);
const statSets = document.getElementById(id('stat_sets'));
const statPoints = document.getElementById(id('stat_points'));
const statValue = document.getElementById(id('stat_value'));
const tabSets = document.getElementById(id('tab_sets'));
if (statSets) statSets.textContent = totalSets.toLocaleString();
if (statPoints) statPoints.textContent = totalPoints.toLocaleString();
if (statValue) statValue.textContent = formatMoney(pointsValue);
if (tabSets) tabSets.textContent = totalSets.toLocaleString();
const highlightBar = document.getElementById(id('highlight_bar'));
if (highlightBar) {
highlightBar.innerHTML = `<div class="${PREFIX}_highlight_text">${daysToMuseumDay} days 🏛️ 10% ${bonusPoints.toLocaleString()} points @ ${formatMoney(bonusValue)}</div>`;
}
const suggestionsContainer = document.getElementById(id('suggestions_content'));
if (suggestionsContainer) {
const categories = ['Plushies', 'Flowers', 'Prehistoric'];
let suggestionsHtml = '';
for (const catName of categories) {
const group = GROUPS[catName];
const bonusPts = group.bonusPts;
const bonusValuePerSet = Math.round(bonusPts * pointsPrice);
const limitingItem = getLimitingItem(inventory, group);
const bonusPtsDisplay = Number.isInteger(bonusPts) ? bonusPts : bonusPts.toFixed(1);
const ptsLabel = bonusPts === 1 ? 'pt' : 'pts';
suggestionsHtml += `<div class="${PREFIX}_suggestion_row">
<img src="${getItemImageUrl(limitingItem.id)}" class="${PREFIX}_suggestion_img">
<span class="${PREFIX}_suggestion_emoji">${group.emoji}</span>
<span class="${PREFIX}_suggestion_text">+${bonusPtsDisplay} ${ptsLabel} → ${formatMoney(bonusValuePerSet)}</span>
</div>`;
}
suggestionsContainer.innerHTML = suggestionsHtml;
}
let inventoryHtml = '';
const categoryOrder = getCategoryOrder();
const collapsedCategories = getCollapsedCategories();
for (const cat of categoryOrder) {
if (categoryHtml[cat]) {
inventoryHtml += categoryHtml[cat];
}
}
if (!inventoryHtml) inventoryHtml = `<div class="${PREFIX}_edge_empty">📦 No items</div>`;
if (container) container.innerHTML = inventoryHtml;
// Clean up old listeners before adding new ones
const upBtns = document.querySelectorAll(`.${PREFIX}_category_btn.up`);
const downBtns = document.querySelectorAll(`.${PREFIX}_category_btn.down`);
const titles = document.querySelectorAll(`.${PREFIX}_category_t`);
upBtns.forEach(btn => {
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
addSafeEventListener(newBtn, 'click', (e) => {
e.stopPropagation();
moveCategoryUp(newBtn.dataset.category);
});
});
downBtns.forEach(btn => {
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
addSafeEventListener(newBtn, 'click', (e) => {
e.stopPropagation();
moveCategoryDown(newBtn.dataset.category);
});
});
titles.forEach(title => {
const category = title.dataset.category;
const content = document.querySelector(`.${PREFIX}_category_content[data-category="${category}"]`);
if (collapsedCategories.includes(category) && content) {
content.style.maxHeight = '0';
content.style.overflow = 'hidden';
}
const newTitle = title.cloneNode(true);
title.parentNode.replaceChild(newTitle, title);
addSafeEventListener(newTitle, 'click', (e) => {
if (e.target.classList && e.target.classList.contains(`${PREFIX}_category_btn`)) return;
toggleCategory(category);
});
});
updateLastUpdateTime();
} catch (error) {
console.error('Points Museum error:', error);
if (container) container.innerHTML = `<div class="${PREFIX}_edge_empty">⚠️ Error: ${error.message}</div>`;
updateStats(0, 0, 0);
} finally {
isLoading = false;
}
}
function updateStats(totalSets, totalPoints, totalValue) {
const setsEl = document.getElementById(id('stat_sets'));
const pointsEl = document.getElementById(id('stat_points'));
const valueEl = document.getElementById(id('stat_value'));
const tabSetsEl = document.getElementById(id('tab_sets'));
if (setsEl) setsEl.textContent = totalSets.toLocaleString();
if (pointsEl) pointsEl.textContent = totalPoints.toLocaleString();
if (valueEl) valueEl.textContent = formatMoney(totalValue);
if (tabSetsEl) tabSetsEl.textContent = totalSets.toLocaleString();
}
async function mainLoop() {
if (isDestroyed) return;
const apiKey = GM_getValue('tornAPIKey');
if (apiKey) {
await fetchPointsPrice(apiKey).catch(() => {});
}
await render();
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => mainLoop(), POLL);
}
function init() {
// Prevent duplicate initialization
if (window.__pointsMuseumFixedRunning) {
console.log('Points Museum already running');
return;
}
window.__pointsMuseumFixedRunning = true;
console.log('✨ Points Museum v14.5 - Fixed memory leaks');
createHaloUI();
initEvents();
loadSavedTheme();
const savedKey = GM_getValue('tornAPIKey');
if (!savedKey) {
const key = prompt('Enter your Torn API key (16 chars):');
if (key && key.length === 16) {
GM_setValue('tornAPIKey', key);
showToast('API key saved!');
mainLoop();
} else if (key) {
showToast('Invalid API key');
}
} else {
mainLoop();
}
}
// Clean up on page unload
window.addEventListener('pagehide', () => {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
window.__pointsMuseumFixedRunning = false;
window.__pointsMuseumFixedInitialized = false;
});
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
// ================= STYLES =================
GM_addStyle(`
.${PREFIX}_edge_container { position: fixed; top: 0; right: 0; height: 100vh; width: 0; z-index: 99999; pointer-events: none; }
.${PREFIX}_dark_edge { --pts-bg: rgba(8,12,20,0.98); --pts-border: rgba(52,152,219,0.35); --pts-text: #e0e0e0; --pts-text-dim: #7f8c8d; --pts-accent: #3498db; --pts-accent-glow: rgba(52,152,219,0.12); --pts-success: #2ecc71; --pts-warning: #f39c12; --pts-danger: #e74c3c; }
.${PREFIX}_light_edge { --pts-bg: rgba(255,255,255,0.98); --pts-border: rgba(52,152,219,0.4); --pts-text: #2c3e50; --pts-text-dim: #95a5a6; --pts-accent: #2980b9; --pts-accent-glow: rgba(52,152,219,0.08); --pts-success: #27ae60; --pts-warning: #f39c12; --pts-danger: #e74c3c; }
.${PREFIX}_pull_tab_right { position: fixed; right: 0; top: 20%; width: 20px; background: var(--pts-bg); backdrop-filter: blur(12px); border: 1px solid var(--pts-border); border-right: none; border-radius: 6px 0 0 6px; padding: 8px 2px; display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; pointer-events: auto; z-index: 100000; }
.${PREFIX}_tab_icon { font-size: 7px; color: var(--pts-accent); }
.${PREFIX}_tab_text { font-size: 6px; font-weight: 700; writing-mode: vertical-rl; color: var(--pts-accent); }
.${PREFIX}_tab_stats { font-size: 8px; font-weight: 700; text-align: center; color: var(--pts-accent); background: var(--pts-accent-glow); padding: 2px 2px; border-radius: 3px; writing-mode: vertical-rl; }
.${PREFIX}_edge_panel_right { position: fixed; right: 0; top: 50%; transform: translateY(-50%) translateX(100%); width: 200px; max-height: 90vh; background: var(--pts-bg); backdrop-filter: blur(16px); border-left: 1px solid var(--pts-border); border-radius: 8px 0 0 8px; transition: transform 0.25s ease; pointer-events: auto; display: flex; flex-direction: column; overflow: hidden; z-index: 100000; }
.${PREFIX}_edge_panel_right.${PREFIX}_open { transform: translateY(-50%) translateX(0); }
.${PREFIX}_panel_header { display: flex; justify-content: space-between; align-items: center; padding: 5px 7px; border-bottom: 1px solid var(--pts-border); background: var(--pts-accent-glow); }
.${PREFIX}_brand { font-size: 9px; font-weight: 700; color: var(--pts-accent); }
.${PREFIX}_star { font-size: 9px; animation: ptsSpinStar 3s linear infinite; }
@keyframes ptsSpinStar { 100% { transform: rotate(360deg); } }
.${PREFIX}_header_buttons { display: flex; gap: 4px; }
.${PREFIX}_settings_btn, .${PREFIX}_refresh_btn_panel, .${PREFIX}_close_panel { background: rgba(0,0,0,0.3); border: none; border-radius: 3px; padding: 2px 5px; font-size: 8px; cursor: pointer; color: var(--pts-text-dim); }
.${PREFIX}_refresh_btn_panel:hover { background: var(--pts-success); color: white; }
.${PREFIX}_settings_btn:hover, .${PREFIX}_close_panel:hover { background: var(--pts-accent); color: white; }
.${PREFIX}_close_panel:hover { background: var(--pts-danger); }
.${PREFIX}_quick_stats { display: flex; padding: 5px; gap: 4px; border-bottom: 1px solid var(--pts-border); background: var(--pts-accent-glow); }
.${PREFIX}_stat_item { flex: 1; text-align: center; background: rgba(0,0,0,0.2); border-radius: 4px; padding: 3px 1px; }
.${PREFIX}_stat_number { font-size: 9px; font-weight: 700; color: var(--pts-accent); }
.${PREFIX}_stat_label { font-size: 6px; color: var(--pts-text-dim); text-transform: uppercase; }
.${PREFIX}_highlight_bar { padding: 5px 5px; margin: 5px 6px; background: rgba(241,196,15,0.15); border-radius: 5px; text-align: center; }
.${PREFIX}_highlight_text { font-size: 8px; font-weight: 700; color: #f1c40f; }
.${PREFIX}_suggestions_section { padding: 5px 6px; border-bottom: 1px solid var(--pts-border); }
.${PREFIX}_suggestions_content { max-height: 110px; overflow-y: auto; }
.${PREFIX}_suggestions_content::-webkit-scrollbar { width: 2px; }
.${PREFIX}_suggestions_content::-webkit-scrollbar-thumb { background: var(--pts-accent); border-radius: 2px; }
.${PREFIX}_suggestion_row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin-bottom: 4px; }
.${PREFIX}_suggestion_img { width: 16px; height: 16px; object-fit: contain; }
.${PREFIX}_suggestion_emoji { font-size: 12px; }
.${PREFIX}_suggestion_text { font-size: 7px; font-weight: 700; color: var(--pts-success); }
.${PREFIX}_inventory_section { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 0 5px; }
.${PREFIX}_section_header.clickable { cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-radius: 3px; padding: 4px 0; font-size: 7px; font-weight: 700; color: var(--pts-accent); }
.${PREFIX}_section_header.clickable:hover { background: var(--pts-accent-glow); }
.${PREFIX}_toggle_icon { font-size: 7px; color: var(--pts-text-dim); }
.${PREFIX}_inventory_content { flex: 1; overflow-y: auto; max-height: 220px; }
.${PREFIX}_inventory_content::-webkit-scrollbar { width: 2px; }
.${PREFIX}_inventory_content::-webkit-scrollbar-thumb { background: var(--pts-accent); border-radius: 2px; }
.${PREFIX}_category_t { padding: 3px 4px; background: var(--pts-accent-glow); color: var(--pts-accent); font-weight: 700; font-size: 6.5px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; border-radius: 3px; margin-top: 3px; }
.${PREFIX}_category_title { display: flex; align-items: center; gap: 3px; }
.${PREFIX}_category_sets { font-size: 5.5px; color: var(--pts-text-dim); margin-left: 3px; }
.${PREFIX}_category_controls { display: flex; gap: 2px; }
.${PREFIX}_category_btn { width: 11px; height: 11px; background: rgba(0,0,0,0.3); border-radius: 2px; color: var(--pts-text-dim); font-size: 5px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.${PREFIX}_category_btn:hover { background: var(--pts-accent); color: white; }
.${PREFIX}_category_content { transition: max-height 0.2s; overflow: hidden; }
.${PREFIX}_item_row {
display: grid;
grid-template-columns: 22px 42px 42px 22px;
gap: 6px;
align-items: center;
padding: 4px 3px;
font-size: 7px;
border-bottom: 1px solid var(--pts-border);
}
.${PREFIX}_item_img {
width: 20px;
height: 20px;
object-fit: contain;
justify-self: center;
}
.${PREFIX}_item_local {
color: var(--pts-success);
font-weight: 700;
text-align: center;
}
.${PREFIX}_item_abroad {
text-align: center;
padding: 2px 4px;
border-radius: 3px;
font-size: 6px;
font-weight: 600;
}
.${PREFIX}_item_flag {
font-size: 11px;
text-align: center;
justify-self: center;
}
.${PREFIX}_status_green { color: var(--pts-success); background: rgba(46,204,113,0.15); }
.${PREFIX}_status_orange { color: var(--pts-warning); background: rgba(243,156,18,0.15); }
.${PREFIX}_status_red { color: var(--pts-danger); background: rgba(231,76,60,0.15); }
.${PREFIX}_loading, .${PREFIX}_edge_empty { text-align: center; padding: 10px 4px; font-size: 6px; color: var(--pts-text-dim); }
.${PREFIX}_footer { padding: 4px; text-align: center; border-top: 1px solid var(--pts-border); font-size: 5.5px; display: flex; justify-content: space-between; align-items: center; }
.${PREFIX}_footer a { color: var(--pts-accent); text-decoration: none; }
.${PREFIX}_update_time { color: var(--pts-text-dim); font-size: 5.5px; }
.${PREFIX}_modal_overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); backdrop-filter: blur(4px); z-index: 200000; display: none; align-items: center; justify-content: center; }
.${PREFIX}_modal { background: var(--pts-bg); border: 1px solid var(--pts-border); border-radius: 8px; width: 240px; max-width: 90%; }
.${PREFIX}_modal_header { padding: 8px 10px; border-bottom: 1px solid var(--pts-border); display: flex; justify-content: space-between; color: var(--pts-accent); font-weight: 600; font-size: 10px; }
.${PREFIX}_modal_close { background: rgba(0,0,0,0.3); border: none; border-radius: 3px; width: 20px; height: 20px; cursor: pointer; color: var(--pts-text-dim); font-size: 9px; }
.${PREFIX}_modal_close:hover { background: var(--pts-danger); color: white; }
.${PREFIX}_modal_body { padding: 10px; }
.${PREFIX}_settings_field { margin-bottom: 10px; }
.${PREFIX}_settings_field label { display: block; font-size: 8px; color: var(--pts-text-dim); margin-bottom: 3px; }
.${PREFIX}_input { width: 100%; padding: 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--pts-border); border-radius: 4px; color: var(--pts-text); font-size: 8px; box-sizing: border-box; }
.${PREFIX}_input:focus { outline: none; border-color: var(--pts-accent); }
.${PREFIX}_btn_primary, .${PREFIX}_btn_secondary, .${PREFIX}_btn_danger { padding: 5px 8px; border-radius: 4px; font-size: 8px; font-weight: 600; cursor: pointer; border: none; }
.${PREFIX}_btn_primary { background: var(--pts-accent); color: white; }
.${PREFIX}_btn_primary:hover { filter: brightness(1.1); }
.${PREFIX}_btn_secondary { background: rgba(0,0,0,0.3); border: 1px solid var(--pts-border); color: var(--pts-text); }
.${PREFIX}_btn_secondary:hover { background: var(--pts-accent-glow); }
.${PREFIX}_btn_danger { background: var(--pts-danger); color: white; }
.${PREFIX}_btn_danger:hover { filter: brightness(1.1); }
.${PREFIX}_toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--pts-bg); border: 1px solid var(--pts-accent); padding: 5px 10px; border-radius: 14px; font-size: 8px; color: var(--pts-accent); z-index: 200001; white-space: nowrap; pointer-events: none; animation: ptsFadeOut 2s forwards; }
@keyframes ptsFadeOut { 0% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } }
`);
})();