// ==UserScript== // @name BM Oversight // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Combined BattleMetrics toolkit: real-time server monitoring with player alerts & activity logging, plus Rust player analytics (playtime, top servers, first seen) // @author jlaiii // @match https://www.battlemetrics.com/* // @updateURL https://raw.githubusercontent.com/jlaiii/BattleMetrics-Rust-Analytics/main/BMOversight.user.js // @downloadURL https://raw.githubusercontent.com/jlaiii/BattleMetrics-Rust-Analytics/main/BMOversight.user.js // @grant none // @run-at document-idle // ==/UserScript== // ============================================================ // SECTION 1: SERVER MONITOR & ALERT SYSTEM // Activates on: /servers/* pages // (Original: BMserver.user.js v1.0.0) // ============================================================ (function () { 'use strict'; // Constants - make them tab-specific to prevent cross-tab interference const SERVER_MONITOR_ID = `bms-server-monitor-${Math.random().toString(36).substr(2, 9)}`; const TOGGLE_BUTTON_ID = `bms-toggle-button-${Math.random().toString(36).substr(2, 9)}`; const ALERT_PANEL_ID = `bms-alert-panel-${Math.random().toString(36).substr(2, 9)}`; const MENU_VISIBLE_KEY = 'bms_menu_visible'; // Server-specific storage keys (will be set after server ID is determined) let ALERTS_KEY = ''; let ACTIVITY_LOG_KEY = ''; let ALERT_SETTINGS_KEY = ''; let SAVED_PLAYERS_KEY = ''; let RECENT_ALERTS_KEY = ''; let PLAYER_DATABASE_KEY = ''; let POPULATION_HISTORY_KEY = ''; let LAST_PLAYER_STATE_KEY = ''; let SESSIONS_KEY = ''; let NOTES_KEY = ''; let TAGS_KEY = ''; // Function to initialize server-specific keys const initializeStorageKeys = (serverID) => { ALERTS_KEY = `bms_player_alerts_${serverID}`; ACTIVITY_LOG_KEY = `bms_activity_log_${serverID}`; ALERT_SETTINGS_KEY = `bms_alert_settings_${serverID}`; SAVED_PLAYERS_KEY = `bms_saved_players_${serverID}`; RECENT_ALERTS_KEY = `bms_recent_alerts_${serverID}`; PLAYER_DATABASE_KEY = `bms_player_database_${serverID}`; POPULATION_HISTORY_KEY = `bms_population_history_${serverID}`; LAST_PLAYER_STATE_KEY = `bms_last_player_state_${serverID}`; SESSIONS_KEY = `bms_script_sessions_${serverID}`; NOTES_KEY = `bms_player_notes_${serverID}`; TAGS_KEY = `bms_player_tags_${serverID}`; }; // Update/check settings (global) const SCRIPT_VERSION = '1.0.0'; const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/jlaiii/BattleMetrics-Rust-Analytics/main/BMOversight.user.js'; const INSTALL_URL = 'https://jlaiii.github.io/BattleMetrics-Rust-Analytics/'; const CHANGELOG_URL = 'https://raw.githubusercontent.com/jlaiii/BattleMetrics-Rust-Analytics/main/changes.json'; // Player tag preset colors const TAG_COLORS = { 'Cheater': '#dc3545', 'Squad': '#6f42c1', 'Admin': '#fd7e14', 'Friendly': '#28a745', 'Toxic': '#e83e8c', 'Watch': '#ffc107', }; const TAG_PRESETS = Object.keys(TAG_COLORS); const getTagColor = (tag) => TAG_COLORS[tag] || '#17a2b8'; const renderTagBadges = (tags) => tags.map(t => `${t.replace(/` ).join(''); const AUTO_CHECK_KEY = 'bms_auto_check_updates'; const WELCOME_SHOWN_KEY = 'bms_welcome_shown'; // Alt detection: ignore events during first 20 min after page load (cold-start guard) const _scriptStartTime = Date.now(); const COLD_START_WAIT_MS = 20 * 60 * 1000; const LAST_VERSION_KEY = 'bms_last_seen_version'; let updateAvailable = false; let updateAvailableVersion = null; const compareVersions = (a, b) => { try { const normalize = (s) => ('' + s).replace(/[^0-9.]/g, '').split('.').map(n => String(parseInt(n, 10) || 0)).join('.'); const naStr = normalize(a); const nbStr = normalize(b); const pa = naStr.split('.').map(n => parseInt(n, 10) || 0); const pb = nbStr.split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] || 0; const nb = pb[i] || 0; if (na > nb) return 1; if (na < nb) return -1; } return 0; } catch (e) { return 0; } }; const loadAutoCheckSetting = () => { const val = localStorage.getItem(AUTO_CHECK_KEY); if (val === null) return true; // default to ON return val === 'true'; }; const saveAutoCheckSetting = (v) => localStorage.setItem(AUTO_CHECK_KEY, v ? 'true' : 'false'); // Note: auto-install behavior removed. Users must click the update banner/toast to install manually. // Debug Console System class DebugConsole { constructor() { this.logs = []; this.enabled = this.loadDebugSetting(); this.verbose = this.loadVerboseSetting(); this.autoExportOnError = this.loadAutoExportSetting(); this.maxLogs = 1000; this.aggregates = {}; // signature -> {count, lastSeen, examples: []} this.version = '1.0.2'; window.toggleAutoExportDebug = (enabled) => { console.log('[Debug Console] toggleAutoExportDebug called with:', enabled); if (debugConsole) { debugConsole.saveAutoExportSetting(enabled); } }; // Temporarily enable verbose capture of site errors for a duration (ms) // Usage: captureSiteErrorsFor(60000) — captures for 60s then restores previous setting window.captureSiteErrorsFor = (ms = 60000) => { if (!debugConsole) return alert('Debug console not initialized'); try { const prev = debugConsole.verbose; debugConsole.saveVerboseSetting(true); debugConsole.info('Temporary verbose capture enabled for ' + ms + 'ms'); setTimeout(() => { debugConsole.saveVerboseSetting(!!prev); debugConsole.info('Temporary verbose capture ended; restored previous setting'); }, ms); } catch (e) { console.error('captureSiteErrorsFor failed', e); alert('Failed to enable capture: ' + e.message); } }; window.exportAggregatedErrors = () => { if (debugConsole) debugConsole.exportAggregates(); }; window.clearAggregatedErrors = () => { if (debugConsole) debugConsole.clearAggregates(); setTimeout(() => debugConsole.updateDebugDisplay(), 100); }; } loadDebugSetting() { const saved = localStorage.getItem('bms_debug_enabled'); // Default to false (off by default) return saved === 'true'; } saveDebugSetting(enabled) { localStorage.setItem('bms_debug_enabled', enabled.toString()); this.enabled = enabled; } loadVerboseSetting() { const saved = localStorage.getItem('bms_debug_verbose'); return saved === 'true'; } saveVerboseSetting(enabled) { localStorage.setItem('bms_debug_verbose', enabled.toString()); this.verbose = enabled; } loadAutoExportSetting() { const saved = localStorage.getItem('bms_debug_autoexport'); return saved === 'true'; } saveAutoExportSetting(enabled) { localStorage.setItem('bms_debug_autoexport', enabled.toString()); this.autoExportOnError = enabled; } log(level, message, data = null) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, level, message, data: data ? JSON.stringify(data, null, 2) : null, url: window.location.href, userAgent: navigator.userAgent }; this.logs.push(logEntry); // Keep only last maxLogs entries if (this.logs.length > this.maxLogs) { this.logs = this.logs.slice(-this.maxLogs); } // Always log to browser console if debug is enabled if (this.enabled) { const consoleMessage = `[BMS Debug ${level.toUpperCase()}] ${message}`; switch (level) { case 'error': console.error(consoleMessage, data); break; case 'warn': console.warn(consoleMessage, data); break; case 'info': console.info(consoleMessage, data); break; default: console.log(consoleMessage, data); } // Debounce debug console display to avoid triggering a DOM rebuild // on every single log() call (can be very frequent during monitoring) clearTimeout(this._displayDebounce); this._displayDebounce = setTimeout(() => this.updateDebugDisplay(), 250); } } error(message, data = null) { this.log('error', message, data); } warn(message, data = null) { this.log('warn', message, data); } info(message, data = null) { this.log('info', message, data); } debug(message, data = null) { this.log('debug', message, data); } exportLogs() { const exportData = { version: this.version, exportTime: new Date().toISOString(), serverID: currentServerID, serverName: currentServerName, totalLogs: this.logs.length, logs: this.logs }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_debug_log_${currentServerID}_${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); this.info('Debug logs exported', { filename: a.download, logCount: this.logs.length }); } // Aggregate errors by signature for quick triage recordAggregate(signature, entry) { try { if (!signature) signature = 'unknown'; const agg = this.aggregates[signature] || { count: 0, lastSeen: null, examples: [] }; agg.count += 1; agg.lastSeen = new Date().toISOString(); if (agg.examples.length < 5) { agg.examples.push(entry); } this.aggregates[signature] = agg; } catch (e) { console.warn('Failed to record aggregate', e); } } // Export aggregated error report exportAggregates() { const blob = new Blob([JSON.stringify({ version: this.version, aggregates: this.aggregates, exportTime: new Date().toISOString() }, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_error_aggregates_${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); this.info('Aggregates exported', { file: a.download }); } clearAggregates() { this.aggregates = {}; this.info('Aggregates cleared'); } clearLogs() { this.logs = []; this.aggregates = {}; this.updateDebugDisplay(); this.updateDebugStats(); } updateDebugStats() { const statsDiv = document.getElementById('debug-stats'); if (!statsDiv) return; const stats = this.getStats(); const oldestTime = stats.oldestLog ? new Date(stats.oldestLog).toLocaleString() : 'N/A'; statsDiv.innerHTML = `Total Logs: ${stats.totalLogs} | Errors: ${stats.errorCount} | Warnings: ${stats.warnCount} | Info: ${stats.infoCount} | Debug: ${stats.debugCount}${stats.oldestLog ? `
Oldest: ${oldestTime}` : ''}`; } updateDebugDisplay() { if (!this.enabled) return; // Skip expensive rebuild when debug mode is off const debugList = document.getElementById('debug-console-list'); if (!debugList) return; this.updateDebugStats(); const recentLogs = this.logs.slice(-50).reverse(); // Show last 50 logs if (recentLogs.length === 0) { debugList.innerHTML = '
No debug logs
'; return; } const esc = (s) => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); let debugHTML = ''; recentLogs.forEach(log => { const levelColor = { 'error': '#dc3545', 'warn': '#ffc107', 'info': '#17a2b8', 'debug': '#6c757d' }[log.level] || '#6c757d'; const time = new Date(log.timestamp).toLocaleTimeString(); debugHTML += `
` + `[${esc(log.level.toUpperCase())}]` + ` ${esc(time)}` + ` ${esc(log.message)}` + (log.data ? `
${esc(log.data)}
` : '') + `
`; }); debugList.innerHTML = debugHTML; } getStats() { let errorCount = 0, warnCount = 0, infoCount = 0, debugCount = 0; for (const l of this.logs) { if (l.level === 'error') errorCount++; else if (l.level === 'warn') warnCount++; else if (l.level === 'info') infoCount++; else if (l.level === 'debug') debugCount++; } return { totalLogs: this.logs.length, errorCount, warnCount, infoCount, debugCount, oldestLog: this.logs.length > 0 ? this.logs[0].timestamp : null, newestLog: this.logs.length > 0 ? this.logs[this.logs.length - 1].timestamp : null }; } getLogsAsText() { const header = `BM Oversight v${this.version} Debug Logs Export Time: ${new Date().toISOString()} Server ID: ${currentServerID || 'Unknown'} Server Name: ${currentServerName || 'Unknown'} Total Logs: ${this.logs.length} URL: ${window.location.href} User Agent: ${navigator.userAgent} === DEBUG LOGS === `; const logsText = this.logs.map(log => { const timestamp = new Date(log.timestamp).toLocaleString(); let logLine = `[${log.level.toUpperCase()}] ${timestamp}: ${log.message}`; if (log.data) { logLine += `\nData: ${log.data}`; } return logLine; }).join('\n\n'); return header + logsText; } } // Initialize debug console const debugConsole = new DebugConsole(); // Log script startup debugConsole.info(`BM Oversight v${SCRIPT_VERSION} loaded`, { url: window.location.href, userAgent: navigator.userAgent, timestamp: new Date().toISOString() }); // Debug console system initialized debugConsole.debug('Debug console system initialized'); // Global variables - each tab will have its own instance let currentServerID = null; let serverMonitor = null; let monitoringInterval = null; let lastPlayerList = new Map(); let currentServerName = ''; let alertReminderInterval = null; let populationStatsInterval = null; let timestampRefreshInterval = null; let autoRefreshIntervalId = null; // Auto-refresh settings keys (global, not server-specific) const AUTO_REFRESH_ENABLED_KEY = 'bms_auto_refresh_enabled'; const AUTO_REFRESH_MS_KEY = 'bms_auto_refresh_ms'; const loadAutoRefreshSettings = () => ({ enabled: localStorage.getItem(AUTO_REFRESH_ENABLED_KEY) === 'true', ms: parseInt(localStorage.getItem(AUTO_REFRESH_MS_KEY), 10) || 120000 }); const saveAutoRefreshSettings = (enabled, ms) => { localStorage.setItem(AUTO_REFRESH_ENABLED_KEY, enabled ? 'true' : 'false'); localStorage.setItem(AUTO_REFRESH_MS_KEY, String(ms)); }; const startAutoRefresh = (ms) => { stopAutoRefresh(); autoRefreshIntervalId = setInterval(() => { location.reload(); }, ms); }; const stopAutoRefresh = () => { if (autoRefreshIntervalId) { clearInterval(autoRefreshIntervalId); autoRefreshIntervalId = null; } }; // Population tracking variables let populationHistory = []; let currentPopulation = 0; let lastHourChange = 0; let predictedNextHour = 0; // Search state tracking to prevent interference let activePlayerSearch = ''; let activeDatabaseSearch = ''; // Generate unique tab identifier to prevent cross-tab interference const tabId = Math.random().toString(36).substr(2, 9); // Utility functions const isMenuVisible = () => { return localStorage.getItem(MENU_VISIBLE_KEY) !== 'false'; }; // Normalize player names for comparison (trim and collapse spaces) const normalizeName = (s) => { if (!s) return ''; return s.replace(/\s+/g, ' ').trim(); }; const namesEqual = (a, b) => normalizeName(a).toLowerCase() === normalizeName(b).toLowerCase(); const setMenuVisibility = (visible) => { localStorage.setItem(MENU_VISIBLE_KEY, visible.toString()); updateUIVisibility(); }; const updateUIVisibility = () => { const monitor = document.getElementById(SERVER_MONITOR_ID); const alertPanel = document.getElementById(ALERT_PANEL_ID); const visible = isMenuVisible(); if (monitor) { monitor.style.display = visible ? 'block' : 'none'; } if (alertPanel && !visible) { alertPanel.style.display = 'none'; } updateToggleButton(); }; const updateToggleButton = () => { const toggleBtn = document.getElementById(TOGGLE_BUTTON_ID); if (toggleBtn) { const visible = isMenuVisible(); toggleBtn.textContent = visible ? 'X' : 'SM'; toggleBtn.title = visible ? 'Hide Server Monitor' : 'Show Server Monitor'; } }; const toRelativeTime = (timestamp) => { const now = Date.now(); const diff = now - +timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; return 'a few seconds ago'; }; // Parse ISO 8601 duration like PT14H53M51.565S -> milliseconds const parseISODurationToMs = (iso) => { if (!iso || typeof iso !== 'string') return null; // Expect format starting with 'PT' const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?/); if (!match) return null; const hours = parseInt(match[1] || '0', 10); const minutes = parseInt(match[2] || '0', 10); const seconds = parseFloat(match[3] || '0'); return Math.round(((hours * 60 + minutes) * 60 + seconds) * 1000); }; // Server Monitor Class class ServerMonitor { constructor() { this.alerts = this.loadAlerts(); this.activityLog = this.loadActivityLog(); this.settings = this.loadSettings(); this.savedPlayers = this.loadSavedPlayers(); this.recentAlerts = this.loadRecentAlerts(); this.playerDatabase = this.loadPlayerDatabase(); this.populationHistory = this.loadPopulationHistory(); this.lastPlayerState = this.loadLastPlayerState(); this.playerNotes = this.loadPlayerNotes(); this.playerTags = this.loadPlayerTags(); this.isMonitoring = false; this.currentPlayers = new Map(); this.soundEnabled = this.settings.soundEnabled !== false; } loadAlerts() { try { return JSON.parse(localStorage.getItem(ALERTS_KEY) || '{}'); } catch { return {}; } } saveAlerts() { localStorage.setItem(ALERTS_KEY, JSON.stringify(this.alerts)); } loadActivityLog() { try { const log = JSON.parse(localStorage.getItem(ACTIVITY_LOG_KEY) || '[]'); // Keep full activity history (persist locally forever unless user clears) return log || []; } catch { return []; } } saveActivityLog() { // Debounce to avoid serializing the full log on every single join/leave event clearTimeout(this._activityLogSaveTimeout); this._activityLogSaveTimeout = setTimeout(() => { try { localStorage.setItem(ACTIVITY_LOG_KEY, JSON.stringify(this.activityLog)); } catch (e) { console.error('Failed to save activity log to localStorage:', e); } }, 2000); } loadSettings() { try { return JSON.parse(localStorage.getItem(ALERT_SETTINGS_KEY) || '{}'); } catch { return {}; } } saveSettings() { localStorage.setItem(ALERT_SETTINGS_KEY, JSON.stringify(this.settings)); } loadSavedPlayers() { try { return JSON.parse(localStorage.getItem(SAVED_PLAYERS_KEY) || '{}'); } catch { return {}; } } saveSavedPlayers() { localStorage.setItem(SAVED_PLAYERS_KEY, JSON.stringify(this.savedPlayers)); } savePlayer(playerName, playerId) { this.savedPlayers[playerId] = { name: playerName, saved: Date.now() }; this.saveSavedPlayers(); } removeSavedPlayer(playerId) { delete this.savedPlayers[playerId]; this.saveSavedPlayers(); } loadRecentAlerts() { try { return JSON.parse(localStorage.getItem(RECENT_ALERTS_KEY) || '{}'); } catch { return {}; } } saveRecentAlerts() { localStorage.setItem(RECENT_ALERTS_KEY, JSON.stringify(this.recentAlerts)); } addRecentAlert(playerName, playerId, action) { const alertId = `${playerId}_${action}_${Date.now()}`; this.recentAlerts[alertId] = { playerName, playerId, action, timestamp: Date.now(), acknowledged: false }; this.saveRecentAlerts(); this.updateRecentAlertsDisplay(); this.startAlertReminders(); updateTabTitleBadge(); } acknowledgeAlert(alertId) { if (this.recentAlerts[alertId]) { this.recentAlerts[alertId].acknowledged = true; this.saveRecentAlerts(); this.updateRecentAlertsDisplay(); updateTabTitleBadge(); // Check if all alerts are acknowledged const unacknowledged = Object.values(this.recentAlerts).filter(alert => !alert.acknowledged); if (unacknowledged.length === 0) { this.stopAlertReminders(); // Trigger reorder when all alerts are acknowledged setTimeout(() => this.reorderSectionsIfNeeded(), 100); } } } clearOldAlerts() { // Run at most once per hour — avoids a localStorage write on every display refresh const now = Date.now(); if (this._lastAlertClean && now - this._lastAlertClean < 3600000) return; this._lastAlertClean = now; const oneDayAgo = now - (24 * 60 * 60 * 1000); let dirty = false; Object.keys(this.recentAlerts).forEach(alertId => { if (this.recentAlerts[alertId].timestamp < oneDayAgo) { delete this.recentAlerts[alertId]; dirty = true; } }); if (dirty) this.saveRecentAlerts(); } acknowledgeAllAlerts() { Object.keys(this.recentAlerts).forEach(alertId => { this.recentAlerts[alertId].acknowledged = true; }); this.saveRecentAlerts(); this.stopAlertReminders(); this.updateRecentAlertsDisplay(); updateTabTitleBadge(); setTimeout(() => this.reorderSectionsIfNeeded(), 100); } clearAllRecentAlerts() { this.recentAlerts = {}; this.saveRecentAlerts(); this.stopAlertReminders(); this.updateRecentAlertsDisplay(); updateTabTitleBadge(); setTimeout(() => this.reorderSectionsIfNeeded(), 100); } startAlertReminders() { if (alertReminderInterval || this.settings.repeatAlerts === false) return; const interval = this.settings.repeatIntervalMs || 60000; alertReminderInterval = setInterval(() => { const unacknowledged = Object.values(this.recentAlerts).filter(alert => !alert.acknowledged); if (unacknowledged.length > 0 && this.soundEnabled && this.settings.repeatAlerts !== false) { this.playAlertSound(); } }, interval); } stopAlertReminders() { if (alertReminderInterval) { clearInterval(alertReminderInterval); alertReminderInterval = null; } } loadPlayerDatabase() { try { const saved = localStorage.getItem(PLAYER_DATABASE_KEY); if (saved) { return JSON.parse(saved); } return {}; } catch (e) { console.error('Failed to load player database:', e); return {}; } } savePlayerDatabase() { // Debounce database saves to reduce localStorage writes clearTimeout(this.databaseSaveTimeout); this.databaseSaveTimeout = setTimeout(() => { try { localStorage.setItem(PLAYER_DATABASE_KEY, JSON.stringify(this.playerDatabase)); } catch (e) { console.error('Failed to save player database:', e); } }, 2000); } loadPopulationHistory() { try { const saved = localStorage.getItem(POPULATION_HISTORY_KEY); if (saved) { const history = JSON.parse(saved); // Keep only last 24 hours of data const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000); const filteredHistory = history.filter(entry => entry.timestamp > oneDayAgo); return filteredHistory; } return []; } catch (e) { console.error('Failed to load population history:', e); return []; } } savePopulationHistory() { try { // Keep only last 24 hours of data const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000); const beforeCount = this.populationHistory.length; this.populationHistory = this.populationHistory.filter(entry => entry.timestamp > oneDayAgo); const afterCount = this.populationHistory.length; localStorage.setItem(POPULATION_HISTORY_KEY, JSON.stringify(this.populationHistory)); } catch (e) { console.error('Failed to save population history:', e); } } getActualPopulationFromUI() { // Method 1: Count actual player rows in the table (most reliable) try { const playerRows = document.querySelectorAll('table tbody tr'); let visibleRows = 0; playerRows.forEach(row => { const nameCell = row.querySelector('td:first-child a'); if (nameCell && nameCell.textContent.trim() && nameCell.href && nameCell.href.includes('/players/')) { visibleRows++; } }); if (visibleRows > 0) { return visibleRows; } } catch (e) { // Continue } // Method 2: Look for population in page title try { const title = document.title; const match = title.match(/(\d+)\/(\d+)/); if (match) { const current = parseInt(match[1]); const max = parseInt(match[2]); if (current <= max && max >= 50 && max <= 500) { return current; } } } catch (e) { // Continue } // Method 3: Look for specific BattleMetrics elements (be very selective) const specificSelectors = [ 'span[data-testid="server-population"]', '.server-population .current', '[class*="population"] .current' ]; for (const selector of specificSelectors) { try { const element = document.querySelector(selector); if (element) { const text = element.textContent.trim(); const match = text.match(/^(\d+)$/); if (match) { const count = parseInt(match[1]); if (count >= 0 && count <= 200) { return count; } } } } catch (e) { // Continue } } return null; // Could not find actual population } recordPopulation(trackedCount) { const now = Date.now(); // Try to get the actual population from BattleMetrics UI const actualPopulation = this.getActualPopulationFromUI(); // Use actual population if available AND it makes sense, otherwise fall back to tracked count let populationToRecord = trackedCount; if (actualPopulation !== null) { const difference = Math.abs(actualPopulation - trackedCount); if (difference <= 5 || trackedCount === 0) { populationToRecord = actualPopulation; currentPopulation = actualPopulation; } else { populationToRecord = trackedCount; } } const entry = { timestamp: now, count: populationToRecord, trackedCount: trackedCount, actualCount: actualPopulation, date: new Date(now).toLocaleString() }; this.populationHistory.push(entry); // Calculate last hour change and prediction this.calculatePopulationStats(); this.savePopulationHistory(); this.updatePopulationDisplay(); } loadLastPlayerState() { try { const saved = localStorage.getItem(LAST_PLAYER_STATE_KEY); if (saved) { const state = JSON.parse(saved); // Only use state if it's recent (within last 5 minutes) const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); if (state.timestamp > fiveMinutesAgo) { return new Map(state.players); } } return new Map(); } catch (e) { console.error('Failed to load last player state:', e); return new Map(); } } saveLastPlayerState() { try { const state = { timestamp: Date.now(), players: Array.from(this.currentPlayers.entries()) }; localStorage.setItem(LAST_PLAYER_STATE_KEY, JSON.stringify(state)); } catch (e) { console.error('Failed to save last player state:', e); } } loadPlayerNotes() { try { return JSON.parse(localStorage.getItem(NOTES_KEY) || '{}'); } catch { return {}; } } savePlayerNotes() { localStorage.setItem(NOTES_KEY, JSON.stringify(this.playerNotes)); } setPlayerNote(playerId, text) { if (!text || !text.trim()) { delete this.playerNotes[playerId]; } else { this.playerNotes[playerId] = { text: text.trim(), updatedAt: Date.now() }; } this.savePlayerNotes(); } loadPlayerTags() { try { return JSON.parse(localStorage.getItem(TAGS_KEY) || '{}'); } catch { return {}; } } savePlayerTags() { localStorage.setItem(TAGS_KEY, JSON.stringify(this.playerTags)); } getPlayerTags(playerId) { return (this.playerTags && this.playerTags[playerId]) ? this.playerTags[playerId] : []; } addPlayerTag(playerId, tag) { if (!tag || !tag.trim()) return; const t = tag.trim(); const current = this.getPlayerTags(playerId); if (!current.includes(t)) { this.playerTags[playerId] = [...current, t]; this.savePlayerTags(); } } removePlayerTag(playerId, tag) { const current = this.getPlayerTags(playerId); const updated = current.filter(t => t !== tag); if (updated.length === 0) { delete this.playerTags[playerId]; } else { this.playerTags[playerId] = updated; } this.savePlayerTags(); } // ── In-memory set of alt pairs already logged this session (avoids duplicates) // Format: "minId|maxId" _loggedAltPairs = new Set(); // Set of every playerId that appears in at least one detected alt pair. // Used to conditionally show the Alts button in the UI without re-running detectAlts. detectedAltPlayers = new Set(); logAltDetection(targetId, targetName, candidateId, candidateName, matchCount, coverage) { // Suppress duplicate pair logs within the same page session const key = [targetId, candidateId].sort().join('|'); if (this._loggedAltPairs.has(key)) return; this._loggedAltPairs.add(key); // Mark both sides so the UI can show the button without re-running the scan this.detectedAltPlayers.add(targetId); this.detectedAltPlayers.add(candidateId); const now = Date.now(); const d = new Date(now); const entry = { timestamp: now, utcISO: d.toISOString(), dayOfWeek: d.getDay(), hourUTC: d.getUTCHours(), hourLocal: d.getHours(), playerName: targetName, playerId: targetId, altPlayerId: candidateId, altPlayerName: candidateName, altMatches: matchCount, altCoverage: coverage, action: 'alt_detected', serverName: currentServerName, serverID: currentServerID, time: d.toLocaleString() }; this.activityLog.push(entry); this.saveActivityLog(); // Store the alt pair on both player database records so searchDatabase can find them try { const _storeAlt = (ownId, ownName, peerId, peerName) => { const rec = this.playerDatabase[ownId]; if (!rec) return; rec.knownAlts = rec.knownAlts || []; if (!rec.knownAlts.some(a => a.id === peerId)) { rec.knownAlts.push({ id: peerId, name: peerName, detectedAt: now }); } }; _storeAlt(targetId, targetName, candidateId, candidateName); _storeAlt(candidateId, candidateName, targetId, targetName); this.savePlayerDatabase(); } catch (_) {} clearTimeout(this.activityUpdateTimeout); this.activityUpdateTimeout = setTimeout(() => { this.updateActivityDisplay(); }, 500); } calculatePopulationStats() { const now = Date.now(); const twoHoursAgo = now - (2 * 60 * 60 * 1000); const oneHourAgo = now - (60 * 60 * 1000); // currentPopulation is kept up-to-date by recordPopulation() (called every 10s). // Avoid a redundant DOM query here; just fall back to tracked count if unset. if (!currentPopulation) { currentPopulation = this.currentPlayers.size; } const recentHistory = this.populationHistory.filter(e => e.timestamp >= twoHoursAgo); if (recentHistory.length < 2) { lastHourChange = 0; predictedNextHour = currentPopulation; return; } // ── Last-hour change ──────────────────────────────────────────── const oneHourEntry = recentHistory .filter(e => Math.abs(e.timestamp - oneHourAgo) < 10 * 60 * 1000) .sort((a, b) => Math.abs(a.timestamp - oneHourAgo) - Math.abs(b.timestamp - oneHourAgo))[0]; lastHourChange = oneHourEntry ? currentPopulation - oneHourEntry.count : 0; // ── Linear regression over all 2-hour data points ─────────────── // Include the live reading as the most recent point. const points = [...recentHistory, { timestamp: now, count: currentPopulation }]; // Express time in hours from the oldest point (keeps numbers small) const t0 = points[0].timestamp; const toHr = ms => (ms - t0) / 3600000; const xs = points.map(p => toHr(p.timestamp)); const ys = points.map(p => p.count); const n = points.length; const sumX = xs.reduce((s, x) => s + x, 0); const sumY = ys.reduce((s, y) => s + y, 0); const sumXY = xs.reduce((s, x, i) => s + x * ys[i], 0); const sumXX = xs.reduce((s, x) => s + x * x, 0); const denom = n * sumXX - sumX * sumX; // slope = players/hour from the regression line const slope = Math.abs(denom) > 0.0001 ? (n * sumXY - sumX * sumY) / denom : 0; const intercept = (sumY - slope * sumX) / n; // Raw prediction: where the regression line sits 1 hour from now const currentX = toHr(now); const rawPredicted = intercept + slope * (currentX + 1); const rawDelta = rawPredicted - currentPopulation; // ── Damping ───────────────────────────────────────────────────── // Pull the projected change 50% back toward 0 so short-term spikes // don't get carried all the way forward. const dampedDelta = rawDelta * 0.5; // Hard-cap the change at ±30% of current population (realistic hourly swing). // Floor the cap at ±8 so small servers still get a meaningful range. const maxSwing = Math.max(8, Math.round(currentPopulation * 0.30)); const clampedDelta = Math.max(-maxSwing, Math.min(maxSwing, dampedDelta)); predictedNextHour = Math.max(0, Math.round(currentPopulation + clampedDelta)); } updatePopulationDisplay() { const popDisplay = document.getElementById('population-stats'); if (!popDisplay) return; const changeColor = lastHourChange > 0 ? '#28a745' : lastHourChange < 0 ? '#dc3545' : '#6c757d'; const changeSymbol = lastHourChange > 0 ? '+' : ''; const lastUpdated = new Date().toLocaleTimeString(); const historyCount = this.populationHistory.length; const oldestEntry = this.populationHistory.length > 0 ? new Date(this.populationHistory[0].timestamp).toLocaleTimeString() : 'None'; // Unique players in last 24h const _now = Date.now(); const _cut24h = _now - 86400000; const _ids24h = new Set(); for (const e of this.activityLog) { if (!e.playerId) continue; if (e.timestamp >= _cut24h) _ids24h.add(e.playerId); } const u24h = _ids24h.size; popDisplay.innerHTML = `
Population Stats
Updated: ${lastUpdated}
${currentPopulation} players
${changeSymbol}${lastHourChange} in the past hour
Data points: ${historyCount} | Oldest: ${oldestEntry}
Predicted next hour:
${predictedNextHour} players
Unique Players (24h)
${u24h}
`; } refreshTimestamps() { // Only refresh the activity log (which shows relative "X min ago" times). // Database and recent alerts are updated by their own cycles; avoid // a full triple-rebuild every 30 s just for timestamp text. this.updateActivityDisplay(); } addToDatabase(playerId, playerName, skipDisplayUpdate = false) { const now = Date.now(); // Always ensure a record exists for the player and update timestamps try { const existing = this.playerDatabase[playerId]; if (existing) { // If name changed, record previous name history (avoid duplicates) if (!namesEqual(existing.currentName, playerName)) { // Silently resolve "Unknown Player" placeholder set by manual add — // don't log a name-change event and don't push it to previousNames const isPlaceholder = existing.manuallyAdded && existing.currentName === 'Unknown Player'; if (isPlaceholder) { existing.currentName = playerName; if (!existing.originalName || existing.originalName === 'Unknown Player') { existing.originalName = playerName; } existing.manuallyAdded = false; // resolved } else { existing.previousNames = existing.previousNames || []; const existingNormalized = normalizeName(existing.currentName); // Avoid duplicate previous names (case/whitespace-insensitive) // Support both legacy plain strings and new timestamped objects const nameStr = e => (typeof e === 'string' ? e : (e && e.name) || ''); if (existing.currentName && !existing.previousNames.some(n => normalizeName(nameStr(n)).toLowerCase() === existingNormalized.toLowerCase())) { existing.previousNames.push({ name: existing.currentName, changedAt: now, changedAtISO: new Date(now).toISOString() }); } const oldName = existing.currentName; existing.currentName = playerName; existing.nameChanged = true; existing.lastNameChange = now; // Record name change in activity log (so it appears in All Activity) try { this.logNameChange(playerId, oldName, playerName); } catch (e) { console.warn('Failed to log name change', e); } } } existing.seenCount = (existing.seenCount || 0) + 1; existing.lastSeen = now; } else { // New player - always add to database this.playerDatabase[playerId] = { id: playerId, currentName: playerName, originalName: playerName, firstSeen: now, lastSeen: now, nameChanged: false, previousNames: [], seenCount: 1 }; } // Persist database (debounced inside savePlayerDatabase) this.savePlayerDatabase(); // Skip display updates during batch operations (initial load) if (!skipDisplayUpdate) { // Debounce database display updates clearTimeout(this.databaseUpdateTimeout); this.databaseUpdateTimeout = setTimeout(() => { this.updateDatabaseDisplay(); }, 1000); } } catch (err) { console.error('addToDatabase failed for', playerId, playerName, err); } } updateDatabaseDisplay() { const databaseDiv = document.getElementById('player-database-list'); if (!databaseDiv) return; // Don't update if user is actively searching - preserve search results if (activeDatabaseSearch && activeDatabaseSearch.length >= 2) { return; } // Only render if the database list is currently visible (collapsed = style.display:none) if (databaseDiv.style.display === 'none') return; // Respect the active filter — if one is set, delegate to filterDatabase // so background updates don't blow away the user's chosen filter. const filterSelect = document.getElementById('database-filter'); const activeFilter = filterSelect ? filterSelect.value : 'all'; if (activeFilter && activeFilter !== 'all') { window.filterDatabase(activeFilter); return; } // Sort by online status first, then by last seen const players = Object.values(this.playerDatabase) .sort((a, b) => { const aOnline = this.currentPlayers.has(a.id); const bOnline = this.currentPlayers.has(b.id); // Online players first if (aOnline && !bOnline) return -1; if (!aOnline && bOnline) return 1; // Then by last seen return b.lastSeen - a.lastSeen; }); // Show all players - no limit if (players.length === 0) { databaseDiv.innerHTML = '
No players in database
'; return; } this.renderDatabasePlayers(players, databaseDiv); } renderDatabasePlayers(players, container) { const parts = []; players.forEach(player => { const lastSeenTime = toRelativeTime(player.lastSeen); const hasAlert = this.alerts[player.id]; const isSaved = this.savedPlayers[player.id]; const isOnline = this.currentPlayers.has(player.id); const note = this.playerNotes && this.playerNotes[player.id]; const hasNote = note && note.text; const tags = this.getPlayerTags(player.id); let nameDisplay = player.currentName; // Support both legacy strings and new timestamped objects in previousNames const _nameStr = e => (typeof e === 'string' ? e : (e && e.name) || ''); if (player.nameChanged && player.previousNames.length > 0) { nameDisplay = `${player.currentName} (was: ${_nameStr(player.previousNames[player.previousNames.length - 1])})`; } const onlineStatus = isOnline ? '[ONLINE]' : '[OFFLINE]'; parts.push(`
${nameDisplay} ${onlineStatus} ${hasAlert ? '[ALERT]' : ''} ${isSaved ? '[SAVED]' : ''} ${hasNote ? '[NOTE]' : ''} ${tags.length > 0 ? renderTagBadges(tags) : ''}
ID: ${player.id} | Last seen: ${lastSeenTime} | Detections: ${player.seenCount || 1}×
${player.nameChanged ? '
⚠ Name changed
' : ''} ${hasNote ? `
📝 ${note.text.replace(//g,'>')}
` : ''}
${this.detectedAltPlayers.has(player.id) ? `` : ''}
`); }); container.innerHTML = parts.join(''); } searchDatabase(query) { if (!query || query.length < 2) return []; const lowerQuery = query.toLowerCase(); const results = []; // Use for...of loop for better performance than filter + multiple array operations for (const player of Object.values(this.playerDatabase)) { // Early exit conditions for better performance if (player.currentName.toLowerCase().includes(lowerQuery) || player.originalName.toLowerCase().includes(lowerQuery) || player.id.includes(query)) { results.push(player); continue; } // Check previous names let matched = false; if (player.previousNames && player.previousNames.length > 0) { for (const entry of player.previousNames) { const n = typeof entry === 'string' ? entry : (entry && entry.name) || ''; if (n.toLowerCase().includes(lowerQuery)) { results.push(player); matched = true; break; } } } // Check known alt names/IDs so searching "altname" surfaces the main player too if (!matched && player.knownAlts && player.knownAlts.length > 0) { for (const alt of player.knownAlts) { if ((alt.name && alt.name.toLowerCase().includes(lowerQuery)) || (alt.id && alt.id.toLowerCase().includes(lowerQuery))) { results.push(player); break; } } } // Limit results to prevent UI lag with large datasets if (results.length >= 100) { break; } } return results; } addAlert(playerName, playerId, alertType = 'both') { try { this.alerts[playerId] = { name: playerName, type: alertType, added: Date.now() }; this.saveAlerts(); this.updateAlertDisplay(); this.updateAlertCount(); this.expandAlertSection(); } catch (error) { if (debugConsole && debugConsole.enabled) debugConsole.error('[Alert System] Error in addAlert', error); } } removeAlert(playerId) { delete this.alerts[playerId]; this.saveAlerts(); this.updateAlertDisplay(); this.updateAlertCount(); } logActivity(playerName, playerId, action) { const now = Date.now(); const d = new Date(now); const entry = { timestamp: now, utcISO: d.toISOString(), dayOfWeek: d.getDay(), // 0=Sun … 6=Sat hourUTC: d.getUTCHours(), // 0-23 UTC hour hourLocal: d.getHours(), // 0-23 local hour playerName, playerId, action, // 'joined' or 'left' serverName: currentServerName, serverID: currentServerID, time: d.toLocaleString() }; this.activityLog.push(entry); this.saveActivityLog(); // Check if we should alert for this player const alert = this.alerts[playerId]; if (alert && (alert.type === 'both' || alert.type === action.replace('ed', ''))) { this.showAlert(playerName, action, playerId); this.addRecentAlert(playerName, playerId, action); if (this.soundEnabled) { this.playAlertSound(); } } // After every join/leave, run a quick alts scan for this player so // any newly-detectable handoff pairs get logged to the activity feed. // – On 'joined': catches the real-time B→A handoff (B left, A just joined). // – On 'left': catches any A→B pattern that has built up over prior sessions. // Rate-limited to at most once per 30 seconds per player to avoid O(n²) spam. // Cold-start guard: skip alt scans for the first 20 min to let history accumulate. if ((action === 'joined' || action === 'left') && (Date.now() - _scriptStartTime >= COLD_START_WAIT_MS)) { if (!this._altsScanTs) this._altsScanTs = {}; const _nowAlt = Date.now(); if (_nowAlt - (this._altsScanTs[playerId] || 0) > 30000) { this._altsScanTs[playerId] = _nowAlt; setTimeout(() => { try { const alts = detectAlts(playerId); for (const alt of alts) { const altName = alt.player.currentName || `Player ${alt.playerId}`; this.logAltDetection(playerId, playerName, alt.playerId, altName, alt.matches, alt.coverage); } } catch (_) { /* detectAlts not yet defined on very first event */ } }, 0); } } // Debounce activity display updates to reduce lag clearTimeout(this.activityUpdateTimeout); this.activityUpdateTimeout = setTimeout(() => { this.updateActivityDisplay(); }, 500); } // Log a name change event with old and new names logNameChange(playerId, oldName, newName) { const now = Date.now(); const d = new Date(now); const entry = { timestamp: now, utcISO: d.toISOString(), dayOfWeek: d.getDay(), hourUTC: d.getUTCHours(), hourLocal: d.getHours(), playerId, playerName: newName, oldName: oldName, action: 'name_changed', serverName: currentServerName, serverID: currentServerID, time: d.toLocaleString() }; this.activityLog.push(entry); this.saveActivityLog(); // Debounce activity display updates to reduce lag clearTimeout(this.activityUpdateTimeout); this.activityUpdateTimeout = setTimeout(() => { this.updateActivityDisplay(); }, 300); } showAlert(playerName, action, playerId) { const alertDiv = document.createElement('div'); alertDiv.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: ${action === 'joined' ? '#28a745' : '#dc3545'}; color: white; padding: 12px 20px; border-radius: 8px; z-index: 10001; font-size: 14px; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.3); animation: slideDown 0.3s ease-out; `; const actionText = action === 'joined' ? 'joined the game' : action === 'left' ? 'left the game' : action === 'name_changed' ? 'changed name' : `${action} the game`; alertDiv.innerHTML = `
${playerName} ${actionText}
${toRelativeTime(Date.now())}
`; // Add CSS animation if not exists if (!document.getElementById('alert-animations')) { const style = document.createElement('style'); style.id = 'alert-animations'; style.textContent = ` @keyframes slideDown { from { transform: translateX(-50%) translateY(-100%); opacity: 0; } to { transform: translateX(-50%) translateY(0); opacity: 1; } } `; document.head.appendChild(style); } document.body.appendChild(alertDiv); // Browser notification (if enabled by user and permitted) if (this.settings && this.settings.browserNotifications) { sendBrowserNotification(playerName, action); } setTimeout(() => { alertDiv.style.animation = 'slideDown 0.3s ease-out reverse'; setTimeout(() => alertDiv.remove(), 300); }, 4000); } async playAlertSound() { try { const audioContext = new (window.AudioContext || window.webkitAudioContext)(); // Resume audio context if suspended (required by modern browsers) if (audioContext.state === 'suspended') { await audioContext.resume(); } const choice = (this.settings && this.settings.soundChoice) ? this.settings.soundChoice : 'osc_sine'; const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); // Default params let now = audioContext.currentTime; gainNode.gain.setValueAtTime(0.0, now); if (choice.startsWith('osc_')) { // Simple oscillator types const type = choice.split('_')[1] || 'sine'; oscillator.type = type; oscillator.frequency.setValueAtTime(880, now); gainNode.gain.linearRampToValueAtTime(0.3, now + 0.01); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.45); oscillator.start(now); oscillator.stop(now + 0.45); } else if (choice === 'short_burst') { // Two short beeps oscillator.type = 'square'; oscillator.frequency.setValueAtTime(880, now); gainNode.gain.linearRampToValueAtTime(0.35, now + 0.005); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.12); oscillator.start(now); oscillator.stop(now + 0.12); // second beep const osc2 = audioContext.createOscillator(); const gain2 = audioContext.createGain(); osc2.type = 'square'; osc2.frequency.setValueAtTime(660, now + 0.18); gain2.gain.setValueAtTime(0.0, now + 0.18); osc2.connect(gain2); gain2.connect(audioContext.destination); gain2.linearRampToValueAtTime(0.25, now + 0.185); gain2.exponentialRampToValueAtTime(0.001, now + 0.35); osc2.start(now + 0.18); osc2.stop(now + 0.35); } else if (choice === 'long_wobble') { // Frequency modulation wobble oscillator.type = 'sine'; const mod = audioContext.createOscillator(); const modGain = audioContext.createGain(); mod.frequency.setValueAtTime(4, now); modGain.gain.setValueAtTime(40, now); mod.connect(modGain); modGain.connect(oscillator.frequency); oscillator.frequency.setValueAtTime(720, now); gainNode.gain.linearRampToValueAtTime(0.25, now + 0.02); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 1.4); oscillator.start(now); mod.start(now); oscillator.stop(now + 1.4); mod.stop(now + 1.4); } else if (choice === 'triple_ping') { // Three ascending quick pings const freqs = [660, 880, 1100]; const offsets = [0, 0.18, 0.36]; // Use the pre-created oscillator/gain for the first ping oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(freqs[0], now + offsets[0]); gainNode.gain.linearRampToValueAtTime(0.3, now + offsets[0] + 0.01); gainNode.gain.exponentialRampToValueAtTime(0.001, now + offsets[0] + 0.14); oscillator.start(now + offsets[0]); oscillator.stop(now + offsets[0] + 0.14); for (let i = 1; i < 3; i++) { const pOsc = audioContext.createOscillator(); const pGain = audioContext.createGain(); pOsc.type = 'sine'; pOsc.frequency.setValueAtTime(freqs[i], now + offsets[i]); pGain.gain.setValueAtTime(0.0, now + offsets[i]); pGain.gain.linearRampToValueAtTime(0.3, now + offsets[i] + 0.01); pGain.gain.exponentialRampToValueAtTime(0.001, now + offsets[i] + 0.14); pOsc.connect(pGain); pGain.connect(audioContext.destination); pOsc.start(now + offsets[i]); pOsc.stop(now + offsets[i] + 0.14); } } else if (choice === 'deep_thud') { // Low bass punch oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(120, now); oscillator.frequency.exponentialRampToValueAtTime(40, now + 0.25); gainNode.gain.linearRampToValueAtTime(0.5, now + 0.005); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.25); oscillator.start(now); oscillator.stop(now + 0.25); // Add a mid-layer click for presence const clickOsc = audioContext.createOscillator(); const clickGain = audioContext.createGain(); clickOsc.type = 'triangle'; clickOsc.frequency.setValueAtTime(300, now); clickGain.gain.setValueAtTime(0.0, now); clickGain.gain.linearRampToValueAtTime(0.2, now + 0.003); clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.08); clickOsc.connect(clickGain); clickGain.connect(audioContext.destination); clickOsc.start(now); clickOsc.stop(now + 0.08); } else if (choice === 'rising_sweep') { // Frequency sweep from low to high oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(200, now); oscillator.frequency.exponentialRampToValueAtTime(1400, now + 0.55); gainNode.gain.linearRampToValueAtTime(0.28, now + 0.02); gainNode.gain.setValueAtTime(0.28, now + 0.45); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.6); oscillator.start(now); oscillator.stop(now + 0.6); } else { // Fallback to a short sine oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, now); gainNode.gain.linearRampToValueAtTime(0.25, now + 0.01); gainNode.gain.exponentialRampToValueAtTime(0.001, now + 0.5); oscillator.start(now); oscillator.stop(now + 0.5); } } catch (e) { console.log('Could not play alert sound:', e); // Fallback: try to use a simple beep try { const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmGgU7k9n1unEiBC13yO/eizEIHWq+8+OWT'); audio.volume = 0.3; audio.play(); } catch (fallbackError) { console.log('Fallback sound also failed:', fallbackError); } } } startMonitoring() { if (this.isMonitoring) return; this.isMonitoring = true; // Show loading indicator this.showLoadingIndicator(); // Initial player list with optimized loading this.updatePlayerList(true); // Pass true for initial load // Calculate initial population stats from loaded history this.calculatePopulationStats(); // Initial population display this.updatePopulationDisplay(); // Monitor every 10 seconds to reduce load let syncCounter = 0; monitoringInterval = setInterval(() => { this.checkPlayerChanges(); // Every 30 cycles (5 minutes), sync population to prevent drift syncCounter++; if (syncCounter >= 30) { this.syncPopulationCount(); syncCounter = 0; } }, 10000); // Update population stats every minute for better predictions populationStatsInterval = setInterval(() => { this.calculatePopulationStats(); this.updatePopulationDisplay(); }, 60000); // 1 minute // Refresh timestamps every 60 seconds to keep "X minutes ago" current timestampRefreshInterval = setInterval(() => { this.refreshTimestamps(); }, 60000); // 60 seconds } stopMonitoring() { this.isMonitoring = false; if (monitoringInterval) { clearInterval(monitoringInterval); monitoringInterval = null; } if (populationStatsInterval) { clearInterval(populationStatsInterval); populationStatsInterval = null; } if (timestampRefreshInterval) { clearInterval(timestampRefreshInterval); timestampRefreshInterval = null; } } updatePlayerList(isInitialLoad = false) { try { const playerRows = document.querySelectorAll('table tbody tr'); const newPlayerList = new Map(); // Batch process players to avoid blocking UI const batchSize = isInitialLoad ? 50 : playerRows.length; // Process in smaller batches on initial load let currentBatch = 0; const processBatch = () => { const startIndex = currentBatch * batchSize; const endIndex = Math.min(startIndex + batchSize, playerRows.length); // Process current batch for (let i = startIndex; i < endIndex; i++) { const row = playerRows[i]; const nameCell = row.querySelector('td:first-child a'); if (nameCell) { const playerName = nameCell.textContent.trim(); const playerLink = nameCell.href; const playerId = playerLink.split('/players/')[1]?.split('/')[0]; if (playerId && playerName) { // Try to extract session duration from a time element (BattleMetrics shows durations as PT...) let sessionMs = null; try { const timeEl = row.querySelector('time[datetime]'); if (timeEl) { const dt = timeEl.getAttribute('datetime'); sessionMs = parseISODurationToMs(dt); } } catch (e) { console.warn('Failed to parse session duration', e); } newPlayerList.set(playerId, { name: playerName, id: playerId, lastSeen: Date.now(), sessionMs: sessionMs }); // Add to database (batched, skip display updates during initial load) this.addToDatabase(playerId, playerName, isInitialLoad); } } } currentBatch++; // If there are more batches, schedule next batch if (endIndex < playerRows.length) { // Use requestAnimationFrame for smooth processing requestAnimationFrame(processBatch); return; } // All batches processed, now handle the rest this.finishPlayerListUpdate(newPlayerList, isInitialLoad); }; // Start processing if (isInitialLoad && playerRows.length > batchSize) { processBatch(); } else { // Process all at once for smaller lists or regular updates playerRows.forEach(row => { const nameCell = row.querySelector('td:first-child a'); if (nameCell) { const playerName = nameCell.textContent.trim(); const playerLink = nameCell.href; const playerId = playerLink.split('/players/')[1]?.split('/')[0]; if (playerId && playerName) { // Try to extract session duration from a time element in the row let sessionMs = null; try { const timeEl = row.querySelector('time[datetime]'); if (timeEl) { const dt = timeEl.getAttribute('datetime'); sessionMs = parseISODurationToMs(dt); } } catch (e) { console.warn('Failed to parse session duration', e); } newPlayerList.set(playerId, { name: playerName, id: playerId, lastSeen: Date.now(), sessionMs: sessionMs }); // Add to database this.addToDatabase(playerId, playerName, isInitialLoad); } } }); this.finishPlayerListUpdate(newPlayerList, isInitialLoad); } } catch (e) { console.error('Error updating player list:', e); } } finishPlayerListUpdate(newPlayerList, isInitialLoad) { try { // Check for changes let comparisonList = lastPlayerList; // On first run, use last saved state if available if (lastPlayerList.size === 0 && this.lastPlayerState.size > 0) { comparisonList = this.lastPlayerState; } if (comparisonList.size > 0) { // Check for new joins newPlayerList.forEach((player, playerId) => { if (!comparisonList.has(playerId)) { this.logActivity(player.name, playerId, 'joined'); } }); // Check for leaves comparisonList.forEach((player, playerId) => { if (!newPlayerList.has(playerId)) { // Use player name from comparison list if available const playerName = player.name || player.playerName || `Player ${playerId}`; this.logActivity(playerName, playerId, 'left'); } }); } lastPlayerList = new Map(newPlayerList); this.currentPlayers = new Map(newPlayerList); // Save current state for next page load this.saveLastPlayerState(); // Record population for tracking this.recordPopulation(newPlayerList.size); // Debounce display updates during initial load to prevent lag if (isInitialLoad) { this.scheduleDisplayUpdates(); } else { // Update all displays when player status changes (regular updates) this.updatePlayerDisplay(); this.updateAlertDisplay(); this.updateSavedPlayersDisplay(); } } catch (e) { console.error('Error finishing player list update:', e); } } scheduleDisplayUpdates() { // Clear any existing update timers clearTimeout(this.displayUpdateTimeout); // Schedule display updates with a small delay to prevent blocking this.displayUpdateTimeout = setTimeout(() => { requestAnimationFrame(() => { this.updatePlayerDisplay(); requestAnimationFrame(() => { this.updateAlertDisplay(); requestAnimationFrame(() => { this.updateSavedPlayersDisplay(); // Hide loading indicator when done this.hideLoadingIndicator(); }); }); }); }, 100); } showLoadingIndicator() { const playerListDiv = document.getElementById('current-players-list'); if (playerListDiv) { playerListDiv.innerHTML = '
Loading players...
Processing large player list, please wait...
'; } } hideLoadingIndicator() { // Loading indicator will be replaced by actual content in updatePlayerDisplay } syncPopulationCount() { const actualPopulation = this.getActualPopulationFromUI(); const trackedPopulation = this.currentPlayers.size; if (actualPopulation !== null) { const difference = Math.abs(actualPopulation - trackedPopulation); if (difference >= 1 && difference <= 3) { this.recordPopulation(trackedPopulation); } } } checkPlayerChanges() { this.updatePlayerList(); } updatePlayerDisplay() { const playerListDiv = document.getElementById('current-players-list'); if (!playerListDiv) return; if (this.currentPlayers.size === 0) { playerListDiv.innerHTML = '
No players online
'; return; } // Use DocumentFragment for better performance with large player lists const fragment = document.createDocumentFragment(); for (const [playerId, player] of this.currentPlayers) { const isAlerted = this.alerts[playerId]; const playerDiv = document.createElement('div'); playerDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.1);'; const playerInfo = document.createElement('div'); playerInfo.style.cssText = 'flex: 1;'; const playerLink = document.createElement('a'); playerLink.href = `https://www.battlemetrics.com/players/${playerId}`; playerLink.target = '_blank'; playerLink.style.cssText = 'color: #17a2b8; text-decoration: none;'; playerLink.textContent = player.name; playerInfo.appendChild(playerLink); // Show session playtime if available (format hours with one decimal, or minutes if <1h) try { if (player && typeof player.sessionMs === 'number' && player.sessionMs !== null) { const ms = player.sessionMs; let sessionText = ''; if (ms >= 3600000) { const hrs = ms / 3600000; sessionText = `${hrs.toFixed(1)}h`; } else { const mins = Math.round(ms / 60000); sessionText = `${mins}m`; } const sessionSpan = document.createElement('div'); sessionSpan.style.cssText = 'color: #6c757d; font-size: 11px; margin-top: 3px;'; sessionSpan.textContent = `Session: ${sessionText}`; playerInfo.appendChild(sessionSpan); } } catch (e) { // Ignore non-critical display errors } if (isAlerted) { const alertSpan = document.createElement('span'); alertSpan.style.cssText = 'color: #ffc107; margin-left: 5px;'; alertSpan.textContent = '[ALERT]'; playerInfo.appendChild(alertSpan); } const buttonsDiv = document.createElement('div'); buttonsDiv.style.cssText = 'display: flex; gap: 5px;'; const alertBtn = document.createElement('button'); alertBtn.textContent = isAlerted ? 'Remove' : 'Add Alert'; alertBtn.title = isAlerted ? 'Remove Alert' : 'Add Alert'; alertBtn.style.cssText = `background: ${isAlerted ? '#dc3545' : '#28a745'}; color: white; border: none; padding: 2px 6px; border-radius: 3px; cursor: pointer; font-size: 10px;`; alertBtn.onclick = () => togglePlayerAlert(player.name, playerId); const saveBtn = document.createElement('button'); saveBtn.textContent = 'Save'; saveBtn.title = 'Save Player'; saveBtn.style.cssText = 'background: #6c757d; color: white; border: none; padding: 2px 6px; border-radius: 3px; cursor: pointer; font-size: 10px;'; saveBtn.onclick = () => savePlayer(player.name, playerId); buttonsDiv.appendChild(alertBtn); buttonsDiv.appendChild(saveBtn); playerDiv.appendChild(playerInfo); playerDiv.appendChild(buttonsDiv); fragment.appendChild(playerDiv); } // Clear and append all at once playerListDiv.innerHTML = ''; playerListDiv.appendChild(fragment); } updateActivityDisplay() { const activityDiv = document.getElementById('recent-activity-list'); if (!activityDiv) return; // If user is actively searching activity, prefer search results if (typeof activeActivitySearch !== 'undefined' && activeActivitySearch && activeActivitySearch.length >= 2) { try { performActivitySearch(activeActivitySearch); } catch (e) {} return; } // Delegate to unified filter renderer (handles both action + time filters) if (window.applyActivityFilters) { window.applyActivityFilters(); } } updateAlertCount() { const alertCountSpan = document.getElementById('alert-count'); if (alertCountSpan) { alertCountSpan.textContent = Object.keys(this.alerts).length; } } expandAlertSection() { const alertList = document.getElementById('alert-players-list'); const alertToggle = document.getElementById('alertplayers-toggle'); if (alertList && alertToggle) { if (alertList.style.display === 'none') { alertList.style.display = 'block'; alertToggle.textContent = '▼'; } } } updateAlertDisplay() { try { const alertDiv = document.getElementById('alert-players-list'); if (!alertDiv) return; const alertedPlayers = Object.keys(this.alerts); if (alertedPlayers.length === 0) { alertDiv.innerHTML = '
No players with alerts
'; return; } let alertHTML = ''; alertedPlayers.forEach(playerId => { const alert = this.alerts[playerId]; const addedDate = new Date(alert.added).toLocaleDateString(); const isOnline = this.currentPlayers.has(playerId); const dbPlayer = this.playerDatabase[playerId]; const isSaved = this.savedPlayers[playerId]; // Get current name and check for name changes let displayName = alert.name; let nameChangeInfo = ''; if (dbPlayer) { displayName = dbPlayer.currentName; if (dbPlayer.nameChanged && dbPlayer.previousNames.length > 0) { const originalAlertName = alert.name; if (originalAlertName !== dbPlayer.currentName) { nameChangeInfo = ` (was: ${originalAlertName})`; } } } const onlineStatus = isOnline ? '[ONLINE]' : '[OFFLINE]'; const lastSeenLine = (!isOnline && dbPlayer && dbPlayer.lastSeen) ? `
Last seen: ${toRelativeTime(dbPlayer.lastSeen)}
` : ''; alertHTML += `
${displayName}${nameChangeInfo} ${onlineStatus} ${isSaved ? '[SAVED]' : ''}
Added: ${addedDate} | ID: ${playerId}
${lastSeenLine} ${dbPlayer && dbPlayer.nameChanged ? '
⚠ Name changed
' : ''}
`; }); alertDiv.innerHTML = alertHTML; } catch (error) { if (debugConsole && debugConsole.enabled) debugConsole.error('[Alert System] Error in updateAlertDisplay:', error); } } updateSavedPlayersDisplay() { const savedDiv = document.getElementById('saved-players-list'); if (!savedDiv) return; const savedPlayers = Object.keys(this.savedPlayers); if (savedPlayers.length === 0) { savedDiv.innerHTML = '
No saved players
'; return; } let savedHTML = ''; savedPlayers.forEach(playerId => { const saved = this.savedPlayers[playerId]; const savedDate = new Date(saved.saved).toLocaleDateString(); const hasAlert = this.alerts[playerId]; const isOnline = this.currentPlayers.has(playerId); const dbPlayer = this.playerDatabase[playerId]; // Get current name and check for name changes let displayName = saved.name; let nameChangeInfo = ''; if (dbPlayer) { displayName = dbPlayer.currentName; if (dbPlayer.nameChanged && dbPlayer.previousNames && dbPlayer.previousNames.length > 0) { const originalSavedName = saved.name; if (!namesEqual(originalSavedName, dbPlayer.currentName)) { nameChangeInfo = ` (was: ${originalSavedName})`; } } } const onlineStatus = isOnline ? '[ONLINE]' : '[OFFLINE]'; const lastSeenLine = (!isOnline && dbPlayer && dbPlayer.lastSeen) ? `
Last seen: ${toRelativeTime(dbPlayer.lastSeen)}
` : ''; savedHTML += `
${displayName}${nameChangeInfo} ${onlineStatus} ${hasAlert ? '[ALERT]' : ''}
Saved: ${savedDate} | ID: ${playerId}
${lastSeenLine} ${dbPlayer && dbPlayer.nameChanged ? '
⚠ Name changed
' : ''}
${hasAlert ? `` : `` }
`; }); savedDiv.innerHTML = savedHTML; } updateRecentAlertsDisplay() { const alertsDiv = document.getElementById('recent-alerts-list'); if (!alertsDiv) return; // Clean old alerts first this.clearOldAlerts(); // Check if we need to reorder sections based on unacknowledged alerts this.reorderSectionsIfNeeded(); const recentAlerts = Object.keys(this.recentAlerts) .map(id => ({ id, ...this.recentAlerts[id] })) .sort((a, b) => b.timestamp - a.timestamp); if (recentAlerts.length === 0) { alertsDiv.innerHTML = '
No recent alerts
'; return; } let alertsHTML = ''; recentAlerts.forEach(alert => { const timeAgo = toRelativeTime(alert.timestamp); const actionColor = alert.action === 'joined' ? '#28a745' : '#dc3545'; const bgColor = alert.acknowledged ? 'rgba(108, 117, 125, 0.1)' : 'rgba(220, 53, 69, 0.1)'; const dbPlayer = this.playerDatabase[alert.playerId]; // Get current name and check for name changes let displayName = alert.playerName; let nameChangeInfo = ''; if (dbPlayer) { displayName = dbPlayer.currentName; if (dbPlayer.nameChanged && dbPlayer.previousNames.length > 0) { // Show the most recent previous name if current name is different from any previous name const mostRecentPreviousName = dbPlayer.previousNames[dbPlayer.previousNames.length - 1]; // Check if the alert name is different from current name OR if we should show previous name if (alert.playerName !== dbPlayer.currentName) { nameChangeInfo = ` (was: ${alert.playerName})`; } else if (mostRecentPreviousName && mostRecentPreviousName !== dbPlayer.currentName) { nameChangeInfo = ` (was: ${mostRecentPreviousName})`; } } } const actionText = alert.action === 'joined' ? 'joined the game' : alert.action === 'left' ? 'left the game' : alert.action === 'name_changed' ? 'changed name' : `${alert.action} the game`; alertsHTML += `
${displayName}${nameChangeInfo} ${actionText}
${timeAgo} | ID: ${alert.playerId}
${dbPlayer && dbPlayer.nameChanged ? '
⚠ Name changed
' : ''} ${alert.acknowledged ? '
✓ Acknowledged
' : '
⚠ Needs acknowledgment
'}
${!alert.acknowledged ? ` ` : ''}
`; }); alertsDiv.innerHTML = alertsHTML; } reorderSectionsIfNeeded() { const unacknowledged = Object.values(this.recentAlerts).filter(alert => !alert.acknowledged); const hasUnacknowledgedAlerts = unacknowledged.length > 0; const recentAlertsSection = document.getElementById('recent-alerts-section'); const populationStats = document.getElementById('population-stats'); const playerSearchSection = populationStats ? populationStats.nextElementSibling : null; if (!recentAlertsSection) return; // Check if Recent Alerts is currently at the top (right after population stats) const currentlyAtTop = playerSearchSection && recentAlertsSection.nextElementSibling === playerSearchSection; if (hasUnacknowledgedAlerts && !currentlyAtTop) { // Move Recent Alerts to top (after population stats, before player search) if (populationStats && playerSearchSection) { populationStats.insertAdjacentElement('afterend', recentAlertsSection); } // Make it more prominent with pulsing effect recentAlertsSection.style.border = '2px solid #dc3545'; recentAlertsSection.style.boxShadow = '0 0 15px rgba(220, 53, 69, 0.5)'; recentAlertsSection.style.animation = 'pulse 2s infinite'; // Add pulse animation if not exists if (!document.getElementById('alert-pulse-animation')) { const style = document.createElement('style'); style.id = 'alert-pulse-animation'; style.textContent = ` @keyframes pulse { 0% { box-shadow: 0 0 15px rgba(220, 53, 69, 0.5); } 50% { box-shadow: 0 0 25px rgba(220, 53, 69, 0.8); } 100% { box-shadow: 0 0 15px rgba(220, 53, 69, 0.5); } } `; document.head.appendChild(style); } // Auto-expand if collapsed const alertsList = document.getElementById('recent-alerts-list'); const toggle = document.getElementById('recentalerts-toggle'); if (alertsList && toggle && alertsList.style.display === 'none') { alertsList.style.display = 'block'; toggle.textContent = '▼'; } } else if (!hasUnacknowledgedAlerts && currentlyAtTop) { // Move Recent Alerts back to original position (after Player Database) const playerDatabaseSection = document.getElementById('player-database-section'); if (playerDatabaseSection) { playerDatabaseSection.insertAdjacentElement('afterend', recentAlertsSection); } // Remove prominence styling recentAlertsSection.style.border = '1px solid #dc3545'; recentAlertsSection.style.boxShadow = 'none'; recentAlertsSection.style.animation = 'none'; } } searchPlayers(query) { if (!query || query.length < 2) return []; const lowerQuery = query.toLowerCase(); const results = []; // Use for...of for better performance than forEach for (const [playerId, player] of this.currentPlayers) { if (player.name.toLowerCase().includes(lowerQuery) || playerId.includes(query)) { results.push(player); } } return results; } exportActivityLog() { const csv = ['Timestamp,Player Name,Player ID,Action,Server Name']; this.activityLog.forEach(entry => { csv.push(`"${entry.time}","${entry.playerName}","${entry.playerId}","${entry.action}","${entry.serverName}"`); }); const blob = new Blob([csv.join('\n')], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `server_activity_${new Date().toISOString().split('T')[0]}.csv`; a.click(); URL.revokeObjectURL(url); } clearActivityLog() { this.activityLog = []; this.saveActivityLog(); this.updateActivityDisplay(); } } // Add global click handler for better Firefox compatibility document.addEventListener('click', (event) => { const target = event.target; if (target.onclick && target.onclick.toString().includes('togglePlayerAlert')) { event.preventDefault(); const onclickStr = target.onclick.toString(); const match = onclickStr.match(/togglePlayerAlert\('([^']+)',\s*'([^']+)'\)/); if (match) { window.togglePlayerAlert(match[1], match[2]); } } }); // Create Toggle Button const createToggleButton = () => { const existingToggleBtn = document.getElementById(TOGGLE_BUTTON_ID); if (existingToggleBtn) existingToggleBtn.remove(); const toggleBtn = document.createElement("button"); toggleBtn.id = TOGGLE_BUTTON_ID; toggleBtn.onclick = () => { const currentlyVisible = isMenuVisible(); setMenuVisibility(!currentlyVisible); }; Object.assign(toggleBtn.style, { position: "fixed", top: "20px", right: "20px", zIndex: "10000", padding: "8px 12px", backgroundColor: "#6c757d", color: "#fff", border: "none", borderRadius: "5px", cursor: "pointer", fontSize: "14px", fontWeight: "bold" }); document.body.appendChild(toggleBtn); updateToggleButton(); }; // Create Server Monitor UI const createServerMonitor = () => { const existingMonitor = document.getElementById(SERVER_MONITOR_ID); if (existingMonitor) existingMonitor.remove(); const monitor = document.createElement('div'); monitor.id = SERVER_MONITOR_ID; Object.assign(monitor.style, { position: "fixed", top: "70px", right: "20px", backgroundColor: "#2c3e50", color: "#fff", padding: "20px", borderRadius: "10px", zIndex: "9999", fontSize: "14px", maxWidth: "450px", maxHeight: "80vh", overflowY: "auto", boxShadow: "0 8px 25px rgba(0,0,0,0.3)", border: "1px solid #34495e", lineHeight: "1.4" }); monitor.innerHTML = `
Server Monitor
Real-time player tracking & alerts
Server ID: ${currentServerID || 'Loading...'}
Server: ${currentServerName || 'Loading...'}
Player Search
Current Online Players (0)
Loading players...
Alert Players (0)
No players with alerts
Saved Players (0)
No saved players
Player Database (0)
Recent Alerts (0)
No recent alerts
All Activity (0)
No recent activity
Settings
BM Oversight v${SCRIPT_VERSION}
💬 Discord ⭐ GitHub Changelog
`; document.body.appendChild(monitor); updateUIVisibility(); // Initialize debug console display setTimeout(() => { console.log('[Debug Console] Initializing debug console display...'); const debugSection = document.getElementById('debug-console-section'); const debugCheckbox = document.getElementById('debug-mode'); if (debugConsole) { console.log('[Debug Console] Debug console enabled:', debugConsole.enabled); console.log('[Debug Console] Debug logs count:', debugConsole.logs.length); if (debugSection) { debugSection.style.display = debugConsole.enabled ? 'block' : 'none'; } if (debugCheckbox) { debugCheckbox.checked = debugConsole.enabled; } // Always try to refresh display debugConsole.updateDebugDisplay(); // Initialize update checkboxes const autoCheckCb = document.getElementById('auto-check-updates'); if (autoCheckCb) autoCheckCb.checked = loadAutoCheckSetting(); // initialize sound select try { const soundSelect = document.getElementById('sound-select'); if (soundSelect && serverMonitor) { const choice = (serverMonitor.settings && serverMonitor.settings.soundChoice) ? serverMonitor.settings.soundChoice : 'osc_sine'; soundSelect.value = choice; } } catch (e) { console.warn('Could not initialize sound select', e); } // Attach safe blur handlers to prevent page-level onblur handlers from firing try { const playerSearch = document.getElementById('player-search'); if (playerSearch) { // Use capture to intercept before page handlers playerSearch.addEventListener('blur', (ev) => { try { ev.stopImmediatePropagation(); } catch (e) {} setTimeout(() => { if (!playerSearch.value) { activePlayerSearch = ''; } }, 200); }, true); } const dbSearch = document.getElementById('database-search'); if (dbSearch) { dbSearch.addEventListener('blur', (ev) => { try { ev.stopImmediatePropagation(); } catch (e) {} setTimeout(() => { if (!dbSearch.value) { activeDatabaseSearch = ''; if (serverMonitor) serverMonitor.updateDatabaseDisplay(); } }, 200); }, true); } } catch (e) { console.warn('Failed to attach safe blur handlers', e); } } }, 500); }; // Global functions for UI interaction window.toggleSection = (sectionId) => { const content = document.getElementById(`${sectionId}-list`) || document.getElementById(`${sectionId}-content`); let toggle; // Handle different toggle ID patterns if (sectionId === 'recent-activity') { toggle = document.getElementById('activity-toggle'); } else if (sectionId === 'player-database') { toggle = document.getElementById('playerdatabase-toggle'); } else if (sectionId === 'current-players') { toggle = document.getElementById('players-toggle'); } else if (sectionId === 'alert-settings') { toggle = document.getElementById('alerts-toggle'); } else { toggle = document.getElementById(`${sectionId.replace('-', '')}-toggle`); } if (content && toggle) { if (content.style.display === 'none') { content.style.display = 'block'; toggle.textContent = '▼'; // Show filters when section is expanded if (sectionId === 'recent-activity') { const filters = document.getElementById('activity-filters'); if (filters) filters.style.display = 'block'; } // Refresh debug stats when debug console is opened if (sectionId === 'debug-console') { setTimeout(() => refreshDebugStats(), 100); } } else { content.style.display = 'none'; toggle.textContent = '▶'; // Hide filters when section is collapsed if (sectionId === 'recent-activity') { const filters = document.getElementById('activity-filters'); if (filters) filters.style.display = 'none'; } } } }; // Debounce timer for search let searchDebounceTimer = null; let lastSearchQuery = ''; let cachedSearchResults = new Map(); // Optimized search function with debouncing and caching const performPlayerSearch = (query) => { const resultsDiv = document.getElementById('search-results'); if (!resultsDiv || !serverMonitor) return; // Check cache first if (cachedSearchResults.has(query)) { const cachedResults = cachedSearchResults.get(query); renderSearchResults(cachedResults, resultsDiv); return; } // Search both current players and database const currentResults = serverMonitor.searchPlayers(query); const databaseResults = serverMonitor.searchDatabase(query); // Combine results efficiently const allResults = []; const seenIds = new Set(); // Add current players first (they're online) for (const player of currentResults) { allResults.push({ ...player, isOnline: true, source: 'current' }); seenIds.add(player.id); } // Add database players that aren't already in current players for (const player of databaseResults) { if (!seenIds.has(player.id)) { allResults.push({ ...player, name: player.currentName, isOnline: false, source: 'database' }); seenIds.add(player.id); } } // Sort by online status first, then by name allResults.sort((a, b) => { if (a.isOnline && !b.isOnline) return -1; if (!a.isOnline && b.isOnline) return 1; return a.name.localeCompare(b.name); }); // Cache results for this query cachedSearchResults.set(query, allResults); // Limit cache size to prevent memory issues if (cachedSearchResults.size > 50) { const firstKey = cachedSearchResults.keys().next().value; cachedSearchResults.delete(firstKey); } renderSearchResults(allResults, resultsDiv); }; // Separate function to render results (reduces code duplication) const renderSearchResults = (allResults, resultsDiv) => { if (allResults.length === 0) { resultsDiv.innerHTML = '
No players found
'; return; } // Use DocumentFragment for better performance const fragment = document.createDocumentFragment(); allResults.forEach(player => { const isAlerted = serverMonitor.alerts[player.id]; const isSaved = serverMonitor.savedPlayers[player.id]; const playerDiv = document.createElement('div'); playerDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 11px;'; const playerInfo = document.createElement('div'); playerInfo.style.cssText = 'flex: 1; overflow: hidden;'; const statusColor = player.isOnline ? '#28a745' : '#6c757d'; const statusText = player.isOnline ? 'ONLINE' : 'OFFLINE'; playerInfo.innerHTML = `
${player.name}
${statusText} • ID: ${player.id}
`; const buttonsDiv = document.createElement('div'); buttonsDiv.style.cssText = 'display: flex; gap: 3px; margin-left: 5px;'; // Profile button const profileBtn = document.createElement('button'); profileBtn.textContent = 'Profile'; profileBtn.title = 'View Profile'; profileBtn.style.cssText = 'background: #17a2b8; color: white; border: none; padding: 2px 5px; border-radius: 2px; cursor: pointer; font-size: 9px;'; profileBtn.onclick = () => window.open(`https://www.battlemetrics.com/players/${player.id}`, '_blank'); // Alert button const alertBtn = document.createElement('button'); alertBtn.textContent = isAlerted ? 'Remove' : 'Add Alert'; alertBtn.title = isAlerted ? 'Remove Alert' : 'Add Alert'; alertBtn.style.cssText = `background: ${isAlerted ? '#dc3545' : '#28a745'}; color: white; border: none; padding: 2px 5px; border-radius: 2px; cursor: pointer; font-size: 9px;`; alertBtn.onclick = () => togglePlayerAlert(player.name, player.id); // Save button const saveBtn = document.createElement('button'); saveBtn.textContent = isSaved ? 'Saved' : 'Save'; saveBtn.title = isSaved ? 'Already Saved' : 'Save Player'; saveBtn.style.cssText = `background: ${isSaved ? '#6c757d' : '#28a745'}; color: white; border: none; padding: 2px 5px; border-radius: 2px; cursor: pointer; font-size: 9px;`; saveBtn.disabled = isSaved; saveBtn.onclick = () => { if (!isSaved) { savePlayer(player.name, player.id); } }; buttonsDiv.appendChild(profileBtn); buttonsDiv.appendChild(alertBtn); buttonsDiv.appendChild(saveBtn); playerDiv.appendChild(playerInfo); playerDiv.appendChild(buttonsDiv); fragment.appendChild(playerDiv); }); // Clear and append all at once for better performance resultsDiv.innerHTML = ''; resultsDiv.appendChild(fragment); }; window.handlePlayerSearch = (query) => { // Track active search state activePlayerSearch = query; // Clear previous timer if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); } if (query.length < 2) { activePlayerSearch = ''; const resultsDiv = document.getElementById('search-results'); if (resultsDiv) { resultsDiv.innerHTML = ''; } // Clear cache when search is cleared cachedSearchResults.clear(); return; } // Debounce search to reduce lag searchDebounceTimer = setTimeout(() => { if (activePlayerSearch === query) { // Only search if query hasn't changed performPlayerSearch(query); } }, 150); // 150ms debounce delay }; // Activity search (debounced, searches activity log for name/id/action) let activitySearchDebounceTimer = null; let activeActivitySearch = ''; window.handleActivitySearch = (query) => { activeActivitySearch = query; if (activitySearchDebounceTimer) clearTimeout(activitySearchDebounceTimer); if (!query || query.length < 2) { activeActivitySearch = ''; if (serverMonitor) serverMonitor.updateActivityDisplay(); return; } activitySearchDebounceTimer = setTimeout(() => { if (activeActivitySearch === query) { performActivitySearch(query); } }, 150); }; const performActivitySearch = (query) => { if (!serverMonitor) return; const lower = query.toLowerCase(); const results = []; // Apply time filter alongside text search const timeFilter = (document.getElementById('activity-time-filter') || {}).value || 'all'; const timeCutoff = getActivityTimeCutoff(timeFilter); // Search from most recent to oldest for better UX for (let i = serverMonitor.activityLog.length - 1; i >= 0; i--) { const e = serverMonitor.activityLog[i]; try { // Time gate first (cheap) if (timeCutoff > 0) { const ts = typeof e.timestamp === 'number' ? e.timestamp : new Date(e.timestamp).getTime(); if (ts < timeCutoff) continue; } if ((e.playerName && e.playerName.toLowerCase().includes(lower)) || (e.playerId && e.playerId.toLowerCase().includes(lower)) || (e.action && e.action.toLowerCase().includes(lower)) || (e.oldName && String(e.oldName).toLowerCase().includes(lower)) || (e.altPlayerName && e.altPlayerName.toLowerCase().includes(lower)) || (e.altPlayerId && e.altPlayerId.toLowerCase().includes(lower))) { results.push(e); } } catch (err) { /* ignore */ } if (results.length >= 500) break; // reasonable cap } const activityDiv = document.getElementById('recent-activity-list'); if (!activityDiv) return; // Update result count const countEl = document.getElementById('activity-result-count'); if (countEl) countEl.textContent = `${results.length} search result${results.length !== 1 ? 's' : ''}`; renderActivitySearchResults(results, activityDiv); }; const renderActivitySearchResults = (results, container) => { if (!results || results.length === 0) { container.innerHTML = '
No activity found
'; return; } let html = ''; results.forEach(entry => { html += buildActivityEntryHTML(entry); }); container.innerHTML = html; }; // Debounce mechanism to prevent double-clicks let alertToggleTimeout = null; window.togglePlayerAlert = (playerName, playerId) => { // Prevent rapid double-clicks if (alertToggleTimeout) { console.log('[Alert System] Ignoring rapid click'); return; } alertToggleTimeout = setTimeout(() => { alertToggleTimeout = null; }, 500); debugConsole.debug('togglePlayerAlert called', { playerName, playerId }); if (!serverMonitor) { debugConsole.error('ServerMonitor not initialized'); alert('Server Monitor not initialized. Please refresh the page.'); return; } debugConsole.debug('Current alerts', serverMonitor.alerts); const isAlerted = serverMonitor.alerts[playerId]; debugConsole.debug('Player is currently alerted:', !!isAlerted, isAlerted); try { if (isAlerted) { debugConsole.info('Removing alert for player: ' + playerName); serverMonitor.removeAlert(playerId); } else { debugConsole.info('Adding alert for player: ' + playerName); serverMonitor.addAlert(playerName, playerId, 'both'); } debugConsole.debug('Alert operation completed', serverMonitor.alerts); // Immediately update displays (the addAlert/removeAlert methods already call updateAlertDisplay) serverMonitor.updatePlayerDisplay(); serverMonitor.updateSavedPlayersDisplay(); // Clear search cache to ensure fresh results if (typeof cachedSearchResults !== 'undefined') { cachedSearchResults.clear(); } // Refresh search results to update button states const searchInput = document.getElementById('player-search'); if (searchInput && searchInput.value.length >= 2) { handlePlayerSearch(searchInput.value); } // Also update with debouncing as backup clearTimeout(serverMonitor.alertUpdateTimeout); serverMonitor.alertUpdateTimeout = setTimeout(() => { serverMonitor.updateAlertDisplay(); }, 300); clearTimeout(serverMonitor.savedUpdateTimeout); serverMonitor.savedUpdateTimeout = setTimeout(() => { serverMonitor.updateSavedPlayersDisplay(); }, 300); } catch (error) { debugConsole.error('Error in togglePlayerAlert', error); alert('Error toggling alert: ' + error.message); } }; window.acknowledgeRecentAlert = (alertId) => { if (serverMonitor) { serverMonitor.acknowledgeAlert(alertId); } }; window.acknowledgeAllRecentAlerts = () => { if (serverMonitor) { serverMonitor.acknowledgeAllAlerts(); } }; window.clearAllRecentAlerts = (() => { let pending = false; let timer = null; return (btn) => { if (!pending) { pending = true; if (btn) { btn.textContent = 'Sure?'; btn.style.background = '#a71d2a'; } timer = setTimeout(() => { pending = false; if (btn) { btn.textContent = '\u2715 Clear All'; btn.style.background = '#dc3545'; } }, 2000); } else { clearTimeout(timer); pending = false; if (btn) { btn.textContent = '\u2715 Clear All'; btn.style.background = '#dc3545'; } if (serverMonitor) serverMonitor.clearAllRecentAlerts(); } }; })(); window.handleDatabaseSearch = (query) => { if (!serverMonitor) return; const databaseDiv = document.getElementById('player-database-list'); if (!databaseDiv) return; // Track active database search state activeDatabaseSearch = query; // Show the database list when user starts searching if (query.length >= 2 && databaseDiv.style.display === 'none') { databaseDiv.style.display = 'block'; const toggle = document.getElementById('playerdatabase-toggle'); if (toggle) toggle.textContent = '▼'; } const results = serverMonitor.searchDatabase(query); const sortedResults = results.sort((a, b) => b.lastSeen - a.lastSeen); if (query.length < 2) { activeDatabaseSearch = ''; databaseDiv.innerHTML = '
Type 2+ characters to search
'; return; } if (sortedResults.length === 0) { databaseDiv.innerHTML = '
No players found
'; return; } serverMonitor.renderDatabasePlayers(sortedResults, databaseDiv); }; window.clearPlayerSearch = () => { const searchInput = document.getElementById('player-search'); const resultsDiv = document.getElementById('search-results'); if (searchInput) { searchInput.value = ''; activePlayerSearch = ''; } if (resultsDiv) { resultsDiv.innerHTML = ''; } }; window.clearDatabaseSearch = () => { const searchInput = document.getElementById('database-search'); if (searchInput) { searchInput.value = ''; activeDatabaseSearch = ''; } if (serverMonitor) { serverMonitor.updateDatabaseDisplay(); } }; // ── Tab title badge ────────────────────────────────────────────────────── let _originalPageTitle = document.title; const updateTabTitleBadge = () => { if (!serverMonitor) return; const count = Object.values(serverMonitor.recentAlerts).filter(a => !a.acknowledged).length; if (count > 0) { document.title = `[${count}] ${_originalPageTitle.replace(/^\[\d+\] /, '')}`; } else { document.title = _originalPageTitle.replace(/^\[\d+\] /, ''); } }; // ── Browser Notification API ───────────────────────────────────────────── const sendBrowserNotification = (playerName, action) => { if (!('Notification' in window) || Notification.permission !== 'granted') return; const actionText = action === 'joined' ? 'joined the server' : action === 'left' ? 'left the server' : action === 'name_changed' ? 'changed their name' : action; try { new Notification(`BMS Alert: ${playerName}`, { body: `${playerName} ${actionText}`, icon: 'https://www.battlemetrics.com/favicon.ico', tag: `bms_${playerName}_${action}`, silent: false, }); } catch (e) { /* Notification blocked or unavailable */ } }; window.requestNotificationPermission = () => { if (!('Notification' in window)) { alert('Browser notifications are not supported in this browser.'); return; } Notification.requestPermission().then(permission => { if (permission === 'granted') { alert('Browser notifications enabled!'); } else { alert('Permission denied. You can change this in browser settings.'); // Uncheck the toggle if permission was denied const toggle = document.getElementById('browser-notif-toggle'); if (toggle) toggle.checked = false; if (serverMonitor) { serverMonitor.settings.browserNotifications = false; serverMonitor.saveSettings(); } } }); }; window.toggleBrowserNotifications = (enabled) => { if (!serverMonitor) return; if (enabled && Notification.permission !== 'granted') { Notification.requestPermission().then(permission => { if (permission === 'granted') { serverMonitor.settings.browserNotifications = true; serverMonitor.saveSettings(); } else { serverMonitor.settings.browserNotifications = false; serverMonitor.saveSettings(); const toggle = document.getElementById('browser-notif-toggle'); if (toggle) toggle.checked = false; } }); } else { serverMonitor.settings.browserNotifications = enabled; serverMonitor.saveSettings(); } }; window.toggleSoundAlerts = (enabled) => { if (serverMonitor) { serverMonitor.soundEnabled = enabled; serverMonitor.settings.soundEnabled = enabled; serverMonitor.saveSettings(); } }; window.changeSoundChoice = (value) => { if (serverMonitor) { serverMonitor.settings = serverMonitor.settings || {}; serverMonitor.settings.soundChoice = value; serverMonitor.saveSettings(); } }; window.testSound = () => { if (serverMonitor) { // Play the currently selected sound once try { serverMonitor.playAlertSound(); } catch (e) { alert('Could not play test sound: ' + e.message); } } else { alert('Server monitor not initialized yet.'); } }; window.toggleRepeatAlerts = (enabled) => { if (serverMonitor) { serverMonitor.settings.repeatAlerts = enabled; serverMonitor.saveSettings(); if (!enabled) { serverMonitor.stopAlertReminders(); } else { // Check if there are unacknowledged alerts to start reminders const unacknowledged = Object.values(serverMonitor.recentAlerts).filter(alert => !alert.acknowledged); if (unacknowledged.length > 0) { serverMonitor.startAlertReminders(); } } } }; window.changeNewPlayerWindow = (ms) => { if (serverMonitor) { serverMonitor.settings.newPlayerWindowMs = parseInt(ms, 10); serverMonitor.saveSettings(); // Refresh activity display so [NEW] badges update immediately serverMonitor.updateActivityDisplay(); } }; window.changeRepeatInterval = (ms) => { if (serverMonitor) { serverMonitor.settings.repeatIntervalMs = parseInt(ms, 10); serverMonitor.saveSettings(); // Restart the reminder timer with the new interval if it's running if (alertReminderInterval) { serverMonitor.stopAlertReminders(); serverMonitor.startAlertReminders(); } } }; window.toggleAutoRefresh = (enabled) => { const { ms } = loadAutoRefreshSettings(); saveAutoRefreshSettings(enabled, ms); if (enabled) { startAutoRefresh(ms); } else { stopAutoRefresh(); } }; window.changeAutoRefreshInterval = (ms) => { ms = parseInt(ms, 10) || 120000; const { enabled } = loadAutoRefreshSettings(); saveAutoRefreshSettings(enabled, ms); if (enabled) { startAutoRefresh(ms); // restart with new interval } }; window.toggleMonitoring = () => { const btn = document.getElementById('monitoring-btn'); if (!serverMonitor || !btn) return; if (serverMonitor.isMonitoring) { serverMonitor.stopMonitoring(); btn.textContent = 'Start Monitoring'; btn.style.background = '#28a745'; } else { serverMonitor.startMonitoring(); btn.textContent = 'Stop Monitoring'; btn.style.background = '#dc3545'; } }; window.exportLog = () => { if (serverMonitor) { serverMonitor.exportActivityLog(); } }; window.clearLog = () => { if (serverMonitor && confirm('Are you sure you want to clear the activity log?')) { serverMonitor.clearActivityLog(); } }; window.resetCurrentServer = () => { if (confirm('Are you sure you want to reset ALL data for THIS SERVER? This will clear:\n\n• All player alerts\n• Activity log\n• Settings\n• Saved players\n• Recent alerts\n• Player database\n• Population history\n\nThis action cannot be undone!')) { // Clear all server-specific localStorage data localStorage.removeItem(ALERTS_KEY); localStorage.removeItem(ACTIVITY_LOG_KEY); localStorage.removeItem(ALERT_SETTINGS_KEY); localStorage.removeItem(SAVED_PLAYERS_KEY); localStorage.removeItem(RECENT_ALERTS_KEY); localStorage.removeItem(PLAYER_DATABASE_KEY); localStorage.removeItem(POPULATION_HISTORY_KEY); localStorage.removeItem(LAST_PLAYER_STATE_KEY); // Reset serverMonitor if it exists if (serverMonitor) { serverMonitor.alerts = {}; serverMonitor.activityLog = []; serverMonitor.settings = {}; serverMonitor.savedPlayers = {}; serverMonitor.recentAlerts = {}; serverMonitor.playerDatabase = {}; serverMonitor.populationHistory = []; serverMonitor.lastPlayerState = new Map(); serverMonitor.soundEnabled = true; // Update displays serverMonitor.updatePlayerDisplay(); serverMonitor.updateActivityDisplay(); serverMonitor.updateAlertDisplay(); serverMonitor.updateSavedPlayersDisplay(); serverMonitor.updateRecentAlertsDisplay(); serverMonitor.updateDatabaseDisplay(); serverMonitor.updatePopulationDisplay(); serverMonitor.stopAlertReminders(); // Reset sound checkboxes const soundCheckbox = document.getElementById('sound-alerts'); const repeatCheckbox = document.getElementById('repeat-alerts'); if (soundCheckbox) { soundCheckbox.checked = true; } if (repeatCheckbox) { repeatCheckbox.checked = true; } } alert('Current server data has been reset successfully!'); } }; window.resetAllData = () => { if (confirm('⚠️ DANGER: Reset ALL data for ALL SERVERS?\n\nThis will permanently delete:\n• All server alerts and settings\n• All activity logs\n• All saved players\n• All player databases\n• All population history\n• UI preferences\n\nThis action cannot be undone!')) { // Get all localStorage keys that start with 'bms_' const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith('bms_')) { keysToRemove.push(key); } } // Remove all BattleMetrics Monitor data keysToRemove.forEach(key => localStorage.removeItem(key)); // Reset current serverMonitor if it exists if (serverMonitor) { serverMonitor.alerts = {}; serverMonitor.activityLog = []; serverMonitor.settings = {}; serverMonitor.savedPlayers = {}; serverMonitor.recentAlerts = {}; serverMonitor.playerDatabase = {}; serverMonitor.populationHistory = []; serverMonitor.lastPlayerState = new Map(); serverMonitor.soundEnabled = true; // Update displays serverMonitor.updatePlayerDisplay(); serverMonitor.updateActivityDisplay(); serverMonitor.updateAlertDisplay(); serverMonitor.updateSavedPlayersDisplay(); serverMonitor.updateRecentAlertsDisplay(); serverMonitor.updateDatabaseDisplay(); serverMonitor.updatePopulationDisplay(); serverMonitor.stopAlertReminders(); } alert('All data for all servers has been permanently deleted!'); // Reload page to ensure clean state setTimeout(() => { location.reload(); }, 1000); } }; // ── Export helpers ───────────────────────────────────────────────────────── // Load and close any still-open session before exporting so the export // always reflects the full current session duration. const getSessionsForExport = (serverID) => { const key = `bms_script_sessions_${serverID}`; try { const sessions = JSON.parse(localStorage.getItem(key) || '[]'); const now = Date.now(); // Patch any still-open session with an up-to-date end time for the export // (doesn't actually close it — the live session stays open) return sessions.map(s => { if (s.end === null) { return { ...s, end: now, endISO: new Date(now).toISOString(), durationSeconds: Math.round((now - s.start) / 1000), note: 'session_still_active' }; } return s; }); } catch (e) { return []; } }; // Build the coverage gaps list from sessions (periods the script was closed) const buildGapsFromSessions = (sessions) => { if (!sessions || sessions.length < 2) return []; const sorted = [...sessions].sort((a, b) => a.start - b.start); const gaps = []; for (let i = 1; i < sorted.length; i++) { const gapStart = sorted[i - 1].end; const gapEnd = sorted[i].start; if (gapStart && gapEnd && gapEnd > gapStart) { const durationSeconds = Math.round((gapEnd - gapStart) / 1000); gaps.push({ gapStart, gapStartISO: sorted[i - 1].endISO, gapEnd, gapEndISO: sorted[i].startISO, durationSeconds, note: 'script_was_closed_during_this_period' }); } } return gaps; }; window.exportCurrentServer = () => { if (!serverMonitor) { alert('No server monitor data available to export.'); return; } const sessions = getSessionsForExport(currentServerID); const exportData = { _format: 'bms_server_export_v2', exportDate: new Date().toISOString(), serverID: currentServerID, serverName: currentServerName, analysis_hints: { activity_log: 'Each entry has timestamp (ms epoch), utcISO, dayOfWeek (0=Sun), hourUTC, hourLocal, action (joined/left/name_changed), playerName, playerId', script_sessions: 'Periods the script was actively running. Gaps between sessions = data was NOT being collected.', coverage_gaps: 'Explicit list of time ranges when the script was closed. Player activity during gaps was NOT logged.', player_database: 'All players ever seen. currentName = latest known name. previousNames = name history.' }, script_sessions: sessions, coverage_gaps: buildGapsFromSessions(sessions), activity_log: serverMonitor.activityLog, player_database: serverMonitor.playerDatabase, population_history: serverMonitor.populationHistory, alerts: serverMonitor.alerts, saved_players: serverMonitor.savedPlayers, recent_alerts: serverMonitor.recentAlerts, settings: serverMonitor.settings }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_server_${currentServerID}_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert('Server data exported successfully!'); }; window.exportAllServers = () => { // Collect all server IDs from localStorage keys const serverIDs = new Set(); for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith('bms_')) continue; const m = key.match(/bms_(?:player_alerts|activity_log|player_database|population_history)_(\d+)/); if (m) serverIDs.add(m[1]); } if (serverIDs.size === 0) { alert('No BattleMetrics Monitor data found to export.'); return; } const loadKey = (key, defaultVal) => { try { return JSON.parse(localStorage.getItem(key) || JSON.stringify(defaultVal)); } catch (e) { return defaultVal; } }; const servers = []; for (const sid of serverIDs) { const sessions = getSessionsForExport(sid); const actLog = loadKey(`bms_activity_log_${sid}`, []); const playerDB = loadKey(`bms_player_database_${sid}`, {}); const popHist = loadKey(`bms_population_history_${sid}`, []); const alerts = loadKey(`bms_player_alerts_${sid}`, {}); const saved = loadKey(`bms_saved_players_${sid}`, {}); // Try to resolve server name from activity log or player DB let sName = ''; if (sid === currentServerID) sName = currentServerName; if (!sName && actLog.length) sName = actLog[actLog.length - 1].serverName || ''; if (!sName && sessions.length) sName = sessions[sessions.length - 1].serverName || ''; if (!sName) sName = `Server ${sid}`; servers.push({ serverID: sid, serverName: sName, script_sessions: sessions, coverage_gaps: buildGapsFromSessions(sessions), activity_log: actLog, player_database: playerDB, population_history: popHist, alerts, saved_players: saved }); } const exportData = { _format: 'bms_all_servers_export_v2', exportDate: new Date().toISOString(), totalServers: servers.length, analysis_hints: { servers: 'Array of server objects. Each has activity_log, player_database, script_sessions, coverage_gaps.', activity_log: 'Chronological events: joined/left/name_changed. Fields: timestamp(ms), utcISO, dayOfWeek(0=Sun..6=Sat), hourUTC, hourLocal, playerName, playerId, action.', script_sessions: 'Time windows when the script was running. start/end are ms epoch. durationSeconds = how long that session lasted.', coverage_gaps: 'Time ranges between sessions when no data was collected (script was closed). Use these to exclude gaps from pattern analysis.', population_history: 'Array of {timestamp, count} snapshots of online player count over time.', player_database: 'Keyed by playerId. Fields: currentName, originalName, previousNames[], firstSeen(ms), lastSeen(ms), nameChanged, manuallyAdded.', usage_tips: [ 'To find when a player is usually online: filter activity_log by playerId, extract hourLocal from "joined" actions, group by hour.', 'To find peak server hours: group activity_log joined events by hourLocal and count.', 'Ignore events inside coverage_gaps when computing absence patterns — the data is simply missing, not that they were offline.', 'dayOfWeek 0=Sunday, 1=Monday... 6=Saturday.' ] }, servers }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_all_servers_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert(`Exported data from ${servers.length} server(s).`); }; window.exportActivityLog = () => { if (!serverMonitor) { alert('No server monitor data available.'); return; } const sessions = getSessionsForExport(currentServerID); const exportData = { _format: 'bms_activity_log_v2', serverID: currentServerID, serverName: currentServerName, exportDate: new Date().toISOString(), analysis_hints: { activity_log: 'Chronological events. Fields: timestamp(ms epoch), utcISO, dayOfWeek(0=Sun..6=Sat), hourUTC, hourLocal, action(joined/left/name_changed), playerName, playerId.', script_sessions: 'When the script was running. Use coverage_gaps to know when data was NOT being collected.', coverage_gaps: 'Periods the script was closed — no player events were captured during these times.' }, script_sessions: sessions, coverage_gaps: buildGapsFromSessions(sessions), activity_log: serverMonitor.activityLog }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_activity_log_${currentServerID}_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert(`Activity log exported (${serverMonitor.activityLog.length} entries, ${sessions.length} sessions).`); }; window.exportPlayerDatabase = () => { if (!serverMonitor) { alert('No server monitor data available.'); return; } const db = serverMonitor.playerDatabase || {}; const count = Object.keys(db).length; if (!count) { alert('Player database is empty.'); return; } const exportData = { serverID: currentServerID, serverName: currentServerName, exportDate: new Date().toISOString(), playerDatabase: db }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_player_database_${currentServerID}_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert(`Player database exported (${count} players).`); }; window.exportSavedPlayers = () => { if (!serverMonitor) { alert('No server monitor data available.'); return; } const saved = serverMonitor.savedPlayers || {}; const count = Object.keys(saved).length; if (!count) { alert('No saved players found.'); return; } const exportData = { serverID: currentServerID, serverName: currentServerName, exportDate: new Date().toISOString(), savedPlayers: saved }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_saved_players_${currentServerID}_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert(`Saved players exported (${count} players).`); }; window.exportAlerts = () => { if (!serverMonitor) { alert('No server monitor data available.'); return; } const alerts = serverMonitor.alerts || {}; const count = Object.keys(alerts).length; if (!count) { alert('No alert players found.'); return; } const exportData = { serverID: currentServerID, serverName: currentServerName, exportDate: new Date().toISOString(), alerts: alerts }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bms_alert_players_${currentServerID}_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert(`Alert players exported (${count} players).`); }; window.importPlayerDatabase = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.style.display = 'none'; document.body.appendChild(input); input.addEventListener('change', () => { const file = input.files && input.files[0]; document.body.removeChild(input); if (!file) return; const reader = new FileReader(); reader.onload = (e) => { let parsed; try { parsed = JSON.parse(e.target.result); } catch (err) { alert('Failed to parse JSON file. Make sure it is a valid player database export.'); return; } // Accept either a raw {id: entry} map or an export envelope with a playerDatabase key const incoming = (parsed && typeof parsed.playerDatabase === 'object' && parsed.playerDatabase !== null) ? parsed.playerDatabase : (parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null); if (!incoming) { alert('Unrecognised format. Expected a player database export file.'); return; } // Validate that it looks like a player DB (values should have id/currentName) const entries = Object.entries(incoming); if (entries.length === 0) { alert('Imported file contains no player entries.'); return; } const sample = entries[0][1]; if (!sample || (typeof sample.currentName === 'undefined' && typeof sample.originalName === 'undefined')) { alert('Unrecognised format — entries do not look like player records.'); return; } if (!serverMonitor) { alert('No active server monitor.'); return; } const db = serverMonitor.playerDatabase; let added = 0, merged = 0; for (const [id, incoming_player] of entries) { const existing = db[id]; if (!existing) { // Brand new player — import as-is (sanitise fields we rely on) db[id] = { id: id, currentName: incoming_player.currentName || incoming_player.originalName || id, originalName: incoming_player.originalName || incoming_player.currentName || id, firstSeen: incoming_player.firstSeen || Date.now(), lastSeen: incoming_player.lastSeen || Date.now(), nameChanged: !!(incoming_player.nameChanged || (incoming_player.previousNames && incoming_player.previousNames.length > 0)), previousNames: Array.isArray(incoming_player.previousNames) ? [...incoming_player.previousNames] : [] }; added++; } else { // Merge into existing record // 1. Merge previousNames — collect all unique names (case+whitespace insensitive) const normalise = n => (n || '').trim().toLowerCase(); const nameStr = e => (typeof e === 'string' ? e : (e && e.name) || ''); const allNames = new Map(); // normalised -> entry (string or object) // seed with existing names, preserving original entries for (const e of (existing.previousNames || [])) allNames.set(normalise(nameStr(e)), e); // add incoming previousNames for (const e of (incoming_player.previousNames || [])) { if (!allNames.has(normalise(nameStr(e)))) allNames.set(normalise(nameStr(e)), e); } // make sure neither currentName ends up in previousNames const currentNorm = normalise(existing.currentName); allNames.delete(currentNorm); // 2. If incoming has a more-recent currentName, push the old current into history const incomingLastSeen = incoming_player.lastSeen || 0; if (incomingLastSeen > (existing.lastSeen || 0)) { const incomingCurrent = incoming_player.currentName || ''; if (incomingCurrent && normalise(incomingCurrent) !== normalise(existing.currentName)) { // old current name goes to history — use object format if we know when if (!allNames.has(currentNorm)) { allNames.set(currentNorm, { name: existing.currentName, changedAt: existing.lastSeen || null, changedAtISO: existing.lastSeen ? new Date(existing.lastSeen).toISOString() : null }); } existing.currentName = incomingCurrent; existing.nameChanged = true; } existing.lastSeen = incomingLastSeen; } // 3. Keep earliest firstSeen if (incoming_player.firstSeen && incoming_player.firstSeen < (existing.firstSeen || Infinity)) { existing.firstSeen = incoming_player.firstSeen; // originalName should reflect the earliest known name existing.originalName = incoming_player.originalName || incoming_player.currentName || existing.originalName; } // 4. Remove current name from history again in case merge added it allNames.delete(normalise(existing.currentName)); existing.previousNames = [...allNames.values()]; existing.nameChanged = existing.nameChanged || existing.previousNames.length > 0; merged++; } } serverMonitor.savePlayerDatabase(); serverMonitor.updateDatabaseDisplay(); alert(`Import complete!\n\nNew players added: ${added}\nExisting players merged: ${merged}\nTotal in database: ${Object.keys(db).length}`); }; reader.readAsText(file); }); input.click(); }; window.savePlayer = (playerName, playerId) => { if (serverMonitor) { serverMonitor.savePlayer(playerName, playerId); serverMonitor.updateSavedPlayersDisplay(); // Clear search cache to ensure fresh results if (typeof cachedSearchResults !== 'undefined') { cachedSearchResults.clear(); } // Refresh search results to update button states const searchInput = document.getElementById('player-search'); if (searchInput && searchInput.value.length >= 2) { handlePlayerSearch(searchInput.value); } } }; window.removeSavedPlayer = (playerId) => { if (serverMonitor) { serverMonitor.removeSavedPlayer(playerId); serverMonitor.updateSavedPlayersDisplay(); // Clear search cache to ensure fresh results if (typeof cachedSearchResults !== 'undefined') { cachedSearchResults.clear(); } // Refresh search results to update button states const searchInput = document.getElementById('player-search'); if (searchInput && searchInput.value.length >= 2) { handlePlayerSearch(searchInput.value); } } }; // Shared pending-confirm map for double-click confirmations const _pendingConfirm = new Map(); window.confirmDeleteSavedPlayer = (playerId, btn) => { const key = 'del_' + playerId; if (_pendingConfirm.has(key)) { clearTimeout(_pendingConfirm.get(key)); _pendingConfirm.delete(key); window.removeSavedPlayer(playerId); } else { const origText = btn.textContent; const origBg = btn.style.background; btn.textContent = 'Sure?'; btn.style.background = '#a71d2a'; const t = setTimeout(() => { _pendingConfirm.delete(key); if (btn) { btn.textContent = origText; btn.style.background = origBg; } }, 2000); _pendingConfirm.set(key, t); } }; window.confirmRemoveAlert = (displayName, playerId, btn) => { const key = 'alert_' + playerId; if (_pendingConfirm.has(key)) { clearTimeout(_pendingConfirm.get(key)); _pendingConfirm.delete(key); window.togglePlayerAlert(displayName, playerId); } else { const origText = btn.textContent; const origBg = btn.style.background; btn.textContent = 'Sure?'; btn.style.background = '#a71d2a'; const t = setTimeout(() => { _pendingConfirm.delete(key); if (btn) { btn.textContent = origText; btn.style.background = origBg; } }, 2000); _pendingConfirm.set(key, t); } }; // Show player history modal (tabs: Session History, Name History, Notes) window.showNameHistory = (playerId) => { if (!serverMonitor) return; const dbPlayer = serverMonitor.playerDatabase[playerId]; if (!dbPlayer) { alert('No player history found for ID: ' + playerId); return; } const existing = document.getElementById('bms-name-history-modal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'bms-name-history-modal'; modal.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:20000;display:flex;align-items:center;justify-content:center;'; modal.addEventListener('mousedown', (e) => { if (e.target === modal) modal.remove(); }); const box = document.createElement('div'); box.style.cssText = 'background:#2c3e50;color:white;padding:16px;border-radius:8px;width:500px;max-width:95vw;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 30px rgba(0,0,0,0.6);'; // ── Header ────────────────────────────────────────────────── const header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-shrink:0;'; const isOnline = serverMonitor.currentPlayers.has(playerId); const onlineDot = isOnline ? '' : ''; header.innerHTML = `
${onlineDot}${dbPlayer.currentName.replace(/`; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'background:transparent;color:#aaa;border:none;font-size:16px;cursor:pointer;padding:0 4px;'; closeBtn.onclick = () => modal.remove(); header.appendChild(closeBtn); box.appendChild(header); // ── Sub-info ───────────────────────────────────────────────── const subInfo = document.createElement('div'); subInfo.style.cssText = 'font-size:11px;color:#adb5bd;margin-bottom:10px;flex-shrink:0;'; subInfo.innerHTML = `ID: ${dbPlayer.id}  |  First seen: ${dbPlayer.firstSeen ? new Date(dbPlayer.firstSeen).toLocaleString() : 'N/A'}  |  Last seen: ${dbPlayer.lastSeen ? new Date(dbPlayer.lastSeen).toLocaleString() : 'N/A'}  |  Detections: ${dbPlayer.seenCount || 1}×`; box.appendChild(subInfo); // ── Tabs ───────────────────────────────────────────────────── const TABS = ['Session History', 'Name History', 'Notes', 'Tags', 'Possible Alts']; let activeTab = 'Session History'; const tabBar = document.createElement('div'); tabBar.style.cssText = 'display:flex;gap:4px;margin-bottom:10px;flex-shrink:0;'; const tabContent = document.createElement('div'); tabContent.style.cssText = 'overflow-y:auto;flex:1;font-size:12px;'; const renderTab = (tab) => { activeTab = tab; // Update tab button styles tabBar.querySelectorAll('button').forEach(btn => { btn.style.background = btn.dataset.tab === tab ? '#6f42c1' : 'rgba(255,255,255,0.07)'; btn.style.color = btn.dataset.tab === tab ? 'white' : '#adb5bd'; }); tabContent.innerHTML = ''; if (tab === 'Session History') { // Build session pairs from the activity log for this player const playerLog = serverMonitor.activityLog.filter(e => e.playerId === playerId); if (playerLog.length === 0) { tabContent.innerHTML = '
No activity recorded yet.
'; return; } // Pair joins with the next leave to show sessions const sessions = []; let pendingJoin = null; for (const entry of playerLog) { if (entry.action === 'joined') { pendingJoin = entry; } else if (entry.action === 'left') { const joinTime = pendingJoin ? pendingJoin.timestamp : null; const duration = joinTime ? Math.round((entry.timestamp - joinTime) / 60000) : null; sessions.push({ join: pendingJoin, leave: entry, duration }); pendingJoin = null; } else if (entry.action === 'name_changed') { sessions.push({ nameChange: entry }); } } if (pendingJoin) { sessions.push({ join: pendingJoin, leave: null, duration: null, stillOnline: isOnline }); } // Most recent first sessions.reverse(); let html = `
${sessions.length} session(s) / events recorded
`; for (const s of sessions) { if (s.nameChange) { html += `
✏ Name changed ${s.nameChange.time || new Date(s.nameChange.timestamp).toLocaleString()}
${(s.nameChange.oldName||'?').replace(/ → ${s.nameChange.playerName.replace(/
`; continue; } const joinStr = s.join ? (s.join.time || new Date(s.join.timestamp).toLocaleString()) : '—'; const leaveStr = s.leave ? (s.leave.time || new Date(s.leave.timestamp).toLocaleString()) : (s.stillOnline ? 'Still online' : '—'); const durStr = s.duration !== null ? (s.duration < 60 ? `${s.duration}m` : `${(s.duration/60).toFixed(1)}h`) : '?'; const nameAtJoin = s.join ? s.join.playerName : (s.leave ? s.leave.playerName : ''); html += `
▶ ${joinStr} ${s.duration !== null ? durStr : ''} ◀ ${typeof leaveStr === 'string' ? leaveStr : ''}
${nameAtJoin && nameAtJoin !== dbPlayer.currentName ? `
Playing as: ${nameAtJoin.replace(/` : ''}
`; } tabContent.innerHTML = html; } else if (tab === 'Name History') { const _ns = e => (typeof e === 'string' ? e : (e && e.name) || ''); const _ts = e => (e && e.changedAt ? new Date(e.changedAt).toLocaleString() : null); const prev = dbPlayer.previousNames && dbPlayer.previousNames.length ? dbPlayer.previousNames.slice().reverse() : []; let html = `
Current: ${dbPlayer.currentName.replace(/
Original: ${(dbPlayer.originalName || dbPlayer.currentName).replace(/`; if (prev.length === 0) { html += '
No previous names recorded.
'; } else { html += `
Previous names (${prev.length}):
`; for (const entry of prev) { const nameText = _ns(entry).replace(/ ${nameText} ${dateText}
`; } } // Reset button inline at bottom html += `
`; tabContent.innerHTML = html; } else if (tab === 'Possible Alts') { const alts = detectAlts(playerId); if (alts.length === 0) { tabContent.innerHTML = `
No alt handoffs detected.
Looking for the relay pattern: this player leaves → candidate joins (or vice versa), ≥${MIN_HANDOFFS} times, at ≥${MIN_HANDOFF_RATE*100}% of sessions. They must not be online at the same time.
`; } else { let html = `
Relay pattern detected: one account logs off and the other joins within 5 min. They are rarely online simultaneously.
`; for (const alt of alts) { const altOnline = serverMonitor.currentPlayers.has(alt.playerId); const dot = altOnline ? '' : ''; const safeAltId = String(alt.playerId); const safeAltName = alt.player.currentName.replace(/'/g,"'").replace(/ ✓ never overlap' : ` · overlap: ${alt.overlap}x`; html += `
${dot} ${alt.player.currentName.replace(/
ID: ${safeAltId}  |  → ${alt.atob}   ← ${alt.btoa}  handoffs  |  rate: ${alt.coverage}%${overlapBadge}
`; } tabContent.innerHTML = html; } } else if (tab === 'Notes') { const existing = serverMonitor.playerNotes && serverMonitor.playerNotes[playerId]; const currentText = existing ? existing.text : ''; const updatedAt = existing && existing.updatedAt ? new Date(existing.updatedAt).toLocaleString() : null; const wrapper = document.createElement('div'); if (updatedAt) { const ts = document.createElement('div'); ts.className = 'bms-note-ts'; ts.style.cssText = 'font-size:10px;color:#6c757d;margin-bottom:6px;'; ts.textContent = `Last updated: ${updatedAt}`; wrapper.appendChild(ts); } const textarea = document.createElement('textarea'); textarea.value = currentText; textarea.placeholder = 'Add your notes about this player here…'; textarea.style.cssText = 'width:100%;height:130px;resize:vertical;background:rgba(255,255,255,0.07);color:white;border:1px solid rgba(255,255,255,0.15);border-radius:4px;padding:8px;font-size:12px;box-sizing:border-box;'; wrapper.appendChild(textarea); const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px;'; const saveNoteBtn = document.createElement('button'); saveNoteBtn.textContent = 'Save Note'; saveNoteBtn.style.cssText = 'background:#28a745;color:white;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:11px;'; saveNoteBtn.onclick = () => { serverMonitor.setPlayerNote(playerId, textarea.value); serverMonitor.updateDatabaseDisplay(); saveNoteBtn.textContent = 'Saved ✓'; saveNoteBtn.style.background = '#17a2b8'; setTimeout(() => { saveNoteBtn.textContent = 'Save Note'; saveNoteBtn.style.background = '#28a745'; }, 1500); // Update or create the timestamp div let tsDiv = wrapper.querySelector('.bms-note-ts'); if (!tsDiv) { tsDiv = document.createElement('div'); tsDiv.className = 'bms-note-ts'; tsDiv.style.cssText = 'font-size:10px;color:#6c757d;margin-bottom:6px;'; wrapper.insertBefore(tsDiv, textarea); } tsDiv.textContent = `Last updated: ${new Date().toLocaleString()}`; }; const clearNoteBtn = document.createElement('button'); clearNoteBtn.textContent = 'Clear'; clearNoteBtn.style.cssText = 'background:#dc3545;color:white;border:none;padding:5px 10px;border-radius:4px;cursor:pointer;font-size:11px;'; clearNoteBtn.onclick = () => { textarea.value = ''; serverMonitor.setPlayerNote(playerId, ''); serverMonitor.updateDatabaseDisplay(); }; btnRow.appendChild(clearNoteBtn); btnRow.appendChild(saveNoteBtn); wrapper.appendChild(btnRow); tabContent.appendChild(wrapper); } else if (tab === 'Tags') { const currentTags = serverMonitor.getPlayerTags(playerId); const wrapper = document.createElement('div'); // Current tags display const tagsDisplay = document.createElement('div'); tagsDisplay.style.cssText = 'display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px;min-height:24px;'; const refreshTagsDisplay = () => { tagsDisplay.innerHTML = ''; const cur = serverMonitor.getPlayerTags(playerId); if (cur.length === 0) { tagsDisplay.innerHTML = 'No tags yet'; } else { cur.forEach(tag => { const pill = document.createElement('span'); pill.style.cssText = `display:inline-flex;align-items:center;gap:3px;background:${getTagColor(tag)};color:white;font-size:10px;font-weight:600;padding:2px 7px;border-radius:12px;cursor:default;`; pill.innerHTML = `${tag.replace(/✕`; pill.querySelector('span').onclick = (e) => { const t = e.currentTarget.dataset.tag; serverMonitor.removePlayerTag(playerId, t); serverMonitor.updateDatabaseDisplay(); refreshTagsDisplay(); }; tagsDisplay.appendChild(pill); }); } }; refreshTagsDisplay(); wrapper.appendChild(tagsDisplay); // Preset buttons const presetsLabel = document.createElement('div'); presetsLabel.style.cssText = 'font-size:10px;color:#adb5bd;margin-bottom:5px;'; presetsLabel.textContent = 'Quick add:'; wrapper.appendChild(presetsLabel); const presetsRow = document.createElement('div'); presetsRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px;'; TAG_PRESETS.forEach(preset => { const btn = document.createElement('button'); btn.textContent = preset; btn.style.cssText = `background:${getTagColor(preset)};color:white;border:none;padding:3px 8px;border-radius:10px;cursor:pointer;font-size:10px;`; btn.onclick = () => { serverMonitor.addPlayerTag(playerId, preset); serverMonitor.updateDatabaseDisplay(); refreshTagsDisplay(); }; presetsRow.appendChild(btn); }); wrapper.appendChild(presetsRow); // Custom tag input const customLabel = document.createElement('div'); customLabel.style.cssText = 'font-size:10px;color:#adb5bd;margin-bottom:4px;'; customLabel.textContent = 'Custom tag:'; wrapper.appendChild(customLabel); const inputRow = document.createElement('div'); inputRow.style.cssText = 'display:flex;gap:6px;'; const customInput = document.createElement('input'); customInput.type = 'text'; customInput.placeholder = 'Enter custom tag…'; customInput.maxLength = 20; customInput.style.cssText = 'flex:1;padding:4px 8px;border-radius:4px;background:rgba(255,255,255,0.07);color:white;border:1px solid rgba(255,255,255,0.15);font-size:11px;'; const addCustomBtn = document.createElement('button'); addCustomBtn.textContent = 'Add'; addCustomBtn.style.cssText = 'background:#17a2b8;color:white;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;'; addCustomBtn.onclick = () => { const val = customInput.value.trim(); if (!val) return; serverMonitor.addPlayerTag(playerId, val); serverMonitor.updateDatabaseDisplay(); customInput.value = ''; refreshTagsDisplay(); }; customInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addCustomBtn.click(); }); inputRow.appendChild(customInput); inputRow.appendChild(addCustomBtn); wrapper.appendChild(inputRow); tabContent.appendChild(wrapper); } }; TABS.forEach(tab => { const btn = document.createElement('button'); btn.textContent = tab; btn.dataset.tab = tab; const isAltTab = tab === 'Possible Alts'; btn.style.cssText = `background:rgba(255,255,255,0.07);color:${isAltTab ? '#ffc107' : '#adb5bd'};border:none;padding:5px 10px;border-radius:4px;cursor:pointer;font-size:11px;`; btn.onclick = () => renderTab(tab); tabBar.appendChild(btn); }); box.appendChild(tabBar); box.appendChild(tabContent); modal.appendChild(box); document.body.appendChild(modal); renderTab('Session History'); }; window.resetNameHistory = (playerId) => { if (!serverMonitor) return; const dbPlayer = serverMonitor.playerDatabase[playerId]; if (!dbPlayer) return; dbPlayer.previousNames = []; dbPlayer.nameChanged = false; serverMonitor.savePlayerDatabase(); serverMonitor.updateDatabaseDisplay(); serverMonitor.updateSavedPlayersDisplay(); serverMonitor.updateAlertDisplay(); alert('Name history reset for ' + (dbPlayer.currentName || playerId)); }; // Quick Notes shortcut — opens History modal directly on the Notes tab window.showPlayerNote = (playerId) => { window.showNameHistory(playerId); // After modal renders, switch to Notes tab setTimeout(() => { const tabBar = document.querySelector('#bms-name-history-modal [data-tab="Notes"]'); if (tabBar) tabBar.click(); }, 50); }; window.showPlayerTagsEditor = (playerId) => { window.showNameHistory(playerId); // After modal renders, switch to Tags tab setTimeout(() => { const tabBtn = document.querySelector('#bms-name-history-modal [data-tab="Tags"]'); if (tabBtn) tabBtn.click(); }, 50); }; window.showPlayerAlts = (playerId) => { window.showNameHistory(playerId); // After modal renders, switch to Possible Alts tab setTimeout(() => { const tabBtn = document.querySelector('#bms-name-history-modal [data-tab="Possible Alts"]'); if (tabBtn) tabBtn.click(); }, 50); }; // ── Alts Detection ─────────────────────────────────────────────────────── // Detects the HANDOFF pattern used by alt accounts: // A leaves ──[0 – SWITCH_MAX min]──► B joins (A→B handoff) // B leaves ──[0 – SWITCH_MAX min]──► A joins (B→A handoff) // // True alts swap accounts — they are almost never online simultaneously. // This is the opposite of "session mirroring" (players who play together). // // Thresholds: switch window is 5 min (real alt swaps are fast, not slow casual switches). // Requires ≥ MIN_HANDOFFS total handoffs AND a ≥ MIN_RATE fraction of the // target's leave events, and the candidate must NOT already be online when the // target leaves (otherwise they're just co-players, not alts). const SWITCH_MAX_MS = 5 * 60 * 1000; // 5 min — real account switches happen quickly const MIN_HANDOFFS = 5; // ≥5 total (A→B + B→A) — strong pattern required const MIN_HANDOFF_RATE = 0.40; // ≥40% of target's leave events const MIN_TARGET_LEAVES = 8; // target must have ≥8 leave events for reliable rate // Build complete sessions (join→leave pairs) for a player. const buildSessions = (log, pid) => { const sessions = []; let pending = null; for (const e of log) { if (e.playerId !== pid) continue; if (e.action === 'joined') { pending = e; } else if (e.action === 'left' && pending) { sessions.push({ join: pending.timestamp, leave: e.timestamp }); pending = null; } } if (pending) sessions.push({ join: pending.timestamp, leave: null }); // still online return sessions; }; // Returns true if a player (given their sessions) was online at timestamp t. const wasOnlineAt = (sessions, t) => { for (const s of sessions) { const leaveT = s.leave ?? Date.now(); if (s.join <= t && t <= leaveT) return true; } return false; }; // Count how many of targetSessions temporally overlap with candSessions. // Used as an informational "how often were they on together" signal. const countOverlap = (targetSessions, candSessions) => { let n = 0; for (const tS of targetSessions) { if (!tS.leave) continue; for (const cS of candSessions) { const cLeave = cS.leave ?? Date.now(); if (Math.max(tS.join, cS.join) < Math.min(tS.leave, cLeave)) { n++; break; } } } return n; }; const detectAlts = (targetId) => { if (!serverMonitor) return []; const log = serverMonitor.activityLog; const targetLeaveEvts = log.filter(e => e.playerId === targetId && e.action === 'left'); const targetJoinEvts = log.filter(e => e.playerId === targetId && e.action === 'joined'); if (targetLeaveEvts.length === 0) return []; // Require enough leave events for rate-based detection to be statistically meaningful if (targetLeaveEvts.length < MIN_TARGET_LEAVES) return []; const targetSessions = buildSessions(log, targetId); // Collect all other player IDs seen in the log const otherIds = new Set(); for (const e of log) { if (e.playerId && e.playerId !== targetId) otherIds.add(e.playerId); } const results = []; for (const pid of otherIds) { const candJoinEvts = log.filter(e => e.playerId === pid && e.action === 'joined'); const candLeaveEvts = log.filter(e => e.playerId === pid && e.action === 'left'); const candSessions = buildSessions(log, pid); // ── A→B handoffs: target leaves (and candidate is NOT already online), // then candidate joins within the switch window. let atob = 0; const usedCandJoins = new Set(); for (const tLeave of targetLeaveEvts) { // Skip: candidate was already online when target left → they're co-players if (wasOnlineAt(candSessions, tLeave.timestamp)) continue; let bestIdx = -1, bestDiff = Infinity; for (let i = 0; i < candJoinEvts.length; i++) { if (usedCandJoins.has(i)) continue; const diff = candJoinEvts[i].timestamp - tLeave.timestamp; if (diff >= 0 && diff <= SWITCH_MAX_MS && diff < bestDiff) { bestDiff = diff; bestIdx = i; } } if (bestIdx !== -1) { atob++; usedCandJoins.add(bestIdx); } } // ── B→A handoffs: candidate leaves (and target is NOT already online), // then target joins within the switch window. let btoa = 0; const usedTargetJoins = new Set(); for (const cLeave of candLeaveEvts) { if (wasOnlineAt(targetSessions, cLeave.timestamp)) continue; let bestIdx = -1, bestDiff = Infinity; for (let i = 0; i < targetJoinEvts.length; i++) { if (usedTargetJoins.has(i)) continue; const diff = targetJoinEvts[i].timestamp - cLeave.timestamp; if (diff >= 0 && diff <= SWITCH_MAX_MS && diff < bestDiff) { bestDiff = diff; bestIdx = i; } } if (bestIdx !== -1) { btoa++; usedTargetJoins.add(bestIdx); } } const total = atob + btoa; if (total < MIN_HANDOFFS) continue; // Handoff rate: what fraction of the target's leave events triggered a handoff const rate = total / Math.max(targetLeaveEvts.length, 1); if (rate < MIN_HANDOFF_RATE) continue; // Overlap: how many of target's complete sessions had candidate also online const completeTgt = targetSessions.filter(s => s.leave !== null); const overlap = countOverlap(completeTgt, candSessions); results.push({ playerId: pid, matches: total, atob, btoa, overlap, coverage: Math.round(rate * 100), player: serverMonitor.playerDatabase[pid] || { currentName: `Player ${pid}`, id: pid } }); } return results.sort((a, b) => b.matches - a.matches); }; window.showAltsModal = (playerId) => { if (!serverMonitor) return; const dbPlayer = serverMonitor.playerDatabase[playerId]; const displayName = dbPlayer ? dbPlayer.currentName : `Player ${playerId}`; const existing = document.getElementById('bms-alts-modal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'bms-alts-modal'; modal.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.55);z-index:20001;display:flex;align-items:center;justify-content:center;'; modal.addEventListener('mousedown', (e) => { if (e.target === modal) modal.remove(); }); const box = document.createElement('div'); box.style.cssText = 'background:#2c3e50;color:white;padding:16px;border-radius:8px;width:460px;max-width:95vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 30px rgba(0,0,0,0.6);'; const header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-shrink:0;'; header.innerHTML = `
🔍 Possible Alts — ${displayName.replace(/`; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'background:transparent;color:#aaa;border:none;font-size:16px;cursor:pointer;'; closeBtn.onclick = () => modal.remove(); header.appendChild(closeBtn); box.appendChild(header); const desc = document.createElement('div'); desc.style.cssText = 'font-size:10px;color:#adb5bd;margin-bottom:10px;flex-shrink:0;'; desc.textContent = `Players who joined within 5 min of ${displayName} leaving (or vice-versa) ≥5 times — potential alt accounts.`; box.appendChild(desc); const content = document.createElement('div'); content.style.cssText = 'overflow-y:auto;flex:1;font-size:12px;'; const alts = detectAlts(playerId); if (alts.length === 0) { content.innerHTML = '
No suspicious timing matches found.
'; } else { let html = ''; for (const alt of alts) { const isOnline = serverMonitor.currentPlayers.has(alt.playerId); const dot = isOnline ? '' : ''; const safeId = String(alt.playerId); const safeName = alt.player.currentName.replace(/'/g,"'").replace(/
${dot} ${alt.player.currentName.replace(/
ID: ${safeId}  |  ${alt.matches} timing match${alt.matches > 1 ? 'es' : ''}
`; } content.innerHTML = html; } box.appendChild(content); modal.appendChild(box); document.body.appendChild(modal); }; // Filter functions // Shared helper: builds HTML for a single activity entry. // Used by both applyActivityFilters and renderActivitySearchResults so display is always consistent. // Cache for first-detection lookups — invalidated when activityLog grows let _fdCacheSize = -1; let _fdMap = new Map(); // playerId -> earliest timestamp in activityLog const getFirstDetectionMap = () => { if (!serverMonitor) return _fdMap; const log = serverMonitor.activityLog; if (log.length === _fdCacheSize) return _fdMap; _fdMap = new Map(); for (const e of log) { if (e.playerId && (!_fdMap.has(e.playerId) || e.timestamp < _fdMap.get(e.playerId))) { _fdMap.set(e.playerId, e.timestamp); } } _fdCacheSize = log.length; return _fdMap; }; // Returns the configured "new player" wear-off window in ms (default 6h) const getNewPlayerWindowMs = () => { return (serverMonitor && serverMonitor.settings && serverMonitor.settings.newPlayerWindowMs) ? serverMonitor.settings.newPlayerWindowMs : 6 * 60 * 60 * 1000; }; // True if this entry is the player's very first detection AND it happened within the wear-off window const checkIsNewPlayer = (entry) => { if (!entry.playerId) return false; const fdMap = getFirstDetectionMap(); const firstTs = fdMap.get(entry.playerId); if (firstTs === undefined || firstTs !== entry.timestamp) return false; // not first entry return (Date.now() - firstTs) < getNewPlayerWindowMs(); }; const buildActivityEntryHTML = (entry) => { const timeAgo = toRelativeTime(entry.timestamp); const hasAlert = serverMonitor && serverMonitor.alerts[entry.playerId]; const isSaved = serverMonitor && serverMonitor.savedPlayers[entry.playerId]; const dbPlayer = serverMonitor && serverMonitor.playerDatabase && serverMonitor.playerDatabase[entry.playerId]; const fdMap = getFirstDetectionMap(); const isNewPlayer = checkIsNewPlayer(entry); const entryTags = serverMonitor ? serverMonitor.getPlayerTags(entry.playerId) : []; // Escape single/double quotes so player names with apostrophes don't break onclick attributes const safeName = String(entry.playerName || '').replace(/'/g, ''').replace(/"/g, '"'); const safeId = String(entry.playerId || ''); let mainLine = ''; let actionColor = '#6c757d'; let actionLabel = ''; if (entry.action === 'joined') { mainLine = `▶ ${entry.playerName} joined the game`; actionColor = '#28a745'; actionLabel = 'Joined'; } else if (entry.action === 'left') { mainLine = `◀ ${entry.playerName} left the game`; actionColor = '#dc3545'; actionLabel = 'Left'; } else if (entry.action === 'name_changed') { const oldName = entry.oldName || '?'; // Show: ✏ PreviousName → NewName // Strikethrough on old, bold on new so the change is obvious at a glance mainLine = `✏ ${oldName} ${entry.playerName}`; actionColor = '#ffc107'; actionLabel = 'Name Changed'; } else if (entry.action === 'alt_detected') { const altName = entry.altPlayerName || `Player ${entry.altPlayerId}`; mainLine = `🔍 ${entry.playerName} may be an alt of ${altName}`; actionColor = '#ffc107'; actionLabel = `Alt Suspected · ${entry.altMatches} sessions · ${entry.altCoverage}%`; } else { mainLine = `${entry.playerName} ${entry.action}`; actionLabel = entry.action; } const timeStr = entry.time || new Date(entry.timestamp).toLocaleString(); // Metadata row: action type · relative time · exact time · ID const metaLine = `${actionLabel} · ${timeAgo} · ${timeStr} · ID: ${safeId}`; // Show History for any name_changed entry — player is always in the DB at this point const showHistory = entry.action === 'name_changed' && !!dbPlayer; return `
${mainLine} ${isNewPlayer ? '[NEW]' : ''} ${hasAlert ? '[ALERT]' : ''} ${isSaved ? '[SAVED]' : ''} ${entryTags.length > 0 ? renderTagBadges(entryTags) : ''}
${metaLine}
`; }; // Helper: convert time filter value to a cutoff timestamp (0 = no cutoff) const getActivityTimeCutoff = (timeFilter) => { const now = Date.now(); switch (timeFilter) { case '1h': return now - (1 * 60 * 60 * 1000); case '3h': return now - (3 * 60 * 60 * 1000); case '6h': return now - (6 * 60 * 60 * 1000); case '24h': return now - (24 * 60 * 60 * 1000); case '7d': return now - (7 * 24 * 60 * 60 * 1000); default: return 0; } }; // Unified activity filter — reads both action select and time select, renders result window.applyActivityFilters = () => { if (!serverMonitor) return; const activityDiv = document.getElementById('recent-activity-list'); if (!activityDiv) return; const actionFilter = (document.getElementById('activity-filter') || {}).value || 'all'; const timeFilter = (document.getElementById('activity-time-filter') || {}).value || 'all'; const timeCutoff = getActivityTimeCutoff(timeFilter); let filtered = serverMonitor.activityLog; // Apply action filter if (actionFilter === 'new_players') { // Show only each player's first-ever entry, and only if still within wear-off window const fdMap = getFirstDetectionMap(); const windowMs = getNewPlayerWindowMs(); filtered = filtered.filter(e => { if (!e.playerId) return false; const firstTs = fdMap.get(e.playerId); return firstTs === e.timestamp && (Date.now() - firstTs) < windowMs; }); } else if (actionFilter !== 'all') { filtered = filtered.filter(e => e.action === actionFilter); } // Apply time filter if (timeCutoff > 0) { filtered = filtered.filter(e => { const ts = typeof e.timestamp === 'number' ? e.timestamp : new Date(e.timestamp).getTime(); return ts >= timeCutoff; }); } // Sort most recent first — cap to 500 entries to keep the DOM lean const MAX_ACTIVITY_DISPLAY = 500; const sorted = filtered.slice().reverse(); const displayEntries = sorted.length > MAX_ACTIVITY_DISPLAY ? sorted.slice(0, MAX_ACTIVITY_DISPLAY) : sorted; // Update result count const countEl = document.getElementById('activity-result-count'); if (countEl) { const total = serverMonitor.activityLog.length; if (sorted.length === total && displayEntries.length === total) { countEl.textContent = `${total} total entries`; } else if (displayEntries.length < sorted.length) { countEl.textContent = `Showing ${displayEntries.length} of ${sorted.length} (filtered from ${total} total)`; } else { countEl.textContent = `Showing ${displayEntries.length} of ${total} entries`; } } if (displayEntries.length === 0) { activityDiv.innerHTML = '
No activity matches filter
'; return; } let activityHTML = ''; displayEntries.forEach(entry => { activityHTML += buildActivityEntryHTML(entry); }); activityDiv.innerHTML = activityHTML; }; // Backward-compat wrapper: callers passing 'joined'/'left'/etc. still work window.filterActivity = (actionType) => { const sel = document.getElementById('activity-filter'); if (sel) sel.value = actionType || 'all'; window.applyActivityFilters(); }; window.clearActivityFilter = () => { const filterSelect = document.getElementById('activity-filter'); const timeSelect = document.getElementById('activity-time-filter'); const searchInput = document.getElementById('activity-search'); const countEl = document.getElementById('activity-result-count'); if (filterSelect) filterSelect.value = 'all'; if (timeSelect) timeSelect.value = 'all'; if (countEl) countEl.textContent = ''; activeActivitySearch = ''; if (searchInput) searchInput.value = ''; if (serverMonitor) serverMonitor.updateActivityDisplay(); }; window.filterDatabase = (filterType) => { if (!serverMonitor) return; const databaseDiv = document.getElementById('player-database-list'); if (!databaseDiv) return; let filteredPlayers = Object.values(serverMonitor.playerDatabase); const threeHoursAgo = Date.now() - (3 * 60 * 60 * 1000); switch (filterType) { case 'online': filteredPlayers = filteredPlayers.filter(player => serverMonitor.currentPlayers.has(player.id)); break; case 'offline': filteredPlayers = filteredPlayers.filter(player => !serverMonitor.currentPlayers.has(player.id)); break; case 'name-changed': filteredPlayers = filteredPlayers.filter(player => player.nameChanged); break; case 'all': default: // Show all break; } // Sort by online status first, then by last seen filteredPlayers.sort((a, b) => { const aOnline = serverMonitor.currentPlayers.has(a.id); const bOnline = serverMonitor.currentPlayers.has(b.id); if (aOnline && !bOnline) return -1; if (!aOnline && bOnline) return 1; return b.lastSeen - a.lastSeen; }); if (filteredPlayers.length === 0) { databaseDiv.innerHTML = '
No players match filter
'; return; } serverMonitor.renderDatabasePlayers(filteredPlayers, databaseDiv); }; window.clearDatabaseFilter = () => { const filterSelect = document.getElementById('database-filter'); if (filterSelect) { filterSelect.value = 'all'; filterDatabase('all'); } }; window.manuallyAddPlayer = () => { const idInput = document.getElementById('manual-add-id'); const nicknameInput = document.getElementById('manual-add-nickname'); const alertCheckbox = document.getElementById('manual-add-alert'); if (!idInput) return; const rawId = idInput.value.trim(); if (!rawId) { idInput.focus(); idInput.style.outline = '1px solid #dc3545'; setTimeout(() => { idInput.style.outline = ''; }, 1500); return; } // Validate: only digits if (!/^\d+$/.test(rawId)) { idInput.style.outline = '1px solid #dc3545'; setTimeout(() => { idInput.style.outline = ''; }, 1500); return; } if (!serverMonitor) return; const nickname = nicknameInput ? nicknameInput.value.trim() : ''; const displayName = nickname || 'Unknown Player'; const wantAlert = alertCheckbox && alertCheckbox.checked; // Add to database — flag as manually added so real-name updates preserve the nickname in history const existing = serverMonitor.playerDatabase[rawId]; if (!existing) { serverMonitor.playerDatabase[rawId] = { id: rawId, currentName: displayName, originalName: displayName, firstSeen: Date.now(), lastSeen: Date.now(), nameChanged: false, previousNames: [], manuallyAdded: true, nickname: nickname || null }; serverMonitor.savePlayerDatabase(); } if (wantAlert) { if (!serverMonitor.alerts[rawId]) { serverMonitor.addAlert(displayName, rawId, 'both'); } } serverMonitor.updateDatabaseDisplay(); if (wantAlert) serverMonitor.updateAlertDisplay(); // Clear inputs idInput.value = ''; if (nicknameInput) nicknameInput.value = ''; if (alertCheckbox) alertCheckbox.checked = false; }; window.testSound = () => { if (serverMonitor) { console.log('Testing alert sound...'); serverMonitor.playAlertSound(); } }; // Debug Console Functions window.toggleDebugMode = (enabled) => { console.log('[Debug Console] toggleDebugMode called with:', enabled); debugConsole.saveDebugSetting(enabled); const debugSection = document.getElementById('debug-console-section'); console.log('[Debug Console] debugSection found:', !!debugSection); if (debugSection) { debugSection.style.display = enabled ? 'block' : 'none'; console.log('[Debug Console] Section display set to:', enabled ? 'block' : 'none'); } if (enabled) { debugConsole.info('Debug mode enabled by user'); console.log('[Debug Console] Current logs count:', debugConsole.logs.length); // Force refresh the debug display setTimeout(() => { console.log('[Debug Console] Refreshing stats and display...'); refreshDebugStats(); debugConsole.updateDebugDisplay(); }, 100); } else { debugConsole.info('Debug mode disabled by user'); } }; window.toggleVerboseDebug = (enabled) => { console.log('[Debug Console] toggleVerboseDebug called with:', enabled); if (debugConsole) { debugConsole.saveVerboseSetting(enabled); debugConsole.info('Verbose debug set to ' + enabled); } }; window.toggleAutoExportDebug = (enabled) => { console.log('[Debug Console] toggleAutoExportDebug called with:', enabled); if (debugConsole) { debugConsole.saveAutoExportSetting(enabled); debugConsole.info('Auto-export on error set to ' + enabled); } }; // Update check handlers window.toggleAutoCheckUpdates = (enabled) => { saveAutoCheckSetting(!!enabled); if (debugConsole) debugConsole.info('Auto-check updates: ' + !!enabled); }; window.checkForUpdatesNow = async () => { try { await checkForUpdatesAvailable(true, true); if (updateAvailable) { showUpdateToast(`Update available v${updateAvailableVersion} — Click to install`, true, updateAvailableVersion); } } catch (e) { console.error('Update check failed', e); showUpdateToast('Update check failed: ' + (e && e.message ? e.message : ''), false); } }; window.openInstall = () => { try { window.open(INSTALL_URL, '_blank'); } catch (e) { console.error('Failed to open install URL', e); } }; // Global error handlers to capture runtime errors into debug console window.addEventListener('error', (ev) => { try { const msg = ev.message || 'Window error'; const src = ev.filename ? `${ev.filename}:${ev.lineno}:${ev.colno}` : ''; const err = ev.error || {}; // Always filter obfuscated site errors (window["__f__..."] pattern) if (/window\[["']__f__/i.test(msg)) return; // Filter out noisy third-party/site errors unless verbose debug is enabled const isSiteError = !ev.filename || ev.filename.includes('battlemetrics.com') || ev.filename.includes('cdn.'); if (isSiteError && debugConsole && !debugConsole.verbose) { // Do not log site errors in normal mode to reduce noise return; } // Build signature for aggregation let signature = msg; try { if (ev.filename) signature += ` @ ${ev.filename}`; } catch (e) {} const data = { stack: err.stack || null, error: err, src }; if (debugConsole) { debugConsole.error(`Uncaught error: ${msg} ${src}`, data); debugConsole.recordAggregate(signature, { timestamp: new Date().toISOString(), message: msg, src, stack: err.stack || null }); if (debugConsole.autoExportOnError) { debugConsole.exportLogs(); } } } catch (e) { console.error('Error in global error handler', e); } }); window.addEventListener('unhandledrejection', (ev) => { try { const reason = ev.reason || {}; // Ignore empty-object rejections (common noisy site behavior) const isEmptyObject = reason && typeof reason === 'object' && !Array.isArray(reason) && Object.keys(reason).length === 0; if (isEmptyObject) return; // Filter noisy rejections from site scripts unless verbose enabled const reasonStr = (reason && reason.stack) ? String(reason.stack) : (reason && reason.message) ? String(reason.message) : ''; const isSiteRejection = reasonStr.includes('battlemetrics.com') || reasonStr.includes('cdn.battlemetrics.com') || /window\[\"__f__/i.test(reasonStr); if (isSiteRejection && debugConsole && !debugConsole.verbose) { return; } // Create a signature for aggregation let sig = 'UnhandledRejection'; try { if (reason && reason.message) sig += `: ${reason.message}`; } catch (e) {} if (debugConsole) { debugConsole.error('Unhandled Promise Rejection', reason); debugConsole.recordAggregate(sig, { timestamp: new Date().toISOString(), reason: reasonStr || reason }); if (debugConsole.autoExportOnError) { debugConsole.exportLogs(); } } } catch (e) { console.error('Error in unhandledrejection handler', e); } }); const refreshDebugStats = () => { if (debugConsole) debugConsole.updateDebugStats(); }; window.exportDebugLogs = () => { debugConsole.exportLogs(); }; window.clearDebugLogs = () => { debugConsole.clearLogs(); refreshDebugStats(); }; window.copyDebugLogs = () => { if (!debugConsole) { console.error('Debug console not initialized'); return; } const debugText = debugConsole.getLogsAsText(); navigator.clipboard.writeText(debugText).then(() => { // Show success feedback const copyBtn = document.querySelector('button[onclick="copyDebugLogs()"]'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; copyBtn.style.background = '#28a745'; setTimeout(() => { copyBtn.textContent = originalText; copyBtn.style.background = '#6c757d'; }, 2000); } }).catch(() => { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = debugText; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); const copyBtn = document.querySelector('button[onclick="copyDebugLogs()"]'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = originalText; }, 2000); } }); }; // Test function for debug console window.testDebugConsole = () => { console.log('[Debug Console] testDebugConsole called'); if (!debugConsole) { alert('Debug console not initialized!'); return; } console.log('[Debug Console] Adding test messages...'); debugConsole.debug('Test debug message from user'); debugConsole.info('Test info message from user'); debugConsole.warn('Test warning message from user'); debugConsole.error('Test error message from user'); console.log('[Debug Console] Current logs after test:', debugConsole.logs.length); // Force refresh setTimeout(() => { console.log('[Debug Console] Forcing refresh...'); refreshDebugStats(); }, 100); console.log('Test messages added to debug console. Check the debug console section.'); }; // Global function to check debug console status window.checkDebugConsole = () => { console.log('=== Debug Console Status ==='); console.log('debugConsole exists:', !!debugConsole); if (debugConsole) { console.log('debugConsole.enabled:', debugConsole.enabled); console.log('debugConsole.logs.length:', debugConsole.logs.length); console.log('Recent logs:', debugConsole.logs.slice(-5)); } console.log('debug-console-section exists:', !!document.getElementById('debug-console-section')); console.log('debug-console-list exists:', !!document.getElementById('debug-console-list')); console.log('debug-stats exists:', !!document.getElementById('debug-stats')); console.log('============================'); }; // Reset settings to defaults (both global and server-specific where applicable) window.resetDefaultSettings = () => { if (!confirm('Reset all settings to defaults? This will clear debug and update prefs for this browser.')) return; // Global settings localStorage.removeItem('bms_debug_enabled'); localStorage.removeItem('bms_debug_verbose'); localStorage.removeItem('bms_debug_autoexport'); localStorage.removeItem('bms_auto_check_updates'); localStorage.removeItem(MENU_VISIBLE_KEY); localStorage.removeItem(AUTO_REFRESH_ENABLED_KEY); localStorage.removeItem(AUTO_REFRESH_MS_KEY); stopAutoRefresh(); // Update auto-refresh UI const arToggle = document.getElementById('auto-refresh-toggle'); if (arToggle) arToggle.checked = false; const arSelect = document.getElementById('auto-refresh-interval'); if (arSelect) arSelect.value = '120000'; // Server-specific settings if serverMonitor exists if (typeof ALERT_SETTINGS_KEY !== 'undefined' && ALERT_SETTINGS_KEY) { localStorage.removeItem(ALERT_SETTINGS_KEY); } // Update UI and runtime objects if (debugConsole) { debugConsole.saveDebugSetting(false); debugConsole.saveVerboseSetting(false); debugConsole.saveAutoExportSetting(false); } if (serverMonitor) { serverMonitor.settings = {}; serverMonitor.saveSettings(); // refresh displays serverMonitor.updateDatabaseDisplay(); serverMonitor.updateSavedPlayersDisplay(); serverMonitor.updateAlertDisplay(); } // Update settings UI checkboxes const debugCb = document.getElementById('debug-mode'); const autoCheckCb = document.getElementById('auto-check-updates'); if (debugCb) debugCb.checked = false; if (autoCheckCb) { autoCheckCb.checked = true; // default to ON saveAutoCheckSetting(true); } alert('Settings reset to defaults.'); }; // Global function to check UI elements window.checkAlertUI = () => { console.log('=== Alert UI Check ==='); const alertDiv = document.getElementById('alert-players-list'); const alertCount = document.getElementById('alert-count'); const alertToggle = document.getElementById('alertplayers-toggle'); console.log('alert-players-list exists:', !!alertDiv); console.log('alert-count exists:', !!alertCount); console.log('alertplayers-toggle exists:', !!alertToggle); if (alertDiv) { console.log('Alert div display:', alertDiv.style.display); console.log('Alert div innerHTML length:', alertDiv.innerHTML.length); console.log('Alert div content preview:', alertDiv.innerHTML.substring(0, 100)); } if (alertCount) { console.log('Alert count text:', alertCount.textContent); } console.log('======================'); }; // Direct test function to force update alert display window.forceUpdateAlerts = () => { console.log('=== Force Update Alerts ==='); if (!serverMonitor) { console.log('ServerMonitor not available'); return; } console.log('Current alerts before update:', serverMonitor.alerts); console.log('Forcing updateAlertDisplay...'); try { serverMonitor.updateAlertDisplay(); console.log('updateAlertDisplay completed'); } catch (error) { console.error('Error calling updateAlertDisplay:', error); } setTimeout(() => { checkAlertUI(); }, 100); console.log('==============================='); }; // Function to manually add alert and update display window.manualAddAlert = (playerName, playerId) => { console.log('=== Manual Add Alert ==='); if (!serverMonitor) { console.log('ServerMonitor not available'); return; } console.log('Adding alert manually for:', playerName, playerId); // Add alert directly serverMonitor.alerts[playerId] = { name: playerName, type: 'both', added: Date.now() }; serverMonitor.saveAlerts(); console.log('Alert added. Current alerts:', serverMonitor.alerts); // Force update display const alertDiv = document.getElementById('alert-players-list'); if (alertDiv) { const alertCount = Object.keys(serverMonitor.alerts).length; if (alertCount === 0) { alertDiv.innerHTML = '
No players with alerts
'; } else { let alertHTML = ''; Object.keys(serverMonitor.alerts).forEach(id => { const alert = serverMonitor.alerts[id]; alertHTML += `
${alert.name} [MANUAL TEST]
ID: ${id}
`; }); alertDiv.innerHTML = alertHTML; } // Update count const alertCountSpan = document.getElementById('alert-count'); if (alertCountSpan) { alertCountSpan.textContent = alertCount; } console.log('Display updated manually'); } else { console.log('Alert div not found!'); } console.log('========================'); }; // ── Welcome, Donate & Changelog System ──────────────────────────────────── window.showDonateModal = () => { const existing = document.getElementById('bms-donate-modal'); if (existing) existing.remove(); const LTC_ADDRESS = 'ltc1qvwf0jf7308ry9xehf3yv0v6zq5wngjwk62ayp3'; const modal = document.createElement('div'); modal.id = 'bms-donate-modal'; modal.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.78);z-index:30001;display:flex;align-items:center;justify-content:center;'; modal.addEventListener('mousedown', (e) => { if (e.target === modal) modal.remove(); }); const box = document.createElement('div'); box.style.cssText = 'background:#2c3e50;color:white;padding:24px;border-radius:12px;width:430px;max-width:95vw;box-shadow:0 8px 40px rgba(0,0,0,0.65);border:1px solid #34495e;'; box.innerHTML = `
☕ Support the Project
This script is free and open source. If it saves you time tracking players, any donation helps support ongoing development and keeps the project alive!
🪙 Litecoin (LTC)
${LTC_ADDRESS}
❤️ Thank you for your support!
💬 Discord   ·   ⭐ GitHub
`; modal.appendChild(box); document.body.appendChild(modal); }; window.showChangelogModal = async () => { const existing = document.getElementById('bms-changelog-modal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'bms-changelog-modal'; modal.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.78);z-index:30000;display:flex;align-items:center;justify-content:center;'; modal.addEventListener('mousedown', (e) => { if (e.target === modal) modal.remove(); }); const box = document.createElement('div'); box.style.cssText = 'background:#2c3e50;color:white;padding:22px;border-radius:12px;width:500px;max-width:95vw;max-height:88vh;display:flex;flex-direction:column;box-shadow:0 8px 40px rgba(0,0,0,0.65);border:1px solid #34495e;'; const hdr = document.createElement('div'); hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-shrink:0;'; hdr.innerHTML = `
📋 Changelog
`; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'background:transparent;color:#aaa;border:none;font-size:18px;cursor:pointer;line-height:1;padding:0 2px;'; closeBtn.onclick = () => modal.remove(); hdr.appendChild(closeBtn); const content = document.createElement('div'); content.style.cssText = 'overflow-y:auto;flex:1;font-size:13px;padding-right:4px;'; content.innerHTML = '
Loading changelog…
'; box.appendChild(hdr); box.appendChild(content); modal.appendChild(box); document.body.appendChild(modal); try { const resp = await fetch(CHANGELOG_URL, { cache: 'no-cache' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); if (!data.versions || !data.versions.length) { content.innerHTML = '
No changelog data found.
'; return; } let html = ''; for (const v of data.versions) { const isCurrent = v.version === SCRIPT_VERSION; html += `
v${v.version}
${isCurrent ? 'CURRENT' : ''} ${v.date ? `${v.date}` : ''}
${v.notes ? `
${v.notes.replace(/` : ''}
    ${(v.changes || []).map(c => `
  • ${c.replace(/`).join('')}
`; } content.innerHTML = html; } catch (e) { content.innerHTML = `
Failed to load changelog: ${e.message || e}
`; } }; const showWelcomePopup = () => { const modal = document.createElement('div'); modal.id = 'bms-welcome-modal'; modal.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.82);z-index:30000;display:flex;align-items:center;justify-content:center;'; const box = document.createElement('div'); box.style.cssText = 'background:#2c3e50;color:white;padding:26px;border-radius:12px;width:490px;max-width:95vw;max-height:90vh;overflow-y:auto;box-shadow:0 10px 50px rgba(0,0,0,0.75);border:1px solid #34495e;'; box.innerHTML = `
🎯

Welcome to BM Oversight

v${SCRIPT_VERSION} · First Public Release
What is this script? A free Tampermonkey userscript that supercharges BattleMetrics. Track real-time player join/leave events, set alerts for specific players, build a persistent player history database, detect alt accounts, and analyze server population trends — all running directly in your browser with zero external servers.
🔔 Real-time alerts
Get notified when tracked players join or leave
🗃 Player database
Name changes, sessions & history tracking
🔍 Alt detection
Spot account-switching patterns automatically
📊 Population stats
Trends and next-hour predictions
☕ Keep the project alive
This script is completely free. If it saves you time, consider a small donation to support ongoing development.
`; modal.appendChild(box); document.body.appendChild(modal); }; const showUpdatePopup = async (newVersion) => { const existing = document.getElementById('bms-update-popup-modal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'bms-update-popup-modal'; modal.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.78);z-index:30000;display:flex;align-items:center;justify-content:center;'; modal.addEventListener('mousedown', (e) => { if (e.target === modal) modal.remove(); }); const box = document.createElement('div'); box.style.cssText = 'background:#2c3e50;color:white;padding:22px;border-radius:12px;width:490px;max-width:95vw;max-height:88vh;display:flex;flex-direction:column;box-shadow:0 8px 40px rgba(0,0,0,0.65);border:1px solid #34495e;'; const hdr = document.createElement('div'); hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-shrink:0;'; hdr.innerHTML = `
🎉 Updated to v${newVersion}!
Here's what's new in this release
`; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'background:transparent;color:#aaa;border:none;font-size:18px;cursor:pointer;line-height:1;padding:0 2px;align-self:flex-start;'; closeBtn.onclick = () => modal.remove(); hdr.appendChild(closeBtn); const content = document.createElement('div'); content.style.cssText = 'overflow-y:auto;flex:1;font-size:13px;padding-right:4px;margin-bottom:14px;'; content.innerHTML = '
⏳ Loading changes…
'; const footer = document.createElement('div'); footer.style.cssText = 'flex-shrink:0;text-align:center;padding-top:4px;'; footer.innerHTML = ` `; box.appendChild(hdr); box.appendChild(content); box.appendChild(footer); modal.appendChild(box); document.body.appendChild(modal); try { const resp = await fetch(CHANGELOG_URL, { cache: 'no-cache' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); if (!data.versions || !data.versions.length) { content.innerHTML = '
No changelog available.
'; return; } let html = ''; for (const v of data.versions) { const isCurrent = v.version === SCRIPT_VERSION; html += `
v${v.version}
${isCurrent ? 'NEW' : ''} ${v.date ? `${v.date}` : ''}
${v.notes ? `
${v.notes.replace(/` : ''}
    ${(v.changes || []).map(c => `
  • ${c.replace(/`).join('')}
`; } content.innerHTML = html; } catch (e) { content.innerHTML = `
Could not load detailed changes: ${e.message || e}
`; } }; const checkWelcomeOrUpdate = () => { const welcomed = localStorage.getItem(WELCOME_SHOWN_KEY); const lastVersion = localStorage.getItem(LAST_VERSION_KEY); if (!welcomed) { // First install ever — show welcome popup localStorage.setItem(WELCOME_SHOWN_KEY, 'true'); localStorage.setItem(LAST_VERSION_KEY, SCRIPT_VERSION); setTimeout(() => showWelcomePopup(), 1800); } else if (lastVersion && lastVersion !== SCRIPT_VERSION) { // User has updated to a new version — show what's new popup localStorage.setItem(LAST_VERSION_KEY, SCRIPT_VERSION); setTimeout(() => showUpdatePopup(SCRIPT_VERSION), 1800); } else if (!lastVersion) { // Welcomed before but version key missing — record silently localStorage.setItem(LAST_VERSION_KEY, SCRIPT_VERSION); } }; // Cleanup function to remove UI elements when leaving server pages const cleanup = () => { // Stop monitoring if (serverMonitor) { serverMonitor.stopMonitoring(); serverMonitor.stopAlertReminders(); } // Clear intervals if (monitoringInterval) { clearInterval(monitoringInterval); monitoringInterval = null; } if (alertReminderInterval) { clearInterval(alertReminderInterval); alertReminderInterval = null; } if (populationStatsInterval) { clearInterval(populationStatsInterval); populationStatsInterval = null; } if (timestampRefreshInterval) { clearInterval(timestampRefreshInterval); timestampRefreshInterval = null; } // Remove UI elements const toggleBtn = document.getElementById(TOGGLE_BUTTON_ID); const monitor = document.getElementById(SERVER_MONITOR_ID); const alertPanel = document.getElementById(ALERT_PANEL_ID); if (toggleBtn) toggleBtn.remove(); if (monitor) monitor.remove(); if (alertPanel) alertPanel.remove(); // Reset variables currentServerID = null; serverMonitor = null; lastPlayerList = new Map(); currentServerName = ''; activePlayerSearch = ''; activeDatabaseSearch = ''; console.log('BattleMetrics Monitor - Cleaned up UI elements'); }; // Initialize when page loads const initialize = () => { debugConsole.info('Starting initialization...'); // Always cleanup first to remove any existing UI elements cleanup(); // Check if we're on a server page - extract the actual server ID number const serverMatch = window.location.pathname.match(/\/servers\/[^\/]+\/(\d+)/); if (!serverMatch) { debugConsole.info('Not on a server page, skipping initialization'); return; } const newServerID = serverMatch[1]; // Debug logging debugConsole.debug('Current URL: ' + window.location.pathname); debugConsole.info('Extracted Server ID: ' + newServerID); // Check if we're already initialized for this server if (currentServerID === newServerID && serverMonitor) { debugConsole.info('Already initialized for this server, recreating UI...'); // Recreate UI elements to ensure they're visible after navigation createToggleButton(); createServerMonitor(); // Update displays setTimeout(() => { if (serverMonitor) { serverMonitor.updateAlertDisplay(); serverMonitor.updateSavedPlayersDisplay(); serverMonitor.updateRecentAlertsDisplay(); serverMonitor.updateDatabaseDisplay(); } }, 500); return; } currentServerID = newServerID; // Initialize server-specific storage keys initializeStorageKeys(currentServerID); // Get server name from page with retry logic const getServerName = () => { // Helper function to clean extracted text from CSS and unwanted content const cleanServerName = (text) => { if (!text) return ''; // Remove CSS content (anything that looks like CSS rules) let cleaned = text.replace(/\.css-[a-zA-Z0-9-]+\{[^}]*\}/g, ''); // Remove any remaining CSS-like patterns cleaned = cleaned.replace(/\{[^}]*\}/g, ''); cleaned = cleaned.replace(/\.css-[a-zA-Z0-9-]+/g, ''); // Remove common CSS properties and values cleaned = cleaned.replace(/(display|margin|padding|font|color|background|border|width|height|position|top|left|right|bottom):[^;]*;?/gi, ''); // Remove CSS pseudo-class/attribute selector sequences (e.g. :hover,:focus,.active,[disabled]) cleaned = cleaned.replace(/(?::(?:hover|focus|active|disabled|checked|visited|focus-within|focus-visible|placeholder-shown)|\.(?:focus|active|disabled|hover)(?::[-\w]+)*|\[disabled\]|fieldset\[disabled\])[\s,]*(?:(?::[-\w]+|\.[\w-]+|\[[\w\s="'-]+\]|[\w-]+\[[\w\s="'-]+\])[\s,]*)*/gi, ''); // Remove "Real-time player tracking & alerts" text cleaned = cleaned.replace(/Real-time\s+player\s+tracking\s*&\s*alerts/gi, ''); // Remove "Server Monitor" text if it appears at the start cleaned = cleaned.replace(/^Server\s+Monitor/i, ''); // Remove Server ID pattern cleaned = cleaned.replace(/Server\s+ID:\s*\d+/gi, ''); // Clean up whitespace and special characters cleaned = cleaned.replace(/\s+/g, ' ').trim(); // Remove any remaining non-printable characters cleaned = cleaned.replace(/[^\x20-\x7E]/g, ''); return cleaned; }; // Try multiple selectors for BattleMetrics server name const selectors = [ 'h2.css-u0fcdd', // BattleMetrics specific server name class 'h1', 'h2', '.server-name', '[data-testid="server-name"]', 'h1.server-title', 'h2.server-title', '.server-header h1', '.server-header h2', '.server-info h1', '.server-info h2', 'header h1', 'header h2', '.page-header h1', '.page-header h2', '.server-details h1', '.server-details h2', 'h1[class*="server"]', 'h2[class*="server"]', 'h1[class*="css-"]', 'h2[class*="css-"]', '.server-name-display', '.title h1', '.title h2' ]; let serverNameElement = null; let rawText = ''; for (const selector of selectors) { serverNameElement = document.querySelector(selector); if (serverNameElement) { rawText = serverNameElement.innerText || serverNameElement.textContent || ''; const cleanedName = cleanServerName(rawText); // Only accept if we have a reasonable server name (not empty, not just CSS) if (cleanedName && cleanedName.length > 3 && !cleanedName.match(/^(css-|Server\s*$|undefined|null)/i)) { currentServerName = cleanedName; debugConsole.info('Server name found via selector "' + selector + '": ' + currentServerName); debugConsole.debug('Raw text was: ' + rawText.substring(0, 100) + (rawText.length > 100 ? '...' : '')); break; } } } // If we still don't have a good server name, try page title if (!currentServerName || currentServerName.length < 3) { const title = document.title; if (title && title !== 'BattleMetrics') { // Remove common suffixes from title const cleanTitle = title.replace(/\s*-\s*BattleMetrics.*$/i, '').trim(); if (cleanTitle && cleanTitle !== 'Server' && cleanTitle.length > 3) { currentServerName = cleanTitle; debugConsole.info('Server name extracted from page title: ' + currentServerName); // Update UI immediately const serverNameSpan = document.getElementById('current-server-name'); if (serverNameSpan) { serverNameSpan.textContent = currentServerName; } return; } } currentServerName = `Server ${currentServerID}`; debugConsole.warn('Server name not found, using default'); } // Update UI with the found server name const serverNameSpan = document.getElementById('current-server-name'); if (serverNameSpan) { serverNameSpan.textContent = currentServerName; } }; // Try to get server name immediately, then retry after a delay getServerName(); setTimeout(getServerName, 1000); // Initialize components with error handling try { debugConsole.debug('Creating new ServerMonitor instance...'); serverMonitor = new ServerMonitor(); debugConsole.info('ServerMonitor initialized successfully'); // ── Script Session Tracking ────────────────────────────────────── // Record that the script is now open so exports can show gaps when // the browser/tab was closed. const startScriptSession = () => { if (!SESSIONS_KEY) return; try { const sessions = JSON.parse(localStorage.getItem(SESSIONS_KEY) || '[]'); const now = Date.now(); const d = new Date(now); sessions.push({ start: now, startISO: d.toISOString(), end: null, endISO: null, durationSeconds: null, serverID: currentServerID, serverName: currentServerName }); // Keep only last 500 sessions to avoid unbounded growth if (sessions.length > 500) sessions.splice(0, sessions.length - 500); localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); } catch (e) { /* non-fatal */ } }; const closeScriptSession = () => { if (!SESSIONS_KEY) return; try { const sessions = JSON.parse(localStorage.getItem(SESSIONS_KEY) || '[]'); // Find the last unclosed session for this server for (let i = sessions.length - 1; i >= 0; i--) { if (sessions[i].serverID === currentServerID && sessions[i].end === null) { const now = Date.now(); sessions[i].end = now; sessions[i].endISO = new Date(now).toISOString(); sessions[i].durationSeconds = Math.round((now - sessions[i].start) / 1000); break; } } localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); } catch (e) { /* non-fatal */ } }; startScriptSession(); // Close session when tab/window is closed or navigated away window.addEventListener('beforeunload', closeScriptSession); // Also catch tab hidden (covers many mobile + minimize cases) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { closeScriptSession(); } else if (document.visibilityState === 'visible') { // Re-open a session when tab becomes visible again startScriptSession(); } }); // ───────────────────────────────────────────────────────────────── // Verify critical methods exist if (typeof serverMonitor.addAlert !== 'function') { throw new Error('ServerMonitor.addAlert method missing'); } if (typeof serverMonitor.removeAlert !== 'function') { throw new Error('ServerMonitor.removeAlert method missing'); } debugConsole.info('All ServerMonitor methods verified'); } catch (error) { debugConsole.error('Failed to initialize ServerMonitor', error); alert('Server Monitor failed to initialize. Please refresh the page.'); return; } // Create UI with retry logic const createUI = () => { try { debugConsole.debug('Creating toggle button...'); createToggleButton(); debugConsole.debug('Creating server monitor UI...'); createServerMonitor(); debugConsole.info('UI created successfully'); } catch (error) { debugConsole.error('Error creating UI', error); // Retry after a short delay setTimeout(createUI, 1000); } }; createUI(); // Start auto-refresh if it was enabled before the last reload const _arSettings = loadAutoRefreshSettings(); if (_arSettings.enabled) { startAutoRefresh(_arSettings.ms); } // Update server ID and name display setTimeout(() => { const serverIdSpan = document.getElementById('current-server-id'); if (serverIdSpan) { serverIdSpan.textContent = currentServerID; } const serverNameSpan = document.getElementById('current-server-name'); if (serverNameSpan) { serverNameSpan.textContent = currentServerName || 'Loading...'; } }, 500); // Start monitoring by default // Wait longer for page to fully load before starting monitoring setTimeout(() => { if (serverMonitor && !serverMonitor.isMonitoring) { console.log('Auto-starting monitoring after page load delay...'); serverMonitor.startMonitoring(); const btn = document.getElementById('monitoring-btn'); if (btn) { btn.textContent = 'Stop Monitoring'; btn.style.background = '#dc3545'; } } }, 3000); // Increased from 2000ms to 3000ms // Update counts less frequently to reduce lag setInterval(() => { if (!serverMonitor) return; const playerCountSpan = document.getElementById('player-count'); const alertCountSpan = document.getElementById('alert-count'); const savedCountSpan = document.getElementById('saved-count'); const recentAlertsCountSpan = document.getElementById('recent-alerts-count'); const activityCountSpan = document.getElementById('activity-count'); const databaseCountSpan = document.getElementById('database-count'); if (playerCountSpan) playerCountSpan.textContent = serverMonitor.currentPlayers.size; if (alertCountSpan) alertCountSpan.textContent = Object.keys(serverMonitor.alerts).length; if (savedCountSpan) savedCountSpan.textContent = Object.keys(serverMonitor.savedPlayers).length; if (activityCountSpan) activityCountSpan.textContent = serverMonitor.activityLog.length; if (databaseCountSpan) databaseCountSpan.textContent = Object.keys(serverMonitor.playerDatabase).length; if (recentAlertsCountSpan) { const unacknowledged = Object.values(serverMonitor.recentAlerts).filter(alert => !alert.acknowledged); recentAlertsCountSpan.textContent = unacknowledged.length; } // Also update the alert and saved displays to show current online/offline status serverMonitor.updateAlertDisplay(); serverMonitor.updateSavedPlayersDisplay(); }, 5000); // Reduced from 3000ms to 5000ms // Initialize displays setTimeout(() => { if (serverMonitor) { console.log('Initializing server monitor displays...'); serverMonitor.updateAlertDisplay(); serverMonitor.updateSavedPlayersDisplay(); serverMonitor.updateRecentAlertsDisplay(); serverMonitor.updateDatabaseDisplay(); // Start alert reminders if there are unacknowledged alerts const unacknowledged = Object.values(serverMonitor.recentAlerts).filter(alert => !alert.acknowledged); if (unacknowledged.length > 0) { serverMonitor.startAlertReminders(); } console.log('Server monitor initialized successfully'); } }, 3000); console.log('BM Oversight initialized for server:', currentServerID); }; // Wait for page to load and initialize with better timing const initializeWhenReady = () => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { setTimeout(initialize, 1000); }); } else { // Page already loaded, initialize immediately but with a small delay setTimeout(initialize, 500); } }; // Try to initialize immediately if we're on a server page if (/\/servers\/[^\/]+\/\d+/.test(window.location.pathname)) { initializeWhenReady(); } // Also set up a fallback initialization check setTimeout(() => { if (!document.getElementById(TOGGLE_BUTTON_ID) && /\/servers\/[^\/]+\/\d+/.test(window.location.pathname)) { console.log('BattleMetrics Monitor - Fallback initialization triggered'); initialize(); } }, 3000); // Periodic update checker const checkForUpdatesAvailable = async (force = false, showNoUpdateToast = false) => { const enabled = loadAutoCheckSetting() || force; if (!enabled) return; try { const resp = await fetch(GITHUB_RAW_URL, { cache: 'no-cache' }); if (!resp || !resp.ok) return; const text = await resp.text(); const m = text.match(/@version\s+([^\s\n\r]+)/i) || text.match(/const\s+SCRIPT_VERSION\s*=\s*['"]([^'"]+)['"]/i); if (m && m[1]) { const remoteVer = m[1].trim(); if (compareVersions(remoteVer, SCRIPT_VERSION) === 1) { updateAvailable = true; updateAvailableVersion = remoteVer; // Show banner in monitor const monitor = document.getElementById(SERVER_MONITOR_ID); if (monitor) { let banner = document.getElementById('bms-update-banner'); if (!banner) { banner = document.createElement('div'); banner.id = 'bms-update-banner'; banner.style.cssText = 'background:#ffc107;color:#000;padding:8px;border-radius:6px;margin-bottom:10px;cursor:pointer;font-weight:bold;text-align:center;'; banner.onclick = () => window.openInstall(); monitor.insertAdjacentElement('afterbegin', banner); } banner.textContent = `Update available v${remoteVer} — Click to install`; } // Auto-install behavior removed: users must click the update banner/toast to install. // Show toast to user for forced checks or always for discovered update showUpdateToast(`Update available v${remoteVer} — Click to install`, true, remoteVer); } else { updateAvailable = false; updateAvailableVersion = null; const old = document.getElementById('bms-update-banner'); if (old) old.remove(); // Only show "no updates found" when explicitly requested to show (e.g., user clicked Check) if (force && showNoUpdateToast) { showUpdateToast(`No updates found — current v${SCRIPT_VERSION}`, false, SCRIPT_VERSION); } } } } catch (e) { if (debugConsole) debugConsole.warn('Update check failed', e); } }; // Initial check and periodic checks (every 1 minute) // Initial auto-check (do not show "no updates" toast on page load) setTimeout(() => checkForUpdatesAvailable(true, false), 2000); // Periodic auto-checks (quiet unless update available) - every 1 minute setInterval(() => checkForUpdatesAvailable(false, false), 60 * 1000); // Toast notification for updates const showUpdateToast = (message, isUpdate = false, version = '') => { try { // Remove existing toast const existing = document.getElementById('bms-update-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.id = 'bms-update-toast'; toast.style.cssText = `position: fixed; right: 20px; bottom: 20px; z-index: 20000; background: ${isUpdate ? '#ffc107' : '#6c757d'}; color: ${isUpdate ? '#000' : '#fff'}; padding: 12px 14px; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.35); cursor: pointer; font-weight:700;`; toast.textContent = message; toast.onclick = () => { if (isUpdate) { window.openInstall(); } toast.remove(); }; document.body.appendChild(toast); // Auto-hide after 8 seconds if not update; leave longer for update (15s) const timeout = isUpdate ? 15000 : 8000; setTimeout(() => { const t = document.getElementById('bms-update-toast'); if (t) t.remove(); }, timeout); } catch (e) { console.error('Failed to show update toast', e); } }; // SIMPLE BRUTE FORCE APPROACH - Just reload if on server page without UI // Auto-initialization functionality (similar to player script) let lastURL = window.location.href; let autoInitInterval = null; const startAutoInit = () => { if (autoInitInterval) return; // Already running debugConsole.info('Starting auto-initialization functionality'); // Check immediately if we're on a server page checkAndInitializeServer(); // Set up interval to check for server page changes autoInitInterval = setInterval(() => { checkAndInitializeServer(); }, 2000); // Check every 2 seconds }; const stopAutoInit = () => { if (autoInitInterval) { clearInterval(autoInitInterval); autoInitInterval = null; debugConsole.info('Auto-initialization functionality stopped'); } }; const checkAndInitializeServer = () => { const currentURL = window.location.href; // Check if we're on a server page if (!currentURL.includes('/servers/')) { // Clear current server ID if we're not on a server page if (currentServerID) { debugConsole.info('Left server page, cleaning up'); cleanup(); currentServerID = null; } return; } // Extract server ID from URL (match any game slug, not just 'rust') const serverMatch = currentURL.match(/\/servers\/[^\/]+\/(\d+)/); if (!serverMatch) { return; } const serverID = serverMatch[1]; // Check if this is a new server or URL changed if (currentServerID !== serverID || lastURL !== currentURL) { debugConsole.info(`Auto-initializing for server ${serverID}`); lastURL = currentURL; // Initialize for the new server setTimeout(() => { initialize(); }, 500); } }; // Start auto-initialization system console.log('BattleMetrics Monitor - Starting auto-initialization system...'); startAutoInit(); // Show welcome popup on first install, or update popup when version changes setTimeout(checkWelcomeOrUpdate, 2500); // That's it! Now it should catch SPA navigation! })(); // ============================================================ // SECTION 2: PLAYER ANALYTICS // Activates on: /players/* pages // (Original: BMplayer.user.js v1.0.0) // ============================================================ (function () { 'use strict'; // Script version constant used for update checks and displays const SCRIPT_VERSION = '1.0.0'; // GitHub raw URL for the userscript (Tampermonkey will detect and offer install) const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/jlaiii/BattleMetrics-Rust-Analytics/main/BMOversight.user.js'; const INSTALL_URL = 'https://jlaiii.github.io/BattleMetrics-Rust-Analytics/'; let updateAvailable = false; let updateAvailableVersion = null; const INFO_BOX_ID = 'bmt-info-box'; const BUTTON_ID = 'bmt-hour-button'; const TOGGLE_BUTTON_ID = 'bmt-toggle-button'; const RELOAD_FLAG = 'bmt_force_recalc_after_load'; const MENU_VISIBLE_KEY = 'bmt_menu_visible'; const AUTO_INSTALL_VERSION_KEY = 'bma_auto_installed_version'; let currentPlayerID = null; let lastURL = window.location.href; let cachedPlayerData = null; let currentServerPage = 0; let allTopServers = []; let autoPullEnabled = true; let debugConsoleEnabled = false; let hideMiniOnLoad = false; // when true, menu starts hidden and only toggle is shown let showServerFirstSeen = false; let autoPullInterval = null; let checkForUpdates = true; // when true, script will check GitHub for newer versions // auto-install removed; users must click update link to install manually let autoInstallUpdates = true; // kept for backward-compat UI but auto-open disabled let topServersCount = 10; // how many top servers to show/copy by default let showCopyAllDetailed = false; // whether to show the "Copy All Servers (detailed)" button let autoCaptureErrors = true; // automatically capture JS errors/unhandled rejections into debug logs // Settings management const loadSettings = () => { try { const settings = JSON.parse(localStorage.getItem('bma_settings') || '{}'); autoPullEnabled = settings.autoPullEnabled !== false; // Default to true debugConsoleEnabled = settings.debugConsoleEnabled === true; // Default to false showServerFirstSeen = settings.showServerFirstSeen === true; // Default to false hideMiniOnLoad = settings.hideMiniOnLoad === true; // Default to false (show menu) checkForUpdates = settings.checkForUpdates !== false; // Default to true autoInstallUpdates = settings.autoInstallUpdates !== false; // Default to true topServersCount = Number(settings.topServersCount) || 10; showCopyAllDetailed = (settings.hasOwnProperty('showCopyAllDetailed')) ? settings.showCopyAllDetailed === true : false; autoCaptureErrors = settings.autoCaptureErrors !== false; } catch (e) { autoPullEnabled = true; debugConsoleEnabled = false; showServerFirstSeen = false; hideMiniOnLoad = false; checkForUpdates = true; autoInstallUpdates = true; topServersCount = 10; showCopyAllDetailed = false; autoCaptureErrors = true; } }; const saveSettings = () => { const settings = { autoPullEnabled, debugConsoleEnabled ,showServerFirstSeen, hideMiniOnLoad ,checkForUpdates ,autoInstallUpdates ,topServersCount ,showCopyAllDetailed ,autoCaptureErrors }; localStorage.setItem('bma_settings', JSON.stringify(settings)); }; // Load settings on startup loadSettings(); // Debug Console System class DebugConsole { constructor() { this.logs = []; this.enabled = debugConsoleEnabled; this.maxLogs = 1000; this.version = SCRIPT_VERSION; } saveDebugSetting(enabled) { debugConsoleEnabled = enabled; this.enabled = enabled; saveSettings(); } log(level, message, data = null) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, level, message, data: (function() { if (data === null || data === undefined) return null; try { return JSON.stringify(data, null, 2); } catch (e) { try { return String(data); } catch (e2) { return '[unserializable]'; } } })(), url: window.location.href, userAgent: navigator.userAgent }; this.logs.push(logEntry); // Keep only last maxLogs entries if (this.logs.length > this.maxLogs) { this.logs = this.logs.slice(-this.maxLogs); } // Always log to browser console if debug is enabled if (this.enabled) { const consoleMessage = `[BMA Debug ${level.toUpperCase()}] ${message}`; switch (level) { case 'error': console.error(consoleMessage, data); break; case 'warn': console.warn(consoleMessage, data); break; case 'info': console.info(consoleMessage, data); break; default: console.log(consoleMessage, data); } } // Update debug console display if it exists this.updateDebugDisplay(); } error(message, data = null) { this.log('error', message, data); } warn(message, data = null) { this.log('warn', message, data); } info(message, data = null) { this.log('info', message, data); } debug(message, data = null) { this.log('debug', message, data); } exportLogs() { const exportData = { version: this.version, exportTime: new Date().toISOString(), playerID: currentPlayerID, totalLogs: this.logs.length, logs: this.logs }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bma_debug_log_${currentPlayerID || 'unknown'}_${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); this.info('Debug logs exported', { filename: a.download, logCount: this.logs.length }); } clearLogs() { this.logs = []; this.updateDebugDisplay(); this.updateDebugStats(); this.info('Debug logs cleared'); } updateDebugDisplay() { this.updateDebugStats(); const debugList = document.getElementById('debug-console-list'); if (!debugList) return; const recentLogs = this.logs.slice(-50).reverse(); // Show last 50 logs if (recentLogs.length === 0) { debugList.innerHTML = '
No debug logs available
'; return; } let debugHTML = ''; recentLogs.forEach(log => { const levelColor = { 'error': '#dc3545', 'warn': '#ffc107', 'info': '#17a2b8', 'debug': '#6c757d' }[log.level] || '#6c757d'; const time = new Date(log.timestamp).toLocaleTimeString(); debugHTML += `
[${log.level.toUpperCase()}] ${time}
${log.message}
${log.data ? `
${log.data}
` : ''}
`; }); debugList.innerHTML = debugHTML; } updateDebugStats() { const statsDiv = document.getElementById('debug-stats'); if (!statsDiv) return; const stats = this.getStats(); const oldestTime = stats.oldestLog ? new Date(stats.oldestLog).toLocaleString() : 'N/A'; statsDiv.innerHTML = ` Total Logs: ${stats.totalLogs} | Errors: ${stats.errorCount} | Warnings: ${stats.warnCount} | Info: ${stats.infoCount} | Debug: ${stats.debugCount} ${stats.oldestLog ? `
Oldest: ${oldestTime}` : ''} `; } getStats() { const stats = { totalLogs: this.logs.length, errorCount: this.logs.filter(l => l.level === 'error').length, warnCount: this.logs.filter(l => l.level === 'warn').length, infoCount: this.logs.filter(l => l.level === 'info').length, debugCount: this.logs.filter(l => l.level === 'debug').length, oldestLog: this.logs.length > 0 ? this.logs[0].timestamp : null, newestLog: this.logs.length > 0 ? this.logs[this.logs.length - 1].timestamp : null }; return stats; } getLogsAsText() { const header = `BM Oversight v${this.version} Debug Logs Export Time: ${new Date().toISOString()} Player ID: ${currentPlayerID || 'Unknown'} Total Logs: ${this.logs.length} URL: ${window.location.href} User Agent: ${navigator.userAgent} === DEBUG LOGS === `; const logsText = this.logs.map(log => { const timestamp = new Date(log.timestamp).toLocaleString(); let logLine = `[${log.level.toUpperCase()}] ${timestamp}: ${log.message}`; if (log.data) { logLine += `\nData: ${log.data}`; } return logLine; }).join('\n\n'); return header + logsText; } } // Initialize debug console const debugConsole = new DebugConsole(); // Error capture handlers (attach/detach so user can toggle) let _bma_onerror = null; let _bma_unhandled = null; const attachErrorHandlers = () => { if (_bma_onerror) return; // already attached _bma_onerror = (event) => { try { // Filter out noisy obfuscated/site errors that are not actionable for this userscript const message = event && (event.message || (event.error && event.error.message)) || ''; if (typeof message === 'string' && /window\[\"__f__/i.test(message)) { // ignore this known noisy site error return; } const err = event.error || event.message || event; debugConsole.error('Uncaught error', err && err.stack ? err.stack : err); } catch (e) { // swallow } }; _bma_unhandled = (ev) => { try { const reason = ev && (ev.reason || ev.detail || ev); // Ignore empty-object rejections (common noisy site behavior) const isEmptyObject = reason && typeof reason === 'object' && !Array.isArray(reason) && Object.keys(reason).length === 0; if (isEmptyObject) return; const reasonMsg = reason && (reason.message || String(reason)) || ''; if (typeof reasonMsg === 'string' && /window\[\"__f__/i.test(reasonMsg)) { return; } // Log structured reason where possible try { debugConsole.error('Unhandled promise rejection', reason); } catch (logErr) { // Fallback to safe string debugConsole.error('Unhandled promise rejection', String(reason)); } } catch (e) { // swallow } }; window.addEventListener('error', _bma_onerror); window.addEventListener('unhandledrejection', _bma_unhandled); }; const detachErrorHandlers = () => { if (_bma_onerror) { window.removeEventListener('error', _bma_onerror); _bma_onerror = null; } if (_bma_unhandled) { window.removeEventListener('unhandledrejection', _bma_unhandled); _bma_unhandled = null; } }; if (autoCaptureErrors) attachErrorHandlers(); // Enhanced debug logging for playerinfo script const debugLog = (level, message, data = null) => { debugConsole.log(level, message, data); }; // Auto-pull functionality const startAutoPull = () => { if (autoPullInterval) return; // Already running debugLog('info', 'Starting auto-pull functionality'); // Check immediately if we're on a player page checkAndPullPlayerData(); // Set up interval to check for player page changes autoPullInterval = setInterval(() => { checkAndPullPlayerData(); }, 2000); // Check every 2 seconds }; const stopAutoPull = () => { if (autoPullInterval) { clearInterval(autoPullInterval); autoPullInterval = null; debugLog('info', 'Auto-pull functionality stopped'); } }; const checkAndPullPlayerData = () => { const currentURL = window.location.href; // Check if we're on a player page if (!currentURL.includes('/players/')) { // Clear current player ID if we're not on a player page if (currentPlayerID) { currentPlayerID = null; removeResults(); } return; } // Extract player ID from URL const playerID = currentURL.split('/players/')[1]?.split('/')[0]; if (!playerID) { return; } // Check if this is a new player or URL changed if (currentPlayerID !== playerID || lastURL !== currentURL) { debugLog('info', `Auto-pulling data for player ${playerID}`); currentPlayerID = playerID; lastURL = currentURL; // Check if data is available before processing const dataScript = document.getElementById('storeBootstrap'); if (dataScript) { try { const pageData = JSON.parse(dataScript.textContent); if (pageData?.state?.players?.serverInfo?.[playerID]) { // Data is ready, process immediately setTimeout(() => { processDataInstantly(); }, 500); } else { // Data not ready, wait a bit longer setTimeout(() => { processDataInstantly(); }, 2000); } } catch (e) { // Error parsing data, wait and try setTimeout(() => { processDataInstantly(); }, 2000); } } else { // No data script yet, wait longer setTimeout(() => { processDataInstantly(); }, 3000); } } }; // Log script startup debugLog('info', `BM Oversight v${SCRIPT_VERSION} loaded`, { url: window.location.href, userAgent: navigator.userAgent, autoPullEnabled, debugConsoleEnabled }); // Debug console system initialized debugLog('debug', 'Debug console system initialized'); // --- Update check utilities --- const compareVersions = (a, b) => { try { const normalize = (v) => { if (!v) return ''; // Strip leading 'v' and capture numeric version prefix (e.g. v1.2.3-beta -> 1.2.3) const m = String(v).trim().match(/^v?(\d+(?:\.\d+)*)/i); return m ? m[1] : ''; }; const naStr = normalize(a); const nbStr = normalize(b); const pa = naStr.split('.').map(n => parseInt(n, 10) || 0); const pb = nbStr.split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] || 0; const nb = pb[i] || 0; if (na > nb) return 1; if (na < nb) return -1; } return 0; } catch (e) { return 0; } }; const updateUpdateBannerDisplay = () => { const infoBox = document.getElementById(INFO_BOX_ID); const existing = document.getElementById('update-available-banner'); if (!updateAvailable) { if (existing) existing.remove(); return; } const bannerHTML = `
Update available v${updateAvailableVersion} — Click to install
`; if (infoBox) { if (existing) { existing.outerHTML = bannerHTML; } else { infoBox.insertAdjacentHTML('afterbegin', bannerHTML); } // Ensure the banner element always has a click handler (use onclick to avoid duplicate listeners) const el = document.getElementById('update-available-banner'); if (el) el.onclick = () => window.openInstall(); // Also update the toggle button indicator so it's visible even when menu is closed updateToggleButton(); } }; const checkForUpdatesAvailable = async () => { if (!checkForUpdates) return; try { const resp = await fetch(GITHUB_RAW_URL, {cache: 'no-cache'}); if (!resp || !resp.ok) return; const text = await resp.text(); const m = text.match(/@version\s+([^\s\n\r]+)/i) || text.match(/const\s+SCRIPT_VERSION\s*=\s*['\"]([^'\"]+)['\"]/i); if (m && m[1]) { const remoteVer = m[1].trim(); if (compareVersions(remoteVer, SCRIPT_VERSION) === 1) { updateAvailable = true; updateAvailableVersion = remoteVer; debugLog('info', `Update available v${remoteVer}`); // If auto-install is enabled, try to auto-open the install URL once per remote version // Auto-install removed: user must click the update banner/toast to install manually } else { updateAvailable = false; updateAvailableVersion = null; } updateUpdateBannerDisplay(); } } catch (e) { debugLog('warn', 'Update check failed', e); } }; const tryAutoInstallUpdate = (remoteVersion) => { // Auto-install removed: keep stub for compatibility and logging only try { if (debugLog) debugLog('info', `Auto-install disabled; user must click update banner for v${remoteVersion}`); } catch (e) { // noop } return; }; window.openInstall = () => { try { window.open(INSTALL_URL, '_blank'); } catch (e) { debugLog('error', 'Failed to open install URL', e); } }; // Run an initial update check if allowed, then periodically every 1 minute if (checkForUpdates) { setTimeout(() => { checkForUpdatesAvailable(); }, 1000); setInterval(() => { checkForUpdatesAvailable(); }, 60 * 1000); } const removeResults = () => { const infoBox = document.getElementById(INFO_BOX_ID); if (infoBox) infoBox.remove(); // Reset pagination state currentServerPage = 0; allTopServers = []; }; const isMenuVisible = () => { return localStorage.getItem(MENU_VISIBLE_KEY) !== 'false'; }; const setMenuVisibility = (visible) => { localStorage.setItem(MENU_VISIBLE_KEY, visible.toString()); updateButtonsVisibility(); }; const updateButtonsVisibility = () => { const button = document.getElementById(BUTTON_ID); const infoBox = document.getElementById(INFO_BOX_ID); const visible = isMenuVisible(); if (button) { button.style.display = visible ? 'block' : 'none'; } if (infoBox) { infoBox.style.display = visible ? 'block' : 'none'; } updateToggleButton(); }; const updateToggleButton = () => { const toggleBtn = document.getElementById(TOGGLE_BUTTON_ID); if (toggleBtn) { const visible = isMenuVisible(); // Show main icon and, when an update is available, a small red dot indicator const base = visible ? '✕' : '☰'; const indicator = (updateAvailable ? '' : ''); toggleBtn.innerHTML = base + indicator; toggleBtn.title = visible ? 'Hide Rust Analytics Menu' : 'Show Rust Analytics Menu'; if (updateAvailable) toggleBtn.title += ` — Update available v${updateAvailableVersion}`; } }; const createToggleButton = () => { // Remove existing toggle button if it exists const existingToggleBtn = document.getElementById(TOGGLE_BUTTON_ID); if (existingToggleBtn) existingToggleBtn.remove(); const toggleBtn = document.createElement("button"); toggleBtn.id = TOGGLE_BUTTON_ID; toggleBtn.onclick = () => { const currentlyVisible = isMenuVisible(); setMenuVisibility(!currentlyVisible); }; Object.assign(toggleBtn.style, { position: "fixed", top: "20px", right: "20px", zIndex: "10000", // Higher than main button padding: "8px 12px", backgroundColor: "#6c757d", color: "#fff", border: "none", borderRadius: "5px", cursor: "pointer", fontSize: "14px", fontWeight: "bold" }); document.body.appendChild(toggleBtn); updateToggleButton(); }; const showInfoBox = (playerName, playerID, totalHours, firstSeenData, topServers, totalRustServers = 0, isError = false, errorMessage = "", earliestFirstSeen = null) => { removeResults(); const infoBox = document.createElement("div"); infoBox.id = INFO_BOX_ID; // Store all servers for pagination allTopServers = topServers; currentServerPage = 0; // Create collapsible sections let content = `
Rust Player Information
Player: ${playerName}
ID: ${playerID}
`; // If an update is available, add a prominent banner at the top of the menu if (updateAvailable && updateAvailableVersion) { const banner = `
Update available v${updateAvailableVersion} — Click to install
`; content = banner + content; } if (isError) { content += `
Error
${errorMessage}
`; } else { // Calculate daily average if we have first seen date let dailyAverageText = ''; if (earliestFirstSeen) { const firstSeenDate = new Date(earliestFirstSeen); const now = new Date(); const daysSinceFirstSeen = Math.max(1, Math.ceil((now - firstSeenDate) / (1000 * 60 * 60 * 24))); const dailyAverage = parseFloat(totalHours) / daysSinceFirstSeen; dailyAverageText = `
Average ${dailyAverage.toFixed(2)} hours a day
`; } // Hours section content += `
True Rust Hours: ${totalHours}
Total time spent across all Rust servers combined
${dailyAverageText}
`; // First seen section content += `
First Time Seen on Rust
${firstSeenData.relative}
${firstSeenData.full ? `
${firstSeenData.full}
` : ''}
`; // Rust servers count section content += `
Total Rust Servers Played: ${totalRustServers}
`; // Top servers section content += `
Top Servers by Hours
`; if (topServers.length === 0) { content += `
No Rust server hours found.
`; } else { content += `
`; // Add pagination controls if more than 10 servers if (topServers.length > 10) { content += `
`; } } content += `
`; // Copy info button at bottom content += `
Settings
Debug Console
Loading debug statistics...
Loading debug logs...
BM Oversight v${SCRIPT_VERSION}
`; } infoBox.innerHTML = content; Object.assign(infoBox.style, { position: "fixed", top: "70px", right: "20px", backgroundColor: "#2c3e50", color: "#fff", padding: "20px", borderRadius: "10px", zIndex: "9999", fontSize: "14px", maxWidth: "450px", maxHeight: "80vh", overflowY: "auto", boxShadow: "0 8px 25px rgba(0,0,0,0.3)", border: "1px solid #34495e", lineHeight: "1.4" }); document.body.appendChild(infoBox); // Ensure copy-all button visibility matches setting (covers any rendering differences) const copyAllBtn = document.getElementById('copy-all-detailed-btn'); if (copyAllBtn) copyAllBtn.style.display = showCopyAllDetailed ? 'inline-block' : 'none'; // Attach click handler to update banner if it was added in the HTML const bannerEl = document.getElementById('update-available-banner'); if (bannerEl) { bannerEl.addEventListener('click', () => window.openInstall()); } // Apply current visibility state updateButtonsVisibility(); // Add toggle functionality for servers section window.toggleServers = () => { const serversList = document.getElementById('servers-list'); const toggle = document.getElementById('servers-toggle'); if (serversList && toggle) { if (serversList.style.display === 'none') { serversList.style.display = 'block'; toggle.textContent = '▼'; } else { serversList.style.display = 'none'; toggle.textContent = '▶'; } } }; // Add toggle functionality for settings section window.toggleSettings = () => { const settingsContent = document.getElementById('settings-content'); const toggle = document.getElementById('settings-toggle'); if (settingsContent && toggle) { if (settingsContent.style.display === 'none') { settingsContent.style.display = 'block'; toggle.textContent = '▼'; } else { settingsContent.style.display = 'none'; toggle.textContent = '▶'; } } }; // Add pagination functionality window.updateServersList = () => { const serversContent = document.getElementById('servers-content'); const pageInfo = document.getElementById('page-info'); const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); if (!serversContent || allTopServers.length === 0) return; const serversPerPage = topServersCount || 10; const startIndex = currentServerPage * serversPerPage; const endIndex = Math.min(startIndex + serversPerPage, allTopServers.length); const currentServers = allTopServers.slice(startIndex, endIndex); // Update servers list let serversList = `
    `; currentServers.forEach(server => { const firstSeenPart = (showServerFirstSeen && server.firstSeen) ? ` — first seen ${new Date(server.firstSeen).toLocaleString()}` : ''; serversList += `
  1. ${server.name} — ${server.hours.toFixed(2)} hrs${firstSeenPart}
  2. `; }); serversList += `
`; serversContent.innerHTML = serversList; // Update pagination info and buttons if (pageInfo) { const totalPages = Math.ceil(allTopServers.length / serversPerPage); pageInfo.textContent = `Page ${currentServerPage + 1} of ${totalPages}`; } if (prevBtn) { prevBtn.disabled = currentServerPage === 0; prevBtn.style.opacity = currentServerPage === 0 ? '0.5' : '1'; } if (nextBtn) { const totalPages = Math.ceil(allTopServers.length / serversPerPage); nextBtn.disabled = currentServerPage >= totalPages - 1; nextBtn.style.opacity = currentServerPage >= totalPages - 1 ? '0.5' : '1'; } }; window.nextServerPage = () => { console.log('[BMA Debug] nextServerPage called, currentPage:', currentServerPage, 'totalServers:', allTopServers.length); const totalPages = Math.ceil(allTopServers.length / (topServersCount || 10)); if (currentServerPage < totalPages - 1) { currentServerPage++; console.log('[BMA Debug] Moving to page:', currentServerPage); window.updateServersList(); } else { console.log('[BMA Debug] Already at last page'); } }; window.previousServerPage = () => { console.log('[BMA Debug] previousServerPage called, currentPage:', currentServerPage); if (currentServerPage > 0) { currentServerPage--; console.log('[BMA Debug] Moving to page:', currentServerPage); window.updateServersList(); } else { console.log('[BMA Debug] Already at first page'); } }; window.copyPlayerInfo = () => { const infoBoxEl = document.getElementById('bmt-info-box'); const infoText = infoBoxEl ? infoBoxEl.textContent : ''; const playerName = infoText.match(/Player:\s*(.+)/)?.[1]?.trim() || 'Unknown'; const playerID = infoText.match(/ID:\s*(.+)/)?.[1]?.trim() || 'Unknown'; const totalHours = infoText.match(/True Rust Hours:\s*(.+)/)?.[1]?.trim() || '0'; // Extract both relative time and full date from the first seen section const fullText = infoText; const firstSeenMatch = fullText.match(/First Time Seen on Rust\s+(.*?)(?=Total Rust Servers Played|$)/s); let firstSeen = 'Unknown'; if (firstSeenMatch) { const firstSeenContent = firstSeenMatch[1].trim(); const lines = firstSeenContent.split('\n').map(line => line.trim()).filter(line => line); if (lines.length >= 2) { // Both relative time and full date are available firstSeen = `${lines[0]} (${lines[1]})`; } else if (lines.length === 1) { // Only relative time available firstSeen = lines[0]; } } const totalServers = fullText.match(/Total Rust Servers Played:\s*(\d+)/)?.[1] || '0'; // Get current server info (most recently played) let currentServer = 'Not currently playing'; if (allTopServers.length > 0) { // Find the server with the most recent lastSeen timestamp const mostRecentServer = allTopServers.reduce((most, current) => { if (!most.lastSeen) return current; if (!current.lastSeen) return most; return new Date(current.lastSeen) > new Date(most.lastSeen) ? current : most; }); currentServer = mostRecentServer.name; } // Full server list (top 10) let fullServerList = ''; if (allTopServers.length > 0) { const topN = allTopServers.slice(0, topServersCount || 10); fullServerList = topN.map((server, index) => `${index + 1}. ${server.name} — ${server.hours.toFixed(2)} hrs` ).join('\n'); } else { fullServerList = 'No servers found'; } // Get top 5 recent servers for "Recently Played" section with last seen let recentServers = ''; if (allTopServers.length > 0) { const recentCount = Math.min(5, topServersCount || 10); const recent5 = allTopServers.slice(0, recentCount); recentServers = recent5.map((server, index) => { let lastSeenText = ''; if (server.lastSeen) { const lastSeenTime = toRelativeTime(server.lastSeen); lastSeenText = ` (last seen ${lastSeenTime})`; } const firstSeenText = (showServerFirstSeen && server.firstSeen) ? ` (first seen ${toRelativeTime(server.firstSeen)})` : ''; return `${index + 1}. ${server.name} — ${server.hours.toFixed(2)} hrs${lastSeenText}${firstSeenText}`; }).join('\n'); } else { recentServers = 'No recent servers found'; } const copyText = `\`\`\`Rust Player Profile\n\nPlayer: ${playerName}\nbattlemetrics id: ${playerID}\nTotal Rust Hours: ${totalHours}\nFirst Seen: ${firstSeen}\nTotal Servers Played: ${totalServers}\n\nCurrent Server: ${currentServer}\n\nTop ${topServersCount || 10} Servers by Hours:\n${fullServerList}\n\nRecently Played Servers:\n${recentServers}\n\nGenerated by BattleMetrics Rust Analytics\`\`\``; navigator.clipboard.writeText(copyText).then(() => { // Show success feedback const copyBtn = document.querySelector('button[onclick="copyPlayerInfo()"]'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; copyBtn.style.background = '#28a745'; setTimeout(() => { copyBtn.textContent = originalText; copyBtn.style.background = '#28a745'; }, 2000); } }).catch(() => { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = copyText; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); const copyBtn = document.querySelector('button[onclick="copyPlayerInfo()"]'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = originalText; }, 2000); } }); }; // Copy ALL servers (detailed JSON) for AI/data export window.copyAllServersDetailed = () => { const infoBoxEl = document.getElementById('bmt-info-box'); const infoText = infoBoxEl ? infoBoxEl.textContent : ''; const playerName = infoText.match(/Player:\s*(.+)/)?.[1]?.trim() || 'Unknown'; const playerID = infoText.match(/ID:\s*(.+)/)?.[1]?.trim() || 'Unknown'; const servers = (allTopServers || []).map((s) => ({ name: s.name || null, hours: typeof s.hours === 'number' ? parseFloat(s.hours.toFixed(2)) : (s.hours || 0), firstSeen: s.firstSeen || null, lastSeen: s.lastSeen || null })); // Compute total hours across exported servers (rounded to 2 decimals) const totalHours = parseFloat((servers.reduce((acc, s) => acc + (s.hours || 0), 0)).toFixed(2)); const payload = { tool: 'BM Oversight', version: SCRIPT_VERSION, exportTime: new Date().toISOString(), player: { name: playerName, id: playerID, totalHours: totalHours || null }, servers: servers }; const jsonText = JSON.stringify(payload, null, 2); navigator.clipboard.writeText(jsonText).then(() => { const copyBtn = document.querySelector('button[onclick="copyAllServersDetailed()"]'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; copyBtn.style.background = '#28a745'; setTimeout(() => { copyBtn.textContent = originalText; copyBtn.style.background = '#17a2b8'; }, 2000); } }).catch(() => { const area = document.createElement('textarea'); area.value = jsonText; document.body.appendChild(area); area.select(); document.execCommand('copy'); document.body.removeChild(area); }); }; // Add event listeners for pagination buttons (Firefox compatibility) setTimeout(() => { const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); if (prevBtn) { prevBtn.addEventListener('click', () => { console.log('[BMA Debug] Previous button clicked'); window.previousServerPage(); }); } if (nextBtn) { nextBtn.addEventListener('click', () => { console.log('[BMA Debug] Next button clicked'); window.nextServerPage(); }); } }, 50); // Add debug console functions window.copyDebugLogs = () => { const debugText = debugConsole.getLogsAsText(); navigator.clipboard.writeText(debugText).then(() => { const copyBtn = document.querySelector('button[onclick="copyDebugLogs()"]'); if (copyBtn) { const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; copyBtn.style.background = '#28a745'; setTimeout(() => { copyBtn.textContent = originalText; copyBtn.style.background = '#17a2b8'; }, 2000); } }).catch(() => { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = debugText; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); }); }; window.exportDebugLogs = () => { debugConsole.exportLogs(); }; window.clearDebugLogs = () => { debugConsole.clearLogs(); }; window.refreshDebugStats = () => { if (debugConsole) { debugConsole.updateDebugStats(); } }; window.testDebugConsole = () => { if (!debugConsole) { alert('Debug console not initialized!'); return; } debugConsole.debug('Test debug message from user'); debugConsole.info('Test info message from user'); debugConsole.warn('Test warning message from user'); debugConsole.error('Test error message from user'); // Force refresh setTimeout(() => { debugConsole.updateDebugDisplay(); debugConsole.updateDebugStats(); }, 100); }; // Initialize servers list if we have servers if (allTopServers.length > 0) { setTimeout(() => window.updateServersList(), 100); } // Initialize debug console display if enabled if (debugConsoleEnabled) { setTimeout(() => { debugConsole.updateDebugDisplay(); debugConsole.updateDebugStats(); }, 200); } // Add event listeners for settings toggles setTimeout(() => { const autoPullToggle = document.getElementById('auto-pull-toggle'); const debugToggle = document.getElementById('debug-console-toggle'); const showFirstSeenToggle = document.getElementById('show-server-firstseen-toggle'); const hideMiniToggle = document.getElementById('hide-mini-toggle'); if (autoPullToggle) { autoPullToggle.addEventListener('change', (e) => { autoPullEnabled = e.target.checked; saveSettings(); debugLog('info', `Auto-pull ${autoPullEnabled ? 'enabled' : 'disabled'}`); if (autoPullEnabled) { startAutoPull(); } else { stopAutoPull(); } // Update button text const button = document.getElementById(BUTTON_ID); if (button) { button.textContent = autoPullEnabled ? "Refresh Analytics" : "Get Rust Analytics"; } }); } if (debugToggle) { debugToggle.addEventListener('change', (e) => { debugConsoleEnabled = e.target.checked; debugConsole.saveDebugSetting(debugConsoleEnabled); debugLog('info', `Debug console ${debugConsoleEnabled ? 'enabled' : 'disabled'}`); // Show/hide debug console section const debugSection = document.getElementById('debug-console-section'); if (debugSection) { debugSection.style.display = debugConsoleEnabled ? 'block' : 'none'; } // Update debug display if enabled if (debugConsoleEnabled) { setTimeout(() => { debugConsole.updateDebugDisplay(); debugConsole.updateDebugStats(); }, 100); } }); } if (hideMiniToggle) { hideMiniToggle.addEventListener('change', (e) => { hideMiniOnLoad = e.target.checked; saveSettings(); debugLog('info', `Start minimized ${hideMiniOnLoad ? 'enabled' : 'disabled'}`); // Apply visibility: if start-minimized enabled, hide menu immediately setMenuVisibility(!hideMiniOnLoad); }); } const checkUpdatesToggle = document.getElementById('check-updates-toggle'); if (checkUpdatesToggle) { checkUpdatesToggle.addEventListener('change', (e) => { checkForUpdates = e.target.checked; saveSettings(); debugLog('info', `Check-for-updates ${checkForUpdates ? 'enabled' : 'disabled'}`); if (checkForUpdates) { checkForUpdatesAvailable(); } else { updateAvailable = false; updateAvailableVersion = null; updateUpdateBannerDisplay(); } }); } const autoInstallToggle = document.getElementById('auto-install-updates-toggle'); if (autoInstallToggle) { autoInstallToggle.addEventListener('change', (e) => { autoInstallUpdates = e.target.checked; saveSettings(); debugLog('info', `Auto-install updates ${autoInstallUpdates ? 'enabled' : 'disabled'}`); // If enabling and an update is already available, user must still click the update banner to install }); } const topServersSelect = document.getElementById('top-servers-count-select'); if (topServersSelect) { topServersSelect.addEventListener('change', (e) => { topServersCount = Number(e.target.value) || 10; saveSettings(); debugLog('info', `Top servers count set to ${topServersCount}`); // Refresh servers list view and copy behavior if (window.updateServersList) window.updateServersList(); }); } const resetBtn = document.getElementById('reset-settings-btn'); if (resetBtn) { resetBtn.addEventListener('click', () => { // Reset all known settings to defaults autoPullEnabled = true; debugConsoleEnabled = false; showServerFirstSeen = false; hideMiniOnLoad = false; checkForUpdates = true; autoInstallUpdates = true; topServersCount = 10; showCopyAllDetailed = false; autoCaptureErrors = true; saveSettings(); debugLog('info', 'Settings reset to defaults'); // Update UI controls to reflect defaults const autoEl = document.getElementById('auto-pull-toggle'); if (autoEl) autoEl.checked = autoPullEnabled; const debugEl = document.getElementById('debug-console-toggle'); if (debugEl) debugEl.checked = debugConsoleEnabled; const showFirstEl = document.getElementById('show-server-firstseen-toggle'); if (showFirstEl) showFirstEl.checked = showServerFirstSeen; const hideMiniEl = document.getElementById('hide-mini-toggle'); if (hideMiniEl) hideMiniEl.checked = hideMiniOnLoad; const checkUpdEl = document.getElementById('check-updates-toggle'); if (checkUpdEl) checkUpdEl.checked = checkForUpdates; const autoInstallEl = document.getElementById('auto-install-updates-toggle'); if (autoInstallEl) autoInstallEl.checked = true; const topSel = document.getElementById('top-servers-count-select'); if (topSel) topSel.value = String(topServersCount); const showCopyEl = document.getElementById('show-copyall-toggle'); if (showCopyEl) showCopyEl.checked = showCopyAllDetailed; const autoCaptureEl = document.getElementById('auto-capture-errors-toggle'); if (autoCaptureEl) autoCaptureEl.checked = autoCaptureErrors; // Apply visibility default setMenuVisibility(!hideMiniOnLoad); // Stop auto-pull if disabled, start if enabled if (autoPullEnabled) startAutoPull(); else stopAutoPull(); // Hide debug console section const debugSection = document.getElementById('debug-console-section'); if (debugSection) debugSection.style.display = debugConsoleEnabled ? 'block' : 'none'; // Attach/detach error handlers according to default if (autoCaptureErrors) attachErrorHandlers(); else detachErrorHandlers(); // Update servers list if (window.updateServersList) window.updateServersList(); }); } const checkNowBtn = document.getElementById('check-updates-now-btn'); if (checkNowBtn) { checkNowBtn.addEventListener('click', async () => { const original = checkNowBtn.textContent; try { checkNowBtn.textContent = 'Checking...'; await checkForUpdatesAvailable(); // Small visual feedback if (updateAvailable) { checkNowBtn.textContent = `Update v${updateAvailableVersion} available`; setTimeout(() => { checkNowBtn.textContent = original; }, 3000); } else { checkNowBtn.textContent = 'Up to date'; setTimeout(() => { checkNowBtn.textContent = original; }, 2000); } } catch (e) { checkNowBtn.textContent = 'Check failed'; setTimeout(() => { checkNowBtn.textContent = original; }, 2000); } }); } const showCopyToggle = document.getElementById('show-copyall-toggle'); if (showCopyToggle) { showCopyToggle.addEventListener('change', (e) => { showCopyAllDetailed = e.target.checked; saveSettings(); debugLog('info', `Show Copy All Detailed ${showCopyAllDetailed ? 'enabled' : 'disabled'}`); const el = document.getElementById('copy-all-detailed-btn'); if (el) el.style.display = showCopyAllDetailed ? 'inline-block' : 'none'; }); } const autoCaptureToggle = document.getElementById('auto-capture-errors-toggle'); if (autoCaptureToggle) { autoCaptureToggle.addEventListener('change', (e) => { autoCaptureErrors = e.target.checked; saveSettings(); debugLog('info', `Auto-capture errors ${autoCaptureErrors ? 'enabled' : 'disabled'}`); if (autoCaptureErrors) attachErrorHandlers(); else detachErrorHandlers(); }); } if (showFirstSeenToggle) { showFirstSeenToggle.addEventListener('change', (e) => { showServerFirstSeen = e.target.checked; saveSettings(); debugLog('info', `Show per-server first seen ${showServerFirstSeen ? 'enabled' : 'disabled'}`); // Refresh servers list rendering if present if (window.updateServersList) window.updateServersList(); }); } }, 100); }; function toRelativeTime(timestamp) { const now = new Date(); const past = new Date(timestamp); const diffInSeconds = Math.round((now - past) / 1000); if (diffInSeconds < 30) return 'just now'; // Years (with decimal precision) if (diffInSeconds >= 31536000) { const years = diffInSeconds / 31536000; return `${years.toFixed(1)} year${years >= 2 ? 's' : ''} ago`; } // Months (with decimal precision for less than a year) if (diffInSeconds >= 2592000) { const months = diffInSeconds / 2592000; return `${months.toFixed(1)} month${months >= 2 ? 's' : ''} ago`; } // Days (show exact days for less than a month) if (diffInSeconds >= 86400) { const days = Math.floor(diffInSeconds / 86400); return `${days} day${days > 1 ? 's' : ''} ago`; } // Hours if (diffInSeconds >= 3600) { const hours = Math.floor(diffInSeconds / 3600); return `${hours} hour${hours > 1 ? 's' : ''} ago`; } // Minutes if (diffInSeconds >= 60) { const minutes = Math.floor(diffInSeconds / 60); return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; } return 'a moment ago'; } const processDataInstantly = () => { const button = document.getElementById(BUTTON_ID); if (button) { button.disabled = true; button.textContent = "Loading..."; } removeResults(); try { const dataScript = document.getElementById('storeBootstrap'); if (!dataScript) { throw new Error("No data available"); } const pageData = JSON.parse(dataScript.textContent); const urlPlayerID = window.location.pathname.split('/players/')[1]?.split('/')[0]; if (!urlPlayerID) throw new Error("Invalid player URL format."); if (!pageData?.state?.players?.serverInfo?.[urlPlayerID]) { throw new Error("Player data not found"); } const serverInfo = pageData.state.players.serverInfo[urlPlayerID]; const serverData = pageData.state.servers.servers; // Get player name let playerName = 'Unknown Player'; if (pageData.state.players.players && pageData.state.players.players[urlPlayerID]) { playerName = pageData.state.players.players[urlPlayerID].name; } else { const titleElement = document.querySelector('h1, .player-name, [data-testid="player-name"]'); if (titleElement) { playerName = titleElement.textContent.trim(); } } // Process server data let totalSeconds = 0; let earliestRustFirstSeen = null; const rustServersPlayed = []; if (serverInfo) { Object.values(serverInfo).forEach(playerStats => { const serverId = playerStats.serverId; const serverDetails = serverData[serverId]; if (serverDetails && serverDetails.game_id === 'rust') { const timePlayed = playerStats.timePlayed || 0; totalSeconds += timePlayed; if (earliestRustFirstSeen === null || playerStats.firstSeen < earliestRustFirstSeen) { earliestRustFirstSeen = playerStats.firstSeen; } rustServersPlayed.push({ name: serverDetails.name || "Unnamed Server", seconds: timePlayed, lastSeen: playerStats.lastSeen, firstSeen: playerStats.firstSeen }); } }); } const totalHours = (totalSeconds / 3600).toFixed(2); let firstSeenData; if (earliestRustFirstSeen) { const firstSeenDate = new Date(earliestRustFirstSeen); const relativeTime = toRelativeTime(earliestRustFirstSeen); const fullDateString = firstSeenDate.toLocaleString(); firstSeenData = { relative: relativeTime, full: fullDateString }; } else { firstSeenData = { relative: "N/A", full: null }; } rustServersPlayed.sort((a, b) => b.seconds - a.seconds); const processedServers = rustServersPlayed.map(s => ({ name: s.name, hours: s.seconds / 3600, lastSeen: s.lastSeen, firstSeen: s.firstSeen || null })); showInfoBox(playerName, urlPlayerID, totalHours, firstSeenData, processedServers, rustServersPlayed.length, false, "", earliestRustFirstSeen); // Reset button if (button) { button.disabled = false; button.textContent = "Get Rust Analytics"; } } catch (e) { debugLog('error', 'Script processing error in processDataInstantly', e); const firstSeenData = { relative: "Error", full: null }; showInfoBox("Unknown Player", "N/A", "Error", firstSeenData, [], 0, true, e.message); if (button) { button.disabled = false; button.textContent = "Get Rust Analytics"; } } }; const calculateOrReload = (retryCount = 0, isAutoLoad = false) => { const button = document.getElementById(BUTTON_ID); if (button) { button.disabled = true; button.textContent = retryCount > 0 ? `Waiting for data... (${retryCount}/5)` : "Fetching data..."; } removeResults(); // Helper function to check if data is ready const isDataReady = () => { const dataScript = document.getElementById('storeBootstrap'); if (!dataScript) return { ready: false, reason: "Missing BattleMetrics data script" }; try { const pageData = JSON.parse(dataScript.textContent); // Debug logging debugLog('debug', 'pageData structure analysis', { hasState: !!pageData?.state, hasPlayers: !!pageData?.state?.players, hasServerInfo: !!pageData?.state?.players?.serverInfo, serverInfoKeys: pageData?.state?.players?.serverInfo ? Object.keys(pageData.state.players.serverInfo) : [], hasServers: !!pageData?.state?.servers, hasServersData: !!pageData?.state?.servers?.servers, currentURL: window.location.href }); if (!pageData || !pageData.state) { return { ready: false, reason: "Invalid page data structure" }; } if (!pageData.state.players || !pageData.state.players.serverInfo) { return { ready: false, reason: "Player data not available" }; } const serverInfoKeys = Object.keys(pageData.state.players.serverInfo); if (serverInfoKeys.length === 0) { return { ready: false, reason: "No server information found" }; } // Check if the data matches the current URL player ID const urlPlayerID = window.location.pathname.split('/players/')[1]?.split('/')[0]; if (urlPlayerID && !pageData.state.players.serverInfo[urlPlayerID]) { return { ready: false, reason: "Data doesn't match current player" }; } if (!pageData.state.servers || !pageData.state.servers.servers) { return { ready: false, reason: "Server data not available" }; } return { ready: true, pageData }; } catch (e) { return { ready: false, reason: `Data parsing error: ${e.message}` }; } }; const dataCheck = isDataReady(); // If data isn't ready, retry up to 8 times (longer wait for navigation) if (!dataCheck.ready) { if (retryCount < 8) { debugLog('warn', `${dataCheck.reason}, retrying in 2 seconds... (attempt ${retryCount + 1}/8)`); setTimeout(() => calculateOrReload(retryCount + 1), 2000); return; } else { // After 8 retries, try to force reload the page data debugLog('error', 'Data not ready after retries, attempting page reload...'); sessionStorage.setItem(RELOAD_FLAG, 'true'); window.location.reload(); return; } } // Data is ready, proceed with processing try { const urlPlayerID = window.location.pathname.split('/players/')[1]?.split('/')[0]; if (!urlPlayerID) throw new Error("Invalid player URL format."); const pageData = dataCheck.pageData; const serverInfoKeys = Object.keys(pageData.state.players.serverInfo); const dataPlayerID = serverInfoKeys[0]; // Get player name for display let playerName = 'Unknown Player'; if (pageData.state.players.players && pageData.state.players.players[urlPlayerID]) { playerName = pageData.state.players.players[urlPlayerID].name; } else { // Try to get name from page title or other sources const titleElement = document.querySelector('h1, .player-name, [data-testid="player-name"]'); if (titleElement) { playerName = titleElement.textContent.trim(); } } // Update current player tracking and clear cache if player changed if (currentPlayerID !== urlPlayerID) { cachedPlayerData = null; currentPlayerID = urlPlayerID; } if (urlPlayerID === dataPlayerID) { const serverInfo = pageData.state.players.serverInfo[urlPlayerID]; const serverData = pageData.state.servers.servers; if (!serverInfo) { // If auto-loading and no server info, reload to get fresh data if (isAutoLoad && retryCount === 0) { console.log("BM Script: Auto-load detected no server info, reloading for fresh data..."); sessionStorage.setItem(RELOAD_FLAG, 'true'); window.location.reload(); return; } const firstSeenData = { relative: "N/A", full: null }; showInfoBox(playerName, urlPlayerID, "0.00", firstSeenData, [], 0); } else { let totalSeconds = 0; let earliestRustFirstSeen = null; const rustServersPlayed = []; Object.values(serverInfo).forEach(playerStats => { const serverId = playerStats.serverId; const serverDetails = serverData[serverId]; if (serverDetails && serverDetails.game_id === 'rust') { const timePlayed = playerStats.timePlayed || 0; totalSeconds += timePlayed; if (earliestRustFirstSeen === null || playerStats.firstSeen < earliestRustFirstSeen) { earliestRustFirstSeen = playerStats.firstSeen; } rustServersPlayed.push({ name: serverDetails.name || "Unnamed Server", seconds: timePlayed, lastSeen: playerStats.lastSeen, firstSeen: playerStats.firstSeen }); } }); const totalHours = (totalSeconds / 3600).toFixed(2); // If auto-loading and hours are 0, reload to get fresh data if (isAutoLoad && parseFloat(totalHours) === 0 && retryCount === 0) { console.log("BM Script: Auto-load detected 0 hours, reloading for fresh data..."); sessionStorage.setItem(RELOAD_FLAG, 'true'); window.location.reload(); return; } let firstSeenData; if (earliestRustFirstSeen) { const firstSeenDate = new Date(earliestRustFirstSeen); const relativeTime = toRelativeTime(earliestRustFirstSeen); const fullDateString = firstSeenDate.toLocaleString(); firstSeenData = { relative: relativeTime, full: fullDateString }; } else { firstSeenData = { relative: "N/A", full: null }; } rustServersPlayed.sort((a, b) => b.seconds - a.seconds); const processedServers = rustServersPlayed.map(s => ({ name: s.name, hours: s.seconds / 3600, lastSeen: s.lastSeen, firstSeen: s.firstSeen || null })); showInfoBox(playerName, urlPlayerID, totalHours, firstSeenData, processedServers, rustServersPlayed.length, false, "", earliestRustFirstSeen); // Reset button on success if (button) { button.disabled = false; button.textContent = "Get Rust Analytics"; } } } else { // Data mismatch - this means we have stale data from previous player console.log("BM Script: Data mismatch detected. Player ID from URL:", urlPlayerID, "Data ID:", dataPlayerID); // Don't use stale data - wait for correct data or force reload if (retryCount < 5) { console.log(`BM Script: Waiting for correct player data... (attempt ${retryCount + 1}/5)`); setTimeout(() => calculateOrReload(retryCount + 1), 2000); return; } else { // After retries, force page reload to get fresh data console.log("BM Script: Data still mismatched after retries. Auto-refreshing to load correct user data."); const firstSeenData = { relative: "Loading...", full: null }; showInfoBox(playerName, urlPlayerID, "Loading...", firstSeenData, [], 0, false, "Loading data for current user..."); sessionStorage.setItem(RELOAD_FLAG, 'true'); setTimeout(() => { window.location.reload(); }, 500); return; } } } catch (e) { console.error("BM Script Error:", e); const firstSeenData = { relative: "Error", full: null }; showInfoBox("Unknown Player", "N/A", "Error", firstSeenData, [], 0, true, e.message); // Reset button if (button) { button.disabled = false; button.textContent = "Get Rust Analytics"; } } }; const createButton = () => { // Remove existing buttons if they exist const existingBtn = document.getElementById(BUTTON_ID); const existingToggleBtn = document.getElementById(TOGGLE_BUTTON_ID); if (existingBtn) existingBtn.remove(); if (existingToggleBtn) existingToggleBtn.remove(); // Verify we're on a player page before creating button if (!window.location.href.includes('/players/')) { console.log("BM Script: Not on player page, skipping button creation"); return; } console.log("BM Script: Creating analytics button"); const btn = document.createElement("button"); btn.id = BUTTON_ID; btn.textContent = autoPullEnabled ? "Refresh Analytics" : "Get Rust Analytics"; btn.onclick = calculateOrReload; Object.assign(btn.style, { position: "fixed", top: "20px", right: "80px", // Moved left to make room for toggle button zIndex: "9999", padding: "10px 20px", backgroundColor: "#007bff", color: "#fff", border: "none", borderRadius: "5px", cursor: "pointer", fontFamily: "Arial, sans-serif", fontSize: "14px", fontWeight: "bold" }); // Ensure button is added to DOM try { document.body.appendChild(btn); console.log("BM Script: Analytics button created successfully"); } catch (error) { console.error("BM Script: Error creating button:", error); return; } // Create toggle button createToggleButton(); // Apply start-minimized setting if enabled (hide the menu but keep toggle visible) if (hideMiniOnLoad) { setMenuVisibility(false); } // Apply current visibility state updateButtonsVisibility(); // Verify button is visible setTimeout(() => { const verifyBtn = document.getElementById(BUTTON_ID); if (!verifyBtn) { console.log("BM Script: Button verification failed, retrying..."); setTimeout(createButton, 1000); } }, 500); }; const waitForDataAndCreateButton = (attempt = 0) => { const maxAttempts = 15; // Wait longer for navigation // Check if data is ready const dataScript = document.getElementById('storeBootstrap'); if (dataScript) { try { const pageData = JSON.parse(dataScript.textContent); if (pageData && pageData.state && pageData.state.players && pageData.state.players.serverInfo) { const serverInfoKeys = Object.keys(pageData.state.players.serverInfo); if (serverInfoKeys.length > 0 && pageData.state.servers && pageData.state.servers.servers) { console.log("BM Script: Data is ready, creating button"); createButton(); return; } } } catch (e) { // Data not ready yet } } // If data not ready and we haven't exceeded max attempts, try again if (attempt < maxAttempts) { console.log(`BM Script: Waiting for data to load... (${attempt + 1}/${maxAttempts})`); setTimeout(() => waitForDataAndCreateButton(attempt + 1), 1000); } else { console.log("BM Script: Data not ready after waiting, creating button anyway (will auto-reload when clicked)"); createButton(); } }; const checkForURLChange = () => { const currentURL = window.location.href; if (currentURL !== lastURL) { console.log("BM Script: URL change detected from", lastURL, "to", currentURL); lastURL = currentURL; // Clear cached data when navigating to a different player const newPlayerID = currentURL.split('/players/')[1]?.split('/')[0]; if (newPlayerID && newPlayerID !== currentPlayerID) { console.log("BM Script: Player changed, clearing cache"); cachedPlayerData = null; currentPlayerID = newPlayerID; } removeResults(); // Only create button on player pages if (currentURL.includes('/players/')) { // Use multiple attempts to ensure button creation after navigation const createButtonWithRetry = (attempts = 0) => { if (attempts > 5) return; // Max 5 attempts setTimeout(() => { if (!document.getElementById(BUTTON_ID)) { console.log(`BM Script: Creating button after navigation (attempt ${attempts + 1})`); createButton(); createToggleButton(); // Verify button was created, retry if not setTimeout(() => { if (!document.getElementById(BUTTON_ID)) { createButtonWithRetry(attempts + 1); } }, 500); } }, 300 + (attempts * 200)); // Increasing delay for each attempt }; createButtonWithRetry(); // Don't auto-reload on every navigation - let user choose when to load data // This prevents the constant reloading issue you mentioned } else { // Remove buttons when not on player pages const existingBtn = document.getElementById(BUTTON_ID); const existingToggleBtn = document.getElementById(TOGGLE_BUTTON_ID); if (existingBtn) existingBtn.remove(); if (existingToggleBtn) existingToggleBtn.remove(); } } }; const initializePageObserver = () => { // Watch for URL changes (navigation between profiles) const urlObserver = new MutationObserver(() => { checkForURLChange(); }); // Watch for content changes const contentObserver = new MutationObserver((mutations) => { // Check if storeBootstrap script was added/modified mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.id === 'storeBootstrap') { console.log("BM Script: storeBootstrap script detected, checking for button creation"); if (window.location.href.includes('/players/') && !document.getElementById(BUTTON_ID)) { setTimeout(createButton, 500); } } }); }); // Ensure button exists after content changes on player pages if (window.location.href.includes('/players/') && !document.getElementById(BUTTON_ID)) { setTimeout(() => { if (!document.getElementById(BUTTON_ID)) { createButton(); } }, 1000); } }); const targetNode = document.getElementById('content-container') || document.body; urlObserver.observe(targetNode, { childList: true, subtree: true }); contentObserver.observe(document.body, { childList: true }); // Also check for URL changes periodically setInterval(checkForURLChange, 1000); // Listen for browser navigation events window.addEventListener('popstate', checkForURLChange); // Override pushState and replaceState to catch programmatic navigation const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function () { originalPushState.apply(history, arguments); setTimeout(checkForURLChange, 100); }; history.replaceState = function () { originalReplaceState.apply(history, arguments); setTimeout(checkForURLChange, 100); }; }; // Initialize everything const initialize = () => { console.log("BM Script: Initializing Rust Analytics..."); // Only create button if we're on a player page if (window.location.href.includes('/players/')) { console.log("BM Script: On player page, creating button"); createButton(); // Don't auto-reload on initial load - let user choose when to get data // This prevents the constant reloading issue you mentioned } initializePageObserver(); // If this was after a reload (user clicked button), process data if (sessionStorage.getItem(RELOAD_FLAG) === 'true') { sessionStorage.removeItem(RELOAD_FLAG); console.log("BM Script: Processing data after user-initiated reload..."); setTimeout(() => { processDataInstantly(); }, 500); } console.log("BM Script: Initialization complete"); }; // Wait for page to be ready with multiple initialization attempts const initializeWhenReady = () => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { setTimeout(initialize, 500); }); } else { // Page already loaded, initialize immediately initialize(); } }; initializeWhenReady(); // Fallback: Check periodically if we're on a player page but don't have a button setInterval(() => { if (window.location.href.includes('/players/') && !document.getElementById(BUTTON_ID)) { console.log("BM Script: Fallback button creation triggered"); createButton(); } }, 5000); // Check every 5 seconds // Auto-reload fallback: If we're on a player page but button isn't showing after navigation setInterval(() => { const isPlayerPage = window.location.href.includes('/players/'); const hasButton = document.getElementById(BUTTON_ID); const hasToggleButton = document.getElementById(TOGGLE_BUTTON_ID); if (isPlayerPage && !hasButton && !hasToggleButton) { console.log('BM Script: UI missing on player page, auto-reloading...'); // Set a flag to prevent infinite reloads const reloadFlag = 'bmt_auto_reload_' + Date.now(); if (!sessionStorage.getItem(reloadFlag)) { sessionStorage.setItem(reloadFlag, 'true'); // Clear old reload flags Object.keys(sessionStorage).forEach(key => { if (key.startsWith('bmt_auto_reload_') && key !== reloadFlag) { sessionStorage.removeItem(key); } }); setTimeout(() => { window.location.reload(); }, 1000); } } }, 10000); // Check every 10 seconds // Initialize auto-pull if enabled if (autoPullEnabled) { debugLog('info', 'Auto-pull is enabled, starting auto-pull functionality'); startAutoPull(); } else { debugLog('info', 'Auto-pull is disabled'); } })();