Compare commits
4 commits
6adb88e0b1
...
a6fafb018a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6fafb018a | ||
|
|
4e0306cd01 | ||
|
|
42d5dab319 | ||
|
|
c7ba4f18bc |
3 changed files with 461 additions and 99 deletions
|
|
@ -73,7 +73,22 @@ class DiscordRareMonitor:
|
||||||
|
|
||||||
# Setup Discord event handlers
|
# Setup Discord event handlers
|
||||||
self.setup_discord_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):
|
def setup_discord_handlers(self):
|
||||||
"""Setup Discord client event handlers."""
|
"""Setup Discord client event handlers."""
|
||||||
|
|
||||||
|
|
@ -110,13 +125,22 @@ class DiscordRareMonitor:
|
||||||
logger.info(f"📍 Great rares channel: #{great_channel.name}")
|
logger.info(f"📍 Great rares channel: #{great_channel.name}")
|
||||||
|
|
||||||
logger.info("🎯 Bot ready to receive messages!")
|
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
|
# Start WebSocket monitoring
|
||||||
self.running = True
|
self.running = True
|
||||||
self.websocket_task = asyncio.create_task(self.monitor_websocket())
|
self.websocket_task = asyncio.create_task(self.monitor_websocket())
|
||||||
logger.info("🔄 Started WebSocket monitoring task")
|
logger.info("🔄 Started WebSocket monitoring task")
|
||||||
|
|
||||||
# Start health 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
|
||||||
self.health_monitor_task = asyncio.create_task(self.monitor_websocket_health())
|
self.health_monitor_task = asyncio.create_task(self.monitor_websocket_health())
|
||||||
logger.info("💓 Started WebSocket health monitoring task")
|
logger.info("💓 Started WebSocket health monitoring task")
|
||||||
|
|
||||||
|
|
@ -133,6 +157,8 @@ class DiscordRareMonitor:
|
||||||
if not self.websocket_task or self.websocket_task.done():
|
if not self.websocket_task or self.websocket_task.done():
|
||||||
logger.warning("🔧 WebSocket task was lost during Discord disconnect - restarting")
|
logger.warning("🔧 WebSocket task was lost during Discord disconnect - restarting")
|
||||||
await self.post_status_to_aclog("🔄 Discord resumed: WebSocket was lost, restarting connection")
|
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())
|
self.websocket_task = asyncio.create_task(self.monitor_websocket())
|
||||||
else:
|
else:
|
||||||
logger.info("✅ WebSocket task still healthy after Discord resume")
|
logger.info("✅ WebSocket task still healthy after Discord resume")
|
||||||
|
|
@ -221,6 +247,8 @@ class DiscordRareMonitor:
|
||||||
# Restart the WebSocket monitoring task
|
# Restart the WebSocket monitoring task
|
||||||
logger.info("🔧 Restarting WebSocket monitoring task")
|
logger.info("🔧 Restarting WebSocket monitoring task")
|
||||||
await self.post_status_to_aclog("🚨 Health check detected WebSocket failure - restarting connection")
|
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())
|
self.websocket_task = asyncio.create_task(self.monitor_websocket())
|
||||||
else:
|
else:
|
||||||
logger.debug("💓 WebSocket task health check passed")
|
logger.debug("💓 WebSocket task health check passed")
|
||||||
|
|
|
||||||
32
main.py
32
main.py
|
|
@ -65,6 +65,7 @@ INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-ser
|
||||||
_cached_live: dict = {"players": []}
|
_cached_live: dict = {"players": []}
|
||||||
_cached_trails: dict = {"trails": []}
|
_cached_trails: dict = {"trails": []}
|
||||||
_cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None}
|
_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
|
_cache_task: asyncio.Task | None = None
|
||||||
_rares_cache_task: asyncio.Task | None = None
|
_rares_cache_task: asyncio.Task | None = None
|
||||||
_cleanup_task: asyncio.Task | None = None
|
_cleanup_task: asyncio.Task | None = None
|
||||||
|
|
@ -739,14 +740,26 @@ async def _refresh_total_rares_cache() -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"rare_events table not available or empty: {e}")
|
logger.debug(f"rare_events table not available or empty: {e}")
|
||||||
today_total = 0
|
today_total = 0
|
||||||
|
|
||||||
# Update cache
|
# 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
|
||||||
_cached_total_rares["all_time"] = all_time_total
|
_cached_total_rares["all_time"] = all_time_total
|
||||||
_cached_total_rares["today"] = today_total
|
_cached_total_rares["today"] = today_total
|
||||||
_cached_total_rares["last_updated"] = datetime.now(timezone.utc)
|
_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
|
consecutive_failures = 0
|
||||||
logger.debug(f"Total rares cache updated: All-time: {all_time_total}, Today: {today_total}")
|
logger.debug(f"Stats cache updated: Rares all-time: {all_time_total}, today: {today_total}, Kills: {total_kills}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
|
|
@ -1179,6 +1192,17 @@ async def get_total_rares():
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
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 ---------------------------------
|
# --- GET Spawn Heat Map Endpoint ---------------------------------
|
||||||
@app.get("/spawns/heatmap")
|
@app.get("/spawns/heatmap")
|
||||||
async def get_spawn_heatmap_data(
|
async def get_spawn_heatmap_data(
|
||||||
|
|
|
||||||
492
static/script.js
492
static/script.js
|
|
@ -33,6 +33,170 @@ const btnContainer = document.getElementById('sortButtons');
|
||||||
const tooltip = document.getElementById('tooltip');
|
const tooltip = document.getElementById('tooltip');
|
||||||
const coordinates = document.getElementById('coordinates');
|
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
|
// Global drag system to prevent event listener accumulation
|
||||||
let currentDragWindow = null;
|
let currentDragWindow = null;
|
||||||
let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
|
let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
|
||||||
|
|
@ -562,16 +726,20 @@ function debounce(fn, ms) {
|
||||||
|
|
||||||
// Show or create a stats window for a character
|
// Show or create a stats window for a character
|
||||||
function showStatsWindow(name) {
|
function showStatsWindow(name) {
|
||||||
|
console.log('📊 showStatsWindow called for:', name);
|
||||||
if (statsWindows[name]) {
|
if (statsWindows[name]) {
|
||||||
const existing = statsWindows[name];
|
const existing = statsWindows[name];
|
||||||
// Toggle: close if already visible, open if hidden
|
console.log('📊 Existing stats window found, showing it:', existing);
|
||||||
if (existing.style.display === 'flex') {
|
// Always show the window (no toggle)
|
||||||
existing.style.display = 'none';
|
existing.style.display = 'flex';
|
||||||
} else {
|
// Bring to front when opening
|
||||||
existing.style.display = 'flex';
|
if (!window.__chatZ) window.__chatZ = 10000;
|
||||||
}
|
window.__chatZ += 1;
|
||||||
|
existing.style.zIndex = window.__chatZ;
|
||||||
|
console.log('📊 Stats window shown with zIndex:', window.__chatZ);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log('📊 Creating new stats window for:', name);
|
||||||
const win = document.createElement('div');
|
const win = document.createElement('div');
|
||||||
win.className = 'stats-window';
|
win.className = 'stats-window';
|
||||||
win.dataset.character = name;
|
win.dataset.character = name;
|
||||||
|
|
@ -615,8 +783,10 @@ function showStatsWindow(name) {
|
||||||
content.className = 'chat-messages';
|
content.className = 'chat-messages';
|
||||||
content.textContent = 'Loading stats...';
|
content.textContent = 'Loading stats...';
|
||||||
win.appendChild(content);
|
win.appendChild(content);
|
||||||
|
console.log('📊 Appending stats window to DOM:', win);
|
||||||
document.body.appendChild(win);
|
document.body.appendChild(win);
|
||||||
statsWindows[name] = win;
|
statsWindows[name] = win;
|
||||||
|
console.log('📊 Stats window added to DOM, total children:', document.body.children.length);
|
||||||
// Load initial stats with default 24h range
|
// Load initial stats with default 24h range
|
||||||
updateStatsTimeRange(content, name, 'now-24h');
|
updateStatsTimeRange(content, name, 'now-24h');
|
||||||
// Enable dragging using the global drag system
|
// Enable dragging using the global drag system
|
||||||
|
|
@ -651,16 +821,20 @@ function updateStatsTimeRange(content, name, timeRange) {
|
||||||
|
|
||||||
// Show or create an inventory window for a character
|
// Show or create an inventory window for a character
|
||||||
function showInventoryWindow(name) {
|
function showInventoryWindow(name) {
|
||||||
|
console.log('🎒 showInventoryWindow called for:', name);
|
||||||
if (inventoryWindows[name]) {
|
if (inventoryWindows[name]) {
|
||||||
const existing = inventoryWindows[name];
|
const existing = inventoryWindows[name];
|
||||||
// Toggle: close if already visible, open if hidden
|
console.log('🎒 Existing inventory window found, showing it:', existing);
|
||||||
if (existing.style.display === 'flex') {
|
// Always show the window (no toggle)
|
||||||
existing.style.display = 'none';
|
existing.style.display = 'flex';
|
||||||
} else {
|
// Bring to front when opening
|
||||||
existing.style.display = 'flex';
|
if (!window.__chatZ) window.__chatZ = 10000;
|
||||||
}
|
window.__chatZ += 1;
|
||||||
|
existing.style.zIndex = window.__chatZ;
|
||||||
|
console.log('🎒 Inventory window shown with zIndex:', window.__chatZ);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log('🎒 Creating new inventory window for:', name);
|
||||||
const win = document.createElement('div');
|
const win = document.createElement('div');
|
||||||
win.className = 'inventory-window';
|
win.className = 'inventory-window';
|
||||||
win.dataset.character = name;
|
win.dataset.character = name;
|
||||||
|
|
@ -852,8 +1026,10 @@ function showInventoryWindow(name) {
|
||||||
console.error('Inventory fetch failed:', err);
|
console.error('Inventory fetch failed:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('🎒 Appending inventory window to DOM:', win);
|
||||||
document.body.appendChild(win);
|
document.body.appendChild(win);
|
||||||
inventoryWindows[name] = win;
|
inventoryWindows[name] = win;
|
||||||
|
console.log('🎒 Inventory window added to DOM, total children:', document.body.children.length);
|
||||||
|
|
||||||
// Enable dragging using the global drag system
|
// Enable dragging using the global drag system
|
||||||
makeDraggable(win, header);
|
makeDraggable(win, header);
|
||||||
|
|
@ -1173,6 +1349,23 @@ 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() {
|
async function pollServerHealth() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/server-health`);
|
const response = await fetch(`${API_BASE}/server-health`);
|
||||||
|
|
@ -1248,10 +1441,13 @@ function startPolling() {
|
||||||
if (pollID !== null) return;
|
if (pollID !== null) return;
|
||||||
pollLive();
|
pollLive();
|
||||||
pollTotalRares(); // Initial fetch
|
pollTotalRares(); // Initial fetch
|
||||||
|
pollTotalKills(); // Initial fetch
|
||||||
pollServerHealth(); // Initial server health check
|
pollServerHealth(); // Initial server health check
|
||||||
pollID = setInterval(pollLive, POLL_MS);
|
pollID = setInterval(pollLive, POLL_MS);
|
||||||
// Poll total rares every 5 minutes (300,000 ms)
|
// Poll total rares every 5 minutes (300,000 ms)
|
||||||
setInterval(pollTotalRares, 300000);
|
setInterval(pollTotalRares, 300000);
|
||||||
|
// Poll total kills every 5 minutes (300,000 ms)
|
||||||
|
setInterval(pollTotalKills, 300000);
|
||||||
// Poll server health every 30 seconds (30,000 ms)
|
// Poll server health every 30 seconds (30,000 ms)
|
||||||
setInterval(pollServerHealth, 30000);
|
setInterval(pollServerHealth, 30000);
|
||||||
}
|
}
|
||||||
|
|
@ -1292,9 +1488,64 @@ function renderList() {
|
||||||
render(sorted);
|
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) {
|
function render(players) {
|
||||||
dots.innerHTML = '';
|
const startTime = performance.now();
|
||||||
list.innerHTML = '';
|
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
|
||||||
|
|
||||||
// Update header with active player count
|
// Update header with active player count
|
||||||
const header = document.getElementById('activePlayersHeader');
|
const header = document.getElementById('activePlayersHeader');
|
||||||
|
|
@ -1307,12 +1558,12 @@ function render(players) {
|
||||||
const kphElement = document.getElementById('serverKphCount');
|
const kphElement = document.getElementById('serverKphCount');
|
||||||
if (kphElement) {
|
if (kphElement) {
|
||||||
// Format with commas and one decimal place for EPIC display
|
// Format with commas and one decimal place for EPIC display
|
||||||
const formattedKPH = totalKPH.toLocaleString('en-US', {
|
const formattedKPH = totalKPH.toLocaleString('en-US', {
|
||||||
minimumFractionDigits: 1,
|
minimumFractionDigits: 1,
|
||||||
maximumFractionDigits: 1
|
maximumFractionDigits: 1
|
||||||
});
|
});
|
||||||
kphElement.textContent = formattedKPH;
|
kphElement.textContent = formattedKPH;
|
||||||
|
|
||||||
// Add extra epic effect for high KPH
|
// Add extra epic effect for high KPH
|
||||||
const container = document.getElementById('serverKphCounter');
|
const container = document.getElementById('serverKphCounter');
|
||||||
if (container) {
|
if (container) {
|
||||||
|
|
@ -1324,49 +1575,89 @@ function render(players) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate and update total kills
|
// Total kills is now fetched from the /total-kills/ API endpoint
|
||||||
const totalKills = players.reduce((sum, p) => sum + (p.total_kills || 0), 0);
|
// (see pollTotalKills function) to include ALL characters, not just online ones
|
||||||
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);
|
const { x, y } = worldToPx(p.ew, p.ns);
|
||||||
|
|
||||||
// dot
|
// Reuse existing dot by player name or create new one
|
||||||
const dot = document.createElement('div');
|
let dot = dotsByPlayer.get(p.character_name);
|
||||||
dot.className = 'dot';
|
if (!dot) {
|
||||||
dot.style.left = `${x}px`;
|
dot = createNewDot();
|
||||||
dot.style.top = `${y}px`;
|
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.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);
|
const color = getColorFor(p.character_name);
|
||||||
li.style.borderLeftColor = color;
|
li.style.borderLeftColor = color;
|
||||||
li.className = 'player-item';
|
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)
|
// Calculate KPR (Kills Per Rare)
|
||||||
const totalKills = p.total_kills || 0;
|
const playerTotalKills = p.total_kills || 0;
|
||||||
const totalRares = p.total_rares || 0;
|
const totalRares = p.total_rares || 0;
|
||||||
const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
|
const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞';
|
||||||
|
|
||||||
li.innerHTML = `
|
// Update only the grid content via innerHTML (buttons preserved)
|
||||||
|
li.gridContent.innerHTML = `
|
||||||
<span class="player-name">${p.character_name}${createVitaeIndicator(p.character_name)} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
|
<span class="player-name">${p.character_name}${createVitaeIndicator(p.character_name)} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
|
||||||
${createVitalsHTML(p.character_name)}
|
${createVitalsHTML(p.character_name)}
|
||||||
<span class="stat kills">${p.kills}</span>
|
<span class="stat kills">${p.kills}</span>
|
||||||
|
|
@ -1385,44 +1676,61 @@ function render(players) {
|
||||||
if (metaSpan) {
|
if (metaSpan) {
|
||||||
const goodStates = ['default', 'default2', 'hunt', 'combat'];
|
const goodStates = ['default', 'default2', 'hunt', 'combat'];
|
||||||
const state = (p.vt_state || '').toString().toLowerCase();
|
const state = (p.vt_state || '').toString().toLowerCase();
|
||||||
|
metaSpan.classList.remove('green', 'red'); // Clear previous
|
||||||
if (goodStates.includes(state)) {
|
if (goodStates.includes(state)) {
|
||||||
metaSpan.classList.add('green');
|
metaSpan.classList.add('green');
|
||||||
} else {
|
} else {
|
||||||
metaSpan.classList.add('red');
|
metaSpan.classList.add('red');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
li.addEventListener('click', () => selectPlayer(p, x, y));
|
// Update selected state
|
||||||
if (p.character_name === selected) li.classList.add('selected');
|
if (p.character_name === selected) {
|
||||||
// Chat button
|
li.classList.add('selected');
|
||||||
const chatBtn = document.createElement('button');
|
} else {
|
||||||
chatBtn.className = 'chat-btn';
|
li.classList.remove('selected');
|
||||||
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 ------------------------------- */
|
/* ---------- rendering trails ------------------------------- */
|
||||||
|
|
@ -1490,20 +1798,20 @@ function initWebSocket() {
|
||||||
|
|
||||||
// Display or create a chat window for a character
|
// Display or create a chat window for a character
|
||||||
function showChatWindow(name) {
|
function showChatWindow(name) {
|
||||||
|
console.log('💬 showChatWindow called for:', name);
|
||||||
if (chatWindows[name]) {
|
if (chatWindows[name]) {
|
||||||
const existing = chatWindows[name];
|
const existing = chatWindows[name];
|
||||||
// Toggle: close if already visible, open if hidden
|
console.log('💬 Existing chat window found, showing it:', existing);
|
||||||
if (existing.style.display === 'flex') {
|
// Always show the window (no toggle)
|
||||||
existing.style.display = 'none';
|
existing.style.display = 'flex';
|
||||||
} else {
|
// Bring to front when opening
|
||||||
existing.style.display = 'flex';
|
if (!window.__chatZ) window.__chatZ = 10000;
|
||||||
// Bring to front when opening
|
window.__chatZ += 1;
|
||||||
if (!window.__chatZ) window.__chatZ = 10000;
|
existing.style.zIndex = window.__chatZ;
|
||||||
window.__chatZ += 1;
|
console.log('💬 Chat window shown with zIndex:', window.__chatZ);
|
||||||
existing.style.zIndex = window.__chatZ;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log('💬 Creating new chat window for:', name);
|
||||||
const win = document.createElement('div');
|
const win = document.createElement('div');
|
||||||
win.className = 'chat-window';
|
win.className = 'chat-window';
|
||||||
win.dataset.character = name;
|
win.dataset.character = name;
|
||||||
|
|
@ -1540,8 +1848,10 @@ function showChatWindow(name) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
});
|
});
|
||||||
win.appendChild(form);
|
win.appendChild(form);
|
||||||
|
console.log('💬 Appending chat window to DOM:', win);
|
||||||
document.body.appendChild(win);
|
document.body.appendChild(win);
|
||||||
chatWindows[name] = win;
|
chatWindows[name] = win;
|
||||||
|
console.log('💬 Chat window added to DOM, total children:', document.body.children.length);
|
||||||
|
|
||||||
// Enable dragging using the global drag system
|
// Enable dragging using the global drag system
|
||||||
makeDraggable(win, header);
|
makeDraggable(win, header);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue