Move the Mana panel to the right of the backpack column, widen the inventory window, and switch to a smaller Mag-Tools-style row layout with compact icons and status dots.
3489 lines
126 KiB
JavaScript
3489 lines
126 KiB
JavaScript
/*
|
||
* 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);
|
||
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';
|
||
iconWrap.appendChild(createInventorySlot(item));
|
||
|
||
const nameEl = document.createElement('div');
|
||
nameEl.className = 'inv-mana-name';
|
||
nameEl.textContent = item.name || item.Name || 'Unknown Item';
|
||
|
||
const stateEl = document.createElement('div');
|
||
const stateName = item.mana_state || 'unknown';
|
||
stateEl.className = `inv-mana-state-dot mana-state-${stateName}`;
|
||
stateEl.title = stateName.replace(/_/g, ' ');
|
||
|
||
const manaEl = document.createElement('div');
|
||
manaEl.className = 'inv-mana-value';
|
||
if (item.current_mana !== undefined && item.max_mana !== undefined) {
|
||
manaEl.textContent = `${item.current_mana} / ${item.max_mana}`;
|
||
} else if (item.mana_display) {
|
||
manaEl.textContent = item.mana_display;
|
||
} else {
|
||
manaEl.textContent = '--';
|
||
}
|
||
|
||
const timeEl = document.createElement('div');
|
||
timeEl.className = 'inv-mana-time';
|
||
timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds);
|
||
|
||
row.appendChild(iconWrap);
|
||
row.appendChild(nameEl);
|
||
row.appendChild(stateEl);
|
||
row.appendChild(manaEl);
|
||
row.appendChild(timeEl);
|
||
state.manaListBody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function showInventoryWindow(name) {
|
||
debugLog('showInventoryWindow called for:', name);
|
||
const windowId = `inventoryWindow-${name}`;
|
||
|
||
const { win, content, isNew } = createWindow(
|
||
windowId, `Inventory: ${name}`, 'inventory-window'
|
||
);
|
||
|
||
if (!isNew) {
|
||
debugLog('Existing inventory window found, showing it');
|
||
return;
|
||
}
|
||
|
||
win.dataset.character = name;
|
||
inventoryWindows[name] = win;
|
||
|
||
const loading = document.createElement('div');
|
||
loading.className = 'inventory-loading';
|
||
loading.textContent = 'Loading inventory...';
|
||
content.appendChild(loading);
|
||
|
||
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 = {
|
||
items: [],
|
||
activePack: null,
|
||
slotMap: slotMap,
|
||
equipGrid: equipGrid,
|
||
itemGrid: itemGrid,
|
||
packList: packList,
|
||
burdenFill: burdenFill,
|
||
burdenLabel: burdenLabel,
|
||
contentsHeader: contentsHeader,
|
||
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');
|
||
}
|
||
|
||
|