Points Museum with item images in suggestions - Fixed memory leaks and refresh issues
// ==UserScript==
// @name ✨ Points Museum - Fixed
// @namespace http://tampermonkey.net/
// @version 14.6.1
// @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;
let styleInjected = false;
// ================= 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 }
}
}
};
function addSafeEventListener(element, event, handler) {
if (!element) return;
element.addEventListener(event, handler);
if (!eventListeners.has(element)) {
eventListeners.set(element, []);
}
eventListeners.get(element).push({ event, handler });
}
function cleanupElementListeners(element) {
if (eventListeners.has(element)) {
eventListeners.get(element).forEach(({ event, handler }) => {
element.removeEventListener(event, handler);
});
eventListeners.delete(element);
}
}
function destroy() {
if (isDestroyed) return;
isDestroyed = true;
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
for (const [element, listeners] of eventListeners) {
if (element && element.removeEventListener) {
listeners.forEach(({ event, handler }) => {
element.removeEventListener(event, handler);
});
}
}
eventListeners.clear();
if (urlObserver) {
urlObserver.disconnect();
urlObserver = null;
}
const container = document.getElementById(id('edge_container'));
if (container) container.remove();
}
window.addEventListener('beforeunload', () => destroy());
let lastUrl = location.href;
urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl && !isDestroyed) {
lastUrl = location.href;
isLoading = false;
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
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);
}
return Math.ceil((museumDay - today) / (1000 * 60 * 60 * 24));
}
function checkMuseumDay() {
const now = new Date();
isMuseumDay = (now.getMonth() + 1 === MUSEUM_DAY_MONTH && now.getDate() === MUSEUM_DAY_DATE);
daysToMuseumDay = calculateDaysToMuseumDay();
return isMuseumDay;
}
function getLimitingItem(inventory, group) {
let minItem = null;
let minQty = Infinity;
for (const [name, data] of Object.entries(group.items)) {
const qty = inventory[name] || 0;
if (qty < minQty) {
minQty = qty;
minItem = { name, id: data.id, quantity: qty };
}
}
return minItem;
}
function getCategoryOrder() {
const saved = GM_getValue(CATEGORY_ORDER_KEY, DEFAULT_CATEGORY_ORDER);
const validOrder = saved.filter(cat => ['Prehistoric', 'Flowers', 'Plushies', 'Special'].includes(cat));
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`, `${PREFIX}_light_edge`);
container.classList.add(theme === 'light' ? `${PREFIX}_light_edge` : `${PREFIX}_dark_edge`);
GM_setValue(THEME_KEY, theme);
}
function toggleTheme() {
const container = document.getElementById(id('edge_container'));
const isDark = container.classList.contains(`${PREFIX}_dark_edge`);
applyTheme(isDark ? 'light' : 'dark');
showToast(isDark ? "🎨 Light theme" : "🎨 Dark theme");
}
function loadSavedTheme() {
applyTheme(GM_getValue(THEME_KEY, 'dark'));
}
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) el.textContent = new Date().toLocaleTimeString();
}
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);
}
function initEvents() {
const panel = document.getElementById(id('edge_panel'));
const pullTab = document.getElementById(id('pull_tab'));
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'));
const modalOverlay = document.getElementById(id('modal_overlay'));
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 ? '▶' : '▼';
});
}
}
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 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;
let categoryHtml = {};
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;
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;
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 };
};
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;
totalSets = prehistoric.sets + flowers.sets + plushies.sets;
totalPoints = prehistoric.points + flowers.points + plushies.points;
totalItemValue = prehistoric.totalValue + flowers.totalValue + plushies.totalValue;
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) {
let suggestionsHtml = '';
for (const catName of ['Plushies', 'Flowers', 'Prehistoric']) {
const group = GROUPS[catName];
const bonusValuePerSet = Math.round(group.bonusPts * pointsPrice);
const limitingItem = getLimitingItem(inventory, group);
const bonusPtsDisplay = Number.isInteger(group.bonusPts) ? group.bonusPts : group.bonusPts.toFixed(1);
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} pts → ${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;
// Apply collapsed states
for (const cat of categoryOrder) {
const content = document.querySelector(`.${PREFIX}_category_content[data-category="${cat}"]`);
if (collapsedCategories.includes(cat) && content) {
content.style.maxHeight = '0';
content.style.overflow = 'hidden';
} else if (content) {
content.style.maxHeight = '';
content.style.overflow = '';
}
}
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;
if (refreshTimer) clearTimeout(refreshTimer);
const apiKey = GM_getValue('tornAPIKey');
if (apiKey) await fetchPointsPrice(apiKey).catch(() => {});
await render();
if (!isDestroyed) refreshTimer = setTimeout(() => mainLoop(), POLL);
}
function injectStyles() {
if (styleInjected) return;
styleInjected = true;
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; } }
`);
}
function init() {
if (window.__pointsMuseumFixedRunning) {
console.log('Points Museum already running');
return;
}
window.__pointsMuseumFixedRunning = true;
console.log('✨ Points Museum v14.6 - Fixed');
injectStyles();
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();
}
}
// CRITICAL FIX: Add event listeners for category buttons AFTER render
// This is done in a MutationObserver to catch dynamically added buttons
function attachCategoryButtonListeners() {
// Up buttons
document.querySelectorAll(`.${PREFIX}_category_btn.up`).forEach(btn => {
if (btn.hasAttribute('data-listener-attached')) return;
const category = btn.getAttribute('data-category');
cleanupElementListeners(btn);
addSafeEventListener(btn, 'click', (e) => {
e.stopPropagation();
if (category) moveCategoryUp(category);
});
btn.setAttribute('data-listener-attached', 'true');
});
// Down buttons
document.querySelectorAll(`.${PREFIX}_category_btn.down`).forEach(btn => {
if (btn.hasAttribute('data-listener-attached')) return;
const category = btn.getAttribute('data-category');
cleanupElementListeners(btn);
addSafeEventListener(btn, 'click', (e) => {
e.stopPropagation();
if (category) moveCategoryDown(category);
});
btn.setAttribute('data-listener-attached', 'true');
});
// Category titles (for collapse/expand)
document.querySelectorAll(`.${PREFIX}_category_t`).forEach(title => {
if (title.hasAttribute('data-listener-attached')) return;
const category = title.getAttribute('data-category');
cleanupElementListeners(title);
addSafeEventListener(title, 'click', (e) => {
if (e.target.classList && e.target.classList.contains(`${PREFIX}_category_btn`)) return;
if (category) toggleCategory(category);
});
title.setAttribute('data-listener-attached', 'true');
});
}
// Watch for DOM changes to attach listeners to new buttons
const observer = new MutationObserver(() => {
attachCategoryButtonListeners();
});
observer.observe(document.body, { childList: true, subtree: true });
// Override render to call attach after HTML is set
const originalRender = render;
render = async function(force = false) {
await originalRender(force);
attachCategoryButtonListeners();
};
window.addEventListener('pagehide', () => {
if (refreshTimer) clearTimeout(refreshTimer);
window.__pointsMuseumFixedRunning = false;
window.__pointsMuseumFixedInitialized = false;
observer.disconnect();
});
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();