MosswartOverlord/static/script.js
Erik 84da2a8752 fix: refine mana panel layout and state display
Widen the inventory layout so the backpack column no longer crowds the paperdoll, and base mana active/inactive display on the live data currently available from inventory payloads.
2026-03-13 07:32:57 +01:00

3515 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 = '572px';
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 = `
<div class="ts-character-header" id="charHeader-${esc}">
<h1>${name} <span class="ts-level"></span></h1>
<div class="ts-subtitle">Awaiting character data...</div>
</div>
<div class="ts-xplum" id="charXpLum-${esc}">
<div class="ts-left">Total XP: \u2014</div>
<div class="ts-right">Unassigned XP: \u2014</div>
<div class="ts-left">Luminance: \u2014</div>
<div class="ts-right">Deaths: \u2014</div>
</div>
<div class="ts-tabrow">
<div class="ts-tabcontainer" id="charTabLeft-${esc}">
<div class="ts-tabbar">
<div class="ts-tab active">Attributes</div>
<div class="ts-tab inactive">Skills</div>
<div class="ts-tab inactive">Titles</div>
</div>
<div class="ts-box active" id="charAttribs-${esc}">
<div class="ts-vitals" id="charVitals-${esc}">
<div class="ts-vital">
<span class="ts-vital-label">Health</span>
<div class="ts-vital-bar ts-health-bar"><div class="ts-vital-fill"></div></div>
<span class="ts-vital-text">\u2014 / \u2014</span>
</div>
<div class="ts-vital">
<span class="ts-vital-label">Stamina</span>
<div class="ts-vital-bar ts-stamina-bar"><div class="ts-vital-fill"></div></div>
<span class="ts-vital-text">\u2014 / \u2014</span>
</div>
<div class="ts-vital">
<span class="ts-vital-label">Mana</span>
<div class="ts-vital-bar ts-mana-bar"><div class="ts-vital-fill"></div></div>
<span class="ts-vital-text">\u2014 / \u2014</span>
</div>
</div>
<table class="ts-char" id="charAttribTable-${esc}">
<tr class="ts-colnames"><td>Attribute</td><td>Creation</td><td>Base</td></tr>
<tr><td class="ts-headerleft">Strength</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Endurance</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Coordination</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Quickness</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Focus</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Self</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
</table>
<table class="ts-char" id="charVitalsTable-${esc}">
<tr class="ts-colnames"><td>Vital</td><td>Base</td></tr>
<tr><td class="ts-headerleft">Health</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Stamina</td><td class="ts-headerright">\u2014</td></tr>
<tr><td class="ts-headerleft">Mana</td><td class="ts-headerright">\u2014</td></tr>
</table>
<table class="ts-char" id="charCredits-${esc}">
<tr><td class="ts-headerleft">Skill Credits</td><td class="ts-headerright">\u2014</td></tr>
</table>
</div>
<div class="ts-box inactive" id="charSkills-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
<div class="ts-box inactive" id="charTitles-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
</div>
<div class="ts-tabcontainer" id="charTabRight-${esc}">
<div class="ts-tabbar">
<div class="ts-tab active">Augmentations</div>
<div class="ts-tab inactive">Ratings</div>
<div class="ts-tab inactive">Other</div>
</div>
<div class="ts-box active" id="charAugs-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
<div class="ts-box inactive" id="charRatings-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
<div class="ts-box inactive" id="charOther-${esc}">
<div class="ts-placeholder">Awaiting data...</div>
</div>
</div>
</div>
<div class="ts-allegiance-section" id="charAllegiance-${esc}">
<div class="ts-section-title">Allegiance</div>
<div class="ts-placeholder">Awaiting data...</div>
</div>
`;
// 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 = '<table class="ts-char">';
html += '<tr class="ts-colnames"><td>Skill</td><td>Level</td></tr>';
if (grouped.Specialized.length) {
for (const s of grouped.Specialized) {
html += `<tr><td class="ts-specialized">${s.name}</td><td class="ts-specialized" style="text-align:right">${s.base}</td></tr>`;
}
}
if (grouped.Trained.length) {
for (const s of grouped.Trained) {
html += `<tr><td class="ts-trained">${s.name}</td><td class="ts-trained" style="text-align:right">${s.base}</td></tr>`;
}
}
html += '</table>';
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 = '<div class="ts-titles-list">';
for (const t of titles) html += `<div>${t}</div>`;
html += '</div>';
titlesBox.innerHTML = html;
} else {
titlesBox.innerHTML = '<div class="ts-placeholder">No titles data</div>';
}
}
// -- 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 += '<div class="ts-section-title">Augmentations</div>';
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
for (const a of augRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
html += '</table>';
}
if (auraRows.length) {
html += '<div class="ts-section-title">Auras</div>';
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
for (const a of auraRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
html += '</table>';
}
augsBox.innerHTML = html;
} else {
augsBox.innerHTML = '<div class="ts-placeholder">No augmentation data</div>';
}
}
// 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 = '<table class="ts-props"><tr class="ts-colnames"><td>Rating</td><td>Value</td></tr>';
for (const r of rows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
html += '</table>';
ratingsBox.innerHTML = html;
} else {
ratingsBox.innerHTML = '<div class="ts-placeholder">No rating data</div>';
}
}
// 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 += '<div class="ts-section-title">General</div>';
html += '<table class="ts-props">';
for (const r of generalRows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
html += '</table>';
}
// 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 += '<div class="ts-section-title">Masteries</div>';
html += '<table class="ts-props">';
for (const m of masteryRows) html += `<tr><td>${m.name}</td><td style="text-align:right">${m.value}</td></tr>`;
html += '</table>';
}
// 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 += '<div class="ts-section-title">Society</div>';
html += '<table class="ts-props">';
for (const s of societyRows) html += `<tr><td>${s.name}</td><td style="text-align:right">${s.rank} (${s.value})</td></tr>`;
html += '</table>';
}
otherBox.innerHTML = html || '<div class="ts-placeholder">No additional data</div>';
}
// -- Allegiance section --
const allegDiv = document.getElementById(`charAllegiance-${esc}`);
if (allegDiv && data.allegiance) {
const a = data.allegiance;
let html = '<div class="ts-section-title">Allegiance</div>';
html += '<table class="ts-allegiance">';
if (a.name) html += `<tr><td>Name</td><td>${a.name}</td></tr>`;
if (a.monarch) html += `<tr><td>Monarch</td><td>${a.monarch.name || '\u2014'}</td></tr>`;
if (a.patron) html += `<tr><td>Patron</td><td>${a.patron.name || '\u2014'}</td></tr>`;
if (a.rank !== undefined) html += `<tr><td>Rank</td><td>${a.rank}</td></tr>`;
if (a.followers !== undefined) html += `<tr><td>Followers</td><td>${a.followers}</td></tr>`;
html += '</table>';
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 = `<div class="tooltip-name">${name}</div>`;
// Basic stats
tooltipHTML += `<div class="tooltip-section">`;
tooltipHTML += `<div class="tooltip-value">Value: ${value.toLocaleString()}</div>`;
tooltipHTML += `<div class="tooltip-burden">Burden: ${burden}</div>`;
// Add workmanship right after basic stats for weapons/items
if (slot.dataset.enhancedData) {
try {
const enhanced = JSON.parse(slot.dataset.enhancedData);
if (enhanced.workmanship_text) {
tooltipHTML += `<div class="tooltip-workmanship">Workmanship: ${enhanced.workmanship_text}</div>`;
}
} catch (e) {
// Ignore parsing errors for this section
}
}
tooltipHTML += `</div>`;
// 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 += `<div class="tooltip-section">`;
// Skill requirement
if (enhanced.equip_skill_name) {
tooltipHTML += `<div class="tooltip-property">Skill: ${enhanced.equip_skill_name}</div>`;
}
// Damage with type
if (enhanced.damage_range && enhanced.damage_type) {
const damageText = enhanced.damage_type !== 'Physical' ?
`${enhanced.damage_range}, ${enhanced.damage_type}` :
enhanced.damage_range;
tooltipHTML += `<div class="tooltip-property">Damage: ${damageText}</div>`;
}
// Speed
if (enhanced.speed_text) {
tooltipHTML += `<div class="tooltip-property">Speed: ${enhanced.speed_text}</div>`;
}
// Attack and defense bonuses (as percentages)
if (isValid(enhanced.attack_bonus)) {
const attackPercent = ((enhanced.attack_bonus - 1) * 100).toFixed(1);
if (attackPercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Bonus to Attack Skill: ${attackPercent > 0 ? '+' : ''}${attackPercent}%</div>`;
}
}
// Defense bonuses
if (enhanced.melee_defense_bonus && isValid(enhanced.melee_defense_bonus)) {
const defensePercent = ((enhanced.melee_defense_bonus - 1) * 100).toFixed(1);
if (defensePercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Bonus to Melee Defense: ${defensePercent > 0 ? '+' : ''}${defensePercent}%</div>`;
}
}
// Magic defense bonus
if (enhanced.magic_defense_bonus && isValid(enhanced.magic_defense_bonus)) {
const magicDefensePercent = ((enhanced.magic_defense_bonus - 1) * 100).toFixed(1);
if (magicDefensePercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Bonus to Magic Defense: ${magicDefensePercent > 0 ? '+' : ''}${magicDefensePercent}%</div>`;
}
}
// Elemental damage vs monsters
if (enhanced.elemental_damage_vs_monsters && isValid(enhanced.elemental_damage_vs_monsters)) {
const elementalPercent = ((enhanced.elemental_damage_vs_monsters - 1) * 100).toFixed(1);
if (elementalPercent !== "0.0") {
tooltipHTML += `<div class="tooltip-property">Elemental Damage vs Monsters: ${elementalPercent > 0 ? '+' : ''}${elementalPercent}%</div>`;
}
}
tooltipHTML += `</div>`;
}
// 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 += `<div class="tooltip-section"><div class="tooltip-section-title">Combat Stats</div>`;
combatProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// 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 += `<div class="tooltip-section"><div class="tooltip-section-title">Requirements</div>`;
reqProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// 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 += `<div class="tooltip-section"><div class="tooltip-section-title">Enhancements</div>`;
enhanceProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// 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 += `<div class="tooltip-section"><div class="tooltip-section-title">Ratings</div>`;
ratingProps.forEach(prop => {
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
});
tooltipHTML += `</div>`;
}
// 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 += `<div class="tooltip-section">`;
tooltipHTML += `<div class="tooltip-property">Spells: ${spellNames}</div>`;
tooltipHTML += `</div>`;
}
// Mana and Spellcraft section
if (enhanced.mana_display || enhanced.spellcraft) {
tooltipHTML += `<div class="tooltip-section">`;
if (enhanced.spellcraft) {
tooltipHTML += `<div class="tooltip-property">Spellcraft: ${enhanced.spellcraft}</div>`;
}
if (enhanced.mana_display) {
tooltipHTML += `<div class="tooltip-property">Mana: ${enhanced.mana_display}</div>`;
}
tooltipHTML += `</div>`;
}
// Detailed Spell Descriptions section
if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Spell Descriptions</div>`;
enhanced.spells.spells.forEach(spell => {
tooltipHTML += `<div class="tooltip-spell">`;
tooltipHTML += `<div class="spell-name">${spell.name}</div>`;
if (spell.description) {
tooltipHTML += `<div class="spell-description">${spell.description}</div>`;
}
tooltipHTML += `</div>`;
});
tooltipHTML += `</div>`;
}
// Object class info
if (enhanced.object_class_name) {
tooltipHTML += `<div class="tooltip-section">`;
tooltipHTML += `<div class="tooltip-info">Type: ${enhanced.object_class_name}</div>`;
tooltipHTML += `</div>`;
}
} // 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 = `
<span class="player-name">${p.character_name}${createVitaeIndicator(p.character_name)} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
${createVitalsHTML(p.character_name)}
<span class="stat kills">${p.kills}</span>
<span class="stat total-kills">${p.total_kills || 0}</span>
<span class="stat kph">${p.kills_per_hour}</span>
<span class="stat rares">${p.session_rares}/${p.total_rares}</span>
<span class="stat kpr">${kpr}</span>
<span class="stat meta">${p.vt_state}</span>
<span class="stat onlinetime">${p.onlinetime}</span>
<span class="stat deaths">${p.deaths}/${p.total_deaths || 0}</span>
<span class="stat tapers">${p.prismatic_taper_count || 0}</span>
`;
// 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 `
<div class="player-vitals">
<div class="vital-bar-inline ${getVitalClass(vitals.health_percentage)}">
<div class="vital-fill health" style="width: ${vitals.health_percentage}%"></div>
</div>
<div class="vital-bar-inline ${getVitalClass(vitals.stamina_percentage)}">
<div class="vital-fill stamina" style="width: ${vitals.stamina_percentage}%"></div>
</div>
<div class="vital-bar-inline ${getVitalClass(vitals.mana_percentage)}">
<div class="vital-fill mana" style="width: ${vitals.mana_percentage}%"></div>
</div>
</div>
`;
}
function createVitaeIndicator(characterName) {
const vitals = characterVitals[characterName];
if (!vitals || !vitals.vitae || vitals.vitae >= 100) {
return ''; // No vitae penalty
}
return `<span class="vitae-indicator">⚰️ ${vitals.vitae}%</span>`;
}
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 = `
<div class="rare-notification-title">🎆 LEGENDARY RARE! 🎆</div>
<div class="rare-notification-mob">${notification.rareName}</div>
<div class="rare-notification-finder">found by</div>
<div class="rare-notification-character">⚔️ ${notification.characterName} ⚔️</div>
`;
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 = `
<div class="milestone-content">
<div class="milestone-number">#${rareNumber}</div>
<div class="milestone-text">🏆 EPIC MILESTONE! 🏆</div>
<div class="milestone-subtitle">Server Achievement Unlocked</div>
</div>
`;
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');
}