/* * script.js - Frontend logic for Dereth Tracker Single-Page Application. * Handles WebSocket communication, UI rendering of player lists, map display, * and user interactions (filtering, sorting, chat, stats windows). */ /** * script.js - Frontend controller for Dereth Tracker SPA * * Responsibilities: * - Establish WebSocket connections to receive live telemetry and chat data * - Fetch and render live player lists, trails, and map dots * - Handle user interactions: filtering, sorting, selecting players * - Manage dynamic UI components: chat windows, stats panels, tooltips * - Provide smooth pan/zoom of map overlay using CSS transforms * * Structure: * 1. DOM references and constant definitions * 2. Color palette and assignment logic * 3. Sorting and filtering setup * 4. Utility functions (coordinate mapping, color hashing) * 5. UI window creation (stats, chat) * 6. Rendering functions for list and map * 7. Event listeners for map interactions and WebSocket messages */ /* ---------- Debug configuration ---------------------------------- */ const DEBUG = false; function debugLog(...args) { if (DEBUG) console.log(...args); } function handleError(context, error, showUI = false) { console.error(`[${context}]`, error); if (showUI) { const msg = document.createElement('div'); msg.className = 'error-toast'; msg.textContent = `${context}: ${error.message || 'Unknown error'}`; document.body.appendChild(msg); setTimeout(() => msg.remove(), GLOW_DURATION_MS); } } /* ---------- DOM references --------------------------------------- */ const wrap = document.getElementById('mapContainer'); const group = document.getElementById('mapGroup'); const img = document.getElementById('map'); const dots = document.getElementById('dots'); const trailsContainer = document.getElementById('trails'); const list = document.getElementById('playerList'); const btnContainer = document.getElementById('sortButtons'); const tooltip = document.getElementById('tooltip'); const coordinates = document.getElementById('coordinates'); /* ---------- Element Pooling System for Performance ------------- */ // Pools for reusing DOM elements to eliminate recreation overhead const elementPools = { dots: [], listItems: [], activeDots: new Set(), activeListItems: new Set() }; // Performance tracking let performanceStats = { // Lifetime totals dotsCreated: 0, dotsReused: 0, listItemsCreated: 0, listItemsReused: 0, // Per-render stats (reset each render) renderDotsCreated: 0, renderDotsReused: 0, renderListItemsCreated: 0, renderListItemsReused: 0, lastRenderTime: 0, renderCount: 0 }; function createNewDot() { const dot = document.createElement('div'); dot.className = 'dot'; performanceStats.dotsCreated++; performanceStats.renderDotsCreated++; // Add event listeners once when creating dot.addEventListener('mouseenter', e => showTooltip(e, dot.playerData)); dot.addEventListener('mousemove', e => showTooltip(e, dot.playerData)); dot.addEventListener('mouseleave', hideTooltip); dot.addEventListener('click', () => { if (dot.playerData) { const { x, y } = worldToPx(dot.playerData.ew, dot.playerData.ns); selectPlayer(dot.playerData, x, y); } }); return dot; } function createNewListItem() { const li = document.createElement('li'); li.className = 'player-item'; performanceStats.listItemsCreated++; performanceStats.renderListItemsCreated++; // Create the grid content container const gridContent = document.createElement('div'); gridContent.className = 'grid-content'; li.appendChild(gridContent); // Create buttons once and keep them (no individual event listeners needed) const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'buttons-container'; const chatBtn = document.createElement('button'); chatBtn.className = 'chat-btn'; chatBtn.textContent = 'Chat'; chatBtn.addEventListener('click', (e) => { debugLog('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget); e.stopPropagation(); // Try button's own playerData first, fallback to DOM traversal const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; debugLog('🔥 Player data found:', playerData); if (playerData) { debugLog('🔥 Opening chat for:', playerData.character_name); showChatWindow(playerData.character_name); } else { debugLog('🔥 No player data found!'); } }); const statsBtn = document.createElement('button'); statsBtn.className = 'stats-btn'; statsBtn.textContent = 'Stats'; statsBtn.addEventListener('click', (e) => { debugLog('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget); e.stopPropagation(); // Try button's own playerData first, fallback to DOM traversal const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; debugLog('📊 Player data found:', playerData); if (playerData) { debugLog('📊 Opening stats for:', playerData.character_name); showStatsWindow(playerData.character_name); } else { debugLog('📊 No player data found!'); } }); const inventoryBtn = document.createElement('button'); inventoryBtn.className = 'inventory-btn'; inventoryBtn.textContent = 'Inventory'; inventoryBtn.addEventListener('click', (e) => { debugLog('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget); e.stopPropagation(); // Try button's own playerData first, fallback to DOM traversal const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; debugLog('🎒 Player data found:', playerData); if (playerData) { debugLog('🎒 Opening inventory for:', playerData.character_name); showInventoryWindow(playerData.character_name); } else { debugLog('🎒 No player data found!'); } }); const charBtn = document.createElement('button'); charBtn.className = 'char-btn'; charBtn.textContent = 'Char'; charBtn.addEventListener('click', (e) => { e.stopPropagation(); const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; if (playerData) { showCharacterWindow(playerData.character_name); } }); buttonsContainer.appendChild(chatBtn); buttonsContainer.appendChild(statsBtn); buttonsContainer.appendChild(inventoryBtn); buttonsContainer.appendChild(charBtn); li.appendChild(buttonsContainer); // Store references for easy access li.gridContent = gridContent; li.chatBtn = chatBtn; li.statsBtn = statsBtn; li.inventoryBtn = inventoryBtn; li.charBtn = charBtn; return li; } function returnToPool() { // Return unused dots to pool elementPools.activeDots.forEach(dot => { if (!dot.parentNode) { elementPools.dots.push(dot); elementPools.activeDots.delete(dot); } }); // Return unused list items to pool elementPools.activeListItems.forEach(li => { if (!li.parentNode) { elementPools.listItems.push(li); elementPools.activeListItems.delete(li); } }); } /* ---------- Event Delegation System ---------------------------- */ // Single event delegation handler for all player list interactions function setupEventDelegation() { list.addEventListener('click', e => { const li = e.target.closest('li.player-item'); if (!li || !li.playerData) return; const player = li.playerData; const { x, y } = worldToPx(player.ew, player.ns); // Handle player selection (clicking anywhere else on the item, not on buttons) // Button clicks are now handled by direct event listeners if (!e.target.closest('button')) { selectPlayer(player, x, y); } }); } // Initialize event delegation when DOM is ready document.addEventListener('DOMContentLoaded', setupEventDelegation); // Global drag system to prevent event listener accumulation let currentDragWindow = null; let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0; function makeDraggable(win, header) { if (!window.__chatZ) window.__chatZ = 10000; header.style.cursor = 'move'; const bringToFront = () => { window.__chatZ += 1; win.style.zIndex = window.__chatZ; }; header.addEventListener('mousedown', e => { if (e.target.closest('button')) return; e.preventDefault(); currentDragWindow = win; bringToFront(); dragStartX = e.clientX; dragStartY = e.clientY; dragStartLeft = win.offsetLeft; dragStartTop = win.offsetTop; document.body.classList.add('noselect'); }); // Touch support header.addEventListener('touchstart', e => { if (e.touches.length !== 1 || e.target.closest('button')) return; currentDragWindow = win; bringToFront(); const t = e.touches[0]; dragStartX = t.clientX; dragStartY = t.clientY; dragStartLeft = win.offsetLeft; dragStartTop = win.offsetTop; }); } // Global mouse handlers (only added once) window.addEventListener('mousemove', e => { if (!currentDragWindow) return; const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; currentDragWindow.style.left = `${dragStartLeft + dx}px`; currentDragWindow.style.top = `${dragStartTop + dy}px`; }); window.addEventListener('mouseup', () => { if (currentDragWindow) { currentDragWindow = null; document.body.classList.remove('noselect'); } }); window.addEventListener('touchmove', e => { if (!currentDragWindow || e.touches.length !== 1) return; const t = e.touches[0]; const dx = t.clientX - dragStartX; const dy = t.clientY - dragStartY; currentDragWindow.style.left = `${dragStartLeft + dx}px`; currentDragWindow.style.top = `${dragStartTop + dy}px`; }); window.addEventListener('touchend', () => { currentDragWindow = null; }); // Filter input for player names (starts-with filter) let currentFilter = ''; const filterInput = document.getElementById('playerFilter'); if (filterInput) { filterInput.addEventListener('input', e => { currentFilter = e.target.value.toLowerCase().trim(); renderList(); }); } // WebSocket for chat and commands let socket; // Keep track of open chat windows: character_name -> DOM element const chatWindows = {}; // Keep track of open stats windows: character_name -> DOM element const statsWindows = {}; // Keep track of open inventory windows: character_name -> DOM element const inventoryWindows = {}; /** * ---------- Application Constants ----------------------------- * Defines key parameters for map rendering, data polling, and UI limits. * * MAX_Z: Maximum altitude difference considered (filter out outliers by Z) * FOCUS_ZOOM: Zoom level when focusing on a selected character * POLL_MS: Millisecond interval to fetch live player data and trails * MAP_BOUNDS: World coordinate bounds for the game map (used for projection) * API_BASE: Prefix for AJAX endpoints (set when behind a proxy) * MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage * CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code */ /* ---------- constants ------------------------------------------- */ const MAX_Z = 20; const FOCUS_ZOOM = 3; // zoom level when you click a name const POLL_MS = 2000; const POLL_RARES_MS = 300000; // 5 minutes const POLL_KILLS_MS = 300000; // 5 minutes const POLL_HEALTH_MS = 30000; // 30 seconds const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time const GLOW_DURATION_MS = 5000; // Player glow after rare find const MAX_HEATMAP_POINTS = 50000; const HEATMAP_HOURS = 24; // UtilityBelt's more accurate coordinate bounds const MAP_BOUNDS = { west: -102.1, east: 102.1, north: 102.1, south: -102.1 }; // Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy // If serving APIs at root, leave empty const API_BASE = ''; // Maximum number of lines to retain in each chat window scrollback const MAX_CHAT_LINES = 1000; // Map numeric chat color codes to CSS hex colors const CHAT_COLOR_MAP = { 0: '#00FF00', // Broadcast 2: '#FFFFFF', // Speech 3: '#FFD700', // Tell 4: '#CCCC00', // OutgoingTell 5: '#FF00FF', // System 6: '#FF0000', // Combat 7: '#00CCFF', // Magic 8: '#DDDDDD', // Channel 9: '#FF9999', // ChannelSend 10: '#FFFF33', // Social 11: '#CCFF33', // SocialSend 12: '#FFFFFF', // Emote 13: '#00FFFF', // Advancement 14: '#66CCFF', // Abuse 15: '#FF0000', // Help 16: '#33FF00', // Appraisal 17: '#0099FF', // Spellcasting 18: '#FF6600', // Allegiance 19: '#CC66FF', // Fellowship 20: '#00FF00', // WorldBroadcast 21: '#FF0000', // CombatEnemy 22: '#FF33CC', // CombatSelf 23: '#00CC00', // Recall 24: '#00FF00', // Craft 25: '#00FF66', // Salvaging 27: '#FFFFFF', // General 28: '#33FF33', // Trade 29: '#CCCCCC', // LFG 30: '#CC00CC', // Roleplay 31: '#FFFF00' // AdminTell }; /* ---------- Heat Map Globals ---------- */ let heatmapCanvas, heatmapCtx; let heatmapEnabled = false; let heatmapData = null; let heatTimeout = null; const HEAT_PADDING = 50; // px beyond viewport to still draw const HEAT_THROTTLE = 16; // ~60 fps /* ---------- Portal Map Globals ---------- */ let portalEnabled = false; let portalData = null; let portalContainer = null; /** * ---------- Player Color Assignment ---------------------------- * Uses a predefined accessible color palette for player dots to ensure * high contrast and colorblind-friendly display. Once the palette * is exhausted, falls back to a deterministic hash-to-hue function. */ /* ---------- player/dot color assignment ------------------------- */ // A base palette of distinct, color-blind-friendly colors const PALETTE = [ // Original colorblind-friendly base palette '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', // Extended high-contrast colors '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488', // Darker variants '#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc', '#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366', // Brighter variants '#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff', '#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa', // Additional distinct colors '#990099', '#009900', '#000099', '#990000', '#009999', '#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055', // Light pastels for contrast '#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff', '#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc' ]; // Map from character name to assigned color const colorMap = {}; // Next index to pick from PALETTE let nextPaletteIndex = 0; /** * Assigns or returns a consistent color for a given name. * Uses a fixed palette first, then falls back to hue hashing. */ function getColorFor(name) { if (colorMap[name]) { return colorMap[name]; } let color; if (nextPaletteIndex < PALETTE.length) { color = PALETTE[nextPaletteIndex++]; } else { // Fallback: hash to HSL hue color = hue(name); } colorMap[name] = color; return color; } /* * ---------- Sort Configuration ------------------------------- * Defines available sort criteria for the active player list: * - name: alphabetical ascending * - kph: kills per hour descending * - kills: total kills descending * - rares: rare events found during current session descending * Each option includes a label for UI display and a comparator function. */ /* ---------- sort configuration ---------------------------------- */ const sortOptions = [ { value: "name", label: "Name", comparator: (a, b) => a.character_name.localeCompare(b.character_name) }, { value: "kph", label: "KPH", comparator: (a, b) => b.kills_per_hour - a.kills_per_hour }, { value: "kills", label: "S.Kills", comparator: (a, b) => b.kills - a.kills }, { value: "rares", label: "S.Rares", comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0) }, { value: "total_kills", label: "T.Kills", comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0) }, { value: "kpr", label: "KPR", comparator: (a, b) => { const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity; const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity; return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding) } } ]; let currentSort = sortOptions[0]; let currentPlayers = []; /* ---------- generate segmented buttons -------------------------- */ sortOptions.forEach(opt => { const btn = document.createElement('div'); btn.className = 'btn'; btn.textContent = opt.label; btn.dataset.value = opt.value; if (opt.value === currentSort.value) btn.classList.add('active'); btn.addEventListener('click', () => { btnContainer.querySelectorAll('.btn') .forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentSort = opt; renderList(); }); btnContainer.appendChild(btn); }); /* ---------- map & state variables ------------------------------- */ let imgW = 0, imgH = 0; let scale = 1, offX = 0, offY = 0, minScale = 1; let dragging = false, sx = 0, sy = 0; let selected = ""; const pollIntervals = []; /* ---------- utility functions ----------------------------------- */ const hue = name => { let h = 0; for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h); return `hsl(${Math.abs(h) % 360},72%,50%)`; }; const loc = (ns, ew) => `${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} ` + `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`; function worldToPx(ew, ns) { const x = ((ew - MAP_BOUNDS.west) / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW; const y = ((MAP_BOUNDS.north - ns) / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH; return { x, y }; } function pxToWorld(x, y) { // Convert screen coordinates to map image coordinates const mapX = (x - offX) / scale; const mapY = (y - offY) / scale; // Convert map image coordinates to world coordinates const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west); const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south); return { ew, ns }; } /* ---------- Heat Map Functions ---------- */ function initHeatMap() { heatmapCanvas = document.getElementById('heatmapCanvas'); if (!heatmapCanvas) { console.error('Heat map canvas not found'); return; } heatmapCtx = heatmapCanvas.getContext('2d'); const toggle = document.getElementById('heatmapToggle'); if (toggle) { toggle.addEventListener('change', e => { heatmapEnabled = e.target.checked; if (heatmapEnabled) { fetchHeatmapData(); } else { clearHeatmap(); } }); } window.addEventListener('resize', debounce(() => { if (heatmapEnabled && heatmapData) { renderHeatmap(); } }, 250)); } async function fetchHeatmapData() { try { const response = await fetch(`${API_BASE}/spawns/heatmap?hours=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`); if (!response.ok) { throw new Error(`Heat map API error: ${response.status}`); } const data = await response.json(); heatmapData = data.spawn_points; // [{ew, ns, intensity}] debugLog(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`); renderHeatmap(); } catch (err) { handleError('Heatmap', err); } } function renderHeatmap() { if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) { return; } // Set canvas size to match map dimensions (1:1 DPI) heatmapCanvas.width = imgW; heatmapCanvas.height = imgH; heatmapCtx.clearRect(0, 0, imgW, imgH); // Current visible map rect in px for viewport culling const vw = wrap.clientWidth; const vh = wrap.clientHeight; const viewL = -offX / scale; const viewT = -offY / scale; const viewR = viewL + vw / scale; const viewB = viewT + vh / scale; // Render heat map points with viewport culling for (const point of heatmapData) { const { x, y } = worldToPx(point.ew, point.ns); // Skip points outside visible area (with padding for smooth edges) if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING || y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) { continue; } // Smaller, more precise spots to clearly show individual spawn locations const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5))); // Sharp gradient with distinct boundaries between spawn points const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius); gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange gradient.addColorStop(1, 'rgba(255, 150, 0, 0)'); heatmapCtx.fillStyle = gradient; heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2); } } function clearHeatmap() { if (heatmapCtx && heatmapCanvas) { heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height); } } /* ---------- Portal Map Functions ---------- */ function initPortalMap() { portalContainer = document.getElementById('portals'); if (!portalContainer) { console.error('Portal container not found'); return; } const toggle = document.getElementById('portalToggle'); if (toggle) { toggle.addEventListener('change', e => { portalEnabled = e.target.checked; if (portalEnabled) { fetchPortalData(); } else { clearPortals(); } }); } } async function fetchPortalData() { try { const response = await fetch(`${API_BASE}/portals`); if (!response.ok) { throw new Error(`Portal API error: ${response.status}`); } const data = await response.json(); portalData = data.portals; // [{portal_name, coordinates: {ns, ew, z}, discovered_by, discovered_at}] debugLog(`Loaded ${portalData.length} portals from last hour`); renderPortals(); } catch (err) { handleError('Portals', err); } } function parseCoordinate(coord) { // Handle both formats: // String format: "42.3N", "15.7S", "33.7E", "28.2W" // Numeric format: "-96.9330958" (already signed) // Check if it's already a number if (typeof coord === 'number') { return coord; } // Check if it's a numeric string const numericValue = parseFloat(coord); if (!isNaN(numericValue) && coord.match(/^-?\d+\.?\d*$/)) { return numericValue; } // Parse string format like "42.3N" const match = coord.match(/^([0-9.]+)([NSEW])$/); if (!match) return 0; const value = parseFloat(match[1]); const direction = match[2]; if (direction === 'S' || direction === 'W') { return -value; } return value; } function renderPortals() { if (!portalEnabled || !portalData || !portalContainer || !imgW || !imgH) { return; } // Clear existing portals clearPortals(); for (const portal of portalData) { // Extract coordinates from new API format const ns = portal.coordinates.ns; const ew = portal.coordinates.ew; // Convert to pixel coordinates const { x, y } = worldToPx(ew, ns); // Create portal icon const icon = document.createElement('div'); icon.className = 'portal-icon'; icon.style.left = `${x}px`; icon.style.top = `${y}px`; icon.title = `${portal.portal_name} (discovered by ${portal.discovered_by})`; portalContainer.appendChild(icon); } debugLog(`Rendered ${portalData.length} portal icons`); } function clearPortals() { if (portalContainer) { portalContainer.innerHTML = ''; } } function debounce(fn, ms) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => fn(...args), ms); }; } /** * Create or show a draggable window. Returns { win, content, isNew }. * If window already exists, brings it to front and returns isNew: false. */ function createWindow(id, title, className, options = {}) { const { onClose } = options; // Check if window already exists - bring to front const existing = document.getElementById(id); if (existing) { existing.style.display = 'flex'; if (!window.__chatZ) window.__chatZ = 10000; window.__chatZ += 1; existing.style.zIndex = window.__chatZ; return { win: existing, content: existing.querySelector('.window-content'), isNew: false }; } // Create new window if (!window.__chatZ) window.__chatZ = 10000; window.__chatZ += 1; const win = document.createElement('div'); win.id = id; win.className = className; win.style.display = 'flex'; win.style.zIndex = window.__chatZ; const header = document.createElement('div'); header.className = 'chat-header'; const titleSpan = document.createElement('span'); titleSpan.textContent = title; header.appendChild(titleSpan); const closeBtn = document.createElement('button'); closeBtn.className = 'chat-close-btn'; closeBtn.textContent = '\u00D7'; closeBtn.addEventListener('click', () => { win.style.display = 'none'; if (onClose) onClose(); }); header.appendChild(closeBtn); const content = document.createElement('div'); content.className = 'window-content'; win.appendChild(header); win.appendChild(content); document.body.appendChild(win); makeDraggable(win, header); return { win, content, isNew: true }; } // Show or create a stats window for a character function showStatsWindow(name) { debugLog('showStatsWindow called for:', name); const windowId = `statsWindow-${name}`; const { win, content, isNew } = createWindow( windowId, `Stats: ${name}`, 'stats-window' ); if (!isNew) { debugLog('Existing stats window found, showing it'); return; } win.dataset.character = name; statsWindows[name] = win; // Time period controls const controls = document.createElement('div'); controls.className = 'stats-controls'; const timeRanges = [ { label: '1H', value: 'now-1h' }, { label: '6H', value: 'now-6h' }, { label: '24H', value: 'now-24h' }, { label: '7D', value: 'now-7d' } ]; // Stats content container (iframes grid) const statsContent = document.createElement('div'); statsContent.className = 'chat-messages'; statsContent.textContent = 'Loading stats...'; timeRanges.forEach(range => { const btn = document.createElement('button'); btn.className = 'time-range-btn'; btn.textContent = range.label; if (range.value === 'now-24h') btn.classList.add('active'); btn.addEventListener('click', () => { controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); updateStatsTimeRange(statsContent, name, range.value); }); controls.appendChild(btn); }); content.appendChild(controls); content.appendChild(statsContent); debugLog('Stats window created for:', name); // Load initial stats with default 24h range updateStatsTimeRange(statsContent, name, 'now-24h'); } function updateStatsTimeRange(content, name, timeRange) { content.innerHTML = ''; const panels = [ { title: 'Kills per Hour', id: 1 }, { title: 'Memory (MB)', id: 2 }, { title: 'CPU (%)', id: 3 }, { title: 'Mem Handles', id: 4 } ]; panels.forEach(p => { const iframe = document.createElement('iframe'); iframe.src = `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` + `?panelId=${p.id}` + `&var-character=${encodeURIComponent(name)}` + `&from=${timeRange}` + `&to=now` + `&theme=light`; iframe.setAttribute('title', p.title); iframe.width = '350'; iframe.height = '200'; iframe.frameBorder = '0'; iframe.allowFullscreen = true; content.appendChild(iframe); }); } // Show or create an inventory window for a character /** * Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot. * Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... } * Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} } * Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... } * * MyWorldObject uses -1 as sentinel for "not set" on int/double properties. */ function normalizeInventoryItem(item) { if (!item) return item; if (item.name && item.item_id) return item; // MyWorldObject uses -1 as "not set" sentinel — filter those out const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined; if (!item.item_id) item.item_id = item.Id; if (!item.icon) item.icon = item.Icon; if (!item.object_class) item.object_class = item.ObjectClass; if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData; const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null; const material = item.Material || null; if (material) { item.material = material; item.material_name = material; } // Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service if (baseName) { if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) { item.name = material + ' ' + baseName; } else { item.name = baseName; } } const iv = item.IntValues || {}; if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']); if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']); // Container/equipment tracking if (item.container_id === undefined) item.container_id = item.ContainerId || 0; if (item.current_wielded_location === undefined) { item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0; } if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']); const armor = v(item.ArmorLevel); if (armor !== undefined) item.armor_level = armor; const maxDmg = v(item.MaxDamage); if (maxDmg !== undefined) item.max_damage = maxDmg; const dmgBonus = v(item.DamageBonus); if (dmgBonus !== undefined) item.damage_bonus = dmgBonus; const atkBonus = v(item.AttackBonus); if (atkBonus !== undefined) item.attack_bonus = atkBonus; const elemDmg = v(item.ElementalDmgBonus); if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg; const meleeD = v(item.MeleeDefenseBonus); if (meleeD !== undefined) item.melee_defense_bonus = meleeD; const magicD = v(item.MagicDBonus); if (magicD !== undefined) item.magic_defense_bonus = magicD; const missileD = v(item.MissileDBonus); if (missileD !== undefined) item.missile_defense_bonus = missileD; const manaC = v(item.ManaCBonus); if (manaC !== undefined) item.mana_conversion_bonus = manaC; const wieldLvl = v(item.WieldLevel); if (wieldLvl !== undefined) item.wield_level = wieldLvl; const skillLvl = v(item.SkillLevel); if (skillLvl !== undefined) item.skill_level = skillLvl; const loreLvl = v(item.LoreRequirement); if (loreLvl !== undefined) item.lore_requirement = loreLvl; if (item.EquipSkill) item.equip_skill = item.EquipSkill; if (item.Mastery) item.mastery = item.Mastery; if (item.ItemSet) item.item_set = item.ItemSet; if (item.Imbue) item.imbue = item.Imbue; const tinks = v(item.Tinks); if (tinks !== undefined) item.tinks = tinks; const work = v(item.Workmanship); if (work !== undefined) item.workmanship = work; const damR = v(item.DamRating); if (damR !== undefined) item.damage_rating = damR; const critR = v(item.CritRating); if (critR !== undefined) item.crit_rating = critR; const healR = v(item.HealBoostRating); if (healR !== undefined) item.heal_boost_rating = healR; const vitalR = v(item.VitalityRating); if (vitalR !== undefined) item.vitality_rating = vitalR; const critDmgR = v(item.CritDamRating); if (critDmgR !== undefined) item.crit_damage_rating = critDmgR; const damResR = v(item.DamResistRating); if (damResR !== undefined) item.damage_resist_rating = damResR; const critResR = v(item.CritResistRating); if (critResR !== undefined) item.crit_resist_rating = critResR; const critDmgResR = v(item.CritDamResistRating); if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR; if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) { item.spells = item.Spells; } return item; } /** * Create a single inventory slot DOM element from item data. * Used by both initial inventory load and live delta updates. */ function createInventorySlot(item) { const slot = document.createElement('div'); slot.className = 'inventory-slot'; slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0); // Create layered icon container const iconContainer = document.createElement('div'); iconContainer.className = 'item-icon-composite'; // Get base icon ID with portal.dat offset const iconRaw = item.icon || item.Icon || 0; const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); // Check for overlay and underlay from enhanced format or legacy format let overlayIconId = null; let underlayIconId = null; // Enhanced format (inventory service) - check for proper icon overlay/underlay properties if (item.icon_overlay_id && item.icon_overlay_id > 0) { overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } if (item.icon_underlay_id && item.icon_underlay_id > 0) { underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } // Fallback: Enhanced format (inventory service) - check spells object for decal info if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } } else if (item.IntValues) { // Raw delta format from plugin - IntValues directly on item if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) { overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) { underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } } else if (item.item_data) { // Legacy format - parse item_data try { const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; if (itemData.IntValues) { if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); } } } catch (e) { console.warn('Failed to parse item data for', item.name || item.Name); } } // Create underlay (bottom layer) if (underlayIconId) { const underlayImg = document.createElement('img'); underlayImg.className = 'icon-underlay'; underlayImg.src = `/icons/${underlayIconId}.png`; underlayImg.alt = 'underlay'; underlayImg.onerror = function() { this.style.display = 'none'; }; iconContainer.appendChild(underlayImg); } // Create base icon (middle layer) const baseImg = document.createElement('img'); baseImg.className = 'icon-base'; baseImg.src = `/icons/${baseIconId}.png`; baseImg.alt = item.name || item.Name || 'Unknown Item'; baseImg.onerror = function() { this.src = '/icons/06000133.png'; }; iconContainer.appendChild(baseImg); // Create overlay (top layer) if (overlayIconId) { const overlayImg = document.createElement('img'); overlayImg.className = 'icon-overlay'; overlayImg.src = `/icons/${overlayIconId}.png`; overlayImg.alt = 'overlay'; overlayImg.onerror = function() { this.style.display = 'none'; }; iconContainer.appendChild(overlayImg); } // Create tooltip data (handle both inventory-service format and raw plugin format) const itemName = item.name || item.Name || 'Unknown Item'; slot.dataset.name = itemName; slot.dataset.value = item.value || item.Value || 0; slot.dataset.burden = item.burden || item.Burden || 0; // Store enhanced data for tooltips if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { const enhancedData = {}; const possibleProps = [ 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' ]; possibleProps.forEach(prop => { if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { enhancedData[prop] = item[prop]; } }); slot.dataset.enhancedData = JSON.stringify(enhancedData); } else { slot.dataset.enhancedData = JSON.stringify({}); } // Add tooltip on hover slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); slot.addEventListener('mouseleave', hideInventoryTooltip); slot.appendChild(iconContainer); // Add stack count if > 1 const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1; if (stackCount > 1) { const countEl = document.createElement('div'); countEl.className = 'inventory-count'; countEl.textContent = stackCount; slot.appendChild(countEl); } return slot; } /** * Equipment slots mapping for the AC inventory layout. * Grid matches the real AC "Equipment Slots Enabled" paperdoll view. * * Layout (6 cols × 6 rows): * Col: 1 2 3 4 5 6 * Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red) * Row 2: Trinket — ChestArmor — — Cloak * Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt) * Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants) * Row 5: — Hands — LowerLegArmor — — * Row 6: Shield — — Feet Weapon Ammo */ const EQUIP_SLOTS = { // Row 1: Necklace, Head, 3× Aetheria/Sigil 32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear 1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear 268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne 536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo 1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree // Row 2: Trinket, Chest Armor, Cloak 67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne 2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor 512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor 134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak // Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt 65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft 4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor 1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor 8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor 131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight 2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear // Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants 262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft 32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear 16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor 524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight 4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear // Row 5: Feet 256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear // Row 6: Shield, Weapon, Ammo 2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield 1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon 4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon 16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held 33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded 8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition }; const SLOT_COLORS = {}; // Purple: jewelry [32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple'); // Blue: armor [1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue'); // Teal: clothing/misc [2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal'); // Dark blue: weapons/combat [2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue'); /** * Handle live inventory delta updates from WebSocket. * Updates the inventory grid for a character if their inventory window is open. */ function updateInventoryLive(delta) { const name = delta.character_name; const win = inventoryWindows[name]; if (!win || !win._inventoryState) { return; } const state = win._inventoryState; const getItemId = (d) => { if (d.item) return d.item.item_id || d.item.Id || d.item.id; return d.item_id; }; const itemId = getItemId(delta); if (delta.action === 'remove') { state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId); } else if (delta.action === 'add' || delta.action === 'update') { normalizeInventoryItem(delta.item); const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId); if (existingIdx >= 0) { state.items[existingIdx] = delta.item; } else { state.items.push(delta.item); } } renderInventoryState(state); } function renderInventoryState(state) { // 1. Clear equipment slots state.slotMap.forEach((slotEl) => { slotEl.innerHTML = ''; const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; slotEl.className = `inv-equip-slot empty ${colorClass}`; delete slotEl.dataset.itemId; }); // 2. Identify containers (object_class === 10) by item_id for sidebar // These are packs/sacks/pouches/foci that appear in inventory as items // but should ONLY show in the pack sidebar, not in the item grid. const containers = []; // container objects (object_class=10) const containerItemIds = new Set(); // item_ids of containers (to exclude from grid) state.items.forEach(item => { if (item.object_class === 10) { containers.push(item); containerItemIds.add(item.item_id); } }); // 3. Separate equipped items from pack items, excluding containers from grid let totalBurden = 0; const packItems = new Map(); // container_id → [items] (non-container items only) // Determine the character body container_id: items with wielded_location > 0 // share a container_id that is NOT 0 and NOT a pack's item_id. // We treat non-wielded items from the body container as "main backpack" items. let bodyContainerId = null; state.items.forEach(item => { if (item.current_wielded_location && item.current_wielded_location > 0) { const cid = item.container_id; if (cid && cid !== 0 && !containerItemIds.has(cid)) { bodyContainerId = cid; } } }); state.items.forEach(item => { totalBurden += (item.burden || 0); // Skip container objects — they go in sidebar only if (containerItemIds.has(item.item_id)) return; if (item.current_wielded_location && item.current_wielded_location > 0) { const mask = item.current_wielded_location; const isArmor = item.object_class === 2; // For armor (object_class=2): render in ALL matching slots (multi-slot display) // For everything else (clothing, jewelry, weapons): place in first matching slot only if (isArmor) { Object.keys(EQUIP_SLOTS).forEach(m => { const slotMask = parseInt(m); if ((mask & slotMask) === slotMask) { const slotDef = EQUIP_SLOTS[slotMask]; const key = `${slotDef.row}-${slotDef.col}`; if (state.slotMap.has(key)) { const slotEl = state.slotMap.get(key); if (!slotEl.dataset.itemId) { slotEl.innerHTML = ''; const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; slotEl.className = `inv-equip-slot equipped ${colorClass}`; slotEl.dataset.itemId = item.item_id; slotEl.appendChild(createInventorySlot(item)); } } } }); } else { // Non-armor: find the first matching slot by exact mask key, then by bit overlap let placed = false; // Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly) if (EQUIP_SLOTS[mask]) { const slotDef = EQUIP_SLOTS[mask]; const key = `${slotDef.row}-${slotDef.col}`; if (state.slotMap.has(key)) { const slotEl = state.slotMap.get(key); if (!slotEl.dataset.itemId) { slotEl.innerHTML = ''; const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; slotEl.className = `inv-equip-slot equipped ${colorClass}`; slotEl.dataset.itemId = item.item_id; slotEl.appendChild(createInventorySlot(item)); placed = true; } } } // If no exact match, find first matching bit in EQUIP_SLOTS if (!placed) { for (const m of Object.keys(EQUIP_SLOTS)) { const slotMask = parseInt(m); if ((mask & slotMask) === slotMask) { const slotDef = EQUIP_SLOTS[slotMask]; const key = `${slotDef.row}-${slotDef.col}`; if (state.slotMap.has(key)) { const slotEl = state.slotMap.get(key); if (!slotEl.dataset.itemId) { slotEl.innerHTML = ''; const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; slotEl.className = `inv-equip-slot equipped ${colorClass}`; slotEl.dataset.itemId = item.item_id; slotEl.appendChild(createInventorySlot(item)); placed = true; break; } } } } } } } else { // Non-equipped, non-container → pack item. Group by container_id. let cid = item.container_id || 0; // Items on the character body (not wielded) → treat as main backpack (cid=0) if (bodyContainerId !== null && cid === bodyContainerId) cid = 0; if (!packItems.has(cid)) packItems.set(cid, []); packItems.get(cid).push(item); } }); state.burdenLabel.textContent = 'Burden'; state.burdenFill.style.height = '0%'; // 4. Sort containers for stable sidebar order (by unsigned item_id) containers.sort((a, b) => { const ua = a.item_id >>> 0; const ub = b.item_id >>> 0; return ua - ub; }); // 5. Render packs in sidebar state.packList.innerHTML = ''; // Helper: compute icon URL from raw icon id const iconUrl = (iconRaw) => { if (!iconRaw) return '/icons/06001080.png'; const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); return `/icons/${hex}.png`; }; // --- Main backpack (container_id === 0, non-containers) --- const mainPackEl = document.createElement('div'); mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`; const mainPackImg = document.createElement('img'); mainPackImg.src = '/icons/06001BB1.png'; mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; }; const mainFillCont = document.createElement('div'); mainFillCont.className = 'inv-pack-fill-container'; const mainFill = document.createElement('div'); mainFill.className = 'inv-pack-fill'; // Main backpack items = container_id 0, excluding container objects const mainPackItems = packItems.get(0) || []; const mainPct = Math.min(100, (mainPackItems.length / 102) * 100); mainFill.style.height = `${mainPct}%`; mainFillCont.appendChild(mainFill); mainPackEl.appendChild(mainPackImg); mainPackEl.appendChild(mainFillCont); mainPackEl.onclick = () => { state.activePack = null; renderInventoryState(state); }; state.packList.appendChild(mainPackEl); // --- Sub-packs: each container object (object_class=10) --- containers.forEach(container => { const cid = container.item_id; // items inside this pack have container_id = this item_id const packEl = document.createElement('div'); packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`; const packImg = document.createElement('img'); // Use the container's actual icon from the API packImg.src = iconUrl(container.icon); packImg.onerror = function() { this.src = '/icons/06001080.png'; }; const fillCont = document.createElement('div'); fillCont.className = 'inv-pack-fill-container'; const fill = document.createElement('div'); fill.className = 'inv-pack-fill'; const pItems = packItems.get(cid) || []; const capacity = container.items_capacity || 24; // default pack capacity in AC const pPct = Math.min(100, (pItems.length / capacity) * 100); fill.style.height = `${pPct}%`; fillCont.appendChild(fill); packEl.appendChild(packImg); packEl.appendChild(fillCont); packEl.onclick = () => { state.activePack = cid; renderInventoryState(state); }; state.packList.appendChild(packEl); }); // 6. Render item grid state.itemGrid.innerHTML = ''; let itemsToShow = []; if (state.activePack === null) { // Main backpack: non-container items with container_id === 0 itemsToShow = mainPackItems; state.contentsHeader.textContent = 'Contents of Backpack'; } else { // Sub-pack: items with matching container_id itemsToShow = packItems.get(state.activePack) || []; // Use the container's name for the header const activeContainer = containers.find(c => c.item_id === state.activePack); state.contentsHeader.textContent = activeContainer ? `Contents of ${activeContainer.name}` : 'Contents of Pack'; } const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6); for (let i = 0; i < numCells; i++) { const cell = document.createElement('div'); if (i < itemsToShow.length) { cell.className = 'inv-item-slot occupied'; const itemNode = createInventorySlot(itemsToShow[i]); cell.appendChild(itemNode); } else { cell.className = 'inv-item-slot'; } state.itemGrid.appendChild(cell); } renderInventoryManaPanel(state); } function getManaTrackedItems(state) { if (!state || !state.items) return []; const snapshotMs = Date.now(); return state.items .filter(item => (item.current_wielded_location || 0) > 0) .filter(item => item.is_mana_tracked || item.current_mana !== undefined || item.max_mana !== undefined || item.spellcraft !== undefined) .map(item => { const result = { ...item }; if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) { const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN; if (!Number.isNaN(snapshotUtc)) { const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000)); result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0); } else { result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds; } } else { result.live_mana_time_remaining_seconds = null; } return result; }) .sort((a, b) => { const aRemaining = a.live_mana_time_remaining_seconds; const bRemaining = b.live_mana_time_remaining_seconds; if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || ''); if (aRemaining === null) return 1; if (bRemaining === null) return -1; if (aRemaining !== bRemaining) return aRemaining - bRemaining; return (a.name || '').localeCompare(b.name || ''); }); } function formatManaRemaining(totalSeconds) { if (totalSeconds === null || totalSeconds === undefined) return '--'; const safeSeconds = Math.max(0, Math.floor(totalSeconds)); const hours = Math.floor(safeSeconds / 3600); const minutes = Math.floor((safeSeconds % 3600) / 60); return `${hours}h${String(minutes).padStart(2, '0')}m`; } function renderInventoryManaPanel(state) { if (!state || !state.manaListBody || !state.manaSummary) return; const items = getManaTrackedItems(state); state.manaListBody.innerHTML = ''; if (items.length === 0) { const empty = document.createElement('div'); empty.className = 'inv-mana-empty'; empty.textContent = 'No equipped mana-bearing items'; state.manaListBody.appendChild(empty); state.manaSummary.textContent = 'Mana: 0 tracked'; return; } const activeCount = items.filter(item => item.mana_state === 'active').length; const lowCount = items.filter(item => (item.live_mana_time_remaining_seconds || 0) > 0 && item.live_mana_time_remaining_seconds <= 7200).length; state.manaSummary.textContent = `Mana: ${items.length} tracked, ${activeCount} active, ${lowCount} low`; items.forEach(item => { const row = document.createElement('div'); row.className = 'inv-mana-row'; const iconWrap = document.createElement('div'); iconWrap.className = 'inv-mana-icon'; const iconSlot = createInventorySlot(item); iconSlot.classList.add('mana-slot'); iconWrap.appendChild(iconSlot); const nameEl = document.createElement('div'); nameEl.className = 'inv-mana-name'; nameEl.textContent = item.name || item.Name || 'Unknown Item'; const stateEl = document.createElement('div'); const stateName = item.mana_state || 'unknown'; stateEl.className = `inv-mana-state-dot mana-state-${stateName}`; stateEl.title = stateName.replace(/_/g, ' '); const manaEl = document.createElement('div'); manaEl.className = 'inv-mana-value'; if (item.current_mana !== undefined && item.max_mana !== undefined) { manaEl.textContent = `${item.current_mana} / ${item.max_mana}`; } else if (item.mana_display) { manaEl.textContent = item.mana_display; } else { manaEl.textContent = '--'; } const timeEl = document.createElement('div'); timeEl.className = 'inv-mana-time'; timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds); row.appendChild(iconWrap); row.appendChild(nameEl); row.appendChild(stateEl); row.appendChild(manaEl); row.appendChild(timeEl); state.manaListBody.appendChild(row); }); } function showInventoryWindow(name) { debugLog('showInventoryWindow called for:', name); const windowId = `inventoryWindow-${name}`; const { win, content, isNew } = createWindow( windowId, `Inventory: ${name}`, 'inventory-window' ); if (!isNew) { debugLog('Existing inventory window found, showing it'); return; } win.dataset.character = name; inventoryWindows[name] = win; const loading = document.createElement('div'); loading.className = 'inventory-loading'; loading.textContent = 'Loading inventory...'; content.appendChild(loading); win.style.width = '540px'; win.style.height = '520px'; const invContent = document.createElement('div'); invContent.className = 'inventory-content'; invContent.style.display = 'none'; content.appendChild(invContent); const topSection = document.createElement('div'); topSection.className = 'inv-top-section'; const equipGrid = document.createElement('div'); equipGrid.className = 'inv-equipment-grid'; const slotMap = new Map(); const createdSlots = new Set(); Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => { const key = `${slotDef.row}-${slotDef.col}`; if (!createdSlots.has(key)) { createdSlots.add(key); const slotEl = document.createElement('div'); const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue'; slotEl.className = `inv-equip-slot empty ${colorClass}`; slotEl.style.left = `${(slotDef.col - 1) * 44}px`; slotEl.style.top = `${(slotDef.row - 1) * 44}px`; slotEl.dataset.pos = key; equipGrid.appendChild(slotEl); slotMap.set(key, slotEl); } }); const sidebar = document.createElement('div'); sidebar.className = 'inv-sidebar'; const manaPanel = document.createElement('div'); manaPanel.className = 'inv-mana-panel'; const manaHeader = document.createElement('div'); manaHeader.className = 'inv-mana-header'; manaHeader.textContent = 'Mana'; const manaSummary = document.createElement('div'); manaSummary.className = 'inv-mana-summary'; manaSummary.textContent = 'Mana: loading'; const manaListBody = document.createElement('div'); manaListBody.className = 'inv-mana-list'; manaPanel.appendChild(manaHeader); manaPanel.appendChild(manaSummary); manaPanel.appendChild(manaListBody); const burdenContainer = document.createElement('div'); burdenContainer.className = 'inv-burden-bar'; const burdenFill = document.createElement('div'); burdenFill.className = 'inv-burden-fill'; const burdenLabel = document.createElement('div'); burdenLabel.className = 'inv-burden-label'; burdenLabel.textContent = 'Burden'; burdenContainer.appendChild(burdenLabel); burdenContainer.appendChild(burdenFill); sidebar.appendChild(burdenContainer); const packList = document.createElement('div'); packList.className = 'inv-pack-list'; sidebar.appendChild(packList); topSection.appendChild(equipGrid); topSection.appendChild(sidebar); topSection.appendChild(manaPanel); const bottomSection = document.createElement('div'); bottomSection.className = 'inv-bottom-section'; const itemSection = document.createElement('div'); itemSection.className = 'inv-item-section'; const contentsHeader = document.createElement('div'); contentsHeader.className = 'inv-contents-header'; contentsHeader.textContent = 'Contents of Backpack'; const itemGrid = document.createElement('div'); itemGrid.className = 'inv-item-grid'; itemSection.appendChild(contentsHeader); itemSection.appendChild(itemGrid); bottomSection.appendChild(itemSection); invContent.appendChild(topSection); invContent.appendChild(bottomSection); const resizeGrip = document.createElement('div'); resizeGrip.className = 'inv-resize-grip'; win.appendChild(resizeGrip); let resizing = false; let startY, startH; resizeGrip.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); resizing = true; startY = e.clientY; startH = win.offsetHeight; document.body.style.cursor = 'ns-resize'; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!resizing) return; const newH = Math.max(400, startH + (e.clientY - startY)); win.style.height = newH + 'px'; }); document.addEventListener('mouseup', () => { if (!resizing) return; resizing = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }); win._inventoryState = { items: [], activePack: null, slotMap: slotMap, equipGrid: equipGrid, itemGrid: itemGrid, packList: packList, burdenFill: burdenFill, burdenLabel: burdenLabel, contentsHeader: contentsHeader, manaSummary: manaSummary, manaListBody: manaListBody, characterName: name }; fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); }) .then(data => { loading.style.display = 'none'; invContent.style.display = 'flex'; data.items.forEach(i => normalizeInventoryItem(i)); win._inventoryState.items = data.items; renderInventoryState(win._inventoryState); }) .catch(err => { handleError('Inventory', err, true); loading.textContent = `Failed to load inventory: ${err.message}`; }); debugLog('Inventory window created for:', name); } // === TreeStats Property ID Mappings === const TS_AUGMENTATIONS = { 218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement", 221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will", 224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence", 227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule", 230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment", 233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune", 236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance", 239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner", 241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler", 244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless", 294: "Master of the Steel Circle", 295: "Master of the Focused Eye", 296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer", 298: "Iron Skin of the Invincible", 299: "Jack of All Trades", 300: "Infused Void Magic", 301: "Infused War Magic", 302: "Infused Life Magic", 309: "Infused Item Magic", 310: "Infused Creature Magic", 326: "Clutch of the Miser", 328: "Enduring Enchantment" }; const TS_AURAS = { 333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution", 336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow", 340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization", 365: "World" }; const TS_RATINGS = { 370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance", 374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost", 379: "Vitality" }; const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" }; const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" }; const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" }; const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" }; function _tsSocietyRank(v) { if (v >= 1001) return "Master"; if (v >= 301) return "Lord"; if (v >= 151) return "Knight"; if (v >= 31) return "Adept"; return "Initiate"; } function _tsSetupTabs(container) { const tabs = container.querySelectorAll('.ts-tab'); const boxes = container.querySelectorAll('.ts-box'); tabs.forEach((tab, i) => { tab.addEventListener('click', () => { tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); }); boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); }); tab.classList.remove('inactive'); tab.classList.add('active'); if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); } }); }); } function showCharacterWindow(name) { debugLog('showCharacterWindow called for:', name); const windowId = `characterWindow-${name}`; const { win, content, isNew } = createWindow( windowId, `Character: ${name}`, 'character-window' ); if (!isNew) { debugLog('Existing character window found, showing it'); return; } win.dataset.character = name; characterWindows[name] = win; const esc = CSS.escape(name); content.innerHTML = `

