diff --git a/discord-rare-monitor/discord_rare_monitor.py b/discord-rare-monitor/discord_rare_monitor.py index 7063691c..849d3963 100644 --- a/discord-rare-monitor/discord_rare_monitor.py +++ b/discord-rare-monitor/discord_rare_monitor.py @@ -73,22 +73,7 @@ class DiscordRareMonitor: # Setup Discord event handlers self.setup_discord_handlers() - - async def cancel_websocket_task(self): - """Safely cancel the existing websocket task if running. - - This prevents duplicate tasks from running in parallel, which would - cause duplicate Discord messages for each rare event. - """ - if self.websocket_task and not self.websocket_task.done(): - logger.info("🛑 Cancelling existing WebSocket task before creating new one") - self.websocket_task.cancel() - try: - await self.websocket_task - except asyncio.CancelledError: - pass - logger.info("✅ Old WebSocket task cancelled") - + def setup_discord_handlers(self): """Setup Discord client event handlers.""" @@ -125,22 +110,13 @@ class DiscordRareMonitor: logger.info(f"📍 Great rares channel: #{great_channel.name}") logger.info("🎯 Bot ready to receive messages!") - - # Cancel any existing WebSocket task first (prevents duplicates if on_ready fires twice) - await self.cancel_websocket_task() - + # Start WebSocket monitoring self.running = True self.websocket_task = asyncio.create_task(self.monitor_websocket()) logger.info("🔄 Started WebSocket monitoring task") - - # Start health monitoring task (also cancel if exists) - if self.health_monitor_task and not self.health_monitor_task.done(): - self.health_monitor_task.cancel() - try: - await self.health_monitor_task - except asyncio.CancelledError: - pass + + # Start health monitoring task self.health_monitor_task = asyncio.create_task(self.monitor_websocket_health()) logger.info("💓 Started WebSocket health monitoring task") @@ -157,8 +133,6 @@ class DiscordRareMonitor: if not self.websocket_task or self.websocket_task.done(): logger.warning("🔧 WebSocket task was lost during Discord disconnect - restarting") await self.post_status_to_aclog("🔄 Discord resumed: WebSocket was lost, restarting connection") - # Cancel any zombie task first (safety measure) - await self.cancel_websocket_task() self.websocket_task = asyncio.create_task(self.monitor_websocket()) else: logger.info("✅ WebSocket task still healthy after Discord resume") @@ -247,8 +221,6 @@ class DiscordRareMonitor: # Restart the WebSocket monitoring task logger.info("🔧 Restarting WebSocket monitoring task") await self.post_status_to_aclog("🚨 Health check detected WebSocket failure - restarting connection") - # Cancel any existing task first to prevent duplicates - await self.cancel_websocket_task() self.websocket_task = asyncio.create_task(self.monitor_websocket()) else: logger.debug("💓 WebSocket task health check passed") diff --git a/main.py b/main.py index 90db523f..0db32326 100644 --- a/main.py +++ b/main.py @@ -65,7 +65,6 @@ INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-ser _cached_live: dict = {"players": []} _cached_trails: dict = {"trails": []} _cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None} -_cached_total_kills: dict = {"total": 0, "last_updated": None} _cache_task: asyncio.Task | None = None _rares_cache_task: asyncio.Task | None = None _cleanup_task: asyncio.Task | None = None @@ -740,26 +739,14 @@ async def _refresh_total_rares_cache() -> None: except Exception as e: logger.debug(f"rare_events table not available or empty: {e}") today_total = 0 - - # Get total kills from char_stats table (all-time, all characters) - try: - kills_query = "SELECT COALESCE(SUM(total_kills), 0) as total FROM char_stats" - kills_result = await conn.fetch_one(kills_query) - total_kills = kills_result["total"] if kills_result else 0 - except Exception as e: - logger.debug(f"char_stats table not available: {e}") - total_kills = 0 - - # Update caches + + # Update cache _cached_total_rares["all_time"] = all_time_total _cached_total_rares["today"] = today_total _cached_total_rares["last_updated"] = datetime.now(timezone.utc) - - _cached_total_kills["total"] = total_kills - _cached_total_kills["last_updated"] = datetime.now(timezone.utc) - + consecutive_failures = 0 - logger.debug(f"Stats cache updated: Rares all-time: {all_time_total}, today: {today_total}, Kills: {total_kills}") + logger.debug(f"Total rares cache updated: All-time: {all_time_total}, Today: {today_total}") except Exception as e: consecutive_failures += 1 @@ -1192,17 +1179,6 @@ async def get_total_rares(): raise HTTPException(status_code=500, detail="Internal server error") -@app.get("/total-kills") -@app.get("/total-kills/") -async def get_total_kills(): - """Return cached total kills statistics (updated every 5 minutes).""" - try: - return JSONResponse(content=jsonable_encoder(_cached_total_kills)) - except Exception as e: - logger.error(f"Failed to get total kills: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - # --- GET Spawn Heat Map Endpoint --------------------------------- @app.get("/spawns/heatmap") async def get_spawn_heatmap_data( diff --git a/static/script.js b/static/script.js index 20b926f4..b37fbecf 100644 --- a/static/script.js +++ b/static/script.js @@ -33,170 +33,6 @@ 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) => { - console.log('🔥 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; - console.log('🔥 Player data found:', playerData); - if (playerData) { - console.log('🔥 Opening chat for:', playerData.character_name); - showChatWindow(playerData.character_name); - } else { - console.log('🔥 No player data found!'); - } - }); - - const statsBtn = document.createElement('button'); - statsBtn.className = 'stats-btn'; - statsBtn.textContent = 'Stats'; - statsBtn.addEventListener('click', (e) => { - console.log('📊 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; - console.log('📊 Player data found:', playerData); - if (playerData) { - console.log('📊 Opening stats for:', playerData.character_name); - showStatsWindow(playerData.character_name); - } else { - console.log('📊 No player data found!'); - } - }); - - const inventoryBtn = document.createElement('button'); - inventoryBtn.className = 'inventory-btn'; - inventoryBtn.textContent = 'Inventory'; - inventoryBtn.addEventListener('click', (e) => { - console.log('🎒 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; - console.log('🎒 Player data found:', playerData); - if (playerData) { - console.log('🎒 Opening inventory for:', playerData.character_name); - showInventoryWindow(playerData.character_name); - } else { - console.log('🎒 No player data found!'); - } - }); - - buttonsContainer.appendChild(chatBtn); - buttonsContainer.appendChild(statsBtn); - buttonsContainer.appendChild(inventoryBtn); - li.appendChild(buttonsContainer); - - // Store references for easy access - li.gridContent = gridContent; - li.chatBtn = chatBtn; - li.statsBtn = statsBtn; - li.inventoryBtn = inventoryBtn; - - 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; @@ -726,20 +562,16 @@ function debounce(fn, ms) { // Show or create a stats window for a character function showStatsWindow(name) { - console.log('📊 showStatsWindow called for:', name); if (statsWindows[name]) { const existing = statsWindows[name]; - console.log('📊 Existing stats window found, showing it:', existing); - // Always show the window (no toggle) - existing.style.display = 'flex'; - // Bring to front when opening - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - console.log('📊 Stats window shown with zIndex:', window.__chatZ); + // Toggle: close if already visible, open if hidden + if (existing.style.display === 'flex') { + existing.style.display = 'none'; + } else { + existing.style.display = 'flex'; + } return; } - console.log('📊 Creating new stats window for:', name); const win = document.createElement('div'); win.className = 'stats-window'; win.dataset.character = name; @@ -783,10 +615,8 @@ function showStatsWindow(name) { content.className = 'chat-messages'; content.textContent = 'Loading stats...'; win.appendChild(content); - console.log('📊 Appending stats window to DOM:', win); document.body.appendChild(win); statsWindows[name] = win; - console.log('📊 Stats window added to DOM, total children:', document.body.children.length); // Load initial stats with default 24h range updateStatsTimeRange(content, name, 'now-24h'); // Enable dragging using the global drag system @@ -821,20 +651,16 @@ function updateStatsTimeRange(content, name, timeRange) { // Show or create an inventory window for a character function showInventoryWindow(name) { - console.log('🎒 showInventoryWindow called for:', name); if (inventoryWindows[name]) { const existing = inventoryWindows[name]; - console.log('🎒 Existing inventory window found, showing it:', existing); - // Always show the window (no toggle) - existing.style.display = 'flex'; - // Bring to front when opening - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - console.log('🎒 Inventory window shown with zIndex:', window.__chatZ); + // Toggle: close if already visible, open if hidden + if (existing.style.display === 'flex') { + existing.style.display = 'none'; + } else { + existing.style.display = 'flex'; + } return; } - console.log('🎒 Creating new inventory window for:', name); const win = document.createElement('div'); win.className = 'inventory-window'; win.dataset.character = name; @@ -1026,10 +852,8 @@ function showInventoryWindow(name) { console.error('Inventory fetch failed:', err); }); - console.log('🎒 Appending inventory window to DOM:', win); document.body.appendChild(win); inventoryWindows[name] = win; - console.log('🎒 Inventory window added to DOM, total children:', document.body.children.length); // Enable dragging using the global drag system makeDraggable(win, header); @@ -1349,23 +1173,6 @@ function updateTotalRaresDisplay(data) { } } -async function pollTotalKills() { - try { - const response = await fetch(`${API_BASE}/total-kills/`); - const data = await response.json(); - updateTotalKillsDisplay(data); - } catch (e) { - console.error('Total kills fetch failed:', 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`); @@ -1441,13 +1248,10 @@ function startPolling() { if (pollID !== null) return; pollLive(); pollTotalRares(); // Initial fetch - pollTotalKills(); // Initial fetch pollServerHealth(); // Initial server health check pollID = setInterval(pollLive, POLL_MS); // Poll total rares every 5 minutes (300,000 ms) setInterval(pollTotalRares, 300000); - // Poll total kills every 5 minutes (300,000 ms) - setInterval(pollTotalKills, 300000); // Poll server health every 30 seconds (30,000 ms) setInterval(pollServerHealth, 30000); } @@ -1488,64 +1292,9 @@ function renderList() { 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(); - console.log('🔄 RENDER STARTING:', new Date().toISOString()); - - // If user is actively clicking, defer this render briefly - if (userInteracting) { - console.log('🔄 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 + dots.innerHTML = ''; + list.innerHTML = ''; // Update header with active player count const header = document.getElementById('activePlayersHeader'); @@ -1558,12 +1307,12 @@ function render(players) { 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 + 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) { @@ -1575,89 +1324,49 @@ function render(players) { } } - // Total kills is now fetched from the /total-kills/ API endpoint - // (see pollTotalKills function) to include ALL characters, not just online ones + // Calculate and update total kills + const totalKills = players.reduce((sum, p) => sum + (p.total_kills || 0), 0); + const killsElement = document.getElementById('totalKillsCount'); + if (killsElement) { + // Format with commas for readability + const formattedKills = totalKills.toLocaleString(); + killsElement.textContent = formattedKills; + } - players.forEach((p) => { + 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 + const dot = document.createElement('div'); + dot.className = 'dot'; + 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); - } + // custom tooltip + dot.addEventListener('mouseenter', e => showTooltip(e, p)); + dot.addEventListener('mousemove', e => showTooltip(e, p)); + dot.addEventListener('mouseleave', hideTooltip); + + // click to select/zoom + dot.addEventListener('click', () => selectPlayer(p, x, y)); + + if (p.character_name === selected) dot.classList.add('highlight'); + dots.appendChild(dot); + //sidebar + const li = document.createElement('li'); 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); - } - } - } - + li.className = 'player-item'; // Calculate KPR (Kills Per Rare) - const playerTotalKills = p.total_kills || 0; + const totalKills = 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 = ` + const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞'; + + li.innerHTML = ` ${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)} ${createVitalsHTML(p.character_name)} ${p.kills} @@ -1676,61 +1385,44 @@ function render(players) { 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'); - } + + li.addEventListener('click', () => selectPlayer(p, x, y)); + if (p.character_name === selected) li.classList.add('selected'); + // Chat button + const chatBtn = document.createElement('button'); + chatBtn.className = 'chat-btn'; + chatBtn.textContent = 'Chat'; + chatBtn.addEventListener('click', e => { + e.stopPropagation(); + showChatWindow(p.character_name); + }); + li.appendChild(chatBtn); + // Stats button + const statsBtn = document.createElement('button'); + statsBtn.className = 'stats-btn'; + statsBtn.textContent = 'Stats'; + statsBtn.addEventListener('click', e => { + e.stopPropagation(); + showStatsWindow(p.character_name); + }); + li.appendChild(statsBtn); + // Inventory button + const inventoryBtn = document.createElement('button'); + inventoryBtn.className = 'inventory-btn'; + inventoryBtn.textContent = 'Inventory'; + inventoryBtn.addEventListener('click', e => { + e.stopPropagation(); + showInventoryWindow(p.character_name); + }); + li.appendChild(inventoryBtn); + list.appendChild(li); }); - - // 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; - console.log('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms'); } /* ---------- rendering trails ------------------------------- */ @@ -1798,20 +1490,20 @@ function initWebSocket() { // Display or create a chat window for a character function showChatWindow(name) { - console.log('💬 showChatWindow called for:', name); if (chatWindows[name]) { const existing = chatWindows[name]; - console.log('💬 Existing chat window found, showing it:', existing); - // Always show the window (no toggle) - existing.style.display = 'flex'; - // Bring to front when opening - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - console.log('💬 Chat window shown with zIndex:', window.__chatZ); + // Toggle: close if already visible, open if hidden + if (existing.style.display === 'flex') { + existing.style.display = 'none'; + } else { + existing.style.display = 'flex'; + // Bring to front when opening + if (!window.__chatZ) window.__chatZ = 10000; + window.__chatZ += 1; + existing.style.zIndex = window.__chatZ; + } return; } - console.log('💬 Creating new chat window for:', name); const win = document.createElement('div'); win.className = 'chat-window'; win.dataset.character = name; @@ -1848,10 +1540,8 @@ function showChatWindow(name) { input.value = ''; }); win.appendChild(form); - console.log('💬 Appending chat window to DOM:', win); document.body.appendChild(win); chatWindows[name] = win; - console.log('💬 Chat window added to DOM, total children:', document.body.children.length); // Enable dragging using the global drag system makeDraggable(win, header);