/*
* 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
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 += ``;
}
// 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);
}