) {
+ if (!(k in parsed)) continue;
+ const validated = validateFilterValue(k, (parsed as any)[k]);
+ if (validated !== undefined) (merged as any)[k] = validated;
+ }
+ return merged;
} catch {
return DEFAULT_FILTERS;
}
@@ -88,8 +151,8 @@ export function useInventorySearch(): InventorySearch {
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
useEffect(() => {
- filtersToUrl(filters);
const timer = setTimeout(async () => {
+ filtersToUrl(filters);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
diff --git a/static/script.js b/static/script.js
deleted file mode 100644
index f7518b6f..00000000
--- a/static/script.js
+++ /dev/null
@@ -1,4940 +0,0 @@
-/*
- * 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);
- }
- });
-
- const radarBtn = document.createElement('button');
- radarBtn.className = 'radar-btn';
- radarBtn.textContent = 'Radar';
- radarBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
- if (playerData) {
- showRadarWindow(playerData.character_name);
- }
- });
-
- buttonsContainer.appendChild(chatBtn);
- buttonsContainer.appendChild(statsBtn);
- buttonsContainer.appendChild(inventoryBtn);
- buttonsContainer.appendChild(charBtn);
- buttonsContainer.appendChild(radarBtn);
- li.appendChild(buttonsContainer);
-
- // Store references for easy access
- li.gridContent = gridContent;
- li.chatBtn = chatBtn;
- li.statsBtn = statsBtn;
- li.inventoryBtn = inventoryBtn;
- li.charBtn = charBtn;
- li.radarBtn = radarBtn;
-
- 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 = {};
-const equipmentCantripStates = {};
-
-/**
- * ---------- 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
- 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 => {
- // 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);
- }
- });
-
- const stats = characterStats[state.characterName] || {};
- const burdenUnits = Number(stats.burden_units || 0);
- const encumbranceCapacity = Number(stats.encumbrance_capacity || 0);
- const burdenPct = encumbranceCapacity > 0
- ? Math.max(0, Math.min(200, (burdenUnits / encumbranceCapacity) * 100))
- : 0;
- const burdenDisplay = Math.floor(burdenPct);
- state.burdenLabel.textContent = `${burdenDisplay}%`;
- state.burdenLabel.title = burdenUnits > 0 && encumbranceCapacity > 0
- ? `${burdenUnits.toLocaleString()} / ${encumbranceCapacity.toLocaleString()}`
- : '';
- // Fill height: map 0-200% burden onto 0-100% bar height
- state.burdenFill.style.height = `${burdenPct / 2}%`;
- // Color by threshold
- state.burdenFill.style.backgroundColor = burdenPct > 150
- ? '#b7432c'
- : burdenPct > 100
- ? '#d8a431'
- : '#2e8b57';
-
- // 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/0600127E.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 overlayItems = equipmentCantripStates[state.characterName]?.items;
- const overlayMap = new Map();
- if (Array.isArray(overlayItems)) {
- overlayItems.forEach(item => {
- if (item && item.item_id != null) {
- overlayMap.set(Number(item.item_id), item);
- }
- });
- }
-
- const snapshotMs = Date.now();
- return state.items
- .filter(item => (item.current_wielded_location || 0) > 0)
- .filter(item => overlayMap.has(Number(item.item_id)))
- .map(item => {
- const result = { ...item };
- const overlay = overlayMap.get(Number(item.item_id)) || {};
- result.mana_state = overlay.state || 'unknown';
- if (overlay.current_mana !== undefined && overlay.current_mana !== null) {
- result.current_mana = overlay.current_mana;
- }
- if (overlay.max_mana !== undefined && overlay.max_mana !== null) {
- result.max_mana = overlay.max_mana;
- }
- if (overlay.mana_time_remaining_seconds !== undefined && overlay.mana_time_remaining_seconds !== null) {
- result.mana_time_remaining_seconds = overlay.mana_time_remaining_seconds;
- result.mana_snapshot_utc = equipmentCantripStates[state.characterName]?.timestamp || null;
- }
-
- 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 = '572px';
- win.style.height = '720px';
-
- const invContent = document.createElement('div');
- invContent.className = 'inventory-content';
- invContent.style.display = 'none';
- content.appendChild(invContent);
-
- 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(burdenFill);
- sidebar.appendChild(burdenLabel);
- sidebar.appendChild(burdenContainer);
-
- const packList = document.createElement('div');
- packList.className = 'inv-pack-list';
- sidebar.appendChild(packList);
-
- const leftColumn = document.createElement('div');
- leftColumn.className = 'inv-left-column';
-
- 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';
-
- leftColumn.appendChild(equipGrid);
- leftColumn.appendChild(contentsHeader);
- leftColumn.appendChild(itemGrid);
-
- invContent.appendChild(leftColumn);
- invContent.appendChild(sidebar);
- invContent.appendChild(manaPanel);
-
- 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 = {
- windowEl: win,
- items: [],
- activePack: null,
- slotMap: slotMap,
- equipGrid: equipGrid,
- itemGrid: itemGrid,
- packList: packList,
- burdenFill: burdenFill,
- burdenLabel: burdenLabel,
- contentsHeader: contentsHeader,
- manaPanel: manaPanel,
- 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}`;
- });
-
- if (!characterStats[name]) {
- fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- if (data && !data.error) {
- characterStats[name] = data;
- if (win._inventoryState) {
- renderInventoryState(win._inventoryState);
- }
- }
- })
- .catch(() => {});
- }
-
- if (!equipmentCantripStates[name]) {
- fetch(`${API_BASE}/equipment-cantrip-state/${encodeURIComponent(name)}`)
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- if (data && !data.error) {
- equipmentCantripStates[name] = data;
- if (win._inventoryState) {
- renderInventoryState(win._inventoryState);
- }
- }
- })
- .catch(() => {});
- }
-
- 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 = `
-
-
-
Total XP: \u2014
-
Unassigned XP: \u2014
-
Luminance: \u2014
-
Deaths: \u2014
-
-
-
-
-
Attributes
-
Skills
-
Titles
-
-
-
-
-
Health
-
-
\u2014 / \u2014
-
-
-
Stamina
-
-
\u2014 / \u2014
-
-
-
Mana
-
-
\u2014 / \u2014
-
-
-
- Attribute Creation Base
- \u2014
- \u2014
- \u2014
- \u2014
- \u2014
- \u2014
-
-
-
-
-
-
-
-
-
-
Augmentations
-
Ratings
-
Other
-
-
-
-
-
-
-
-
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 += 'Skill Level ';
- if (grouped.Specialized.length) {
- for (const s of grouped.Specialized) {
- html += `${s.name} ${s.base} `;
- }
- }
- if (grouped.Trained.length) {
- for (const s of grouped.Trained) {
- html += `${s.name} ${s.base} `;
- }
- }
- html += '
';
- 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 += 'Name Uses ';
- for (const a of augRows) html += `${a.name} ${a.uses} `;
- html += '
';
- }
- if (auraRows.length) {
- html += 'Auras
';
- html += 'Name Uses ';
- for (const a of auraRows) html += `${a.name} ${a.uses} `;
- html += '
';
- }
- 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 = 'Rating Value ';
- for (const r of rows) html += `${r.name} ${r.value} `;
- html += '
';
- 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 += `${r.name} ${r.value} `;
- html += '
';
- }
-
- // 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 += `${m.name} ${m.value} `;
- html += '
';
- }
-
- // 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 += `${s.name} ${s.rank} (${s.value}) `;
- html += '
';
- }
-
- 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 += `Name ${a.name} `;
- if (a.monarch) html += `Monarch ${a.monarch.name || '\u2014'} `;
- if (a.patron) html += `Patron ${a.patron.name || '\u2014'} `;
- if (a.rank !== undefined) html += `Rank ${a.rank} `;
- if (a.followers !== undefined) html += `Followers ${a.followers} `;
- html += '
';
- 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 += ``;
-
- // 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 += ``;
- }
-
- // 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 += ``;
- }
-
- // 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 += ``;
- }
-
- // 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 += ``;
- }
-
- // 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 += ``;
- }
-
- // Mana and Spellcraft section
- if (enhanced.mana_display || enhanced.spellcraft) {
- tooltipHTML += ``;
- }
-
- // Detailed Spell Descriptions section
- if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
- tooltipHTML += ``;
- }
-
- // Object class info
- if (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;
- if (li.radarBtn) li.radarBtn.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);
- if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
- renderInventoryState(inventoryWindows[msg.character_name]._inventoryState);
- }
- } else if (msg.type === 'equipment_cantrip_state') {
- equipmentCantripStates[msg.character_name] = msg;
- if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
- renderInventoryState(inventoryWindows[msg.character_name]._inventoryState);
- }
- } else if (msg.type === 'inventory_delta') {
- updateInventoryLive(msg);
- } else if (msg.type === 'server_status') {
- handleServerStatusUpdate(msg);
- } else if (msg.type === 'nearby_objects') {
- updateRadarWindow(msg);
- } else if (msg.type === 'dungeon_map') {
- if (msg.landblock) {
- dungeonMapCache[msg.landblock] = msg;
- // Update active radar window for this character
- const rw = radarWindows[msg.character_name];
- if (rw) rw._radarDungeonLandblock = msg.landblock;
- }
- } else if (msg.type === 'combat_stats') {
- liveCombatStats[msg.character_name] = msg;
- updateCombatStatsWindows(msg.character_name);
- } else if (msg.type === 'share_peer_removed') {
- removeVitalSharingPeer(msg.character_name);
- } else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) {
- updateVitalSharingPeer(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');
-}
-
-// ─── Radar Window ──────────────────────────────────────────────────
-
-const radarWindows = {}; // character_name -> window element
-const dungeonMapCache = {}; // landblock hex string -> dungeon map data
-let dungeonTileCanvases = null; // env_id -> processed offscreen canvas
-
-// UB default source colors (ARGB as signed int32 → R,G,B)
-// These are the colors embedded in the tile images that get remapped
-const UB_TILE_COLORS = {
- walls: { r: 0, g: 0, b: 255 }, // -16777089 → #0000FF
- innerWalls: { r: 127, g: 127, b: 255 }, // -8404993 → #7F7FFF
- rampedWalls: { r: 77, g: 255, b: 255 }, // -11622657 → #4DFFFF (approx)
- floors: { r: 0, g: 127, b: 255 }, // -16744513 → #007FFF
- stairs: { r: 0, g: 63, b: 255 }, // -16760961 → #003FFF
-};
-
-// Target display colors (UB-like appearance on dark background)
-const DUNGEON_DISPLAY_COLORS = {
- walls: { r: 140, g: 140, b: 180 }, // light gray-blue walls
- innerWalls: { r: 100, g: 100, b: 140 }, // darker inner walls
- rampedWalls: { r: 120, g: 160, b: 120 }, // greenish ramps
- floors: { r: 60, g: 80, b: 60 }, // dark green floors
- stairs: { r: 180, g: 160, b: 80 }, // yellowish stairs
-};
-
-// Process a tile image: make white transparent, remap UB colors
-function processTileImage(img) {
- const c = document.createElement('canvas');
- c.width = 10;
- c.height = 10;
- const ctx = c.getContext('2d');
- ctx.drawImage(img, 0, 0, 10, 10);
- const imageData = ctx.getImageData(0, 0, 10, 10);
- const d = imageData.data;
-
- for (let i = 0; i < d.length; i += 4) {
- const r = d[i], g = d[i+1], b = d[i+2];
-
- // Make white (and near-white) transparent
- if (r > 240 && g > 240 && b > 240) {
- d[i+3] = 0; // alpha = 0
- continue;
- }
-
- // Color remap: match source colors and replace with display colors
- let matched = false;
- for (const [key, src] of Object.entries(UB_TILE_COLORS)) {
- if (Math.abs(r - src.r) < 15 && Math.abs(g - src.g) < 15 && Math.abs(b - src.b) < 15) {
- const dst = DUNGEON_DISPLAY_COLORS[key];
- d[i] = dst.r; d[i+1] = dst.g; d[i+2] = dst.b;
- matched = true;
- break;
- }
- }
-
- // Make black semi-transparent (outlines)
- if (!matched && r < 15 && g < 15 && b < 15) {
- d[i+3] = 100;
- }
- }
-
- ctx.putImageData(imageData, 0, 0);
- return c;
-}
-
-// Load dungeon tile textures (614 tiles, ~287KB JSON)
-function loadDungeonTiles() {
- if (dungeonTileCanvases) return; // already loading/loaded
- dungeonTileCanvases = {};
- fetch('dungeon_tiles.json')
- .then(r => r.json())
- .then(data => {
- let loaded = 0;
- const total = Object.keys(data).length;
- Object.entries(data).forEach(([envId, dataUrl]) => {
- const img = new Image();
- img.onload = () => {
- dungeonTileCanvases[envId] = processTileImage(img);
- loaded++;
- if (loaded === total) console.log(`Processed ${total} dungeon tiles`);
- };
- img.src = dataUrl;
- });
- })
- .catch(err => console.warn('Failed to load dungeon tiles:', err));
-}
-
-const RADAR_COLORS = {
- Monster: '#ff4444',
- Player: '#4488ff',
- NPC: '#44cc44',
- Vendor: '#44cc44',
- Portal: '#aa44ff',
- Corpse: '#ff8800',
- Container: '#cccc44',
- Door: '#888888',
-};
-
-const RADAR_CANVAS_SIZE = 300;
-const RADAR_DEFAULT_RANGE = 0.5; // AC coordinate units (~120m)
-
-function showRadarWindow(name) {
- const windowId = `radarWindow-${name}`;
-
- const { win, content, isNew } = createWindow(
- windowId, `Radar: ${name}`, 'radar-window', {
- onClose: () => {
- // Send stop_radar command
- if (socket && socket.readyState === WebSocket.OPEN) {
- socket.send(JSON.stringify({ player_name: name, command: 'stop_radar' }));
- }
- delete radarWindows[name];
- }
- }
- );
-
- if (!isNew) {
- // Window was hidden, not destroyed — re-register and restart streaming
- radarWindows[name] = win;
- if (socket && socket.readyState === WebSocket.OPEN) {
- socket.send(JSON.stringify({ player_name: name, command: 'start_radar' }));
- }
- return;
- }
-
- radarWindows[name] = win;
- win.dataset.character = name;
-
- // Radar controls
- const controls = document.createElement('div');
- controls.className = 'radar-controls';
-
- const rangeDisplay = document.createElement('span');
- rangeDisplay.className = 'radar-range-display';
- rangeDisplay.textContent = 'Range: ~120m';
- controls.appendChild(rangeDisplay);
-
- const zoomHint = document.createElement('span');
- zoomHint.className = 'radar-zoom-hint';
- zoomHint.textContent = 'Scroll to zoom';
- controls.appendChild(zoomHint);
-
- content.appendChild(controls);
-
- // Canvas
- const canvas = document.createElement('canvas');
- canvas.className = 'radar-canvas';
- canvas.width = RADAR_CANVAS_SIZE;
- canvas.height = RADAR_CANVAS_SIZE;
- content.appendChild(canvas);
-
- // Entity list
- const listContainer = document.createElement('div');
- listContainer.className = 'radar-entity-list';
-
- const listHeader = document.createElement('div');
- listHeader.className = 'radar-entity-header';
- listHeader.innerHTML = 'Name Type Dist Dir ';
- listContainer.appendChild(listHeader);
-
- const listBody = document.createElement('div');
- listBody.className = 'radar-entity-body';
- listContainer.appendChild(listBody);
- content.appendChild(listContainer);
-
- // Load map image for radar background
- const radarMapImg = new Image();
- radarMapImg.src = 'dereth.png';
-
- // Store refs on the window
- win._radarCanvas = canvas;
- win._radarListBody = listBody;
- win._radarRange = RADAR_DEFAULT_RANGE;
- win._radarRangeDisplay = rangeDisplay;
- win._radarSelectedId = null;
- win._radarLastObjects = []; // cache for click hit-testing
- win._radarMapImg = radarMapImg;
- win._radarIsDungeon = false;
- win._radarDungeonLandblock = null;
-
- // Scroll-wheel zoom on canvas
- canvas.addEventListener('wheel', (e) => {
- e.preventDefault();
- const factor = e.deltaY > 0 ? 1.25 : 0.8;
- win._radarRange = Math.max(0.02, Math.min(5.0, win._radarRange * factor));
- const meters = Math.round(win._radarRange * 240);
- win._radarRangeDisplay.textContent = `Range: ~${meters}m`;
- });
-
- // Click canvas to select nearest object
- canvas.addEventListener('click', (e) => {
- const rect = canvas.getBoundingClientRect();
- const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
- const my = (e.clientY - rect.top) * (canvas.height / rect.height);
- // Find closest rendered dot
- let closest = null;
- let closestDist = 20; // max pixel distance to select
- (win._radarLastObjects || []).forEach(obj => {
- if (obj._px === undefined) return;
- const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py) ** 2);
- if (d < closestDist) { closestDist = d; closest = obj; }
- });
- win._radarSelectedId = closest ? closest.id : null;
- });
-
- // Load dungeon tiles on first radar open
- loadDungeonTiles();
-
- // Send start_radar command
- if (socket && socket.readyState === WebSocket.OPEN) {
- socket.send(JSON.stringify({ player_name: name, command: 'start_radar' }));
- }
-}
-
-function updateRadarWindow(msg) {
- const name = msg.character_name;
- const win = radarWindows[name];
- if (!win || win.style.display === 'none') return;
-
- const canvas = win._radarCanvas;
- const listBody = win._radarListBody;
- const range = win._radarRange || RADAR_DEFAULT_RANGE;
- const selectedId = win._radarSelectedId;
- const objects = msg.objects || [];
-
- const playerEW = msg.player_ew;
- const playerNS = msg.player_ns;
- const playerHeading = msg.player_heading || 0;
- const isDungeon = msg.is_dungeon || false;
- const landblock = msg.landblock || null;
- const playerX = msg.player_x || 0; // raw physics X (dungeon)
- const playerY = msg.player_y || 0; // raw physics Y (dungeon)
- const playerRawZ = msg.player_raw_z || 0;
-
- // Update dungeon state
- win._radarIsDungeon = isDungeon;
- if (isDungeon && landblock) {
- win._radarDungeonLandblock = landblock;
- // Request dungeon map if not cached
- if (!dungeonMapCache[landblock] && socket && socket.readyState === WebSocket.OPEN) {
- socket.send(JSON.stringify({ type: 'request_dungeon_map', landblock }));
- }
- }
-
- // ─── Draw radar canvas ───
- const ctx = canvas.getContext('2d');
- const size = RADAR_CANVAS_SIZE;
- const cx = size / 2;
- const cy = size / 2;
- // In dungeons, raw coords are game units; range is AC units (1 AC unit = 240 game units)
- const scale = isDungeon ? (size / 2) / (range * 240) : (size / 2) / range;
-
- ctx.clearRect(0, 0, size, size);
-
- // Background
- ctx.fillStyle = '#111';
- ctx.beginPath();
- ctx.arc(cx, cy, cx, 0, Math.PI * 2);
- ctx.fill();
-
- // Background: overworld map or dungeon cells
- ctx.save();
- ctx.beginPath();
- ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
- ctx.clip();
-
- if (isDungeon && landblock && dungeonMapCache[landblock]) {
- // ─── Dungeon cell background with tile textures ───
- const dmap = dungeonMapCache[landblock];
- const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6;
- ctx.translate(cx, cy);
- // UB applies (heading - 180) rotation for dungeons (line 1013 DungeonMaps.cs)
- // The layer has Y increasing downward; the 180° offset flips N/S
- ctx.rotate(-(playerHeading - 180) * Math.PI / 180);
- const cellSize = 10 * scale; // each cell is 10 game units
- const hasTiles = dungeonTileCanvases && Object.keys(dungeonTileCanvases).length > 0;
-
- // Normalize rotation value to radians (exact copy of UB's DungeonCell.cs)
- function cellRotation(rot) {
- if (rot === 1) return Math.PI;
- if (rot < -0.70 && rot > -0.8) return Math.PI / 2;
- if (rot > 0.70 && rot < 0.8) return -Math.PI / 2;
- return 0;
- }
-
- // Draw non-current floors first (dimmed), then current floor on top
- const sortedLevels = (dmap.z_levels || []).slice().sort((a, b) => {
- const aCur = a.z === playerRoundedZ ? 1 : 0;
- const bCur = b.z === playerRoundedZ ? 1 : 0;
- return aCur - bCur; // current floor drawn last (on top)
- });
-
- sortedLevels.forEach(level => {
- const isCurrentFloor = (level.z === playerRoundedZ);
- ctx.globalAlpha = isCurrentFloor ? 0.85 : 0.12;
-
- (level.cells || []).forEach(cell => {
- // X mirrored (matches UB's layer rendering), Y direct
- const dx = -(cell.x - playerX) * scale;
- const dy = (cell.y - playerY) * scale;
- const tileCanvas = hasTiles ? dungeonTileCanvases[String(cell.env_id)] : null;
-
- if (tileCanvas) {
- // Draw processed tile with per-cell rotation
- ctx.save();
- ctx.translate(dx, dy);
- ctx.rotate(cellRotation(cell.rotation));
- ctx.drawImage(tileCanvas, -cellSize / 2, -cellSize / 2, cellSize, cellSize);
- ctx.restore();
- } else {
- // Fallback: colored rectangle
- ctx.fillStyle = isCurrentFloor ? '#3a5a3a' : '#1a2a1a';
- ctx.fillRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize);
- }
- });
- });
- ctx.globalAlpha = 1.0;
- ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
- } else if (!isDungeon) {
- // ─── Overworld map background ───
- const mapImg = win._radarMapImg;
- if (mapImg && mapImg.complete && mapImg.naturalWidth) {
- const mapCoordRange = 204.2;
- const pixPerCoord = mapImg.naturalWidth / mapCoordRange;
- const mapPx = (playerEW + 102.1) * pixPerCoord;
- const mapPy = (102.1 - playerNS) * pixPerCoord;
- ctx.translate(cx, cy);
- ctx.rotate(-playerHeading * Math.PI / 180);
- ctx.globalAlpha = 0.4;
- const srcSize = range * pixPerCoord * 2;
- ctx.drawImage(mapImg,
- mapPx - srcSize / 2, mapPy - srcSize / 2, srcSize, srcSize,
- -cx, -cy, size, size
- );
- ctx.globalAlpha = 1.0;
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- }
- }
- ctx.restore();
-
- // Range rings
- ctx.strokeStyle = '#333';
- ctx.lineWidth = 1;
- for (let i = 1; i <= 4; i++) {
- ctx.beginPath();
- ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2);
- ctx.stroke();
- }
-
- // Crosshairs
- ctx.strokeStyle = '#333';
- ctx.beginPath();
- ctx.moveTo(cx, 0); ctx.lineTo(cx, size);
- ctx.moveTo(0, cy); ctx.lineTo(size, cy);
- ctx.stroke();
-
- // Always heading-up: player facing direction = up on canvas.
- // Compass labels rotate around the edge based on player heading.
- // AC heading: 0=N, 90=E, 180=S, 270=W (standard clockwise)
- const headingRad = playerHeading * Math.PI / 180;
- const compassLabels = [
- { label: 'N', angle: 0 },
- { label: 'E', angle: Math.PI / 2 },
- { label: 'S', angle: Math.PI },
- { label: 'W', angle: -Math.PI / 2 },
- ];
- ctx.font = 'bold 12px monospace';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- compassLabels.forEach(({ label, angle }) => {
- // Rotate compass direction by negative heading so labels move as player turns
- const a = angle - headingRad;
- const lx = cx + Math.sin(a) * (cx - 12);
- const ly = cy - Math.cos(a) * (cx - 12);
- ctx.fillStyle = label === 'N' ? '#cc4444' : '#888';
- ctx.fillText(label, lx, ly);
- });
-
- // Facing direction indicator (always points up from center)
- ctx.strokeStyle = '#666';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(cx, cy);
- ctx.lineTo(cx, cy - cx * 0.85);
- ctx.stroke();
-
- // Rotate world by heading so player facing = up on canvas
- // In dungeons, use (PI - heading) to match UB's canvas rotation
- const rotAngle = isDungeon ? (Math.PI - headingRad) : headingRad;
-
- // Draw objects and cache positions for click hit-testing
- const cosA = Math.cos(rotAngle);
- const sinA = Math.sin(rotAngle);
- objects.forEach(obj => {
- // Use raw physics coords in dungeons (X mirrored), EW/NS on surface
- let dX, dY;
- if (isDungeon && obj.raw_x !== undefined) {
- dX = -(obj.raw_x - playerX); // X mirrored to match tile space
- dY = (obj.raw_y - playerY); // Y direct (180° heading offset handles N/S)
- } else {
- dX = obj.ew - playerEW;
- dY = obj.ns - playerNS;
- }
-
- const dx = dX * cosA - dY * sinA;
- // In dungeons, don't negate dy (180° heading offset handles N/S)
- // In overworld, negate for north-up
- const dy = isDungeon ? (dX * sinA + dY * cosA) : -(dX * sinA + dY * cosA);
-
- const px = cx + dx * scale;
- const py = cy + dy * scale;
-
- // Store pixel position for click hit-testing
- obj._px = px;
- obj._py = py;
-
- // Clip to circle
- const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
- if (distFromCenter > cx - 4) return;
-
- const color = RADAR_COLORS[obj.object_class] || '#999';
- const isSelected = obj.id === selectedId;
- const dotSize = isSelected ? 6 : ((obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3);
-
- // Selection ring
- if (isSelected) {
- ctx.strokeStyle = '#fff';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2);
- ctx.stroke();
- }
-
- ctx.fillStyle = color;
- ctx.beginPath();
- ctx.arc(px, py, dotSize, 0, Math.PI * 2);
- ctx.fill();
-
- // Label for players, portals, and selected object
- if (obj.object_class === 'Player' || obj.object_class === 'Portal' || isSelected) {
- ctx.fillStyle = isSelected ? '#fff' : color;
- ctx.font = '9px monospace';
- ctx.textAlign = 'left';
- ctx.fillText(obj.name, px + 6, py + 3);
- }
- });
- win._radarLastObjects = objects;
-
- // Player dot (center)
- ctx.fillStyle = '#ffcc00';
- ctx.beginPath();
- ctx.arc(cx, cy, 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = '#fff';
- ctx.lineWidth = 1;
- ctx.stroke();
-
- // ─── Update entity list ───
- // Sort by distance
- const withDist = objects.map(obj => {
- let distMeters, dX, dY;
- if (isDungeon && obj.raw_x !== undefined) {
- dX = -(obj.raw_x - playerX); // X mirrored
- dY = (obj.raw_y - playerY); // Y direct
- distMeters = Math.sqrt(dX * dX + dY * dY); // raw coords are ~meters
- } else {
- dX = obj.ew - playerEW;
- dY = obj.ns - playerNS;
- distMeters = Math.sqrt(dX * dX + dY * dY) * 240;
- }
-
- // Compass direction
- const angle = Math.atan2(dX, dY) * 180 / Math.PI;
- const dir = compassDir(angle);
-
- return { ...obj, distMeters, dir };
- });
- withDist.sort((a, b) => a.distMeters - b.distMeters);
-
- // Rebuild list with selection support
- listBody.innerHTML = '';
- let selectedRow = null;
- withDist.forEach(obj => {
- const row = document.createElement('div');
- row.className = 'radar-entity-row';
- if (obj.id === selectedId) {
- row.classList.add('radar-entity-selected');
- selectedRow = row;
- }
-
- // Click row to select on canvas
- row.addEventListener('click', () => {
- win._radarSelectedId = (win._radarSelectedId === obj.id) ? null : obj.id;
- });
-
- const colorDot = document.createElement('span');
- colorDot.className = 're-color';
- colorDot.style.background = RADAR_COLORS[obj.object_class] || '#999';
- row.appendChild(colorDot);
-
- const nameSpan = document.createElement('span');
- nameSpan.className = 're-name';
- nameSpan.textContent = obj.name;
- row.appendChild(nameSpan);
-
- const typeSpan = document.createElement('span');
- typeSpan.className = 're-type';
- typeSpan.textContent = obj.object_class;
- row.appendChild(typeSpan);
-
- const distSpan = document.createElement('span');
- distSpan.className = 're-dist';
- distSpan.textContent = obj.distMeters < 1000
- ? `${Math.round(obj.distMeters)}m`
- : `${(obj.distMeters / 1000).toFixed(1)}km`;
- row.appendChild(distSpan);
-
- const dirSpan = document.createElement('span');
- dirSpan.className = 're-dir';
- dirSpan.textContent = obj.dir;
- row.appendChild(dirSpan);
-
- listBody.appendChild(row);
- });
-
- // Scroll selected row into view
- if (selectedRow) selectedRow.scrollIntoView({ block: 'nearest' });
-}
-
-function compassDir(angleDeg) {
- // angleDeg: 0 = N, 90 = E, -90 = W, 180 = S
- const a = ((angleDeg % 360) + 360) % 360;
- const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
- return dirs[Math.round(a / 45) % 8];
-}
-
-// ─── Version Display ────────────────────────────────────────────
-
-fetch('/api-version').then(r => r.json()).then(d => {
- const el = document.getElementById('versionDisplay');
- if (el) el.textContent = 'v' + d.version;
-}).catch(() => {});
-
-// ─── Current User Info ──────────────────────────────────────────
-let _currentUser = null;
-
-fetch('/me').then(r => {
- if (!r.ok) throw new Error('not authenticated');
- return r.json();
-}).then(data => {
- _currentUser = data;
- const userInfo = document.getElementById('userInfo');
- const nameEl = document.getElementById('currentUsername');
- const adminLink = document.getElementById('adminLink');
- if (userInfo && nameEl) {
- nameEl.textContent = data.username;
- userInfo.style.display = 'flex';
- }
- if (adminLink && data.is_admin) {
- adminLink.style.display = 'inline';
- }
-}).catch(() => {});
-
-// ─── Issues Board ───────────────────────────────────────────────
-
-const ISSUE_CATEGORIES = {
- plugin: { label: 'Plugin', color: '#8844cc' },
- overlord: { label: 'Overlord', color: '#4488cc' },
- nav: { label: 'Nav', color: '#44aa44' },
- macro: { label: 'Macro', color: '#cc8844' },
- other: { label: 'Other', color: '#888888' },
-};
-
-function escapeHtml(str) {
- const div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
-}
-
-function showIssuesWindow() {
- const { win, content, isNew } = createWindow(
- 'issuesWindow', 'Issues Board', 'issues-window'
- );
-
- if (!isNew) {
- refreshIssuesList(win);
- return;
- }
-
- win.style.width = '540px';
- win.style.height = '520px';
-
- // Issues list container
- const listDiv = document.createElement('div');
- listDiv.className = 'issues-list';
- content.appendChild(listDiv);
- win._issuesList = listDiv;
-
- // Add issue form
- const form = document.createElement('div');
- form.className = 'issues-form';
- form.innerHTML = `
-
-
-
- Plugin
- Overlord
- Nav
- Macro
- Other
-
-
-
-
- Add
-
- `;
- content.appendChild(form);
-
- // Add button handler
- form.querySelector('#issueAddBtn').addEventListener('click', async () => {
- const title = document.getElementById('issueTitle').value.trim();
- const desc = document.getElementById('issueDescription').value.trim();
- const cat = document.getElementById('issueCategory').value;
- if (!title) return;
-
- await fetch('/issues', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title, description: desc, category: cat })
- });
- document.getElementById('issueTitle').value = '';
- document.getElementById('issueDescription').value = '';
- refreshIssuesList(win);
- });
-
- refreshIssuesList(win);
-}
-
-async function refreshIssuesList(win) {
- const listDiv = win._issuesList;
- if (!listDiv) return;
-
- try {
- const resp = await fetch('/issues');
- const data = await resp.json();
- const issues = data.issues || [];
-
- if (issues.length === 0) {
- listDiv.innerHTML = 'No open issues
';
- return;
- }
-
- // Sort: unresolved first, then resolved
- issues.sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0));
-
- listDiv.innerHTML = '';
- issues.forEach(issue => {
- const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
- const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
- const isResolved = !!issue.resolved;
- const author = issue.author || 'User';
- const comments = issue.comments || [];
-
- const row = document.createElement('div');
- row.className = 'issue-row' + (isResolved ? ' issue-resolved' : '');
-
- // Header line
- const headerDiv = document.createElement('div');
- headerDiv.className = 'issue-header';
- headerDiv.innerHTML = `
- ${cat.label}
- ${escapeHtml(issue.title)}
- by ${escapeHtml(author)}
- ${date}
- `;
- row.appendChild(headerDiv);
-
- // Description
- if (issue.description) {
- const descDiv = document.createElement('div');
- descDiv.className = 'issue-description';
- descDiv.textContent = issue.description;
- row.appendChild(descDiv);
- }
-
- // Action buttons
- const actionsDiv = document.createElement('div');
- actionsDiv.className = 'issue-actions';
-
- if (isResolved) {
- const reopenBtn = document.createElement('button');
- reopenBtn.textContent = '\u21BA Reopen';
- reopenBtn.className = 'issue-reopen-btn';
- reopenBtn.addEventListener('click', async () => {
- await fetch(`/issues/${issue.id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ resolved: false })
- });
- refreshIssuesList(win);
- });
- actionsDiv.appendChild(reopenBtn);
-
- const deleteBtn = document.createElement('button');
- deleteBtn.textContent = '\uD83D\uDDD1 Delete';
- deleteBtn.className = 'issue-delete-btn';
- deleteBtn.addEventListener('click', async () => {
- if (!confirm(`Delete issue "${issue.title}"?`)) return;
- await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
- refreshIssuesList(win);
- });
- actionsDiv.appendChild(deleteBtn);
- } else {
- const resolveBtn = document.createElement('button');
- resolveBtn.textContent = '\u2713 Resolve';
- resolveBtn.className = 'issue-resolve-btn';
- resolveBtn.addEventListener('click', async () => {
- await fetch(`/issues/${issue.id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ resolved: true })
- });
- refreshIssuesList(win);
- });
- actionsDiv.appendChild(resolveBtn);
- }
-
- // Edit button
- const editBtn = document.createElement('button');
- editBtn.textContent = '\u270E Edit';
- editBtn.className = 'issue-edit-btn';
- editBtn.addEventListener('click', () => {
- showEditIssueForm(row, issue, win);
- });
- actionsDiv.appendChild(editBtn);
-
- row.appendChild(actionsDiv);
-
- // Comments section — always visible inline
- showCommentsSection(row, issue, win);
-
- listDiv.appendChild(row);
- });
- } catch (err) {
- listDiv.innerHTML = 'Failed to load issues
';
- }
-}
-
-function showEditIssueForm(row, issue, win) {
- // Remove any existing edit form
- const existing = row.querySelector('.issue-edit-form');
- if (existing) { existing.remove(); return; }
-
- const cat = issue.category || 'other';
- const form = document.createElement('div');
- form.className = 'issue-edit-form';
- form.innerHTML = `
-
-
-
- Plugin
- Overlord
- Nav
- Macro
- Other
-
-
-
- `;
-
- form.querySelector('.edit-save-btn').addEventListener('click', async () => {
- const title = form.querySelector('.edit-title').value.trim();
- const desc = form.querySelector('.edit-description').value.trim();
- const category = form.querySelector('.edit-category').value;
- if (!title) return;
- await fetch(`/issues/${issue.id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title, description: desc, category })
- });
- refreshIssuesList(win);
- });
-
- form.querySelector('.edit-cancel-btn').addEventListener('click', () => {
- form.remove();
- });
-
- row.appendChild(form);
-}
-
-function showCommentsSection(row, issue, win) {
- const section = document.createElement('div');
- section.className = 'issue-comments-section';
-
- const comments = issue.comments || [];
-
- // Render existing comments
- const commentsListDiv = document.createElement('div');
- commentsListDiv.className = 'issue-comments-list';
- if (comments.length === 0) {
- commentsListDiv.innerHTML = 'No comments yet
';
- } else {
- comments.forEach(c => {
- const cDiv = document.createElement('div');
- cDiv.className = 'issue-comment';
- const cDate = c.created ? new Date(c.created).toLocaleDateString('sv-SE') : '';
- cDiv.innerHTML = ` `;
- const textDiv = document.createElement('div');
- textDiv.className = 'comment-text';
- textDiv.textContent = c.text;
- cDiv.appendChild(textDiv);
- commentsListDiv.appendChild(cDiv);
- });
- }
- section.appendChild(commentsListDiv);
-
- // Add comment form
- const addDiv = document.createElement('div');
- addDiv.className = 'issue-comment-form';
- addDiv.innerHTML = `
-
-
-
-
- `;
-
- addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => {
- const text = addDiv.querySelector('.comment-text-input').value.trim();
- if (!text) return;
- await fetch(`/issues/${issue.id}/comments`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ text })
- });
- refreshIssuesList(win);
- });
-
- section.appendChild(addDiv);
- row.appendChild(section);
-}
-
-// ─── Vital Sharing NetworkUI ───────────────────────────────────────────────
-// Live state fed by share_* WebSocket messages AND by /vital-sharing/peers
-// polling. Keyed by character_name.
-const vitalSharingPeers = {};
-let _vitalSharingPollTimer = null;
-
-function removeVitalSharingPeer(name) {
- if (!name) return;
- if (vitalSharingPeers[name]) {
- delete vitalSharingPeers[name];
- renderVitalSharingWindow();
- }
-}
-
-function updateVitalSharingPeer(msg) {
- const name = msg.character_name;
- if (!name) return;
- const entry = vitalSharingPeers[name] || (vitalSharingPeers[name] = {
- character_name: name,
- tags: [],
- vitals: null,
- position: null,
- last_update: null,
- connected: true,
- });
- entry.last_update = msg.timestamp || new Date().toISOString();
- if (Array.isArray(msg.tags)) entry.tags = msg.tags;
- if (msg.type === 'share_vital_update') {
- entry.vitals = {
- current_health: msg.current_health,
- max_health: msg.max_health,
- current_stamina: msg.current_stamina,
- max_stamina: msg.max_stamina,
- current_mana: msg.current_mana,
- max_mana: msg.max_mana,
- };
- } else if (msg.type === 'share_position_update') {
- entry.position = {
- ew: msg.ew, ns: msg.ns, z: msg.z, heading: msg.heading,
- };
- }
- renderVitalSharingWindow();
-}
-
-function showVitalSharingWindow() {
- const { win, content, isNew } = createWindow(
- 'vitalSharingWindow', 'Vital Sharing Network', 'issues-window',
- { onClose: () => {
- if (_vitalSharingPollTimer) {
- clearInterval(_vitalSharingPollTimer);
- _vitalSharingPollTimer = null;
- }
- }
- }
- );
-
- if (!isNew) {
- refreshVitalSharingPeers();
- return;
- }
-
- win.style.width = '520px';
- win.style.height = '500px';
-
- const listDiv = document.createElement('div');
- listDiv.className = 'vital-sharing-list';
- listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;';
- content.appendChild(listDiv);
- win._vitalSharingList = listDiv;
-
- refreshVitalSharingPeers();
- if (_vitalSharingPollTimer) clearInterval(_vitalSharingPollTimer);
- _vitalSharingPollTimer = setInterval(refreshVitalSharingPeers, 5000);
-}
-
-async function refreshVitalSharingPeers() {
- try {
- const resp = await fetch('/vital-sharing/peers');
- if (!resp.ok) return;
- const data = await resp.json();
- const serverNames = new Set();
- (data.peers || []).forEach(p => {
- serverNames.add(p.character_name);
- const existing = vitalSharingPeers[p.character_name] || {};
- vitalSharingPeers[p.character_name] = { ...existing, ...p };
- });
- // Prune any local entries the server no longer knows about (unsubscribed
- // or disconnected). This catches any race where the share_peer_removed
- // broadcast didn't arrive.
- Object.keys(vitalSharingPeers).forEach(name => {
- if (!serverNames.has(name)) delete vitalSharingPeers[name];
- });
- } catch (e) {
- // ignore transient errors
- }
- renderVitalSharingWindow();
-}
-
-function renderVitalSharingWindow() {
- const win = document.getElementById('vitalSharingWindow');
- if (!win || win.style.display === 'none') return;
- const listDiv = win._vitalSharingList;
- if (!listDiv) return;
-
- const peers = Object.values(vitalSharingPeers).sort((a, b) =>
- (a.character_name || '').localeCompare(b.character_name || '')
- );
-
- if (peers.length === 0) {
- listDiv.innerHTML = 'No vital-sharing peers connected
';
- return;
- }
-
- listDiv.innerHTML = '';
- peers.forEach(p => {
- const row = document.createElement('div');
- row.className = 'vital-sharing-peer';
- row.style.cssText = 'border:1px solid #444;background:#1f1f1f;padding:6px 8px;margin-bottom:5px;border-radius:3px;font-size:0.78rem;color:#ddd;';
-
- const dot = p.plugin_connected
- ? '● '
- : '● ';
- const subscribed = p.subscribed ? ' [subscribed] ' : '';
- const tags = (p.tags || []).join(', ') || 'no tags ';
-
- let vitalsHtml = '(no vitals yet) ';
- if (p.vitals && p.vitals.max_health) {
- const v = p.vitals;
- const pct = (cur, max) => max > 0 ? Math.max(0, Math.min(100, (cur / max) * 100)) : 0;
- const bar = (label, cur, max, color) => `
-
-
${label}
-
-
${cur ?? 0}/${max ?? 0}
-
`;
- vitalsHtml =
- bar('HP', v.current_health, v.max_health, 'linear-gradient(90deg, #ff4444, #ff6666)') +
- bar('STA', v.current_stamina, v.max_stamina, 'linear-gradient(90deg, #ffaa00, #ffcc44)') +
- bar('MANA', v.current_mana, v.max_mana, 'linear-gradient(90deg, #4488ff, #66aaff)');
- }
-
- let posHtml = '';
- if (p.position && p.position.ew !== undefined) {
- const fmt = (n) => (typeof n === 'number' ? n.toFixed(1) : n);
- posHtml = `
- ${fmt(p.position.ns)}N, ${fmt(p.position.ew)}E z=${fmt(p.position.z)}
-
`;
- }
-
- row.innerHTML = `
-
- ${dot}
- ${p.character_name}
- ${subscribed}
-
- tags: ${tags}
- ${vitalsHtml}
- ${posHtml}
- `;
- listDiv.appendChild(row);
- });
-}
-
-// ─── Combat Stats (Mag-Tools style) ───────────────────────────────────────
-const liveCombatStats = {};
-const combatStatsWindows = {};
-const DAMAGE_ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
-
-function showCombatStatsWindow(charName) {
- // If called without a name, show a picker of all characters with data
- if (!charName) {
- const chars = Object.keys(liveCombatStats).sort();
- if (chars.length === 0) {
- // Try fetching from API
- fetch('/combat-stats').then(r => r.json()).then(data => {
- if (data.stats && data.stats.length > 0) {
- data.stats.forEach(s => { liveCombatStats[s.character_name] = s; });
- showCombatStatsPickerWindow();
- } else {
- alert('No combat stats available yet. Kill some monsters first!');
- }
- });
- return;
- }
- showCombatStatsPickerWindow();
- return;
- }
-
- const windowId = `combatStatsWindow-${charName}`;
- const { win, content, isNew } = createWindow(windowId, `Combat: ${charName}`, 'issues-window');
-
- if (!isNew) {
- renderCombatStatsContent(win, charName);
- return;
- }
-
- win.style.width = '620px';
- win.style.height = '560px';
- combatStatsWindows[charName] = win;
-
- // Session/Lifetime toggle
- const controls = document.createElement('div');
- controls.style.cssText = 'padding:4px 8px;display:flex;gap:6px;align-items:center;border-bottom:1px solid #444;';
- const sessionBtn = document.createElement('button');
- sessionBtn.textContent = 'Session';
- sessionBtn.className = 'combat-stats-toggle active';
- sessionBtn.onclick = () => { win._combatMode = 'session'; sessionBtn.classList.add('active'); lifetimeBtn.classList.remove('active'); renderCombatStatsContent(win, charName); };
- const lifetimeBtn = document.createElement('button');
- lifetimeBtn.textContent = 'Lifetime';
- lifetimeBtn.className = 'combat-stats-toggle';
- lifetimeBtn.onclick = () => { win._combatMode = 'lifetime'; lifetimeBtn.classList.add('active'); sessionBtn.classList.remove('active'); renderCombatStatsContent(win, charName); };
- controls.appendChild(sessionBtn);
- controls.appendChild(lifetimeBtn);
- content.appendChild(controls);
-
- // Monster list (top)
- const monsterDiv = document.createElement('div');
- monsterDiv.className = 'combat-monster-list';
- monsterDiv.style.cssText = 'height:180px;overflow-y:auto;border-bottom:1px solid #444;';
- content.appendChild(monsterDiv);
- win._monsterList = monsterDiv;
-
- // Damage breakdown grid (bottom)
- const breakdownDiv = document.createElement('div');
- breakdownDiv.className = 'combat-breakdown';
- breakdownDiv.style.cssText = 'flex:1;overflow-y:auto;padding:4px 6px;';
- content.appendChild(breakdownDiv);
- win._breakdownGrid = breakdownDiv;
-
- win._combatMode = 'session';
- win._selectedMonster = null; // null = "All"
-
- renderCombatStatsContent(win, charName);
-}
-
-function showCombatStatsPickerWindow() {
- const { win, content, isNew } = createWindow('combatStatsPicker', 'Combat Stats — Select Character', 'issues-window');
- if (!isNew) { renderCombatStatsPicker(win); return; }
- win.style.width = '320px';
- win.style.height = '400px';
- const listDiv = document.createElement('div');
- listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;';
- content.appendChild(listDiv);
- win._pickerList = listDiv;
- renderCombatStatsPicker(win);
-}
-
-function renderCombatStatsPicker(win) {
- const listDiv = win._pickerList;
- if (!listDiv) return;
- const chars = Object.keys(liveCombatStats).sort();
- listDiv.innerHTML = '';
- if (chars.length === 0) {
- listDiv.innerHTML = 'No combat data yet
';
- return;
- }
- chars.forEach(name => {
- const row = document.createElement('div');
- row.style.cssText = 'padding:4px 8px;cursor:pointer;border-bottom:1px solid #333;color:#ddd;font-size:0.85rem;';
- row.textContent = name;
- row.onmouseenter = () => row.style.background = '#333';
- row.onmouseleave = () => row.style.background = '';
- row.onclick = () => { showCombatStatsWindow(name); };
- listDiv.appendChild(row);
- });
-}
-
-function updateCombatStatsWindows(charName) {
- const win = combatStatsWindows[charName];
- if (win && win.style.display !== 'none') {
- renderCombatStatsContent(win, charName);
- }
-}
-
-function renderCombatStatsContent(win, charName) {
- const data = liveCombatStats[charName];
- if (!data) return;
-
- const mode = win._combatMode || 'session';
- const stateData = data[mode];
- if (!stateData) return;
-
- const monsters = stateData.monsters || {};
- const monsterNames = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
-
- // ── Monster list ──
- const monsterDiv = win._monsterList;
- if (monsterDiv) {
- monsterDiv.innerHTML = '';
-
- // Header row
- const hdr = document.createElement('div');
- hdr.style.cssText = 'display:flex;gap:4px;padding:2px 6px;font-size:0.72rem;color:#888;border-bottom:1px solid #333;font-weight:bold;';
- hdr.innerHTML = `Monster Kills Dmg Recv Dmg Given `;
- monsterDiv.appendChild(hdr);
-
- // "All" row
- const allRow = createMonsterRow('*', 'All', stateData.total_kills, stateData.total_damage_received, stateData.total_damage_given, win._selectedMonster === null);
- allRow.onclick = () => { win._selectedMonster = null; renderCombatStatsContent(win, charName); };
- monsterDiv.appendChild(allRow);
-
- // Per-monster rows
- monsterNames.forEach(name => {
- const m = monsters[name];
- const selected = win._selectedMonster === name;
- const row = createMonsterRow('', name, m.kill_count, m.damage_received, m.damage_given, selected);
- row.onclick = () => { win._selectedMonster = name; renderCombatStatsContent(win, charName); };
- monsterDiv.appendChild(row);
- });
- }
-
- // ── Breakdown grid ──
- const grid = win._breakdownGrid;
- if (!grid) return;
-
- // Gather stats for the selected monster or all
- let offense = {}, defense = {};
- let totalAethSurges = 0, totalCloakSurges = 0;
-
- if (win._selectedMonster === null) {
- // Aggregate all monsters
- monsterNames.forEach(name => {
- const m = monsters[name];
- mergeAttackStats(offense, m.offense || {});
- mergeAttackStats(defense, m.defense || {});
- totalAethSurges += m.aetheria_surges || 0;
- totalCloakSurges += m.cloak_surges || 0;
- });
- // Add cloak surges from the synthetic key
- if (monsters['__cloak_surges__']) {
- totalCloakSurges += monsters['__cloak_surges__'].cloak_surges || 0;
- }
- } else {
- const m = monsters[win._selectedMonster];
- if (m) {
- offense = m.offense || {};
- defense = m.defense || {};
- totalAethSurges = m.aetheria_surges || 0;
- totalCloakSurges = m.cloak_surges || 0;
- }
- }
-
- // Compute aggregates for the right-side stats
- const offAll = flattenStats(offense);
- const defAll = flattenStats(defense);
- const defMM = flattenStatsForType(defense, 'MeleeMissile');
- const defMag = flattenStatsForType(defense, 'Magic');
-
- const totalAttacks = offAll.totalAttacks;
- const failedAttacks = offAll.failedAttacks;
- const hitRate = totalAttacks > 0 ? ((totalAttacks - failedAttacks) / totalAttacks * 100).toFixed(0) : '0';
-
- const totalMeleeDefends = defMM.totalAttacks;
- const totalEvades = defMM.failedAttacks;
- const evadeRate = totalMeleeDefends > 0 ? (totalEvades / totalMeleeDefends * 100).toFixed(0) : '0';
-
- const totalMagicDefends = defMag.totalAttacks;
- const totalResists = defMag.failedAttacks;
- const resistRate = totalMagicDefends > 0 ? (totalResists / totalMagicDefends * 100).toFixed(0) : '0';
-
- const aethRate = totalAttacks > 0 ? (totalAethSurges / totalAttacks * 100).toFixed(1) : '0.0';
- const totalDefHits = (totalMeleeDefends - totalEvades) + (totalMagicDefends - totalResists);
- const cloakRate = totalDefHits > 0 ? (totalCloakSurges / totalDefHits * 100).toFixed(1) : '0.0';
-
- const crits = offAll.crits;
- const hitsNonKill = totalAttacks - failedAttacks;
- const critRate = hitsNonKill > 0 ? (crits / hitsNonKill * 100).toFixed(1) : '0.0';
- const normalHits = hitsNonKill - crits;
- const avgNormal = normalHits > 0 ? Math.round(offAll.totalNormalDamage / normalHits) : 0;
- const avgCrit = crits > 0 ? Math.round(offAll.totalCritDamage / crits) : 0;
-
- // Damage by element — offense (given) and defense (received)
- const offDmgMM = {}, offDmgMag = {}, defDmgMM = {}, defDmgMag = {};
- let totalOffMM = 0, totalOffMag = 0, totalDefMM = 0, totalDefMag = 0;
- DAMAGE_ELEMENTS.forEach(el => {
- offDmgMM[el] = getDefDmg(offense, 'MeleeMissile', el);
- offDmgMag[el] = getDefDmg(offense, 'Magic', el);
- defDmgMM[el] = getDefDmg(defense, 'MeleeMissile', el);
- defDmgMag[el] = getDefDmg(defense, 'Magic', el);
- totalOffMM += offDmgMM[el];
- totalOffMag += offDmgMag[el];
- totalDefMM += defDmgMM[el];
- totalDefMag += defDmgMag[el];
- });
-
- const totalDmgGiven = offAll.totalNormalDamage + offAll.totalCritDamage;
-
- // Build the grid HTML
- const fmtN = n => n === 0 ? '' : n.toLocaleString();
- const rightCell = (text) => `${text} `;
- const labelCell = (text) => `${text} `;
- const headerCell = (text) => `${text} `;
- const statLabel = (text) => `${text} `;
- const sepCell = ` `;
-
- let html = '';
-
- // Column headers: Element | Dmg Given (M/M, Mag) | Dmg Recv (M/M, Mag) | Stats
- html += ` ${headerCell('Given M/M')}${headerCell('Given Mag')}${sepCell}${headerCell('Recv M/M')}${headerCell('Recv Mag')}${sepCell}${statLabel('Attacks')}${rightCell(totalAttacks > 0 ? `${fmtN(totalAttacks)} (${hitRate}%)` : '')} `;
-
- // Stats to show on the right side of each element row
- const rightStats = [
- ['Evades', () => totalMeleeDefends > 0 ? `${fmtN(totalMeleeDefends)} (${evadeRate}%)` : ''],
- ['Resists', () => totalMagicDefends > 0 ? `${fmtN(totalMagicDefends)} (${resistRate}%)` : ''],
- ['A.Surges', () => totalAethSurges > 0 ? `${fmtN(totalAethSurges)} (${aethRate}%)` : ''],
- ['C.Surges', () => totalCloakSurges > 0 ? `${fmtN(totalCloakSurges)} (${cloakRate}%)` : ''],
- ['', () => ''],
- ['', () => ''],
- ['Av/Mx', () => avgNormal > 0 ? `${fmtN(avgNormal)} / ${fmtN(offAll.maxNormalDamage)}` : ''],
- ['Crits', () => crits > 0 ? `${fmtN(crits)} (${critRate}%)` : ''],
- ];
-
- for (let i = 0; i < DAMAGE_ELEMENTS.length; i++) {
- const el = DAMAGE_ELEMENTS[i];
- const rs = rightStats[i] || ['', () => ''];
- html += `${labelCell(el)}${rightCell(fmtN(offDmgMM[el]))}${rightCell(fmtN(offDmgMag[el]))}${sepCell}${rightCell(fmtN(defDmgMM[el]))}${rightCell(fmtN(defDmgMag[el]))}${sepCell}${rs[0] ? statLabel(rs[0]) : ' '}${rightCell(rs[1]())} `;
- }
-
- // Crit Avg/Max row
- html += ` ${sepCell} ${sepCell}${statLabel('Av/Mx')}${rightCell(avgCrit > 0 ? `${fmtN(avgCrit)} / ${fmtN(offAll.maxCritDamage)}` : '')} `;
-
- // Blank row
- html += ` `;
-
- // Total row
- html += `${labelCell('Total')}${rightCell(fmtN(totalOffMM))}${rightCell(fmtN(totalOffMag))}${sepCell}${rightCell(fmtN(totalDefMM))}${rightCell(fmtN(totalDefMag))}${sepCell}${statLabel('Total')}${rightCell(fmtN(totalDmgGiven))} `;
-
- html += '
';
- grid.innerHTML = html;
-}
-
-function createMonsterRow(marker, name, kills, dmgRecv, dmgGiven, selected) {
- const row = document.createElement('div');
- const bg = selected ? 'background:#2a3a4a;' : '';
- row.style.cssText = `display:flex;gap:4px;padding:2px 6px;font-size:0.78rem;cursor:pointer;border-bottom:1px solid #222;${bg}color:#ddd;`;
- row.onmouseenter = () => { if (!selected) row.style.background = '#252525'; };
- row.onmouseleave = () => { if (!selected) row.style.background = ''; };
- const fmtN = n => (!n || n === 0) ? '' : Number(n).toLocaleString();
- row.innerHTML = `${marker} ${name} ${fmtN(kills)} ${fmtN(dmgRecv)} ${fmtN(dmgGiven)} `;
- return row;
-}
-
-// ── Helpers for aggregating nested combat data ──
-function mergeAttackStats(target, source) {
- for (const [atk, byEl] of Object.entries(source)) {
- if (!target[atk]) target[atk] = {};
- for (const [el, stats] of Object.entries(byEl)) {
- if (!target[atk][el]) target[atk][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
- const t = target[atk][el];
- t.total_attacks += stats.total_attacks || 0;
- t.failed_attacks += stats.failed_attacks || 0;
- t.crits += stats.crits || 0;
- t.total_normal_damage += stats.total_normal_damage || 0;
- t.max_normal_damage = Math.max(t.max_normal_damage, stats.max_normal_damage || 0);
- t.total_crit_damage += stats.total_crit_damage || 0;
- t.max_crit_damage = Math.max(t.max_crit_damage, stats.max_crit_damage || 0);
- }
- }
-}
-
-function flattenStats(attackTypes) {
- let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 };
- for (const byEl of Object.values(attackTypes)) {
- for (const s of Object.values(byEl)) {
- r.totalAttacks += s.total_attacks || 0;
- r.failedAttacks += s.failed_attacks || 0;
- r.crits += s.crits || 0;
- r.totalNormalDamage += s.total_normal_damage || 0;
- r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0);
- r.totalCritDamage += s.total_crit_damage || 0;
- r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0);
- }
- }
- return r;
-}
-
-function flattenStatsForType(attackTypes, type) {
- let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 };
- const byEl = attackTypes[type];
- if (!byEl) return r;
- for (const s of Object.values(byEl)) {
- r.totalAttacks += s.total_attacks || 0;
- r.failedAttacks += s.failed_attacks || 0;
- r.crits += s.crits || 0;
- r.totalNormalDamage += s.total_normal_damage || 0;
- r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0);
- r.totalCritDamage += s.total_crit_damage || 0;
- r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0);
- }
- return r;
-}
-
-function getDefDmg(defense, atkType, element) {
- const byEl = defense[atkType];
- if (!byEl) return 0;
- const s = byEl[element];
- if (!s) return 0;
- return (s.total_normal_damage || 0) + (s.total_crit_damage || 0);
-}