/*
* script.js - Frontend logic for Dereth Tracker Single-Page Application.
* Handles WebSocket communication, UI rendering of player lists, map display,
* and user interactions (filtering, sorting, chat, stats windows).
*/
/**
* script.js - Frontend controller for Dereth Tracker SPA
*
* Responsibilities:
* - Establish WebSocket connections to receive live telemetry and chat data
* - Fetch and render live player lists, trails, and map dots
* - Handle user interactions: filtering, sorting, selecting players
* - Manage dynamic UI components: chat windows, stats panels, tooltips
* - Provide smooth pan/zoom of map overlay using CSS transforms
*
* Structure:
* 1. DOM references and constant definitions
* 2. Color palette and assignment logic
* 3. Sorting and filtering setup
* 4. Utility functions (coordinate mapping, color hashing)
* 5. UI window creation (stats, chat)
* 6. Rendering functions for list and map
* 7. Event listeners for map interactions and WebSocket messages
*/
/* ---------- Debug configuration ---------------------------------- */
const DEBUG = false;
function debugLog(...args) { if (DEBUG) console.log(...args); }
function handleError(context, error, showUI = false) {
console.error(`[${context}]`, error);
if (showUI) {
const msg = document.createElement('div');
msg.className = 'error-toast';
msg.textContent = `${context}: ${error.message || 'Unknown error'}`;
document.body.appendChild(msg);
setTimeout(() => msg.remove(), GLOW_DURATION_MS);
}
}
/* ---------- DOM references --------------------------------------- */
const wrap = document.getElementById('mapContainer');
const group = document.getElementById('mapGroup');
const img = document.getElementById('map');
const dots = document.getElementById('dots');
const trailsContainer = document.getElementById('trails');
const list = document.getElementById('playerList');
const btnContainer = document.getElementById('sortButtons');
const tooltip = document.getElementById('tooltip');
const coordinates = document.getElementById('coordinates');
/* ---------- Element Pooling System for Performance ------------- */
// Pools for reusing DOM elements to eliminate recreation overhead
const elementPools = {
dots: [],
listItems: [],
activeDots: new Set(),
activeListItems: new Set()
};
// Performance tracking
let performanceStats = {
// Lifetime totals
dotsCreated: 0,
dotsReused: 0,
listItemsCreated: 0,
listItemsReused: 0,
// Per-render stats (reset each render)
renderDotsCreated: 0,
renderDotsReused: 0,
renderListItemsCreated: 0,
renderListItemsReused: 0,
lastRenderTime: 0,
renderCount: 0
};
function createNewDot() {
const dot = document.createElement('div');
dot.className = 'dot';
performanceStats.dotsCreated++;
performanceStats.renderDotsCreated++;
// Add event listeners once when creating
dot.addEventListener('mouseenter', e => showTooltip(e, dot.playerData));
dot.addEventListener('mousemove', e => showTooltip(e, dot.playerData));
dot.addEventListener('mouseleave', hideTooltip);
dot.addEventListener('click', () => {
if (dot.playerData) {
const { x, y } = worldToPx(dot.playerData.ew, dot.playerData.ns);
selectPlayer(dot.playerData, x, y);
}
});
return dot;
}
function createNewListItem() {
const li = document.createElement('li');
li.className = 'player-item';
performanceStats.listItemsCreated++;
performanceStats.renderListItemsCreated++;
// Create the grid content container
const gridContent = document.createElement('div');
gridContent.className = 'grid-content';
li.appendChild(gridContent);
// Create buttons once and keep them (no individual event listeners needed)
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'buttons-container';
const chatBtn = document.createElement('button');
chatBtn.className = 'chat-btn';
chatBtn.textContent = 'Chat';
chatBtn.addEventListener('click', (e) => {
debugLog('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget);
e.stopPropagation();
// Try button's own playerData first, fallback to DOM traversal
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
debugLog('🔥 Player data found:', playerData);
if (playerData) {
debugLog('🔥 Opening chat for:', playerData.character_name);
showChatWindow(playerData.character_name);
} else {
debugLog('🔥 No player data found!');
}
});
const statsBtn = document.createElement('button');
statsBtn.className = 'stats-btn';
statsBtn.textContent = 'Stats';
statsBtn.addEventListener('click', (e) => {
debugLog('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget);
e.stopPropagation();
// Try button's own playerData first, fallback to DOM traversal
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
debugLog('📊 Player data found:', playerData);
if (playerData) {
debugLog('📊 Opening stats for:', playerData.character_name);
showStatsWindow(playerData.character_name);
} else {
debugLog('📊 No player data found!');
}
});
const inventoryBtn = document.createElement('button');
inventoryBtn.className = 'inventory-btn';
inventoryBtn.textContent = 'Inventory';
inventoryBtn.addEventListener('click', (e) => {
debugLog('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget);
e.stopPropagation();
// Try button's own playerData first, fallback to DOM traversal
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
debugLog('🎒 Player data found:', playerData);
if (playerData) {
debugLog('🎒 Opening inventory for:', playerData.character_name);
showInventoryWindow(playerData.character_name);
} else {
debugLog('🎒 No player data found!');
}
});
const charBtn = document.createElement('button');
charBtn.className = 'char-btn';
charBtn.textContent = 'Char';
charBtn.addEventListener('click', (e) => {
e.stopPropagation();
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
if (playerData) {
showCharacterWindow(playerData.character_name);
}
});
buttonsContainer.appendChild(chatBtn);
buttonsContainer.appendChild(statsBtn);
buttonsContainer.appendChild(inventoryBtn);
buttonsContainer.appendChild(charBtn);
li.appendChild(buttonsContainer);
// Store references for easy access
li.gridContent = gridContent;
li.chatBtn = chatBtn;
li.statsBtn = statsBtn;
li.inventoryBtn = inventoryBtn;
li.charBtn = charBtn;
return li;
}
function returnToPool() {
// Return unused dots to pool
elementPools.activeDots.forEach(dot => {
if (!dot.parentNode) {
elementPools.dots.push(dot);
elementPools.activeDots.delete(dot);
}
});
// Return unused list items to pool
elementPools.activeListItems.forEach(li => {
if (!li.parentNode) {
elementPools.listItems.push(li);
elementPools.activeListItems.delete(li);
}
});
}
/* ---------- Event Delegation System ---------------------------- */
// Single event delegation handler for all player list interactions
function setupEventDelegation() {
list.addEventListener('click', e => {
const li = e.target.closest('li.player-item');
if (!li || !li.playerData) return;
const player = li.playerData;
const { x, y } = worldToPx(player.ew, player.ns);
// Handle player selection (clicking anywhere else on the item, not on buttons)
// Button clicks are now handled by direct event listeners
if (!e.target.closest('button')) {
selectPlayer(player, x, y);
}
});
}
// Initialize event delegation when DOM is ready
document.addEventListener('DOMContentLoaded', setupEventDelegation);
// Global drag system to prevent event listener accumulation
let currentDragWindow = null;
let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
function makeDraggable(win, header) {
if (!window.__chatZ) window.__chatZ = 10000;
header.style.cursor = 'move';
const bringToFront = () => {
window.__chatZ += 1;
win.style.zIndex = window.__chatZ;
};
header.addEventListener('mousedown', e => {
if (e.target.closest('button')) return;
e.preventDefault();
currentDragWindow = win;
bringToFront();
dragStartX = e.clientX;
dragStartY = e.clientY;
dragStartLeft = win.offsetLeft;
dragStartTop = win.offsetTop;
document.body.classList.add('noselect');
});
// Touch support
header.addEventListener('touchstart', e => {
if (e.touches.length !== 1 || e.target.closest('button')) return;
currentDragWindow = win;
bringToFront();
const t = e.touches[0];
dragStartX = t.clientX;
dragStartY = t.clientY;
dragStartLeft = win.offsetLeft;
dragStartTop = win.offsetTop;
});
}
// Global mouse handlers (only added once)
window.addEventListener('mousemove', e => {
if (!currentDragWindow) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
currentDragWindow.style.left = `${dragStartLeft + dx}px`;
currentDragWindow.style.top = `${dragStartTop + dy}px`;
});
window.addEventListener('mouseup', () => {
if (currentDragWindow) {
currentDragWindow = null;
document.body.classList.remove('noselect');
}
});
window.addEventListener('touchmove', e => {
if (!currentDragWindow || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - dragStartX;
const dy = t.clientY - dragStartY;
currentDragWindow.style.left = `${dragStartLeft + dx}px`;
currentDragWindow.style.top = `${dragStartTop + dy}px`;
});
window.addEventListener('touchend', () => {
currentDragWindow = null;
});
// Filter input for player names (starts-with filter)
let currentFilter = '';
const filterInput = document.getElementById('playerFilter');
if (filterInput) {
filterInput.addEventListener('input', e => {
currentFilter = e.target.value.toLowerCase().trim();
renderList();
});
}
// WebSocket for chat and commands
let socket;
// Keep track of open chat windows: character_name -> DOM element
const chatWindows = {};
// Keep track of open stats windows: character_name -> DOM element
const statsWindows = {};
// Keep track of open inventory windows: character_name -> DOM element
const inventoryWindows = {};
/**
* ---------- Application Constants -----------------------------
* Defines key parameters for map rendering, data polling, and UI limits.
*
* MAX_Z: Maximum altitude difference considered (filter out outliers by Z)
* FOCUS_ZOOM: Zoom level when focusing on a selected character
* POLL_MS: Millisecond interval to fetch live player data and trails
* MAP_BOUNDS: World coordinate bounds for the game map (used for projection)
* API_BASE: Prefix for AJAX endpoints (set when behind a proxy)
* MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage
* CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code
*/
/* ---------- constants ------------------------------------------- */
const MAX_Z = 20;
const FOCUS_ZOOM = 3; // zoom level when you click a name
const POLL_MS = 2000;
const POLL_RARES_MS = 300000; // 5 minutes
const POLL_KILLS_MS = 300000; // 5 minutes
const POLL_HEALTH_MS = 30000; // 30 seconds
const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time
const GLOW_DURATION_MS = 5000; // Player glow after rare find
const MAX_HEATMAP_POINTS = 50000;
const HEATMAP_HOURS = 24;
// UtilityBelt's more accurate coordinate bounds
const MAP_BOUNDS = {
west: -102.1,
east: 102.1,
north: 102.1,
south: -102.1
};
// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
// If serving APIs at root, leave empty
const API_BASE = '';
// Maximum number of lines to retain in each chat window scrollback
const MAX_CHAT_LINES = 1000;
// Map numeric chat color codes to CSS hex colors
const CHAT_COLOR_MAP = {
0: '#00FF00', // Broadcast
2: '#FFFFFF', // Speech
3: '#FFD700', // Tell
4: '#CCCC00', // OutgoingTell
5: '#FF00FF', // System
6: '#FF0000', // Combat
7: '#00CCFF', // Magic
8: '#DDDDDD', // Channel
9: '#FF9999', // ChannelSend
10: '#FFFF33', // Social
11: '#CCFF33', // SocialSend
12: '#FFFFFF', // Emote
13: '#00FFFF', // Advancement
14: '#66CCFF', // Abuse
15: '#FF0000', // Help
16: '#33FF00', // Appraisal
17: '#0099FF', // Spellcasting
18: '#FF6600', // Allegiance
19: '#CC66FF', // Fellowship
20: '#00FF00', // WorldBroadcast
21: '#FF0000', // CombatEnemy
22: '#FF33CC', // CombatSelf
23: '#00CC00', // Recall
24: '#00FF00', // Craft
25: '#00FF66', // Salvaging
27: '#FFFFFF', // General
28: '#33FF33', // Trade
29: '#CCCCCC', // LFG
30: '#CC00CC', // Roleplay
31: '#FFFF00' // AdminTell
};
/* ---------- Heat Map Globals ---------- */
let heatmapCanvas, heatmapCtx;
let heatmapEnabled = false;
let heatmapData = null;
let heatTimeout = null;
const HEAT_PADDING = 50; // px beyond viewport to still draw
const HEAT_THROTTLE = 16; // ~60 fps
/* ---------- Portal Map Globals ---------- */
let portalEnabled = false;
let portalData = null;
let portalContainer = null;
/**
* ---------- Player Color Assignment ----------------------------
* Uses a predefined accessible color palette for player dots to ensure
* high contrast and colorblind-friendly display. Once the palette
* is exhausted, falls back to a deterministic hash-to-hue function.
*/
/* ---------- player/dot color assignment ------------------------- */
// A base palette of distinct, color-blind-friendly colors
const PALETTE = [
// Original colorblind-friendly base palette
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf',
// Extended high-contrast colors
'#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff',
'#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488',
// Darker variants
'#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc',
'#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366',
// Brighter variants
'#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff',
'#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa',
// Additional distinct colors
'#990099', '#009900', '#000099', '#990000', '#009999',
'#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055',
// Light pastels for contrast
'#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff',
'#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc'
];
// Map from character name to assigned color
const colorMap = {};
// Next index to pick from PALETTE
let nextPaletteIndex = 0;
/**
* Assigns or returns a consistent color for a given name.
* Uses a fixed palette first, then falls back to hue hashing.
*/
function getColorFor(name) {
if (colorMap[name]) {
return colorMap[name];
}
let color;
if (nextPaletteIndex < PALETTE.length) {
color = PALETTE[nextPaletteIndex++];
} else {
// Fallback: hash to HSL hue
color = hue(name);
}
colorMap[name] = color;
return color;
}
/*
* ---------- Sort Configuration -------------------------------
* Defines available sort criteria for the active player list:
* - name: alphabetical ascending
* - kph: kills per hour descending
* - kills: total kills descending
* - rares: rare events found during current session descending
* Each option includes a label for UI display and a comparator function.
*/
/* ---------- sort configuration ---------------------------------- */
const sortOptions = [
{
value: "name",
label: "Name",
comparator: (a, b) => a.character_name.localeCompare(b.character_name)
},
{
value: "kph",
label: "KPH",
comparator: (a, b) => b.kills_per_hour - a.kills_per_hour
},
{
value: "kills",
label: "S.Kills",
comparator: (a, b) => b.kills - a.kills
},
{
value: "rares",
label: "S.Rares",
comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0)
},
{
value: "total_kills",
label: "T.Kills",
comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0)
},
{
value: "kpr",
label: "KPR",
comparator: (a, b) => {
const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity;
const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity;
return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding)
}
}
];
let currentSort = sortOptions[0];
let currentPlayers = [];
/* ---------- generate segmented buttons -------------------------- */
sortOptions.forEach(opt => {
const btn = document.createElement('div');
btn.className = 'btn';
btn.textContent = opt.label;
btn.dataset.value = opt.value;
if (opt.value === currentSort.value) btn.classList.add('active');
btn.addEventListener('click', () => {
btnContainer.querySelectorAll('.btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSort = opt;
renderList();
});
btnContainer.appendChild(btn);
});
/* ---------- map & state variables ------------------------------- */
let imgW = 0, imgH = 0;
let scale = 1, offX = 0, offY = 0, minScale = 1;
let dragging = false, sx = 0, sy = 0;
let selected = "";
const pollIntervals = [];
/* ---------- utility functions ----------------------------------- */
const hue = name => {
let h = 0;
for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h);
return `hsl(${Math.abs(h) % 360},72%,50%)`;
};
const loc = (ns, ew) =>
`${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} `
+ `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`;
function worldToPx(ew, ns) {
const x = ((ew - MAP_BOUNDS.west)
/ (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
const y = ((MAP_BOUNDS.north - ns)
/ (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
return { x, y };
}
function pxToWorld(x, y) {
// Convert screen coordinates to map image coordinates
const mapX = (x - offX) / scale;
const mapY = (y - offY) / scale;
// Convert map image coordinates to world coordinates
const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
return { ew, ns };
}
/* ---------- Heat Map Functions ---------- */
function initHeatMap() {
heatmapCanvas = document.getElementById('heatmapCanvas');
if (!heatmapCanvas) {
console.error('Heat map canvas not found');
return;
}
heatmapCtx = heatmapCanvas.getContext('2d');
const toggle = document.getElementById('heatmapToggle');
if (toggle) {
toggle.addEventListener('change', e => {
heatmapEnabled = e.target.checked;
if (heatmapEnabled) {
fetchHeatmapData();
} else {
clearHeatmap();
}
});
}
window.addEventListener('resize', debounce(() => {
if (heatmapEnabled && heatmapData) {
renderHeatmap();
}
}, 250));
}
async function fetchHeatmapData() {
try {
const response = await fetch(`${API_BASE}/spawns/heatmap?hours=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`);
if (!response.ok) {
throw new Error(`Heat map API error: ${response.status}`);
}
const data = await response.json();
heatmapData = data.spawn_points; // [{ew, ns, intensity}]
debugLog(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`);
renderHeatmap();
} catch (err) {
handleError('Heatmap', err);
}
}
function renderHeatmap() {
if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) {
return;
}
// Set canvas size to match map dimensions (1:1 DPI)
heatmapCanvas.width = imgW;
heatmapCanvas.height = imgH;
heatmapCtx.clearRect(0, 0, imgW, imgH);
// Current visible map rect in px for viewport culling
const vw = wrap.clientWidth;
const vh = wrap.clientHeight;
const viewL = -offX / scale;
const viewT = -offY / scale;
const viewR = viewL + vw / scale;
const viewB = viewT + vh / scale;
// Render heat map points with viewport culling
for (const point of heatmapData) {
const { x, y } = worldToPx(point.ew, point.ns);
// Skip points outside visible area (with padding for smooth edges)
if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING ||
y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) {
continue;
}
// Smaller, more precise spots to clearly show individual spawn locations
const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
// Sharp gradient with distinct boundaries between spawn points
const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center
gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
heatmapCtx.fillStyle = gradient;
heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
}
function clearHeatmap() {
if (heatmapCtx && heatmapCanvas) {
heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height);
}
}
/* ---------- Portal Map Functions ---------- */
function initPortalMap() {
portalContainer = document.getElementById('portals');
if (!portalContainer) {
console.error('Portal container not found');
return;
}
const toggle = document.getElementById('portalToggle');
if (toggle) {
toggle.addEventListener('change', e => {
portalEnabled = e.target.checked;
if (portalEnabled) {
fetchPortalData();
} else {
clearPortals();
}
});
}
}
async function fetchPortalData() {
try {
const response = await fetch(`${API_BASE}/portals`);
if (!response.ok) {
throw new Error(`Portal API error: ${response.status}`);
}
const data = await response.json();
portalData = data.portals; // [{portal_name, coordinates: {ns, ew, z}, discovered_by, discovered_at}]
debugLog(`Loaded ${portalData.length} portals from last hour`);
renderPortals();
} catch (err) {
handleError('Portals', err);
}
}
function parseCoordinate(coord) {
// Handle both formats:
// String format: "42.3N", "15.7S", "33.7E", "28.2W"
// Numeric format: "-96.9330958" (already signed)
// Check if it's already a number
if (typeof coord === 'number') {
return coord;
}
// Check if it's a numeric string
const numericValue = parseFloat(coord);
if (!isNaN(numericValue) && coord.match(/^-?\d+\.?\d*$/)) {
return numericValue;
}
// Parse string format like "42.3N"
const match = coord.match(/^([0-9.]+)([NSEW])$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const direction = match[2];
if (direction === 'S' || direction === 'W') {
return -value;
}
return value;
}
function renderPortals() {
if (!portalEnabled || !portalData || !portalContainer || !imgW || !imgH) {
return;
}
// Clear existing portals
clearPortals();
for (const portal of portalData) {
// Extract coordinates from new API format
const ns = portal.coordinates.ns;
const ew = portal.coordinates.ew;
// Convert to pixel coordinates
const { x, y } = worldToPx(ew, ns);
// Create portal icon
const icon = document.createElement('div');
icon.className = 'portal-icon';
icon.style.left = `${x}px`;
icon.style.top = `${y}px`;
icon.title = `${portal.portal_name} (discovered by ${portal.discovered_by})`;
portalContainer.appendChild(icon);
}
debugLog(`Rendered ${portalData.length} portal icons`);
}
function clearPortals() {
if (portalContainer) {
portalContainer.innerHTML = '';
}
}
function debounce(fn, ms) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
};
}
/**
* Create or show a draggable window. Returns { win, content, isNew }.
* If window already exists, brings it to front and returns isNew: false.
*/
function createWindow(id, title, className, options = {}) {
const { onClose } = options;
// Check if window already exists - bring to front
const existing = document.getElementById(id);
if (existing) {
existing.style.display = 'flex';
if (!window.__chatZ) window.__chatZ = 10000;
window.__chatZ += 1;
existing.style.zIndex = window.__chatZ;
return { win: existing, content: existing.querySelector('.window-content'), isNew: false };
}
// Create new window
if (!window.__chatZ) window.__chatZ = 10000;
window.__chatZ += 1;
const win = document.createElement('div');
win.id = id;
win.className = className;
win.style.display = 'flex';
win.style.zIndex = window.__chatZ;
const header = document.createElement('div');
header.className = 'chat-header';
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
header.appendChild(titleSpan);
const closeBtn = document.createElement('button');
closeBtn.className = 'chat-close-btn';
closeBtn.textContent = '\u00D7';
closeBtn.addEventListener('click', () => {
win.style.display = 'none';
if (onClose) onClose();
});
header.appendChild(closeBtn);
const content = document.createElement('div');
content.className = 'window-content';
win.appendChild(header);
win.appendChild(content);
document.body.appendChild(win);
makeDraggable(win, header);
return { win, content, isNew: true };
}
// Show or create a stats window for a character
function showStatsWindow(name) {
debugLog('showStatsWindow called for:', name);
const windowId = `statsWindow-${name}`;
const { win, content, isNew } = createWindow(
windowId, `Stats: ${name}`, 'stats-window'
);
if (!isNew) {
debugLog('Existing stats window found, showing it');
return;
}
win.dataset.character = name;
statsWindows[name] = win;
// Time period controls
const controls = document.createElement('div');
controls.className = 'stats-controls';
const timeRanges = [
{ label: '1H', value: 'now-1h' },
{ label: '6H', value: 'now-6h' },
{ label: '24H', value: 'now-24h' },
{ label: '7D', value: 'now-7d' }
];
// Stats content container (iframes grid)
const statsContent = document.createElement('div');
statsContent.className = 'chat-messages';
statsContent.textContent = 'Loading stats...';
timeRanges.forEach(range => {
const btn = document.createElement('button');
btn.className = 'time-range-btn';
btn.textContent = range.label;
if (range.value === 'now-24h') btn.classList.add('active');
btn.addEventListener('click', () => {
controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateStatsTimeRange(statsContent, name, range.value);
});
controls.appendChild(btn);
});
content.appendChild(controls);
content.appendChild(statsContent);
debugLog('Stats window created for:', name);
// Load initial stats with default 24h range
updateStatsTimeRange(statsContent, name, 'now-24h');
}
function updateStatsTimeRange(content, name, timeRange) {
content.innerHTML = '';
const panels = [
{ title: 'Kills per Hour', id: 1 },
{ title: 'Memory (MB)', id: 2 },
{ title: 'CPU (%)', id: 3 },
{ title: 'Mem Handles', id: 4 }
];
panels.forEach(p => {
const iframe = document.createElement('iframe');
iframe.src =
`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` +
`?panelId=${p.id}` +
`&var-character=${encodeURIComponent(name)}` +
`&from=${timeRange}` +
`&to=now` +
`&theme=light`;
iframe.setAttribute('title', p.title);
iframe.width = '350';
iframe.height = '200';
iframe.frameBorder = '0';
iframe.allowFullscreen = true;
content.appendChild(iframe);
});
}
// Show or create an inventory window for a character
/**
* Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot.
* Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... }
* Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} }
* Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... }
*
* MyWorldObject uses -1 as sentinel for "not set" on int/double properties.
*/
function normalizeInventoryItem(item) {
if (!item) return item;
if (item.name && item.item_id) return item;
// MyWorldObject uses -1 as "not set" sentinel — filter those out
const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined;
if (!item.item_id) item.item_id = item.Id;
if (!item.icon) item.icon = item.Icon;
if (!item.object_class) item.object_class = item.ObjectClass;
if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData;
const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null;
const material = item.Material || null;
if (material) {
item.material = material;
item.material_name = material;
}
// Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service
if (baseName) {
if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) {
item.name = material + ' ' + baseName;
} else {
item.name = baseName;
}
}
const iv = item.IntValues || {};
if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']);
if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']);
// Container/equipment tracking
if (item.container_id === undefined) item.container_id = item.ContainerId || 0;
if (item.current_wielded_location === undefined) {
item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0;
}
if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']);
const armor = v(item.ArmorLevel);
if (armor !== undefined) item.armor_level = armor;
const maxDmg = v(item.MaxDamage);
if (maxDmg !== undefined) item.max_damage = maxDmg;
const dmgBonus = v(item.DamageBonus);
if (dmgBonus !== undefined) item.damage_bonus = dmgBonus;
const atkBonus = v(item.AttackBonus);
if (atkBonus !== undefined) item.attack_bonus = atkBonus;
const elemDmg = v(item.ElementalDmgBonus);
if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg;
const meleeD = v(item.MeleeDefenseBonus);
if (meleeD !== undefined) item.melee_defense_bonus = meleeD;
const magicD = v(item.MagicDBonus);
if (magicD !== undefined) item.magic_defense_bonus = magicD;
const missileD = v(item.MissileDBonus);
if (missileD !== undefined) item.missile_defense_bonus = missileD;
const manaC = v(item.ManaCBonus);
if (manaC !== undefined) item.mana_conversion_bonus = manaC;
const wieldLvl = v(item.WieldLevel);
if (wieldLvl !== undefined) item.wield_level = wieldLvl;
const skillLvl = v(item.SkillLevel);
if (skillLvl !== undefined) item.skill_level = skillLvl;
const loreLvl = v(item.LoreRequirement);
if (loreLvl !== undefined) item.lore_requirement = loreLvl;
if (item.EquipSkill) item.equip_skill = item.EquipSkill;
if (item.Mastery) item.mastery = item.Mastery;
if (item.ItemSet) item.item_set = item.ItemSet;
if (item.Imbue) item.imbue = item.Imbue;
const tinks = v(item.Tinks);
if (tinks !== undefined) item.tinks = tinks;
const work = v(item.Workmanship);
if (work !== undefined) item.workmanship = work;
const damR = v(item.DamRating);
if (damR !== undefined) item.damage_rating = damR;
const critR = v(item.CritRating);
if (critR !== undefined) item.crit_rating = critR;
const healR = v(item.HealBoostRating);
if (healR !== undefined) item.heal_boost_rating = healR;
const vitalR = v(item.VitalityRating);
if (vitalR !== undefined) item.vitality_rating = vitalR;
const critDmgR = v(item.CritDamRating);
if (critDmgR !== undefined) item.crit_damage_rating = critDmgR;
const damResR = v(item.DamResistRating);
if (damResR !== undefined) item.damage_resist_rating = damResR;
const critResR = v(item.CritResistRating);
if (critResR !== undefined) item.crit_resist_rating = critResR;
const critDmgResR = v(item.CritDamResistRating);
if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR;
if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) {
item.spells = item.Spells;
}
return item;
}
/**
* Create a single inventory slot DOM element from item data.
* Used by both initial inventory load and live delta updates.
*/
function createInventorySlot(item) {
const slot = document.createElement('div');
slot.className = 'inventory-slot';
slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0);
// Create layered icon container
const iconContainer = document.createElement('div');
iconContainer.className = 'item-icon-composite';
// Get base icon ID with portal.dat offset
const iconRaw = item.icon || item.Icon || 0;
const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
// Check for overlay and underlay from enhanced format or legacy format
let overlayIconId = null;
let underlayIconId = null;
// Enhanced format (inventory service) - check for proper icon overlay/underlay properties
if (item.icon_overlay_id && item.icon_overlay_id > 0) {
overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.icon_underlay_id && item.icon_underlay_id > 0) {
underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
// Fallback: Enhanced format (inventory service) - check spells object for decal info
if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') {
if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) {
overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) {
underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
} else if (item.IntValues) {
// Raw delta format from plugin - IntValues directly on item
if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) {
overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) {
underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
} else if (item.item_data) {
// Legacy format - parse item_data
try {
const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data;
if (itemData.IntValues) {
if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) {
overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) {
underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
}
} catch (e) {
console.warn('Failed to parse item data for', item.name || item.Name);
}
}
// Create underlay (bottom layer)
if (underlayIconId) {
const underlayImg = document.createElement('img');
underlayImg.className = 'icon-underlay';
underlayImg.src = `/icons/${underlayIconId}.png`;
underlayImg.alt = 'underlay';
underlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(underlayImg);
}
// Create base icon (middle layer)
const baseImg = document.createElement('img');
baseImg.className = 'icon-base';
baseImg.src = `/icons/${baseIconId}.png`;
baseImg.alt = item.name || item.Name || 'Unknown Item';
baseImg.onerror = function() { this.src = '/icons/06000133.png'; };
iconContainer.appendChild(baseImg);
// Create overlay (top layer)
if (overlayIconId) {
const overlayImg = document.createElement('img');
overlayImg.className = 'icon-overlay';
overlayImg.src = `/icons/${overlayIconId}.png`;
overlayImg.alt = 'overlay';
overlayImg.onerror = function() { this.style.display = 'none'; };
iconContainer.appendChild(overlayImg);
}
// Create tooltip data (handle both inventory-service format and raw plugin format)
const itemName = item.name || item.Name || 'Unknown Item';
slot.dataset.name = itemName;
slot.dataset.value = item.value || item.Value || 0;
slot.dataset.burden = item.burden || item.Burden || 0;
// Store enhanced data for tooltips
if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) {
const enhancedData = {};
const possibleProps = [
'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus',
'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name',
'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks',
'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating',
'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells',
'enhanced_properties', 'damage_range', 'damage_type', 'min_damage',
'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana',
'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus',
'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id'
];
possibleProps.forEach(prop => {
if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) {
enhancedData[prop] = item[prop];
}
});
slot.dataset.enhancedData = JSON.stringify(enhancedData);
} else {
slot.dataset.enhancedData = JSON.stringify({});
}
// Add tooltip on hover
slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot));
slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot));
slot.addEventListener('mouseleave', hideInventoryTooltip);
slot.appendChild(iconContainer);
// Add stack count if > 1
const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1;
if (stackCount > 1) {
const countEl = document.createElement('div');
countEl.className = 'inventory-count';
countEl.textContent = stackCount;
slot.appendChild(countEl);
}
return slot;
}
/**
* Equipment slots mapping for the AC inventory layout.
* Grid matches the real AC "Equipment Slots Enabled" paperdoll view.
*
* Layout (6 cols × 6 rows):
* Col: 1 2 3 4 5 6
* Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red)
* Row 2: Trinket — ChestArmor — — Cloak
* Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt)
* Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants)
* Row 5: — Hands — LowerLegArmor — —
* Row 6: Shield — — Feet Weapon Ammo
*/
const EQUIP_SLOTS = {
// Row 1: Necklace, Head, 3× Aetheria/Sigil
32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear
1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear
268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne
536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo
1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree
// Row 2: Trinket, Chest Armor, Cloak
67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne
2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor
512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor
134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak
// Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt
65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft
4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor
1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor
8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor
131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight
2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear
// Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants
262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft
32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear
16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor
524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight
4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear
// Row 5: Feet
256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear
// Row 6: Shield, Weapon, Ammo
2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield
1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon
4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon
16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held
33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded
8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition
};
const SLOT_COLORS = {};
// Purple: jewelry
[32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple');
// Blue: armor
[1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue');
// Teal: clothing/misc
[2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal');
// Dark blue: weapons/combat
[2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue');
/**
* Handle live inventory delta updates from WebSocket.
* Updates the inventory grid for a character if their inventory window is open.
*/
function updateInventoryLive(delta) {
const name = delta.character_name;
const win = inventoryWindows[name];
if (!win || !win._inventoryState) {
return;
}
const state = win._inventoryState;
const getItemId = (d) => {
if (d.item) return d.item.item_id || d.item.Id || d.item.id;
return d.item_id;
};
const itemId = getItemId(delta);
if (delta.action === 'remove') {
state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId);
} else if (delta.action === 'add' || delta.action === 'update') {
normalizeInventoryItem(delta.item);
const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId);
if (existingIdx >= 0) {
state.items[existingIdx] = delta.item;
} else {
state.items.push(delta.item);
}
}
renderInventoryState(state);
}
function renderInventoryState(state) {
// 1. Clear equipment slots
state.slotMap.forEach((slotEl) => {
slotEl.innerHTML = '';
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
slotEl.className = `inv-equip-slot empty ${colorClass}`;
delete slotEl.dataset.itemId;
});
// 2. Identify containers (object_class === 10) by item_id for sidebar
// These are packs/sacks/pouches/foci that appear in inventory as items
// but should ONLY show in the pack sidebar, not in the item grid.
const containers = []; // container objects (object_class=10)
const containerItemIds = new Set(); // item_ids of containers (to exclude from grid)
state.items.forEach(item => {
if (item.object_class === 10) {
containers.push(item);
containerItemIds.add(item.item_id);
}
});
// 3. Separate equipped items from pack items, excluding containers from grid
let totalBurden = 0;
const packItems = new Map(); // container_id → [items] (non-container items only)
// Determine the character body container_id: items with wielded_location > 0
// share a container_id that is NOT 0 and NOT a pack's item_id.
// We treat non-wielded items from the body container as "main backpack" items.
let bodyContainerId = null;
state.items.forEach(item => {
if (item.current_wielded_location && item.current_wielded_location > 0) {
const cid = item.container_id;
if (cid && cid !== 0 && !containerItemIds.has(cid)) {
bodyContainerId = cid;
}
}
});
state.items.forEach(item => {
totalBurden += (item.burden || 0);
// Skip container objects — they go in sidebar only
if (containerItemIds.has(item.item_id)) return;
if (item.current_wielded_location && item.current_wielded_location > 0) {
const mask = item.current_wielded_location;
const isArmor = item.object_class === 2;
// For armor (object_class=2): render in ALL matching slots (multi-slot display)
// For everything else (clothing, jewelry, weapons): place in first matching slot only
if (isArmor) {
Object.keys(EQUIP_SLOTS).forEach(m => {
const slotMask = parseInt(m);
if ((mask & slotMask) === slotMask) {
const slotDef = EQUIP_SLOTS[slotMask];
const key = `${slotDef.row}-${slotDef.col}`;
if (state.slotMap.has(key)) {
const slotEl = state.slotMap.get(key);
if (!slotEl.dataset.itemId) {
slotEl.innerHTML = '';
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
slotEl.className = `inv-equip-slot equipped ${colorClass}`;
slotEl.dataset.itemId = item.item_id;
slotEl.appendChild(createInventorySlot(item));
}
}
}
});
} else {
// Non-armor: find the first matching slot by exact mask key, then by bit overlap
let placed = false;
// Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly)
if (EQUIP_SLOTS[mask]) {
const slotDef = EQUIP_SLOTS[mask];
const key = `${slotDef.row}-${slotDef.col}`;
if (state.slotMap.has(key)) {
const slotEl = state.slotMap.get(key);
if (!slotEl.dataset.itemId) {
slotEl.innerHTML = '';
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
slotEl.className = `inv-equip-slot equipped ${colorClass}`;
slotEl.dataset.itemId = item.item_id;
slotEl.appendChild(createInventorySlot(item));
placed = true;
}
}
}
// If no exact match, find first matching bit in EQUIP_SLOTS
if (!placed) {
for (const m of Object.keys(EQUIP_SLOTS)) {
const slotMask = parseInt(m);
if ((mask & slotMask) === slotMask) {
const slotDef = EQUIP_SLOTS[slotMask];
const key = `${slotDef.row}-${slotDef.col}`;
if (state.slotMap.has(key)) {
const slotEl = state.slotMap.get(key);
if (!slotEl.dataset.itemId) {
slotEl.innerHTML = '';
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
slotEl.className = `inv-equip-slot equipped ${colorClass}`;
slotEl.dataset.itemId = item.item_id;
slotEl.appendChild(createInventorySlot(item));
placed = true;
break;
}
}
}
}
}
}
} else {
// Non-equipped, non-container → pack item. Group by container_id.
let cid = item.container_id || 0;
// Items on the character body (not wielded) → treat as main backpack (cid=0)
if (bodyContainerId !== null && cid === bodyContainerId) cid = 0;
if (!packItems.has(cid)) packItems.set(cid, []);
packItems.get(cid).push(item);
}
});
state.burdenLabel.textContent = 'Burden';
state.burdenFill.style.height = '0%';
// 4. Sort containers for stable sidebar order (by unsigned item_id)
containers.sort((a, b) => {
const ua = a.item_id >>> 0;
const ub = b.item_id >>> 0;
return ua - ub;
});
// 5. Render packs in sidebar
state.packList.innerHTML = '';
// Helper: compute icon URL from raw icon id
const iconUrl = (iconRaw) => {
if (!iconRaw) return '/icons/06001080.png';
const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
return `/icons/${hex}.png`;
};
// --- Main backpack (container_id === 0, non-containers) ---
const mainPackEl = document.createElement('div');
mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`;
const mainPackImg = document.createElement('img');
mainPackImg.src = '/icons/06001BB1.png';
mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; };
const mainFillCont = document.createElement('div');
mainFillCont.className = 'inv-pack-fill-container';
const mainFill = document.createElement('div');
mainFill.className = 'inv-pack-fill';
// Main backpack items = container_id 0, excluding container objects
const mainPackItems = packItems.get(0) || [];
const mainPct = Math.min(100, (mainPackItems.length / 102) * 100);
mainFill.style.height = `${mainPct}%`;
mainFillCont.appendChild(mainFill);
mainPackEl.appendChild(mainPackImg);
mainPackEl.appendChild(mainFillCont);
mainPackEl.onclick = () => {
state.activePack = null;
renderInventoryState(state);
};
state.packList.appendChild(mainPackEl);
// --- Sub-packs: each container object (object_class=10) ---
containers.forEach(container => {
const cid = container.item_id; // items inside this pack have container_id = this item_id
const packEl = document.createElement('div');
packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`;
const packImg = document.createElement('img');
// Use the container's actual icon from the API
packImg.src = iconUrl(container.icon);
packImg.onerror = function() { this.src = '/icons/06001080.png'; };
const fillCont = document.createElement('div');
fillCont.className = 'inv-pack-fill-container';
const fill = document.createElement('div');
fill.className = 'inv-pack-fill';
const pItems = packItems.get(cid) || [];
const capacity = container.items_capacity || 24; // default pack capacity in AC
const pPct = Math.min(100, (pItems.length / capacity) * 100);
fill.style.height = `${pPct}%`;
fillCont.appendChild(fill);
packEl.appendChild(packImg);
packEl.appendChild(fillCont);
packEl.onclick = () => {
state.activePack = cid;
renderInventoryState(state);
};
state.packList.appendChild(packEl);
});
// 6. Render item grid
state.itemGrid.innerHTML = '';
let itemsToShow = [];
if (state.activePack === null) {
// Main backpack: non-container items with container_id === 0
itemsToShow = mainPackItems;
state.contentsHeader.textContent = 'Contents of Backpack';
} else {
// Sub-pack: items with matching container_id
itemsToShow = packItems.get(state.activePack) || [];
// Use the container's name for the header
const activeContainer = containers.find(c => c.item_id === state.activePack);
state.contentsHeader.textContent = activeContainer
? `Contents of ${activeContainer.name}`
: 'Contents of Pack';
}
const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6);
for (let i = 0; i < numCells; i++) {
const cell = document.createElement('div');
if (i < itemsToShow.length) {
cell.className = 'inv-item-slot occupied';
const itemNode = createInventorySlot(itemsToShow[i]);
cell.appendChild(itemNode);
} else {
cell.className = 'inv-item-slot';
}
state.itemGrid.appendChild(cell);
}
renderInventoryManaPanel(state);
}
function getManaTrackedItems(state) {
if (!state || !state.items) return [];
const snapshotMs = Date.now();
return state.items
.filter(item => (item.current_wielded_location || 0) > 0)
.filter(item => item.is_mana_tracked || item.current_mana !== undefined || item.max_mana !== undefined || item.spellcraft !== undefined)
.map(item => {
const result = { ...item };
if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) {
const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN;
if (!Number.isNaN(snapshotUtc)) {
const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000));
result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0);
} else {
result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds;
}
} else {
result.live_mana_time_remaining_seconds = null;
}
return result;
})
.sort((a, b) => {
const aRemaining = a.live_mana_time_remaining_seconds;
const bRemaining = b.live_mana_time_remaining_seconds;
if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || '');
if (aRemaining === null) return 1;
if (bRemaining === null) return -1;
if (aRemaining !== bRemaining) return aRemaining - bRemaining;
return (a.name || '').localeCompare(b.name || '');
});
}
function formatManaRemaining(totalSeconds) {
if (totalSeconds === null || totalSeconds === undefined) return '--';
const safeSeconds = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
return `${hours}h${String(minutes).padStart(2, '0')}m`;
}
function renderInventoryManaPanel(state) {
if (!state || !state.manaListBody || !state.manaSummary) return;
const items = getManaTrackedItems(state);
adjustInventoryLayoutForMana(state, items.length);
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 adjustInventoryLayoutForMana(state, itemCount) {
if (!state || !state.windowEl || !state.topSection || !state.manaPanel) return;
const baseWindowHeight = 520;
const baseTopHeight = 264;
const basePanelHeight = 260;
const visibleRowsAtBase = 9;
const rowHeight = 22;
const extraRows = Math.max(0, itemCount - visibleRowsAtBase);
const extraHeight = extraRows * rowHeight;
state.topSection.style.height = `${baseTopHeight + extraHeight}px`;
state.manaPanel.style.height = `${basePanelHeight + extraHeight}px`;
state.windowEl.style.height = `${baseWindowHeight + extraHeight}px`;
}
function showInventoryWindow(name) {
debugLog('showInventoryWindow called for:', name);
const windowId = `inventoryWindow-${name}`;
const { win, content, isNew } = createWindow(
windowId, `Inventory: ${name}`, 'inventory-window'
);
if (!isNew) {
debugLog('Existing inventory window found, showing it');
return;
}
win.dataset.character = name;
inventoryWindows[name] = win;
const loading = document.createElement('div');
loading.className = 'inventory-loading';
loading.textContent = 'Loading inventory...';
content.appendChild(loading);
win.style.width = '540px';
win.style.height = '520px';
const invContent = document.createElement('div');
invContent.className = 'inventory-content';
invContent.style.display = 'none';
content.appendChild(invContent);
const topSection = document.createElement('div');
topSection.className = 'inv-top-section';
const equipGrid = document.createElement('div');
equipGrid.className = 'inv-equipment-grid';
const slotMap = new Map();
const createdSlots = new Set();
Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => {
const key = `${slotDef.row}-${slotDef.col}`;
if (!createdSlots.has(key)) {
createdSlots.add(key);
const slotEl = document.createElement('div');
const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue';
slotEl.className = `inv-equip-slot empty ${colorClass}`;
slotEl.style.left = `${(slotDef.col - 1) * 44}px`;
slotEl.style.top = `${(slotDef.row - 1) * 44}px`;
slotEl.dataset.pos = key;
equipGrid.appendChild(slotEl);
slotMap.set(key, slotEl);
}
});
const sidebar = document.createElement('div');
sidebar.className = 'inv-sidebar';
const manaPanel = document.createElement('div');
manaPanel.className = 'inv-mana-panel';
const manaHeader = document.createElement('div');
manaHeader.className = 'inv-mana-header';
manaHeader.textContent = 'Mana';
const manaSummary = document.createElement('div');
manaSummary.className = 'inv-mana-summary';
manaSummary.textContent = 'Mana: loading';
const manaListBody = document.createElement('div');
manaListBody.className = 'inv-mana-list';
manaPanel.appendChild(manaHeader);
manaPanel.appendChild(manaSummary);
manaPanel.appendChild(manaListBody);
const burdenContainer = document.createElement('div');
burdenContainer.className = 'inv-burden-bar';
const burdenFill = document.createElement('div');
burdenFill.className = 'inv-burden-fill';
const burdenLabel = document.createElement('div');
burdenLabel.className = 'inv-burden-label';
burdenLabel.textContent = 'Burden';
burdenContainer.appendChild(burdenLabel);
burdenContainer.appendChild(burdenFill);
sidebar.appendChild(burdenContainer);
const packList = document.createElement('div');
packList.className = 'inv-pack-list';
sidebar.appendChild(packList);
topSection.appendChild(equipGrid);
topSection.appendChild(sidebar);
topSection.appendChild(manaPanel);
const bottomSection = document.createElement('div');
bottomSection.className = 'inv-bottom-section';
const itemSection = document.createElement('div');
itemSection.className = 'inv-item-section';
const contentsHeader = document.createElement('div');
contentsHeader.className = 'inv-contents-header';
contentsHeader.textContent = 'Contents of Backpack';
const itemGrid = document.createElement('div');
itemGrid.className = 'inv-item-grid';
itemSection.appendChild(contentsHeader);
itemSection.appendChild(itemGrid);
bottomSection.appendChild(itemSection);
invContent.appendChild(topSection);
invContent.appendChild(bottomSection);
const resizeGrip = document.createElement('div');
resizeGrip.className = 'inv-resize-grip';
win.appendChild(resizeGrip);
let resizing = false;
let startY, startH;
resizeGrip.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizing = true;
startY = e.clientY;
startH = win.offsetHeight;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!resizing) return;
const newH = Math.max(400, startH + (e.clientY - startY));
win.style.height = newH + 'px';
});
document.addEventListener('mouseup', () => {
if (!resizing) return;
resizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
win._inventoryState = {
windowEl: win,
items: [],
activePack: null,
topSection: topSection,
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}`;
});
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;
// Only reorder element if it's actually out of place for current sort order
// Check if this element needs to be moved to maintain sort order
const expectedIndex = players.indexOf(p);
const currentIndex = Array.from(list.children).indexOf(li);
if (currentIndex !== expectedIndex && li.parentNode) {
// Find the correct position to insert
if (expectedIndex === players.length - 1) {
// Should be last - only move if it's not already last
if (li !== list.lastElementChild) {
list.appendChild(li);
}
} else {
// Should be at a specific position
const nextPlayer = players[expectedIndex + 1];
const nextElement = Array.from(list.children).find(el =>
el.playerData && el.playerData.character_name === nextPlayer.character_name
);
if (nextElement && li.nextElementSibling !== nextElement) {
list.insertBefore(li, nextElement);
}
}
}
// Calculate KPR (Kills Per Rare)
const playerTotalKills = p.total_kills || 0;
const totalRares = p.total_rares || 0;
const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞';
// Update only the grid content via innerHTML (buttons preserved)
li.gridContent.innerHTML = `
${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)}
${createVitalsHTML(p.character_name)}
${p.kills}
${p.total_kills || 0}
${p.kills_per_hour}
${p.session_rares}/${p.total_rares}
${kpr}
${p.vt_state}
${p.onlinetime}
${p.deaths}/${p.total_deaths || 0}
${p.prismatic_taper_count || 0}
`;
// Color the metastate pill according to its value
const metaSpan = li.querySelector('.stat.meta');
if (metaSpan) {
const goodStates = ['default', 'default2', 'hunt', 'combat'];
const state = (p.vt_state || '').toString().toLowerCase();
metaSpan.classList.remove('green', 'red'); // Clear previous
if (goodStates.includes(state)) {
metaSpan.classList.add('green');
} else {
metaSpan.classList.add('red');
}
}
// Update selected state
if (p.character_name === selected) {
li.classList.add('selected');
} else {
li.classList.remove('selected');
}
});
// Remove unused elements (any elements left in the maps are unused)
// These are dots for players that are no longer in the current player list
dotsByPlayer.forEach((dot, playerName) => {
dots.removeChild(dot);
});
// These are list items for players that are no longer in the current player list
listItemsByPlayer.forEach((li, playerName) => {
list.removeChild(li);
});
// Update performance stats
performanceStats.lastRenderTime = performance.now() - startTime;
// Determine optimization status
const totalCreatedThisRender = performanceStats.renderDotsCreated + performanceStats.renderListItemsCreated;
const totalReusedThisRender = performanceStats.renderDotsReused + performanceStats.renderListItemsReused;
const isOptimized = totalCreatedThisRender === 0 && totalReusedThisRender > 0;
const isPartiallyOptimized = totalCreatedThisRender < players.length && totalReusedThisRender > 0;
// Choose icon and color
let statusIcon = '🚀';
let colorStyle = '';
if (isOptimized) {
statusIcon = '✨';
colorStyle = 'background: #1a4a1a; color: #4ade80; font-weight: bold;';
} else if (isPartiallyOptimized) {
statusIcon = '⚡';
colorStyle = 'background: #4a3a1a; color: #fbbf24; font-weight: bold;';
} else {
statusIcon = '🔥';
colorStyle = 'background: #4a1a1a; color: #f87171; font-weight: bold;';
}
// Performance stats are tracked but not logged to keep console clean
// Optimization is achieving 100% element reuse consistently
const renderTime = performance.now() - startTime;
debugLog('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms');
}
/* ---------- rendering trails ------------------------------- */
function renderTrails(trailData) {
trailsContainer.innerHTML = '';
// Build point strings directly - avoid intermediate arrays
const byChar = {};
for (const pt of trailData) {
const { x, y } = worldToPx(pt.ew, pt.ns);
const key = pt.character_name;
if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 };
else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; }
}
for (const name in byChar) {
if (byChar[name].count < 2) continue;
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
poly.setAttribute('points', byChar[name].points);
poly.setAttribute('stroke', getColorFor(name));
poly.setAttribute('fill', 'none');
poly.setAttribute('class', 'trail-path');
trailsContainer.appendChild(poly);
}
}
/* ---------- selection centering, focus zoom & blink ------------ */
function selectPlayer(p, x, y) {
selected = p.character_name;
// set focus zoom
scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM));
// center on the player
const r = wrap.getBoundingClientRect();
offX = r.width / 2 - x * scale;
offY = r.height / 2 - y * scale;
updateView();
renderList(); // keep sorted + highlight
}
/*
* ---------- Chat & Command WebSocket Handlers ------------------
* Maintains a persistent WebSocket connection to the /ws/live endpoint
* for receiving chat messages and sending user commands to plugin clients.
* Reconnects automatically on close and logs errors.
*/
// Initialize WebSocket for chat and command streams
function initWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
socket = new WebSocket(wsUrl);
socket.addEventListener('message', evt => {
let msg;
try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'chat') {
appendChatMessage(msg);
} else if (msg.type === 'vitals') {
updateVitalsDisplay(msg);
} else if (msg.type === 'rare') {
triggerEpicRareNotification(msg.character_name, msg.name);
} else if (msg.type === 'character_stats') {
characterStats[msg.character_name] = msg;
updateCharacterWindow(msg.character_name, msg);
} else if (msg.type === 'inventory_delta') {
updateInventoryLive(msg);
} else if (msg.type === 'server_status') {
handleServerStatusUpdate(msg);
}
});
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
socket.addEventListener('error', e => handleError('WebSocket', e));
}
// Display or create a chat window for a character
function showChatWindow(name) {
debugLog('showChatWindow called for:', name);
const windowId = `chatWindow-${name}`;
const { win, content, isNew } = createWindow(
windowId, `Chat: ${name}`, 'chat-window'
);
if (!isNew) {
debugLog('Existing chat window found, showing it');
return;
}
win.dataset.character = name;
chatWindows[name] = win;
// Messages container
const msgs = document.createElement('div');
msgs.className = 'chat-messages';
content.appendChild(msgs);
// Input form
const form = document.createElement('form');
form.className = 'chat-form';
const input = document.createElement('input');
input.type = 'text';
input.className = 'chat-input';
input.placeholder = 'Enter chat...';
form.appendChild(input);
form.addEventListener('submit', e => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
// Send command envelope: player_name and command only
socket.send(JSON.stringify({ player_name: name, command: text }));
input.value = '';
});
content.appendChild(form);
debugLog('Chat window created for:', name);
}
// Append a chat message to the correct window
/**
* Append a chat message to the correct window, optionally coloring the text.
* msg: { type: 'chat', character_name, text, color? }
*/
function appendChatMessage(msg) {
const { character_name: name, text, color } = msg;
const win = chatWindows[name];
if (!win) return;
const msgs = win.querySelector('.chat-messages');
const p = document.createElement('div');
if (color !== undefined) {
let c = color;
if (typeof c === 'number') {
// map numeric chat code to configured color, or fallback to raw hex
if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
c = CHAT_COLOR_MAP[c];
} else {
c = '#' + c.toString(16).padStart(6, '0');
}
}
p.style.color = c;
}
p.textContent = text;
msgs.appendChild(p);
// Enforce max number of lines in scrollback
while (msgs.children.length > MAX_CHAT_LINES) {
msgs.removeChild(msgs.firstChild);
}
// Scroll to bottom
msgs.scrollTop = msgs.scrollHeight;
}
/* ---------- pan & zoom handlers -------------------------------- */
wrap.addEventListener('wheel', e => {
e.preventDefault();
if (!imgW) return;
const r = wrap.getBoundingClientRect();
const mx = (e.clientX - r.left - offX) / scale;
const my = (e.clientY - r.top - offY) / scale;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
let ns = scale * factor;
ns = Math.max(minScale, Math.min(MAX_Z, ns));
offX -= mx * (ns - scale);
offY -= my * (ns - scale);
scale = ns;
scheduleViewUpdate();
}, { passive: false });
wrap.addEventListener('mousedown', e => {
dragging = true; sx = e.clientX; sy = e.clientY;
wrap.classList.add('dragging');
});
window.addEventListener('mousemove', e => {
if (!dragging) return;
offX += e.clientX - sx; offY += e.clientY - sy;
sx = e.clientX; sy = e.clientY;
scheduleViewUpdate();
});
window.addEventListener('mouseup', () => {
dragging = false; wrap.classList.remove('dragging');
});
wrap.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
dragging = true;
sx = e.touches[0].clientX; sy = e.touches[0].clientY;
});
wrap.addEventListener('touchmove', e => {
if (!dragging || e.touches.length !== 1) return;
const t = e.touches[0];
offX += t.clientX - sx; offY += t.clientY - sy;
sx = t.clientX; sy = t.clientY;
scheduleViewUpdate();
});
wrap.addEventListener('touchend', () => {
dragging = false;
});
/* ---------- coordinate display on hover ---------------------------- */
wrap.addEventListener('mousemove', e => {
if (!imgW) return;
const r = wrap.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
const { ew, ns } = pxToWorld(x, y);
// Display coordinates using the same format as the existing loc function
coordinates.textContent = loc(ns, ew);
coordinates.style.left = `${x + 10}px`;
coordinates.style.top = `${y + 10}px`;
coordinates.style.display = 'block';
});
wrap.addEventListener('mouseleave', () => {
coordinates.style.display = 'none';
});
/* ---------- vitals display functions ----------------------------- */
// Store vitals data per character
const characterVitals = {};
const characterStats = {};
const characterWindows = {};
function updateVitalsDisplay(vitalsMsg) {
// Store the vitals data for this character
characterVitals[vitalsMsg.character_name] = {
health_percentage: vitalsMsg.health_percentage,
stamina_percentage: vitalsMsg.stamina_percentage,
mana_percentage: vitalsMsg.mana_percentage,
health_current: vitalsMsg.health_current,
health_max: vitalsMsg.health_max,
stamina_current: vitalsMsg.stamina_current,
stamina_max: vitalsMsg.stamina_max,
mana_current: vitalsMsg.mana_current,
mana_max: vitalsMsg.mana_max,
vitae: vitalsMsg.vitae
};
// Re-render the player list to update vitals in the UI
renderList();
// Also update character window if open
updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]);
}
function createVitalsHTML(characterName) {
const vitals = characterVitals[characterName];
if (!vitals) {
return ''; // No vitals data available
}
return `
`;
}
function createVitaeIndicator(characterName) {
const vitals = characterVitals[characterName];
if (!vitals || !vitals.vitae || vitals.vitae >= 100) {
return ''; // No vitae penalty
}
return `⚰️ ${vitals.vitae}%`;
}
function getVitalClass(percentage) {
if (percentage <= 25) {
return 'critical-vital';
} else if (percentage <= 50) {
return 'low-vital';
}
return '';
}
/* ---------- epic rare notification system ------------------------ */
// Track previous rare count to detect increases
let lastRareCount = 0;
let notificationQueue = [];
let isShowingNotification = false;
function triggerEpicRareNotification(characterName, rareName) {
// Add to queue
notificationQueue.push({ characterName, rareName });
// Process queue if not already showing a notification
if (!isShowingNotification) {
processNotificationQueue();
}
// Trigger fireworks immediately
createFireworks();
// Highlight the player in the list
highlightRareFinder(characterName);
}
function processNotificationQueue() {
if (notificationQueue.length === 0) {
isShowingNotification = false;
return;
}
isShowingNotification = true;
const notification = notificationQueue.shift();
// Create notification element
const container = document.getElementById('rareNotifications');
const notifEl = document.createElement('div');
notifEl.className = 'rare-notification';
notifEl.innerHTML = `
🎆 LEGENDARY RARE! 🎆
${notification.rareName}
found by
⚔️ ${notification.characterName} ⚔️
`;
container.appendChild(notifEl);
// Remove notification after display duration and process next
setTimeout(() => {
notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards';
setTimeout(() => {
notifEl.remove();
processNotificationQueue();
}, 500);
}, NOTIFICATION_DURATION_MS);
}
// Add slide out animation
const style = document.createElement('style');
style.textContent = `
@keyframes notification-slide-out {
to {
transform: translateY(-100px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
function createFireworks() {
const container = document.getElementById('fireworksContainer');
const rareCounter = document.getElementById('totalRaresCounter');
const rect = rareCounter.getBoundingClientRect();
// Create 30 particles
const particleCount = 30;
const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue'];
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`;
// Start position at rare counter
particle.style.left = `${rect.left + rect.width / 2}px`;
particle.style.top = `${rect.top + rect.height / 2}px`;
// Random explosion direction
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const velocity = 100 + Math.random() * 200;
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - 50; // Slight upward bias
// Create custom animation for this particle
const animName = `particle-${Date.now()}-${i}`;
const keyframes = `
@keyframes ${animName} {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(${dx}px, ${dy + 200}px) scale(0);
opacity: 0;
}
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = keyframes;
document.head.appendChild(styleEl);
particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
container.appendChild(particle);
// Clean up particle and animation after completion
setTimeout(() => {
particle.remove();
styleEl.remove();
}, 2000);
}
}
function highlightRareFinder(characterName) {
// Use element pool for O(1) lookup instead of querySelectorAll
for (const item of elementPools.activeListItems) {
if (item.playerData && item.playerData.character_name === characterName) {
item.classList.add('rare-finder-glow');
setTimeout(() => {
item.classList.remove('rare-finder-glow');
}, GLOW_DURATION_MS);
break;
}
}
}
// Update total rares display to trigger fireworks on increase
const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay;
updateTotalRaresDisplay = function(data) {
originalUpdateTotalRaresDisplay(data);
// Check if total increased
const newTotal = data.all_time || 0;
if (newTotal > lastRareCount && lastRareCount > 0) {
// Don't trigger on initial load
createFireworks();
// Check for milestones when count increases
if (newTotal > 0 && newTotal % 100 === 0) {
triggerMilestoneCelebration(newTotal);
}
}
lastRareCount = newTotal;
}
function triggerMilestoneCelebration(rareNumber) {
debugLog(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`);
// Create full-screen milestone overlay
const overlay = document.createElement('div');
overlay.className = 'milestone-overlay';
overlay.innerHTML = `
#${rareNumber}
🏆 EPIC MILESTONE! 🏆
Server Achievement Unlocked
`;
document.body.appendChild(overlay);
// Add screen shake effect
document.body.classList.add('screen-shake');
// Create massive firework explosion
createMilestoneFireworks();
// Remove milestone overlay after 5 seconds
setTimeout(() => {
overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse';
document.body.classList.remove('screen-shake');
setTimeout(() => {
overlay.remove();
}, 500);
}, 5000);
}
function createMilestoneFireworks() {
const container = document.getElementById('fireworksContainer');
// Create multiple bursts across the screen
const burstCount = 5;
const particlesPerBurst = 50;
const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00'];
for (let burst = 0; burst < burstCount; burst++) {
setTimeout(() => {
// Random position for each burst
const x = Math.random() * window.innerWidth;
const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15);
for (let i = 0; i < particlesPerBurst; i++) {
const particle = document.createElement('div');
particle.className = 'milestone-particle';
particle.style.background = colors[Math.floor(Math.random() * colors.length)];
particle.style.boxShadow = `0 0 12px ${particle.style.background}`;
// Start position
particle.style.left = `${x}px`;
particle.style.top = `${y}px`;
// Random explosion direction
const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8;
const velocity = 200 + Math.random() * 300;
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - 100; // Upward bias
// Create custom animation
const animName = `milestone-particle-${Date.now()}-${burst}-${i}`;
const keyframes = `
@keyframes ${animName} {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(${dx}px, ${dy + 400}px) scale(0);
opacity: 0;
}
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = keyframes;
document.head.appendChild(styleEl);
particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
container.appendChild(particle);
// Clean up
setTimeout(() => {
particle.remove();
styleEl.remove();
}, 3000);
}
}, burst * 200); // Stagger bursts
}
}
/* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */
/**
* Opens the dedicated inventory search page in a new browser tab.
*/
function openInventorySearch() {
// Open the dedicated inventory search page in a new tab
window.open('/inventory.html', '_blank');
}
/**
* Opens the Suitbuilder interface in a new browser tab.
*/
function openSuitbuilder() {
// Open the Suitbuilder page in a new tab
window.open('/suitbuilder.html', '_blank');
}
/**
* Opens the Player Debug interface in a new browser tab.
*/
function openPlayerDebug() {
// Open the Player Debug page in a new tab
window.open('/debug.html', '_blank');
}
/**
* Opens the Quest Status interface in a new browser tab.
*/
function openQuestStatus() {
// Open the Quest Status page in a new tab
window.open('/quest-status.html', '_blank');
}
/**
* Opens the Player Dashboard interface in a new browser tab.
*/
function openPlayerDashboard() {
// Open the Player Dashboard page in a new tab
window.open('/player-dashboard.html', '_blank');
}