${name}

Awaiting character data...
Total XP: \u2014
Unassigned XP: \u2014
Luminance: \u2014
Deaths: \u2014
Attributes
Skills
Titles
Health
\u2014 / \u2014
Stamina
\u2014 / \u2014
Mana
\u2014 / \u2014
AttributeCreationBase
Strength\u2014\u2014
Endurance\u2014\u2014
Coordination\u2014\u2014
Quickness\u2014\u2014
Focus\u2014\u2014
Self\u2014\u2014
VitalBase
Health\u2014
Stamina\u2014
Mana\u2014
Skill Credits\u2014
Awaiting data...
Awaiting data...
Augmentations
Ratings
Other
Awaiting data...
Awaiting data...
Awaiting data...
Allegiance
Awaiting data...
`; // Wire up tab switching const leftTabs = document.getElementById(`charTabLeft-${esc}`); const rightTabs = document.getElementById(`charTabRight-${esc}`); if (leftTabs) _tsSetupTabs(leftTabs); if (rightTabs) _tsSetupTabs(rightTabs); // Fetch existing data from API fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`) .then(r => r.ok ? r.json() : null) .then(data => { if (data && !data.error) { characterStats[name] = data; updateCharacterWindow(name, data); } }) .catch(err => handleError('Character stats', err)); // If we already have vitals from the live stream, apply them if (characterVitals[name]) { updateCharacterVitals(name, characterVitals[name]); } } function updateCharacterWindow(name, data) { const esc = CSS.escape(name); const fmt = n => n != null ? n.toLocaleString() : '\u2014'; // -- Header -- const header = document.getElementById(`charHeader-${esc}`); if (header) { const level = data.level || '?'; const race = data.race || ''; const gender = data.gender || ''; const parts = [gender, race].filter(Boolean).join(' '); header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...'; const levelSpan = header.querySelector('.ts-level'); if (levelSpan) levelSpan.textContent = level; } // -- XP / Luminance row -- const xplum = document.getElementById(`charXpLum-${esc}`); if (xplum) { const divs = xplum.querySelectorAll('div'); if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`; if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`; if (divs[2]) { const lum = data.luminance_earned != null && data.luminance_total != null ? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}` : '\u2014'; divs[2].textContent = `Luminance: ${lum}`; } if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`; } // -- Attributes table -- const attribTable = document.getElementById(`charAttribTable-${esc}`); if (attribTable && data.attributes) { const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self']; const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)'); order.forEach((attr, i) => { if (rows[i] && data.attributes[attr]) { const cells = rows[i].querySelectorAll('td'); if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014'; if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014'; } }); } // -- Vitals table (base values) -- const vitalsTable = document.getElementById(`charVitalsTable-${esc}`); if (vitalsTable && data.vitals) { const vOrder = ['health', 'stamina', 'mana']; const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)'); vOrder.forEach((v, i) => { if (vRows[i] && data.vitals[v]) { const cells = vRows[i].querySelectorAll('td'); if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014'; } }); } // -- Skill credits -- const creditsTable = document.getElementById(`charCredits-${esc}`); if (creditsTable) { const cell = creditsTable.querySelector('td.ts-headerright'); if (cell) cell.textContent = fmt(data.skill_credits); } // -- Skills tab -- const skillsBox = document.getElementById(`charSkills-${esc}`); if (skillsBox && data.skills) { const grouped = { Specialized: [], Trained: [] }; for (const [skill, info] of Object.entries(data.skills)) { const training = info.training || 'Untrained'; if (training === 'Untrained' || training === 'Unusable') continue; const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); if (grouped[training]) grouped[training].push({ name: displayName, base: info.base }); } for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name)); let html = ''; html += ''; if (grouped.Specialized.length) { for (const s of grouped.Specialized) { html += ``; } } if (grouped.Trained.length) { for (const s of grouped.Trained) { html += ``; } } html += '
SkillLevel
${s.name}${s.base}
${s.name}${s.base}
'; skillsBox.innerHTML = html; } // -- Titles tab -- const titlesBox = document.getElementById(`charTitles-${esc}`); if (titlesBox) { const statsData = data.stats_data || data; const titles = statsData.titles; if (titles && titles.length > 0) { let html = '
'; for (const t of titles) html += `
${t}
`; html += '
'; titlesBox.innerHTML = html; } else { titlesBox.innerHTML = '
No titles data
'; } } // -- Properties-based tabs (Augmentations, Ratings, Other) -- const statsData = data.stats_data || data; const props = statsData.properties || {}; // Augmentations tab const augsBox = document.getElementById(`charAugs-${esc}`); if (augsBox) { let augRows = [], auraRows = []; for (const [id, val] of Object.entries(props)) { const nid = parseInt(id); if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val }); if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val }); } if (augRows.length || auraRows.length) { let html = ''; if (augRows.length) { html += '
Augmentations
'; html += ''; for (const a of augRows) html += ``; html += '
NameUses
${a.name}${a.uses}
'; } if (auraRows.length) { html += '
Auras
'; html += ''; for (const a of auraRows) html += ``; html += '
NameUses
${a.name}${a.uses}
'; } augsBox.innerHTML = html; } else { augsBox.innerHTML = '
No augmentation data
'; } } // Ratings tab const ratingsBox = document.getElementById(`charRatings-${esc}`); if (ratingsBox) { let rows = []; for (const [id, val] of Object.entries(props)) { const nid = parseInt(id); if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val }); } if (rows.length) { let html = ''; for (const r of rows) html += ``; html += '
RatingValue
${r.name}${r.value}
'; ratingsBox.innerHTML = html; } else { ratingsBox.innerHTML = '
No rating data
'; } } // Other tab (General, Masteries, Society) const otherBox = document.getElementById(`charOther-${esc}`); if (otherBox) { let html = ''; // General section let generalRows = []; if (data.birth) generalRows.push({ name: 'Birth', value: data.birth }); if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) }); for (const [id, val] of Object.entries(props)) { const nid = parseInt(id); if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val }); } if (generalRows.length) { html += '
General
'; html += ''; for (const r of generalRows) html += ``; html += '
${r.name}${r.value}
'; } // Masteries section let masteryRows = []; for (const [id, val] of Object.entries(props)) { const nid = parseInt(id); if (TS_MASTERIES[nid]) { const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`; masteryRows.push({ name: TS_MASTERIES[nid], value: mName }); } } if (masteryRows.length) { html += '
Masteries
'; html += ''; for (const m of masteryRows) html += ``; html += '
${m.name}${m.value}
'; } // Society section let societyRows = []; for (const [id, val] of Object.entries(props)) { const nid = parseInt(id); if (TS_SOCIETY[nid] && val > 0) { societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val }); } } if (societyRows.length) { html += '
Society
'; html += ''; for (const s of societyRows) html += ``; html += '
${s.name}${s.rank} (${s.value})
'; } otherBox.innerHTML = html || '
No additional data
'; } // -- Allegiance section -- const allegDiv = document.getElementById(`charAllegiance-${esc}`); if (allegDiv && data.allegiance) { const a = data.allegiance; let html = '
Allegiance
'; html += ''; if (a.name) html += ``; if (a.monarch) html += ``; if (a.patron) html += ``; if (a.rank !== undefined) html += ``; if (a.followers !== undefined) html += ``; html += '
Name${a.name}
Monarch${a.monarch.name || '\u2014'}
Patron${a.patron.name || '\u2014'}
Rank${a.rank}
Followers${a.followers}
'; allegDiv.innerHTML = html; } } function updateCharacterVitals(name, vitals) { const esc = CSS.escape(name); const vitalsDiv = document.getElementById(`charVitals-${esc}`); if (!vitalsDiv) return; const vitalElements = vitalsDiv.querySelectorAll('.ts-vital'); if (vitalElements[0]) { const fill = vitalElements[0].querySelector('.ts-vital-fill'); const txt = vitalElements[0].querySelector('.ts-vital-text'); if (fill) fill.style.width = `${vitals.health_percentage || 0}%`; if (txt && vitals.health_current !== undefined) { txt.textContent = `${vitals.health_current} / ${vitals.health_max}`; } } if (vitalElements[1]) { const fill = vitalElements[1].querySelector('.ts-vital-fill'); const txt = vitalElements[1].querySelector('.ts-vital-text'); if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`; if (txt && vitals.stamina_current !== undefined) { txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`; } } if (vitalElements[2]) { const fill = vitalElements[2].querySelector('.ts-vital-fill'); const txt = vitalElements[2].querySelector('.ts-vital-text'); if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`; if (txt && vitals.mana_current !== undefined) { txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`; } } } // Inventory tooltip functions let inventoryTooltip = null; function showInventoryTooltip(e, slot) { if (!inventoryTooltip) { inventoryTooltip = document.createElement('div'); inventoryTooltip.className = 'inventory-tooltip'; document.body.appendChild(inventoryTooltip); } const name = slot.dataset.name; const value = parseInt(slot.dataset.value) || 0; const burden = parseInt(slot.dataset.burden) || 0; // Build enhanced tooltip let tooltipHTML = `
${name}
`; // Basic stats tooltipHTML += `
`; tooltipHTML += `
Value: ${value.toLocaleString()}
`; tooltipHTML += `
Burden: ${burden}
`; // Add workmanship right after basic stats for weapons/items if (slot.dataset.enhancedData) { try { const enhanced = JSON.parse(slot.dataset.enhancedData); if (enhanced.workmanship_text) { tooltipHTML += `
Workmanship: ${enhanced.workmanship_text}
`; } } catch (e) { // Ignore parsing errors for this section } } tooltipHTML += `
`; // Enhanced data from inventory service if (slot.dataset.enhancedData) { try { const enhanced = JSON.parse(slot.dataset.enhancedData); // Only show enhanced sections if we have enhanced data if (Object.keys(enhanced).length > 0) { // Helper function to check for valid values const isValid = (val) => val !== undefined && val !== null && val !== -1 && val !== -1.0; // Weapon-specific stats section (for weapons) if (enhanced.damage_range || enhanced.speed_text || enhanced.equip_skill_name) { tooltipHTML += `
`; // Skill requirement if (enhanced.equip_skill_name) { tooltipHTML += `
Skill: ${enhanced.equip_skill_name}
`; } // Damage with type if (enhanced.damage_range && enhanced.damage_type) { const damageText = enhanced.damage_type !== 'Physical' ? `${enhanced.damage_range}, ${enhanced.damage_type}` : enhanced.damage_range; tooltipHTML += `
Damage: ${damageText}
`; } // Speed if (enhanced.speed_text) { tooltipHTML += `
Speed: ${enhanced.speed_text}
`; } // Attack and defense bonuses (as percentages) if (isValid(enhanced.attack_bonus)) { const attackPercent = ((enhanced.attack_bonus - 1) * 100).toFixed(1); if (attackPercent !== "0.0") { tooltipHTML += `
Bonus to Attack Skill: ${attackPercent > 0 ? '+' : ''}${attackPercent}%
`; } } // Defense bonuses if (enhanced.melee_defense_bonus && isValid(enhanced.melee_defense_bonus)) { const defensePercent = ((enhanced.melee_defense_bonus - 1) * 100).toFixed(1); if (defensePercent !== "0.0") { tooltipHTML += `
Bonus to Melee Defense: ${defensePercent > 0 ? '+' : ''}${defensePercent}%
`; } } // Magic defense bonus if (enhanced.magic_defense_bonus && isValid(enhanced.magic_defense_bonus)) { const magicDefensePercent = ((enhanced.magic_defense_bonus - 1) * 100).toFixed(1); if (magicDefensePercent !== "0.0") { tooltipHTML += `
Bonus to Magic Defense: ${magicDefensePercent > 0 ? '+' : ''}${magicDefensePercent}%
`; } } // Elemental damage vs monsters if (enhanced.elemental_damage_vs_monsters && isValid(enhanced.elemental_damage_vs_monsters)) { const elementalPercent = ((enhanced.elemental_damage_vs_monsters - 1) * 100).toFixed(1); if (elementalPercent !== "0.0") { tooltipHTML += `
Elemental Damage vs Monsters: ${elementalPercent > 0 ? '+' : ''}${elementalPercent}%
`; } } tooltipHTML += `
`; } // Traditional combat stats section (for non-weapons or additional stats) const combatProps = []; if (isValid(enhanced.armor_level)) combatProps.push(`Armor Level: ${enhanced.armor_level}`); if (!enhanced.damage_range && isValid(enhanced.max_damage)) combatProps.push(`Max Damage: ${enhanced.max_damage}`); if (!enhanced.attack_bonus && isValid(enhanced.damage_bonus)) combatProps.push(`Damage Bonus: ${enhanced.damage_bonus.toFixed(1)}`); if (combatProps.length > 0) { tooltipHTML += `
Combat Stats
`; combatProps.forEach(prop => { tooltipHTML += `
${prop}
`; }); tooltipHTML += `
`; } // Requirements section const reqProps = []; if (isValid(enhanced.wield_level)) reqProps.push(`Level Required: ${enhanced.wield_level}`); if (isValid(enhanced.skill_level)) reqProps.push(`Skill Level: ${enhanced.skill_level}`); if (enhanced.equip_skill_name) reqProps.push(`Skill: ${enhanced.equip_skill_name}`); if (isValid(enhanced.lore_requirement)) reqProps.push(`Lore: ${enhanced.lore_requirement}`); if (reqProps.length > 0) { tooltipHTML += `
Requirements
`; reqProps.forEach(prop => { tooltipHTML += `
${prop}
`; }); tooltipHTML += `
`; } // Enhancement section const enhanceProps = []; if (enhanced.material_name) enhanceProps.push(`Material: ${enhanced.material_name}`); if (enhanced.imbue) enhanceProps.push(`Imbue: ${enhanced.imbue}`); if (enhanced.item_set) enhanceProps.push(`Set: ${enhanced.item_set}`); if (isValid(enhanced.tinks)) enhanceProps.push(`Tinks: ${enhanced.tinks}`); // Use workmanship_text if available, otherwise numeric value if (enhanced.workmanship_text) { enhanceProps.push(`Workmanship: ${enhanced.workmanship_text}`); } else if (isValid(enhanced.workmanship)) { enhanceProps.push(`Workmanship: ${enhanced.workmanship.toFixed(1)}`); } if (enhanceProps.length > 0) { tooltipHTML += `
Enhancements
`; enhanceProps.forEach(prop => { tooltipHTML += `
${prop}
`; }); tooltipHTML += `
`; } // Ratings section const ratingProps = []; if (isValid(enhanced.damage_rating)) ratingProps.push(`Damage Rating: ${enhanced.damage_rating}`); if (isValid(enhanced.crit_rating)) ratingProps.push(`Crit Rating: ${enhanced.crit_rating}`); if (isValid(enhanced.heal_boost_rating)) ratingProps.push(`Heal Boost: ${enhanced.heal_boost_rating}`); if (ratingProps.length > 0) { tooltipHTML += `
Ratings
`; ratingProps.forEach(prop => { tooltipHTML += `
${prop}
`; }); tooltipHTML += `
`; } // Spells section (condensed list) if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) { const spellNames = enhanced.spells.spells.map(spell => spell.name).join(', '); tooltipHTML += `
`; tooltipHTML += `
Spells: ${spellNames}
`; tooltipHTML += `
`; } // Mana and Spellcraft section if (enhanced.mana_display || enhanced.spellcraft) { tooltipHTML += `
`; if (enhanced.spellcraft) { tooltipHTML += `
Spellcraft: ${enhanced.spellcraft}
`; } if (enhanced.mana_display) { tooltipHTML += `
Mana: ${enhanced.mana_display}
`; } tooltipHTML += `
`; } // Detailed Spell Descriptions section if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) { tooltipHTML += `
Spell Descriptions
`; enhanced.spells.spells.forEach(spell => { tooltipHTML += `
`; tooltipHTML += `
${spell.name}
`; if (spell.description) { tooltipHTML += `
${spell.description}
`; } tooltipHTML += `
`; }); tooltipHTML += `
`; } // Object class info if (enhanced.object_class_name) { tooltipHTML += `
`; tooltipHTML += `
Type: ${enhanced.object_class_name}
`; tooltipHTML += `
`; } } // End of enhanced data check } catch (e) { console.warn('Failed to parse enhanced tooltip data', e); } } inventoryTooltip.innerHTML = tooltipHTML; // Position tooltip near cursor const x = e.clientX + 10; const y = e.clientY + 10; inventoryTooltip.style.left = `${x}px`; inventoryTooltip.style.top = `${y}px`; inventoryTooltip.style.display = 'block'; } function hideInventoryTooltip() { if (inventoryTooltip) { inventoryTooltip.style.display = 'none'; } } const applyTransform = () => group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; function clampPan() { if (!imgW) return; const r = wrap.getBoundingClientRect(); const vw = r.width, vh = r.height; const mw = imgW * scale, mh = imgH * scale; offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX)); offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY)); } function updateView() { clampPan(); applyTransform(); // Throttled heat map re-rendering during pan/zoom if (heatmapEnabled && heatmapData && !heatTimeout) { heatTimeout = setTimeout(() => { renderHeatmap(); heatTimeout = null; }, HEAT_THROTTLE); } } let pendingFrame = null; function scheduleViewUpdate() { if (!pendingFrame) { pendingFrame = requestAnimationFrame(() => { updateView(); pendingFrame = null; }); } } function fitToWindow() { const r = wrap.getBoundingClientRect(); scale = Math.min(r.width / imgW, r.height / imgH); minScale = scale; updateView(); } /* ---------- tooltip handlers ------------------------------------ */ function showTooltip(evt, p) { tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`; const r = wrap.getBoundingClientRect(); tooltip.style.left = `${evt.clientX - r.left + 10}px`; tooltip.style.top = `${evt.clientY - r.top + 10}px`; tooltip.style.display = 'block'; } function hideTooltip() { tooltip.style.display = 'none'; } /* ---------- polling and initialization -------------------------- */ async function pollLive() { try { const [liveRes, trailsRes] = await Promise.all([ fetch(`${API_BASE}/live/`), fetch(`${API_BASE}/trails/?seconds=600`), ]); const { players } = await liveRes.json(); const { trails } = await trailsRes.json(); currentPlayers = players; renderTrails(trails); renderList(); } catch (e) { handleError('Player update', e); } } async function pollTotalRares() { try { const response = await fetch(`${API_BASE}/total-rares/`); const data = await response.json(); updateTotalRaresDisplay(data); } catch (e) { handleError('Rare counter', e); } } function updateTotalRaresDisplay(data) { const countElement = document.getElementById('totalRaresCount'); if (countElement && data.all_time !== undefined && data.today !== undefined) { const allTimeFormatted = data.all_time.toLocaleString(); const todayFormatted = data.today.toLocaleString(); countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`; } } async function pollTotalKills() { try { const response = await fetch(`${API_BASE}/total-kills/`); const data = await response.json(); updateTotalKillsDisplay(data); } catch (e) { handleError('Kill counter', e); } } function updateTotalKillsDisplay(data) { const killsElement = document.getElementById('totalKillsCount'); if (killsElement && data.total !== undefined) { killsElement.textContent = data.total.toLocaleString(); } } async function pollServerHealth() { try { const response = await fetch(`${API_BASE}/server-health`); const data = await response.json(); updateServerStatusDisplay(data); } catch (e) { handleError('Server health', e); updateServerStatusDisplay({ status: 'error' }); } } function updateServerStatusDisplay(data) { const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const playerCount = document.getElementById('playerCount'); const latencyMs = document.getElementById('latencyMs'); const uptime = document.getElementById('uptime'); const lastRestart = document.getElementById('lastRestart'); if (!statusDot || !statusText) return; // Update status indicator const status = data.status || 'unknown'; statusDot.className = `status-dot status-${status}`; statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1); // Update player count if (playerCount) { playerCount.textContent = data.player_count !== null && data.player_count !== undefined ? data.player_count : '-'; } // Update latency if (latencyMs) { latencyMs.textContent = data.latency_ms ? Math.round(data.latency_ms) : '-'; } // Update uptime if (uptime) { uptime.textContent = data.uptime || '-'; } // Update last restart with Stockholm timezone (24h format, no year) if (lastRestart) { if (data.last_restart) { const restartDate = new Date(data.last_restart); const formattedDate = restartDate.toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); lastRestart.textContent = formattedDate; } else { lastRestart.textContent = 'Unknown'; } } } function handleServerStatusUpdate(msg) { // Handle real-time server status updates via WebSocket if (msg.status === 'up' && msg.message) { // Show notification for server coming back online debugLog(`Server Status: ${msg.message}`); } // Trigger an immediate server health poll to refresh the display pollServerHealth(); } function startPolling() { // Clear any existing intervals first (prevents leak on re-init) pollIntervals.forEach(id => clearInterval(id)); pollIntervals.length = 0; // Initial fetches pollLive(); pollTotalRares(); pollTotalKills(); pollServerHealth(); // Set up recurring polls pollIntervals.push(setInterval(pollLive, POLL_MS)); pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS)); pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS)); pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS)); } img.onload = () => { imgW = img.naturalWidth; imgH = img.naturalHeight; // size the SVG trails container to match the map dimensions if (trailsContainer) { trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`); trailsContainer.setAttribute('width', `${imgW}`); trailsContainer.setAttribute('height', `${imgH}`); } fitToWindow(); startPolling(); initWebSocket(); initHeatMap(); initPortalMap(); }; // Ensure server health polling starts regardless of image loading document.addEventListener('DOMContentLoaded', () => { // Start server health polling immediately on DOM ready pollServerHealth(); }); /* ---------- rendering sorted list & dots ------------------------ */ /** * Filter and sort the currentPlayers, then render them. */ function renderList() { // Filter by name prefix const filtered = currentPlayers.filter(p => p.character_name.toLowerCase().startsWith(currentFilter) ); // Sort filtered list filtered.sort(currentSort.comparator); const sorted = filtered; render(sorted); } // Track when user might be interacting to avoid DOM manipulation during clicks let userInteracting = false; let interactionTimeout = null; // Add global mousedown/mouseup tracking to detect when user is clicking document.addEventListener('mousedown', () => { userInteracting = true; if (interactionTimeout) clearTimeout(interactionTimeout); }); document.addEventListener('mouseup', () => { // Give a small buffer after mouseup to ensure click events complete if (interactionTimeout) clearTimeout(interactionTimeout); interactionTimeout = setTimeout(() => { userInteracting = false; }, 50); // 50ms buffer }); function render(players) { const startTime = performance.now(); debugLog('🔄 RENDER STARTING:', new Date().toISOString()); // If user is actively clicking, defer this render briefly if (userInteracting) { debugLog('🔄 RENDER DEFERRED: User interaction detected'); setTimeout(() => render(players), 100); return; } // Reset per-render stats performanceStats.renderDotsCreated = 0; performanceStats.renderDotsReused = 0; performanceStats.renderListItemsCreated = 0; performanceStats.renderListItemsReused = 0; performanceStats.renderCount++; // Get existing elements and map them by player name for reuse const existingDots = Array.from(dots.children); const existingListItems = Array.from(list.children); // Create maps for efficient lookup by player name const dotsByPlayer = new Map(); const listItemsByPlayer = new Map(); existingDots.forEach(dot => { if (dot.playerData && dot.playerData.character_name) { dotsByPlayer.set(dot.playerData.character_name, dot); } }); existingListItems.forEach(li => { if (li.playerData && li.playerData.character_name) { listItemsByPlayer.set(li.playerData.character_name, li); } }); // DON'T clear containers - we need to reuse elements // Update header with active player count const header = document.getElementById('activePlayersHeader'); if (header) { header.textContent = `Active Mosswart Enjoyers (${players.length})`; } // Calculate and update server KPH const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0); const kphElement = document.getElementById('serverKphCount'); if (kphElement) { // Format with commas and one decimal place for EPIC display const formattedKPH = totalKPH.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); kphElement.textContent = formattedKPH; // Add extra epic effect for high KPH const container = document.getElementById('serverKphCounter'); if (container) { if (totalKPH > 5000) { container.classList.add('ultra-epic'); } else { container.classList.remove('ultra-epic'); } } } // Total kills is now fetched from the /total-kills/ API endpoint // (see pollTotalKills function) to include ALL characters, not just online ones players.forEach((p) => { const { x, y } = worldToPx(p.ew, p.ns); // Reuse existing dot by player name or create new one let dot = dotsByPlayer.get(p.character_name); if (!dot) { dot = createNewDot(); dots.appendChild(dot); } else { performanceStats.dotsReused++; performanceStats.renderDotsReused++; // Remove from the map so we don't count it as unused later dotsByPlayer.delete(p.character_name); } // Update dot properties dot.style.left = `${x}px`; dot.style.top = `${y}px`; dot.style.background = getColorFor(p.character_name); dot.playerData = p; // Store for event handlers // Update highlight state if (p.character_name === selected) { dot.classList.add('highlight'); } else { dot.classList.remove('highlight'); } // Reuse existing list item by player name or create new one let li = listItemsByPlayer.get(p.character_name); if (!li) { li = createNewListItem(); list.appendChild(li); } else { performanceStats.listItemsReused++; performanceStats.renderListItemsReused++; // Remove from the map so we don't count it as unused later listItemsByPlayer.delete(p.character_name); } const color = getColorFor(p.character_name); li.style.borderLeftColor = color; li.playerData = p; // Store for event handlers BEFORE any DOM movement // Also store playerData directly on buttons for more reliable access if (li.chatBtn) li.chatBtn.playerData = p; if (li.statsBtn) li.statsBtn.playerData = p; if (li.inventoryBtn) li.inventoryBtn.playerData = p; // Only reorder element if it's actually out of place for current sort order // Check if this element needs to be moved to maintain sort order const expectedIndex = players.indexOf(p); const currentIndex = Array.from(list.children).indexOf(li); if (currentIndex !== expectedIndex && li.parentNode) { // Find the correct position to insert if (expectedIndex === players.length - 1) { // Should be last - only move if it's not already last if (li !== list.lastElementChild) { list.appendChild(li); } } else { // Should be at a specific position const nextPlayer = players[expectedIndex + 1]; const nextElement = Array.from(list.children).find(el => el.playerData && el.playerData.character_name === nextPlayer.character_name ); if (nextElement && li.nextElementSibling !== nextElement) { list.insertBefore(li, nextElement); } } } // Calculate KPR (Kills Per Rare) const playerTotalKills = p.total_kills || 0; const totalRares = p.total_rares || 0; const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞'; // Update only the grid content via innerHTML (buttons preserved) li.gridContent.innerHTML = ` ${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)} ${createVitalsHTML(p.character_name)} ${p.kills} ${p.total_kills || 0} ${p.kills_per_hour} ${p.session_rares}/${p.total_rares} ${kpr} ${p.vt_state} ${p.onlinetime} ${p.deaths}/${p.total_deaths || 0} ${p.prismatic_taper_count || 0} `; // Color the metastate pill according to its value const metaSpan = li.querySelector('.stat.meta'); if (metaSpan) { const goodStates = ['default', 'default2', 'hunt', 'combat']; const state = (p.vt_state || '').toString().toLowerCase(); metaSpan.classList.remove('green', 'red'); // Clear previous if (goodStates.includes(state)) { metaSpan.classList.add('green'); } else { metaSpan.classList.add('red'); } } // Update selected state if (p.character_name === selected) { li.classList.add('selected'); } else { li.classList.remove('selected'); } }); // Remove unused elements (any elements left in the maps are unused) // These are dots for players that are no longer in the current player list dotsByPlayer.forEach((dot, playerName) => { dots.removeChild(dot); }); // These are list items for players that are no longer in the current player list listItemsByPlayer.forEach((li, playerName) => { list.removeChild(li); }); // Update performance stats performanceStats.lastRenderTime = performance.now() - startTime; // Determine optimization status const totalCreatedThisRender = performanceStats.renderDotsCreated + performanceStats.renderListItemsCreated; const totalReusedThisRender = performanceStats.renderDotsReused + performanceStats.renderListItemsReused; const isOptimized = totalCreatedThisRender === 0 && totalReusedThisRender > 0; const isPartiallyOptimized = totalCreatedThisRender < players.length && totalReusedThisRender > 0; // Choose icon and color let statusIcon = '🚀'; let colorStyle = ''; if (isOptimized) { statusIcon = '✨'; colorStyle = 'background: #1a4a1a; color: #4ade80; font-weight: bold;'; } else if (isPartiallyOptimized) { statusIcon = '⚡'; colorStyle = 'background: #4a3a1a; color: #fbbf24; font-weight: bold;'; } else { statusIcon = '🔥'; colorStyle = 'background: #4a1a1a; color: #f87171; font-weight: bold;'; } // Performance stats are tracked but not logged to keep console clean // Optimization is achieving 100% element reuse consistently const renderTime = performance.now() - startTime; debugLog('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms'); } /* ---------- rendering trails ------------------------------- */ function renderTrails(trailData) { trailsContainer.innerHTML = ''; // Build point strings directly - avoid intermediate arrays const byChar = {}; for (const pt of trailData) { const { x, y } = worldToPx(pt.ew, pt.ns); const key = pt.character_name; if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 }; else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; } } for (const name in byChar) { if (byChar[name].count < 2) continue; const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); poly.setAttribute('points', byChar[name].points); poly.setAttribute('stroke', getColorFor(name)); poly.setAttribute('fill', 'none'); poly.setAttribute('class', 'trail-path'); trailsContainer.appendChild(poly); } } /* ---------- selection centering, focus zoom & blink ------------ */ function selectPlayer(p, x, y) { selected = p.character_name; // set focus zoom scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM)); // center on the player const r = wrap.getBoundingClientRect(); offX = r.width / 2 - x * scale; offY = r.height / 2 - y * scale; updateView(); renderList(); // keep sorted + highlight } /* * ---------- Chat & Command WebSocket Handlers ------------------ * Maintains a persistent WebSocket connection to the /ws/live endpoint * for receiving chat messages and sending user commands to plugin clients. * Reconnects automatically on close and logs errors. */ // Initialize WebSocket for chat and command streams function initWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`; socket = new WebSocket(wsUrl); socket.addEventListener('message', evt => { let msg; try { msg = JSON.parse(evt.data); } catch { return; } if (msg.type === 'chat') { appendChatMessage(msg); } else if (msg.type === 'vitals') { updateVitalsDisplay(msg); } else if (msg.type === 'rare') { triggerEpicRareNotification(msg.character_name, msg.name); } else if (msg.type === 'character_stats') { characterStats[msg.character_name] = msg; updateCharacterWindow(msg.character_name, msg); } else if (msg.type === 'inventory_delta') { updateInventoryLive(msg); } else if (msg.type === 'server_status') { handleServerStatusUpdate(msg); } }); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); socket.addEventListener('error', e => handleError('WebSocket', e)); } // Display or create a chat window for a character function showChatWindow(name) { debugLog('showChatWindow called for:', name); const windowId = `chatWindow-${name}`; const { win, content, isNew } = createWindow( windowId, `Chat: ${name}`, 'chat-window' ); if (!isNew) { debugLog('Existing chat window found, showing it'); return; } win.dataset.character = name; chatWindows[name] = win; // Messages container const msgs = document.createElement('div'); msgs.className = 'chat-messages'; content.appendChild(msgs); // Input form const form = document.createElement('form'); form.className = 'chat-form'; const input = document.createElement('input'); input.type = 'text'; input.className = 'chat-input'; input.placeholder = 'Enter chat...'; form.appendChild(input); form.addEventListener('submit', e => { e.preventDefault(); const text = input.value.trim(); if (!text) return; // Send command envelope: player_name and command only socket.send(JSON.stringify({ player_name: name, command: text })); input.value = ''; }); content.appendChild(form); debugLog('Chat window created for:', name); } // Append a chat message to the correct window /** * Append a chat message to the correct window, optionally coloring the text. * msg: { type: 'chat', character_name, text, color? } */ function appendChatMessage(msg) { const { character_name: name, text, color } = msg; const win = chatWindows[name]; if (!win) return; const msgs = win.querySelector('.chat-messages'); const p = document.createElement('div'); if (color !== undefined) { let c = color; if (typeof c === 'number') { // map numeric chat code to configured color, or fallback to raw hex if (CHAT_COLOR_MAP.hasOwnProperty(c)) { c = CHAT_COLOR_MAP[c]; } else { c = '#' + c.toString(16).padStart(6, '0'); } } p.style.color = c; } p.textContent = text; msgs.appendChild(p); // Enforce max number of lines in scrollback while (msgs.children.length > MAX_CHAT_LINES) { msgs.removeChild(msgs.firstChild); } // Scroll to bottom msgs.scrollTop = msgs.scrollHeight; } /* ---------- pan & zoom handlers -------------------------------- */ wrap.addEventListener('wheel', e => { e.preventDefault(); if (!imgW) return; const r = wrap.getBoundingClientRect(); const mx = (e.clientX - r.left - offX) / scale; const my = (e.clientY - r.top - offY) / scale; const factor = e.deltaY > 0 ? 0.9 : 1.1; let ns = scale * factor; ns = Math.max(minScale, Math.min(MAX_Z, ns)); offX -= mx * (ns - scale); offY -= my * (ns - scale); scale = ns; scheduleViewUpdate(); }, { passive: false }); wrap.addEventListener('mousedown', e => { dragging = true; sx = e.clientX; sy = e.clientY; wrap.classList.add('dragging'); }); window.addEventListener('mousemove', e => { if (!dragging) return; offX += e.clientX - sx; offY += e.clientY - sy; sx = e.clientX; sy = e.clientY; scheduleViewUpdate(); }); window.addEventListener('mouseup', () => { dragging = false; wrap.classList.remove('dragging'); }); wrap.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; dragging = true; sx = e.touches[0].clientX; sy = e.touches[0].clientY; }); wrap.addEventListener('touchmove', e => { if (!dragging || e.touches.length !== 1) return; const t = e.touches[0]; offX += t.clientX - sx; offY += t.clientY - sy; sx = t.clientX; sy = t.clientY; scheduleViewUpdate(); }); wrap.addEventListener('touchend', () => { dragging = false; }); /* ---------- coordinate display on hover ---------------------------- */ wrap.addEventListener('mousemove', e => { if (!imgW) return; const r = wrap.getBoundingClientRect(); const x = e.clientX - r.left; const y = e.clientY - r.top; const { ew, ns } = pxToWorld(x, y); // Display coordinates using the same format as the existing loc function coordinates.textContent = loc(ns, ew); coordinates.style.left = `${x + 10}px`; coordinates.style.top = `${y + 10}px`; coordinates.style.display = 'block'; }); wrap.addEventListener('mouseleave', () => { coordinates.style.display = 'none'; }); /* ---------- vitals display functions ----------------------------- */ // Store vitals data per character const characterVitals = {}; const characterStats = {}; const characterWindows = {}; function updateVitalsDisplay(vitalsMsg) { // Store the vitals data for this character characterVitals[vitalsMsg.character_name] = { health_percentage: vitalsMsg.health_percentage, stamina_percentage: vitalsMsg.stamina_percentage, mana_percentage: vitalsMsg.mana_percentage, health_current: vitalsMsg.health_current, health_max: vitalsMsg.health_max, stamina_current: vitalsMsg.stamina_current, stamina_max: vitalsMsg.stamina_max, mana_current: vitalsMsg.mana_current, mana_max: vitalsMsg.mana_max, vitae: vitalsMsg.vitae }; // Re-render the player list to update vitals in the UI renderList(); // Also update character window if open updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]); } function createVitalsHTML(characterName) { const vitals = characterVitals[characterName]; if (!vitals) { return ''; // No vitals data available } return `
`; } function createVitaeIndicator(characterName) { const vitals = characterVitals[characterName]; if (!vitals || !vitals.vitae || vitals.vitae >= 100) { return ''; // No vitae penalty } return `⚰️ ${vitals.vitae}%`; } function getVitalClass(percentage) { if (percentage <= 25) { return 'critical-vital'; } else if (percentage <= 50) { return 'low-vital'; } return ''; } /* ---------- epic rare notification system ------------------------ */ // Track previous rare count to detect increases let lastRareCount = 0; let notificationQueue = []; let isShowingNotification = false; function triggerEpicRareNotification(characterName, rareName) { // Add to queue notificationQueue.push({ characterName, rareName }); // Process queue if not already showing a notification if (!isShowingNotification) { processNotificationQueue(); } // Trigger fireworks immediately createFireworks(); // Highlight the player in the list highlightRareFinder(characterName); } function processNotificationQueue() { if (notificationQueue.length === 0) { isShowingNotification = false; return; } isShowingNotification = true; const notification = notificationQueue.shift(); // Create notification element const container = document.getElementById('rareNotifications'); const notifEl = document.createElement('div'); notifEl.className = 'rare-notification'; notifEl.innerHTML = `
🎆 LEGENDARY RARE! 🎆
${notification.rareName}
found by
⚔️ ${notification.characterName} ⚔️
`; container.appendChild(notifEl); // Remove notification after display duration and process next setTimeout(() => { notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards'; setTimeout(() => { notifEl.remove(); processNotificationQueue(); }, 500); }, NOTIFICATION_DURATION_MS); } // Add slide out animation const style = document.createElement('style'); style.textContent = ` @keyframes notification-slide-out { to { transform: translateY(-100px); opacity: 0; } } `; document.head.appendChild(style); function createFireworks() { const container = document.getElementById('fireworksContainer'); const rareCounter = document.getElementById('totalRaresCounter'); const rect = rareCounter.getBoundingClientRect(); // Create 30 particles const particleCount = 30; const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue']; for (let i = 0; i < particleCount; i++) { const particle = document.createElement('div'); particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`; // Start position at rare counter particle.style.left = `${rect.left + rect.width / 2}px`; particle.style.top = `${rect.top + rect.height / 2}px`; // Random explosion direction const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5; const velocity = 100 + Math.random() * 200; const dx = Math.cos(angle) * velocity; const dy = Math.sin(angle) * velocity - 50; // Slight upward bias // Create custom animation for this particle const animName = `particle-${Date.now()}-${i}`; const keyframes = ` @keyframes ${animName} { 0% { transform: translate(0, 0) scale(1); opacity: 1; } 100% { transform: translate(${dx}px, ${dy + 200}px) scale(0); opacity: 0; } } `; const styleEl = document.createElement('style'); styleEl.textContent = keyframes; document.head.appendChild(styleEl); particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`; container.appendChild(particle); // Clean up particle and animation after completion setTimeout(() => { particle.remove(); styleEl.remove(); }, 2000); } } function highlightRareFinder(characterName) { // Use element pool for O(1) lookup instead of querySelectorAll for (const item of elementPools.activeListItems) { if (item.playerData && item.playerData.character_name === characterName) { item.classList.add('rare-finder-glow'); setTimeout(() => { item.classList.remove('rare-finder-glow'); }, GLOW_DURATION_MS); break; } } } // Update total rares display to trigger fireworks on increase const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay; updateTotalRaresDisplay = function(data) { originalUpdateTotalRaresDisplay(data); // Check if total increased const newTotal = data.all_time || 0; if (newTotal > lastRareCount && lastRareCount > 0) { // Don't trigger on initial load createFireworks(); // Check for milestones when count increases if (newTotal > 0 && newTotal % 100 === 0) { triggerMilestoneCelebration(newTotal); } } lastRareCount = newTotal; } function triggerMilestoneCelebration(rareNumber) { debugLog(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`); // Create full-screen milestone overlay const overlay = document.createElement('div'); overlay.className = 'milestone-overlay'; overlay.innerHTML = `
#${rareNumber}
🏆 EPIC MILESTONE! 🏆
Server Achievement Unlocked
`; document.body.appendChild(overlay); // Add screen shake effect document.body.classList.add('screen-shake'); // Create massive firework explosion createMilestoneFireworks(); // Remove milestone overlay after 5 seconds setTimeout(() => { overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse'; document.body.classList.remove('screen-shake'); setTimeout(() => { overlay.remove(); }, 500); }, 5000); } function createMilestoneFireworks() { const container = document.getElementById('fireworksContainer'); // Create multiple bursts across the screen const burstCount = 5; const particlesPerBurst = 50; const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00']; for (let burst = 0; burst < burstCount; burst++) { setTimeout(() => { // Random position for each burst const x = Math.random() * window.innerWidth; const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15); for (let i = 0; i < particlesPerBurst; i++) { const particle = document.createElement('div'); particle.className = 'milestone-particle'; particle.style.background = colors[Math.floor(Math.random() * colors.length)]; particle.style.boxShadow = `0 0 12px ${particle.style.background}`; // Start position particle.style.left = `${x}px`; particle.style.top = `${y}px`; // Random explosion direction const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8; const velocity = 200 + Math.random() * 300; const dx = Math.cos(angle) * velocity; const dy = Math.sin(angle) * velocity - 100; // Upward bias // Create custom animation const animName = `milestone-particle-${Date.now()}-${burst}-${i}`; const keyframes = ` @keyframes ${animName} { 0% { transform: translate(0, 0) scale(1); opacity: 1; } 100% { transform: translate(${dx}px, ${dy + 400}px) scale(0); opacity: 0; } } `; const styleEl = document.createElement('style'); styleEl.textContent = keyframes; document.head.appendChild(styleEl); particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`; container.appendChild(particle); // Clean up setTimeout(() => { particle.remove(); styleEl.remove(); }, 3000); } }, burst * 200); // Stagger bursts } } /* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */ /** * Opens the dedicated inventory search page in a new browser tab. */ function openInventorySearch() { // Open the dedicated inventory search page in a new tab window.open('/inventory.html', '_blank'); } /** * Opens the Suitbuilder interface in a new browser tab. */ function openSuitbuilder() { // Open the Suitbuilder page in a new tab window.open('/suitbuilder.html', '_blank'); } /** * Opens the Player Debug interface in a new browser tab. */ function openPlayerDebug() { // Open the Player Debug page in a new tab window.open('/debug.html', '_blank'); } /** * Opens the Quest Status interface in a new browser tab. */ function openQuestStatus() { // Open the Quest Status page in a new tab window.open('/quest-status.html', '_blank'); } /** * Opens the Player Dashboard interface in a new browser tab. */ function openPlayerDashboard() { // Open the Player Dashboard page in a new tab window.open('/player-dashboard.html', '_blank'); }