diff --git a/README.md b/README.md index 381cd775..87e1922f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ preserved on the `python-legacy` branch. | Telemetry DB | TimescaleDB | Docker `dereth-db`, 5432 | hypertables `telemetry_events`, `spawn_events` | | Inventory DB | postgres:14 | Docker `inventory-db`, 5433 | 7-table normalized item schema | | React frontend | `frontend/` → `static/` | served by `tracker-go` | unchanged by the migration — same paths, same API | -| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` | `/classic`, `/suitbuilder.html`, `/inventory.html` | +| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` | `/classic`, `/suitbuilder.html`; inventory search is now the React page at `/?view=inventory` | | Grafana | compose `dereth-grafana` | 127.0.0.1:3000 | anonymous Viewer auth, proxied at `/grafana/` | | Discord rare bot | `discord-rare-monitor/` (Python) | Docker, reads Go `/ws/live` | posts rares + relays allegiance chat | | Overlord Agent (assistant) | `agent/` | host-side systemd `overlord-agent`, 127.0.0.1:8767 | shells out to `claude -p`; outside Docker by design | diff --git a/frontend/src/components/inventory/ActiveChips.tsx b/frontend/src/components/inventory/ActiveChips.tsx index fffb135c..6139e1bc 100644 --- a/frontend/src/components/inventory/ActiveChips.tsx +++ b/frontend/src/components/inventory/ActiveChips.tsx @@ -23,7 +23,13 @@ export function ActiveChips({ search }: { search: InventorySearch }) { if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) }); for (const [param, v] of Object.entries(f.ratings)) if (v !== '') { const def = RATING_DEFS.find(r => r.param === param); - chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) }); + chips.push({ + label: `${def?.label ?? param} ≥ ${v}`, + remove: () => { + const { [param]: _omit, ...rest } = f.ratings; + update({ ratings: rest }); + }, + }); } if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) }); if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) }); diff --git a/frontend/src/components/inventory/InventorySearchPage.tsx b/frontend/src/components/inventory/InventorySearchPage.tsx index 88e55cd4..3152d340 100644 --- a/frontend/src/components/inventory/InventorySearchPage.tsx +++ b/frontend/src/components/inventory/InventorySearchPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react'; import { useInventorySearch } from './useInventorySearch'; import { FilterSidebar } from './FilterSidebar'; import { ActiveChips } from './ActiveChips'; @@ -6,10 +6,54 @@ import { ResultsTable } from './ResultsTable'; import { DetailPanel } from './DetailPanel'; import type { InvItem } from './types'; -export function InventorySearchPage() { +/** Composite key used to re-match a selected item across refetches (objects are recreated each fetch). */ +function itemKey(i: InvItem): string { + return `${i.name} ${i.character_name} ${i.last_updated ?? ''}`; +} + +class InventorySearchErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('Inventory search crashed:', error, info); + } + render(): ReactNode { + if (this.state.hasError) { + return ( +
+
+

Something went wrong loading the inventory search page.

+ +
+
+ ); + } + return this.props.children; + } +} + +function InventorySearchPageInner() { const search = useInventorySearch(); const [selected, setSelected] = useState(null); + // Selection is by object identity; every refetch produces new item objects, so re-match the + // previously selected item by composite key in the new result set (or clear it if it's gone). + useEffect(() => { + if (!selected) return; + const items = search.result?.items ?? []; + const key = itemKey(selected); + const match = items.find(i => itemKey(i) === key); + if (match && match !== selected) setSelected(match); + else if (!match) setSelected(null); + }, [search.result]); + return (
@@ -38,3 +82,11 @@ export function InventorySearchPage() {
); } + +export function InventorySearchPage() { + return ( + + + + ); +} diff --git a/frontend/src/components/inventory/ResultsTable.tsx b/frontend/src/components/inventory/ResultsTable.tsx index 59adf9a5..553875f6 100644 --- a/frontend/src/components/inventory/ResultsTable.tsx +++ b/frontend/src/components/inventory/ResultsTable.tsx @@ -41,7 +41,11 @@ export function ResultsTable({ search, selected, onSelect }: { const items = result?.items ?? []; useEffect(() => { - localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible])); + try { + localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible])); + } catch { + /* Safari private mode throws on quota — non-fatal, column prefs just won't persist. */ + } }, [visible]); // Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs. diff --git a/frontend/src/components/inventory/useInventorySearch.ts b/frontend/src/components/inventory/useInventorySearch.ts index a96a0c30..02db2961 100644 --- a/frontend/src/components/inventory/useInventorySearch.ts +++ b/frontend/src/components/inventory/useInventorySearch.ts @@ -12,14 +12,77 @@ function filtersToUrl(f: SearchFilters): void { url.searchParams.set('view', 'inventory'); if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff)); else url.searchParams.delete('q'); - window.history.replaceState(null, '', url); + try { + window.history.replaceState(null, '', url); + } catch { + /* Safari throttles history.replaceState and throws when exceeded — non-fatal. */ + } +} + +const ITEM_TYPES = new Set(['all', 'armor', 'jewelry', 'weapon', 'clothing', 'shirt', 'pants']); +const SORT_DIRS = new Set(['asc', 'desc']); + +function isStringArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(x => typeof x === 'string'); +} + +/** Validate a single filter key's parsed value; return the valid value or undefined to drop it. */ +function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown { + switch (key) { + case 'characters': + if (value === 'all' || isStringArray(value)) return value; + return undefined; + case 'slots': + case 'cantrips': + return isStringArray(value) ? value : undefined; + case 'itemType': + return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined; + case 'sortDir': + return typeof value === 'string' && SORT_DIRS.has(value) ? value : undefined; + case 'ratings': { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (v === '' || typeof v === 'number') out[k] = v; + } + return out; + } + case 'text': + case 'weaponType': + case 'spellContains': + case 'itemSet': + case 'sortBy': + return typeof value === 'string' ? value : undefined; + case 'equippedOnly': + case 'bonded': + case 'attuned': + case 'rare': + return typeof value === 'boolean' ? value : undefined; + case 'maxLevel': + case 'minValue': + case 'minWorkmanship': + case 'maxBurden': + return value === '' || typeof value === 'number' ? value : undefined; + case 'page': + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; + default: + return undefined; + } } function filtersFromUrl(): SearchFilters { try { const q = new URLSearchParams(window.location.search).get('q'); if (!q) return DEFAULT_FILTERS; - return { ...DEFAULT_FILTERS, ...JSON.parse(q) }; + const parsed = JSON.parse(q); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return DEFAULT_FILTERS; + const merged: SearchFilters = { ...DEFAULT_FILTERS }; + for (const k of Object.keys(DEFAULT_FILTERS) as Array) { + if (!(k in parsed)) continue; + const validated = validateFilterValue(k, (parsed as any)[k]); + if (validated !== undefined) (merged as any)[k] = validated; + } + return merged; } catch { return DEFAULT_FILTERS; } @@ -88,8 +151,8 @@ export function useInventorySearch(): InventorySearch { const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []); useEffect(() => { - filtersToUrl(filters); const timer = setTimeout(async () => { + filtersToUrl(filters); abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; diff --git a/static/script.js b/static/script.js deleted file mode 100644 index f7518b6f..00000000 --- a/static/script.js +++ /dev/null @@ -1,4940 +0,0 @@ -/* - * script.js - Frontend logic for Dereth Tracker Single-Page Application. - * Handles WebSocket communication, UI rendering of player lists, map display, - * and user interactions (filtering, sorting, chat, stats windows). - */ -/** - * script.js - Frontend controller for Dereth Tracker SPA - * - * Responsibilities: - * - Establish WebSocket connections to receive live telemetry and chat data - * - Fetch and render live player lists, trails, and map dots - * - Handle user interactions: filtering, sorting, selecting players - * - Manage dynamic UI components: chat windows, stats panels, tooltips - * - Provide smooth pan/zoom of map overlay using CSS transforms - * - * Structure: - * 1. DOM references and constant definitions - * 2. Color palette and assignment logic - * 3. Sorting and filtering setup - * 4. Utility functions (coordinate mapping, color hashing) - * 5. UI window creation (stats, chat) - * 6. Rendering functions for list and map - * 7. Event listeners for map interactions and WebSocket messages - */ -/* ---------- Debug configuration ---------------------------------- */ -const DEBUG = false; -function debugLog(...args) { if (DEBUG) console.log(...args); } - -function handleError(context, error, showUI = false) { - console.error(`[${context}]`, error); - if (showUI) { - const msg = document.createElement('div'); - msg.className = 'error-toast'; - msg.textContent = `${context}: ${error.message || 'Unknown error'}`; - document.body.appendChild(msg); - setTimeout(() => msg.remove(), GLOW_DURATION_MS); - } -} - -/* ---------- DOM references --------------------------------------- */ -const wrap = document.getElementById('mapContainer'); -const group = document.getElementById('mapGroup'); -const img = document.getElementById('map'); -const dots = document.getElementById('dots'); -const trailsContainer = document.getElementById('trails'); -const list = document.getElementById('playerList'); -const btnContainer = document.getElementById('sortButtons'); -const tooltip = document.getElementById('tooltip'); -const coordinates = document.getElementById('coordinates'); - -/* ---------- Element Pooling System for Performance ------------- */ -// Pools for reusing DOM elements to eliminate recreation overhead -const elementPools = { - dots: [], - listItems: [], - activeDots: new Set(), - activeListItems: new Set() -}; - -// Performance tracking -let performanceStats = { - // Lifetime totals - dotsCreated: 0, - dotsReused: 0, - listItemsCreated: 0, - listItemsReused: 0, - // Per-render stats (reset each render) - renderDotsCreated: 0, - renderDotsReused: 0, - renderListItemsCreated: 0, - renderListItemsReused: 0, - lastRenderTime: 0, - renderCount: 0 -}; - -function createNewDot() { - const dot = document.createElement('div'); - dot.className = 'dot'; - performanceStats.dotsCreated++; - performanceStats.renderDotsCreated++; - - // Add event listeners once when creating - dot.addEventListener('mouseenter', e => showTooltip(e, dot.playerData)); - dot.addEventListener('mousemove', e => showTooltip(e, dot.playerData)); - dot.addEventListener('mouseleave', hideTooltip); - dot.addEventListener('click', () => { - if (dot.playerData) { - const { x, y } = worldToPx(dot.playerData.ew, dot.playerData.ns); - selectPlayer(dot.playerData, x, y); - } - }); - - return dot; -} - -function createNewListItem() { - const li = document.createElement('li'); - li.className = 'player-item'; - performanceStats.listItemsCreated++; - performanceStats.renderListItemsCreated++; - - // Create the grid content container - const gridContent = document.createElement('div'); - gridContent.className = 'grid-content'; - li.appendChild(gridContent); - - // Create buttons once and keep them (no individual event listeners needed) - const buttonsContainer = document.createElement('div'); - buttonsContainer.className = 'buttons-container'; - - const chatBtn = document.createElement('button'); - chatBtn.className = 'chat-btn'; - chatBtn.textContent = 'Chat'; - chatBtn.addEventListener('click', (e) => { - debugLog('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget); - e.stopPropagation(); - // Try button's own playerData first, fallback to DOM traversal - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - debugLog('🔥 Player data found:', playerData); - if (playerData) { - debugLog('🔥 Opening chat for:', playerData.character_name); - showChatWindow(playerData.character_name); - } else { - debugLog('🔥 No player data found!'); - } - }); - - const statsBtn = document.createElement('button'); - statsBtn.className = 'stats-btn'; - statsBtn.textContent = 'Stats'; - statsBtn.addEventListener('click', (e) => { - debugLog('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget); - e.stopPropagation(); - // Try button's own playerData first, fallback to DOM traversal - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - debugLog('📊 Player data found:', playerData); - if (playerData) { - debugLog('📊 Opening stats for:', playerData.character_name); - showStatsWindow(playerData.character_name); - } else { - debugLog('📊 No player data found!'); - } - }); - - const inventoryBtn = document.createElement('button'); - inventoryBtn.className = 'inventory-btn'; - inventoryBtn.textContent = 'Inventory'; - inventoryBtn.addEventListener('click', (e) => { - debugLog('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget); - e.stopPropagation(); - // Try button's own playerData first, fallback to DOM traversal - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - debugLog('🎒 Player data found:', playerData); - if (playerData) { - debugLog('🎒 Opening inventory for:', playerData.character_name); - showInventoryWindow(playerData.character_name); - } else { - debugLog('🎒 No player data found!'); - } - }); - - const charBtn = document.createElement('button'); - charBtn.className = 'char-btn'; - charBtn.textContent = 'Char'; - charBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - if (playerData) { - showCharacterWindow(playerData.character_name); - } - }); - - const radarBtn = document.createElement('button'); - radarBtn.className = 'radar-btn'; - radarBtn.textContent = 'Radar'; - radarBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; - if (playerData) { - showRadarWindow(playerData.character_name); - } - }); - - buttonsContainer.appendChild(chatBtn); - buttonsContainer.appendChild(statsBtn); - buttonsContainer.appendChild(inventoryBtn); - buttonsContainer.appendChild(charBtn); - buttonsContainer.appendChild(radarBtn); - li.appendChild(buttonsContainer); - - // Store references for easy access - li.gridContent = gridContent; - li.chatBtn = chatBtn; - li.statsBtn = statsBtn; - li.inventoryBtn = inventoryBtn; - li.charBtn = charBtn; - li.radarBtn = radarBtn; - - return li; -} - -function returnToPool() { - // Return unused dots to pool - elementPools.activeDots.forEach(dot => { - if (!dot.parentNode) { - elementPools.dots.push(dot); - elementPools.activeDots.delete(dot); - } - }); - - // Return unused list items to pool - elementPools.activeListItems.forEach(li => { - if (!li.parentNode) { - elementPools.listItems.push(li); - elementPools.activeListItems.delete(li); - } - }); -} - -/* ---------- Event Delegation System ---------------------------- */ -// Single event delegation handler for all player list interactions -function setupEventDelegation() { - list.addEventListener('click', e => { - const li = e.target.closest('li.player-item'); - if (!li || !li.playerData) return; - - const player = li.playerData; - const { x, y } = worldToPx(player.ew, player.ns); - - // Handle player selection (clicking anywhere else on the item, not on buttons) - // Button clicks are now handled by direct event listeners - if (!e.target.closest('button')) { - selectPlayer(player, x, y); - } - }); -} - -// Initialize event delegation when DOM is ready -document.addEventListener('DOMContentLoaded', setupEventDelegation); - -// Global drag system to prevent event listener accumulation -let currentDragWindow = null; -let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0; - -function makeDraggable(win, header) { - if (!window.__chatZ) window.__chatZ = 10000; - header.style.cursor = 'move'; - - const bringToFront = () => { - window.__chatZ += 1; - win.style.zIndex = window.__chatZ; - }; - - header.addEventListener('mousedown', e => { - if (e.target.closest('button')) return; - e.preventDefault(); - currentDragWindow = win; - bringToFront(); - dragStartX = e.clientX; - dragStartY = e.clientY; - dragStartLeft = win.offsetLeft; - dragStartTop = win.offsetTop; - document.body.classList.add('noselect'); - }); - - // Touch support - header.addEventListener('touchstart', e => { - if (e.touches.length !== 1 || e.target.closest('button')) return; - currentDragWindow = win; - bringToFront(); - const t = e.touches[0]; - dragStartX = t.clientX; - dragStartY = t.clientY; - dragStartLeft = win.offsetLeft; - dragStartTop = win.offsetTop; - }); -} - -// Global mouse handlers (only added once) -window.addEventListener('mousemove', e => { - if (!currentDragWindow) return; - const dx = e.clientX - dragStartX; - const dy = e.clientY - dragStartY; - currentDragWindow.style.left = `${dragStartLeft + dx}px`; - currentDragWindow.style.top = `${dragStartTop + dy}px`; -}); - -window.addEventListener('mouseup', () => { - if (currentDragWindow) { - currentDragWindow = null; - document.body.classList.remove('noselect'); - } -}); - -window.addEventListener('touchmove', e => { - if (!currentDragWindow || e.touches.length !== 1) return; - const t = e.touches[0]; - const dx = t.clientX - dragStartX; - const dy = t.clientY - dragStartY; - currentDragWindow.style.left = `${dragStartLeft + dx}px`; - currentDragWindow.style.top = `${dragStartTop + dy}px`; -}); - -window.addEventListener('touchend', () => { - currentDragWindow = null; -}); -// Filter input for player names (starts-with filter) -let currentFilter = ''; -const filterInput = document.getElementById('playerFilter'); -if (filterInput) { - filterInput.addEventListener('input', e => { - currentFilter = e.target.value.toLowerCase().trim(); - renderList(); - }); -} - -// WebSocket for chat and commands -let socket; -// Keep track of open chat windows: character_name -> DOM element -const chatWindows = {}; -// Keep track of open stats windows: character_name -> DOM element -const statsWindows = {}; -// Keep track of open inventory windows: character_name -> DOM element -const inventoryWindows = {}; -const equipmentCantripStates = {}; - -/** - * ---------- Application Constants ----------------------------- - * Defines key parameters for map rendering, data polling, and UI limits. - * - * MAX_Z: Maximum altitude difference considered (filter out outliers by Z) - * FOCUS_ZOOM: Zoom level when focusing on a selected character - * POLL_MS: Millisecond interval to fetch live player data and trails - * MAP_BOUNDS: World coordinate bounds for the game map (used for projection) - * API_BASE: Prefix for AJAX endpoints (set when behind a proxy) - * MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage - * CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code - */ -/* ---------- constants ------------------------------------------- */ -const MAX_Z = 20; -const FOCUS_ZOOM = 3; // zoom level when you click a name -const POLL_MS = 2000; -const POLL_RARES_MS = 300000; // 5 minutes -const POLL_KILLS_MS = 300000; // 5 minutes -const POLL_HEALTH_MS = 30000; // 30 seconds -const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time -const GLOW_DURATION_MS = 5000; // Player glow after rare find -const MAX_HEATMAP_POINTS = 50000; -const HEATMAP_HOURS = 24; -// UtilityBelt's more accurate coordinate bounds -const MAP_BOUNDS = { - west: -102.1, - east: 102.1, - north: 102.1, - south: -102.1 -}; - -// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy -// If serving APIs at root, leave empty -const API_BASE = ''; -// Maximum number of lines to retain in each chat window scrollback -const MAX_CHAT_LINES = 1000; -// Map numeric chat color codes to CSS hex colors -const CHAT_COLOR_MAP = { - 0: '#00FF00', // Broadcast - 2: '#FFFFFF', // Speech - 3: '#FFD700', // Tell - 4: '#CCCC00', // OutgoingTell - 5: '#FF00FF', // System - 6: '#FF0000', // Combat - 7: '#00CCFF', // Magic - 8: '#DDDDDD', // Channel - 9: '#FF9999', // ChannelSend - 10: '#FFFF33', // Social - 11: '#CCFF33', // SocialSend - 12: '#FFFFFF', // Emote - 13: '#00FFFF', // Advancement - 14: '#66CCFF', // Abuse - 15: '#FF0000', // Help - 16: '#33FF00', // Appraisal - 17: '#0099FF', // Spellcasting - 18: '#FF6600', // Allegiance - 19: '#CC66FF', // Fellowship - 20: '#00FF00', // WorldBroadcast - 21: '#FF0000', // CombatEnemy - 22: '#FF33CC', // CombatSelf - 23: '#00CC00', // Recall - 24: '#00FF00', // Craft - 25: '#00FF66', // Salvaging - 27: '#FFFFFF', // General - 28: '#33FF33', // Trade - 29: '#CCCCCC', // LFG - 30: '#CC00CC', // Roleplay - 31: '#FFFF00' // AdminTell -}; - -/* ---------- Heat Map Globals ---------- */ -let heatmapCanvas, heatmapCtx; -let heatmapEnabled = false; -let heatmapData = null; -let heatTimeout = null; -const HEAT_PADDING = 50; // px beyond viewport to still draw -const HEAT_THROTTLE = 16; // ~60 fps - -/* ---------- Portal Map Globals ---------- */ -let portalEnabled = false; -let portalData = null; -let portalContainer = null; - -/** - * ---------- Player Color Assignment ---------------------------- - * Uses a predefined accessible color palette for player dots to ensure - * high contrast and colorblind-friendly display. Once the palette - * is exhausted, falls back to a deterministic hash-to-hue function. - */ -/* ---------- player/dot color assignment ------------------------- */ -// A base palette of distinct, color-blind-friendly colors -const PALETTE = [ - // Original colorblind-friendly base palette - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', - - // Extended high-contrast colors - '#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', - '#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488', - - // Darker variants - '#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc', - '#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366', - - // Brighter variants - '#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff', - '#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa', - - // Additional distinct colors - '#990099', '#009900', '#000099', '#990000', '#009999', - '#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055', - - // Light pastels for contrast - '#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff', - '#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc' -]; -// Map from character name to assigned color -const colorMap = {}; -// Next index to pick from PALETTE -let nextPaletteIndex = 0; -/** - * Assigns or returns a consistent color for a given name. - * Uses a fixed palette first, then falls back to hue hashing. - */ -function getColorFor(name) { - if (colorMap[name]) { - return colorMap[name]; - } - let color; - if (nextPaletteIndex < PALETTE.length) { - color = PALETTE[nextPaletteIndex++]; - } else { - // Fallback: hash to HSL hue - color = hue(name); - } - colorMap[name] = color; - return color; -} - -/* - * ---------- Sort Configuration ------------------------------- - * Defines available sort criteria for the active player list: - * - name: alphabetical ascending - * - kph: kills per hour descending - * - kills: total kills descending - * - rares: rare events found during current session descending - * Each option includes a label for UI display and a comparator function. - */ -/* ---------- sort configuration ---------------------------------- */ -const sortOptions = [ - { - value: "name", - label: "Name", - comparator: (a, b) => a.character_name.localeCompare(b.character_name) - }, - { - value: "kph", - label: "KPH", - comparator: (a, b) => b.kills_per_hour - a.kills_per_hour - }, - { - value: "kills", - label: "S.Kills", - comparator: (a, b) => b.kills - a.kills - }, - { - value: "rares", - label: "S.Rares", - comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0) - }, - { - value: "total_kills", - label: "T.Kills", - comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0) - }, - { - value: "kpr", - label: "KPR", - comparator: (a, b) => { - const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity; - const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity; - return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding) - } - } -]; - -let currentSort = sortOptions[0]; -let currentPlayers = []; - -/* ---------- generate segmented buttons -------------------------- */ -sortOptions.forEach(opt => { - const btn = document.createElement('div'); - btn.className = 'btn'; - btn.textContent = opt.label; - btn.dataset.value = opt.value; - if (opt.value === currentSort.value) btn.classList.add('active'); - - btn.addEventListener('click', () => { - btnContainer.querySelectorAll('.btn') - .forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - currentSort = opt; - renderList(); - }); - - btnContainer.appendChild(btn); -}); - -/* ---------- map & state variables ------------------------------- */ -let imgW = 0, imgH = 0; -let scale = 1, offX = 0, offY = 0, minScale = 1; -let dragging = false, sx = 0, sy = 0; -let selected = ""; -const pollIntervals = []; - -/* ---------- utility functions ----------------------------------- */ -const hue = name => { - let h = 0; - for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h); - return `hsl(${Math.abs(h) % 360},72%,50%)`; -}; - -const loc = (ns, ew) => - `${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} ` - + `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`; - -function worldToPx(ew, ns) { - const x = ((ew - MAP_BOUNDS.west) - / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW; - const y = ((MAP_BOUNDS.north - ns) - / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH; - return { x, y }; -} - -function pxToWorld(x, y) { - // Convert screen coordinates to map image coordinates - const mapX = (x - offX) / scale; - const mapY = (y - offY) / scale; - - // Convert map image coordinates to world coordinates - const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west); - const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south); - - return { ew, ns }; -} - -/* ---------- Heat Map Functions ---------- */ - -function initHeatMap() { - heatmapCanvas = document.getElementById('heatmapCanvas'); - if (!heatmapCanvas) { - console.error('Heat map canvas not found'); - return; - } - - heatmapCtx = heatmapCanvas.getContext('2d'); - - const toggle = document.getElementById('heatmapToggle'); - if (toggle) { - toggle.addEventListener('change', e => { - heatmapEnabled = e.target.checked; - if (heatmapEnabled) { - fetchHeatmapData(); - } else { - clearHeatmap(); - } - }); - } - - window.addEventListener('resize', debounce(() => { - if (heatmapEnabled && heatmapData) { - renderHeatmap(); - } - }, 250)); -} - -async function fetchHeatmapData() { - try { - const response = await fetch(`${API_BASE}/spawns/heatmap?hours=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`); - if (!response.ok) { - throw new Error(`Heat map API error: ${response.status}`); - } - - const data = await response.json(); - heatmapData = data.spawn_points; // [{ew, ns, intensity}] - debugLog(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`); - renderHeatmap(); - } catch (err) { - handleError('Heatmap', err); - } -} - -function renderHeatmap() { - if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) { - return; - } - - // Set canvas size to match map dimensions (1:1 DPI) - heatmapCanvas.width = imgW; - heatmapCanvas.height = imgH; - heatmapCtx.clearRect(0, 0, imgW, imgH); - - // Current visible map rect in px for viewport culling - const vw = wrap.clientWidth; - const vh = wrap.clientHeight; - const viewL = -offX / scale; - const viewT = -offY / scale; - const viewR = viewL + vw / scale; - const viewB = viewT + vh / scale; - - // Render heat map points with viewport culling - for (const point of heatmapData) { - const { x, y } = worldToPx(point.ew, point.ns); - - // Skip points outside visible area (with padding for smooth edges) - if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING || - y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) { - continue; - } - - // Smaller, more precise spots to clearly show individual spawn locations - const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5))); - - // Sharp gradient with distinct boundaries between spawn points - const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius); - gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center - gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange - gradient.addColorStop(1, 'rgba(255, 150, 0, 0)'); - - heatmapCtx.fillStyle = gradient; - heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2); - } -} - -function clearHeatmap() { - if (heatmapCtx && heatmapCanvas) { - heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height); - } -} - -/* ---------- Portal Map Functions ---------- */ -function initPortalMap() { - portalContainer = document.getElementById('portals'); - if (!portalContainer) { - console.error('Portal container not found'); - return; - } - - const toggle = document.getElementById('portalToggle'); - if (toggle) { - toggle.addEventListener('change', e => { - portalEnabled = e.target.checked; - if (portalEnabled) { - fetchPortalData(); - } else { - clearPortals(); - } - }); - } -} - -async function fetchPortalData() { - try { - const response = await fetch(`${API_BASE}/portals`); - if (!response.ok) { - throw new Error(`Portal API error: ${response.status}`); - } - - const data = await response.json(); - portalData = data.portals; // [{portal_name, coordinates: {ns, ew, z}, discovered_by, discovered_at}] - debugLog(`Loaded ${portalData.length} portals from last hour`); - renderPortals(); - } catch (err) { - handleError('Portals', err); - } -} - -function parseCoordinate(coord) { - // Handle both formats: - // String format: "42.3N", "15.7S", "33.7E", "28.2W" - // Numeric format: "-96.9330958" (already signed) - - // Check if it's already a number - if (typeof coord === 'number') { - return coord; - } - - // Check if it's a numeric string - const numericValue = parseFloat(coord); - if (!isNaN(numericValue) && coord.match(/^-?\d+\.?\d*$/)) { - return numericValue; - } - - // Parse string format like "42.3N" - const match = coord.match(/^([0-9.]+)([NSEW])$/); - if (!match) return 0; - - const value = parseFloat(match[1]); - const direction = match[2]; - - if (direction === 'S' || direction === 'W') { - return -value; - } - return value; -} - -function renderPortals() { - if (!portalEnabled || !portalData || !portalContainer || !imgW || !imgH) { - return; - } - - // Clear existing portals - clearPortals(); - - for (const portal of portalData) { - // Extract coordinates from new API format - const ns = portal.coordinates.ns; - const ew = portal.coordinates.ew; - - // Convert to pixel coordinates - const { x, y } = worldToPx(ew, ns); - - // Create portal icon - const icon = document.createElement('div'); - icon.className = 'portal-icon'; - icon.style.left = `${x}px`; - icon.style.top = `${y}px`; - icon.title = `${portal.portal_name} (discovered by ${portal.discovered_by})`; - - portalContainer.appendChild(icon); - } - - debugLog(`Rendered ${portalData.length} portal icons`); -} - -function clearPortals() { - if (portalContainer) { - portalContainer.innerHTML = ''; - } -} - -function debounce(fn, ms) { - let timeout; - return (...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), ms); - }; -} - -/** - * Create or show a draggable window. Returns { win, content, isNew }. - * If window already exists, brings it to front and returns isNew: false. - */ -function createWindow(id, title, className, options = {}) { - const { onClose } = options; - - // Check if window already exists - bring to front - const existing = document.getElementById(id); - if (existing) { - existing.style.display = 'flex'; - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - existing.style.zIndex = window.__chatZ; - return { win: existing, content: existing.querySelector('.window-content'), isNew: false }; - } - - // Create new window - if (!window.__chatZ) window.__chatZ = 10000; - window.__chatZ += 1; - - const win = document.createElement('div'); - win.id = id; - win.className = className; - win.style.display = 'flex'; - win.style.zIndex = window.__chatZ; - - const header = document.createElement('div'); - header.className = 'chat-header'; - - const titleSpan = document.createElement('span'); - titleSpan.textContent = title; - header.appendChild(titleSpan); - - const closeBtn = document.createElement('button'); - closeBtn.className = 'chat-close-btn'; - closeBtn.textContent = '\u00D7'; - closeBtn.addEventListener('click', () => { - win.style.display = 'none'; - if (onClose) onClose(); - }); - header.appendChild(closeBtn); - - const content = document.createElement('div'); - content.className = 'window-content'; - - win.appendChild(header); - win.appendChild(content); - document.body.appendChild(win); - makeDraggable(win, header); - - return { win, content, isNew: true }; -} - -// Show or create a stats window for a character -function showStatsWindow(name) { - debugLog('showStatsWindow called for:', name); - const windowId = `statsWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Stats: ${name}`, 'stats-window' - ); - - if (!isNew) { - debugLog('Existing stats window found, showing it'); - return; - } - - win.dataset.character = name; - statsWindows[name] = win; - - // Time period controls - const controls = document.createElement('div'); - controls.className = 'stats-controls'; - const timeRanges = [ - { label: '1H', value: 'now-1h' }, - { label: '6H', value: 'now-6h' }, - { label: '24H', value: 'now-24h' }, - { label: '7D', value: 'now-7d' } - ]; - - // Stats content container (iframes grid) - const statsContent = document.createElement('div'); - statsContent.className = 'chat-messages'; - statsContent.textContent = 'Loading stats...'; - - timeRanges.forEach(range => { - const btn = document.createElement('button'); - btn.className = 'time-range-btn'; - btn.textContent = range.label; - if (range.value === 'now-24h') btn.classList.add('active'); - btn.addEventListener('click', () => { - controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - updateStatsTimeRange(statsContent, name, range.value); - }); - controls.appendChild(btn); - }); - - content.appendChild(controls); - content.appendChild(statsContent); - - debugLog('Stats window created for:', name); - // Load initial stats with default 24h range - updateStatsTimeRange(statsContent, name, 'now-24h'); -} - -function updateStatsTimeRange(content, name, timeRange) { - content.innerHTML = ''; - const panels = [ - { title: 'Kills per Hour', id: 1 }, - { title: 'Memory (MB)', id: 2 }, - { title: 'CPU (%)', id: 3 }, - { title: 'Mem Handles', id: 4 } - ]; - panels.forEach(p => { - const iframe = document.createElement('iframe'); - iframe.src = - `/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` + - `?panelId=${p.id}` + - `&var-character=${encodeURIComponent(name)}` + - `&from=${timeRange}` + - `&to=now` + - `&theme=light`; - iframe.setAttribute('title', p.title); - iframe.width = '350'; - iframe.height = '200'; - iframe.frameBorder = '0'; - iframe.allowFullscreen = true; - content.appendChild(iframe); - }); -} - -// Show or create an inventory window for a character -/** - * Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot. - * Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... } - * Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} } - * Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... } - * - * MyWorldObject uses -1 as sentinel for "not set" on int/double properties. - */ -function normalizeInventoryItem(item) { - if (!item) return item; - if (item.name && item.item_id) return item; - - // MyWorldObject uses -1 as "not set" sentinel — filter those out - const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined; - - if (!item.item_id) item.item_id = item.Id; - if (!item.icon) item.icon = item.Icon; - if (!item.object_class) item.object_class = item.ObjectClass; - if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData; - - const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null; - const material = item.Material || null; - if (material) { - item.material = material; - item.material_name = material; - } - - // Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service - if (baseName) { - if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) { - item.name = material + ' ' + baseName; - } else { - item.name = baseName; - } - } - - const iv = item.IntValues || {}; - if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']); - if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']); - - // Container/equipment tracking - if (item.container_id === undefined) item.container_id = item.ContainerId || 0; - if (item.current_wielded_location === undefined) { - item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0; - } - if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']); - - const armor = v(item.ArmorLevel); - if (armor !== undefined) item.armor_level = armor; - - const maxDmg = v(item.MaxDamage); - if (maxDmg !== undefined) item.max_damage = maxDmg; - - const dmgBonus = v(item.DamageBonus); - if (dmgBonus !== undefined) item.damage_bonus = dmgBonus; - - const atkBonus = v(item.AttackBonus); - if (atkBonus !== undefined) item.attack_bonus = atkBonus; - - const elemDmg = v(item.ElementalDmgBonus); - if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg; - - const meleeD = v(item.MeleeDefenseBonus); - if (meleeD !== undefined) item.melee_defense_bonus = meleeD; - - const magicD = v(item.MagicDBonus); - if (magicD !== undefined) item.magic_defense_bonus = magicD; - - const missileD = v(item.MissileDBonus); - if (missileD !== undefined) item.missile_defense_bonus = missileD; - - const manaC = v(item.ManaCBonus); - if (manaC !== undefined) item.mana_conversion_bonus = manaC; - - const wieldLvl = v(item.WieldLevel); - if (wieldLvl !== undefined) item.wield_level = wieldLvl; - - const skillLvl = v(item.SkillLevel); - if (skillLvl !== undefined) item.skill_level = skillLvl; - - const loreLvl = v(item.LoreRequirement); - if (loreLvl !== undefined) item.lore_requirement = loreLvl; - - if (item.EquipSkill) item.equip_skill = item.EquipSkill; - if (item.Mastery) item.mastery = item.Mastery; - if (item.ItemSet) item.item_set = item.ItemSet; - if (item.Imbue) item.imbue = item.Imbue; - - const tinks = v(item.Tinks); - if (tinks !== undefined) item.tinks = tinks; - - const work = v(item.Workmanship); - if (work !== undefined) item.workmanship = work; - - const damR = v(item.DamRating); - if (damR !== undefined) item.damage_rating = damR; - - const critR = v(item.CritRating); - if (critR !== undefined) item.crit_rating = critR; - - const healR = v(item.HealBoostRating); - if (healR !== undefined) item.heal_boost_rating = healR; - - const vitalR = v(item.VitalityRating); - if (vitalR !== undefined) item.vitality_rating = vitalR; - - const critDmgR = v(item.CritDamRating); - if (critDmgR !== undefined) item.crit_damage_rating = critDmgR; - - const damResR = v(item.DamResistRating); - if (damResR !== undefined) item.damage_resist_rating = damResR; - - const critResR = v(item.CritResistRating); - if (critResR !== undefined) item.crit_resist_rating = critResR; - - const critDmgResR = v(item.CritDamResistRating); - if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR; - - if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) { - item.spells = item.Spells; - } - - return item; -} - -/** - * Create a single inventory slot DOM element from item data. - * Used by both initial inventory load and live delta updates. - */ -function createInventorySlot(item) { - const slot = document.createElement('div'); - slot.className = 'inventory-slot'; - slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0); - - // Create layered icon container - const iconContainer = document.createElement('div'); - iconContainer.className = 'item-icon-composite'; - - // Get base icon ID with portal.dat offset - const iconRaw = item.icon || item.Icon || 0; - const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - - // Check for overlay and underlay from enhanced format or legacy format - let overlayIconId = null; - let underlayIconId = null; - - // Enhanced format (inventory service) - check for proper icon overlay/underlay properties - if (item.icon_overlay_id && item.icon_overlay_id > 0) { - overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - if (item.icon_underlay_id && item.icon_underlay_id > 0) { - underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - // Fallback: Enhanced format (inventory service) - check spells object for decal info - if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { - if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { - overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { - underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } else if (item.IntValues) { - // Raw delta format from plugin - IntValues directly on item - if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) { - overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) { - underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } else if (item.item_data) { - // Legacy format - parse item_data - try { - const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; - if (itemData.IntValues) { - if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { - overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { - underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } - } catch (e) { - console.warn('Failed to parse item data for', item.name || item.Name); - } - } - - // Create underlay (bottom layer) - if (underlayIconId) { - const underlayImg = document.createElement('img'); - underlayImg.className = 'icon-underlay'; - underlayImg.src = `/icons/${underlayIconId}.png`; - underlayImg.alt = 'underlay'; - underlayImg.onerror = function() { this.style.display = 'none'; }; - iconContainer.appendChild(underlayImg); - } - - // Create base icon (middle layer) - const baseImg = document.createElement('img'); - baseImg.className = 'icon-base'; - baseImg.src = `/icons/${baseIconId}.png`; - baseImg.alt = item.name || item.Name || 'Unknown Item'; - baseImg.onerror = function() { this.src = '/icons/06000133.png'; }; - iconContainer.appendChild(baseImg); - - // Create overlay (top layer) - if (overlayIconId) { - const overlayImg = document.createElement('img'); - overlayImg.className = 'icon-overlay'; - overlayImg.src = `/icons/${overlayIconId}.png`; - overlayImg.alt = 'overlay'; - overlayImg.onerror = function() { this.style.display = 'none'; }; - iconContainer.appendChild(overlayImg); - } - - // Create tooltip data (handle both inventory-service format and raw plugin format) - const itemName = item.name || item.Name || 'Unknown Item'; - slot.dataset.name = itemName; - slot.dataset.value = item.value || item.Value || 0; - slot.dataset.burden = item.burden || item.Burden || 0; - - // Store enhanced data for tooltips - if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { - const enhancedData = {}; - const possibleProps = [ - 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', - 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', - 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', - 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', - 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', - 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', - 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', - 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', - 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' - ]; - possibleProps.forEach(prop => { - if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { - enhancedData[prop] = item[prop]; - } - }); - slot.dataset.enhancedData = JSON.stringify(enhancedData); - } else { - slot.dataset.enhancedData = JSON.stringify({}); - } - - // Add tooltip on hover - slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); - slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); - slot.addEventListener('mouseleave', hideInventoryTooltip); - - slot.appendChild(iconContainer); - - // Add stack count if > 1 - const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1; - if (stackCount > 1) { - const countEl = document.createElement('div'); - countEl.className = 'inventory-count'; - countEl.textContent = stackCount; - slot.appendChild(countEl); - } - - return slot; -} - -/** - * Equipment slots mapping for the AC inventory layout. - * Grid matches the real AC "Equipment Slots Enabled" paperdoll view. - * - * Layout (6 cols × 6 rows): - * Col: 1 2 3 4 5 6 - * Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red) - * Row 2: Trinket — ChestArmor — — Cloak - * Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt) - * Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants) - * Row 5: — Hands — LowerLegArmor — — - * Row 6: Shield — — Feet Weapon Ammo - */ -const EQUIP_SLOTS = { - // Row 1: Necklace, Head, 3× Aetheria/Sigil - 32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear - 1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear - 268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne - 536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo - 1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree - - // Row 2: Trinket, Chest Armor, Cloak - 67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne - 2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor - 512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor - 134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak - - // Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt - 65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft - 4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor - 1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor - 8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor - 131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight - 2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear - - // Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants - 262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft - 32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear - 16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor - 524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight - 4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear - - // Row 5: Feet - 256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear - - // Row 6: Shield, Weapon, Ammo - 2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield - 1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon - 4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon - 16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held - 33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded - 8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition -}; - -const SLOT_COLORS = {}; -// Purple: jewelry -[32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple'); -// Blue: armor -[1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue'); -// Teal: clothing/misc -[2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal'); -// Dark blue: weapons/combat -[2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue'); - -/** - * Handle live inventory delta updates from WebSocket. - * Updates the inventory grid for a character if their inventory window is open. - */ -function updateInventoryLive(delta) { - const name = delta.character_name; - const win = inventoryWindows[name]; - if (!win || !win._inventoryState) { - return; - } - - const state = win._inventoryState; - const getItemId = (d) => { - if (d.item) return d.item.item_id || d.item.Id || d.item.id; - return d.item_id; - }; - - const itemId = getItemId(delta); - - if (delta.action === 'remove') { - state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId); - } else if (delta.action === 'add' || delta.action === 'update') { - normalizeInventoryItem(delta.item); - const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId); - if (existingIdx >= 0) { - state.items[existingIdx] = delta.item; - } else { - state.items.push(delta.item); - } - } - - renderInventoryState(state); -} - -function renderInventoryState(state) { - // 1. Clear equipment slots - state.slotMap.forEach((slotEl) => { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot empty ${colorClass}`; - delete slotEl.dataset.itemId; - }); - - // 2. Identify containers (object_class === 10) by item_id for sidebar - // These are packs/sacks/pouches/foci that appear in inventory as items - // but should ONLY show in the pack sidebar, not in the item grid. - const containers = []; // container objects (object_class=10) - const containerItemIds = new Set(); // item_ids of containers (to exclude from grid) - - state.items.forEach(item => { - if (item.object_class === 10) { - containers.push(item); - containerItemIds.add(item.item_id); - } - }); - - // 3. Separate equipped items from pack items, excluding containers from grid - const packItems = new Map(); // container_id → [items] (non-container items only) - - // Determine the character body container_id: items with wielded_location > 0 - // share a container_id that is NOT 0 and NOT a pack's item_id. - // We treat non-wielded items from the body container as "main backpack" items. - let bodyContainerId = null; - state.items.forEach(item => { - if (item.current_wielded_location && item.current_wielded_location > 0) { - const cid = item.container_id; - if (cid && cid !== 0 && !containerItemIds.has(cid)) { - bodyContainerId = cid; - } - } - }); - - state.items.forEach(item => { - // Skip container objects — they go in sidebar only - if (containerItemIds.has(item.item_id)) return; - - if (item.current_wielded_location && item.current_wielded_location > 0) { - const mask = item.current_wielded_location; - const isArmor = item.object_class === 2; - - // For armor (object_class=2): render in ALL matching slots (multi-slot display) - // For everything else (clothing, jewelry, weapons): place in first matching slot only - if (isArmor) { - Object.keys(EQUIP_SLOTS).forEach(m => { - const slotMask = parseInt(m); - if ((mask & slotMask) === slotMask) { - const slotDef = EQUIP_SLOTS[slotMask]; - const key = `${slotDef.row}-${slotDef.col}`; - if (state.slotMap.has(key)) { - const slotEl = state.slotMap.get(key); - if (!slotEl.dataset.itemId) { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot equipped ${colorClass}`; - slotEl.dataset.itemId = item.item_id; - slotEl.appendChild(createInventorySlot(item)); - } - } - } - }); - } else { - // Non-armor: find the first matching slot by exact mask key, then by bit overlap - let placed = false; - // Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly) - if (EQUIP_SLOTS[mask]) { - const slotDef = EQUIP_SLOTS[mask]; - const key = `${slotDef.row}-${slotDef.col}`; - if (state.slotMap.has(key)) { - const slotEl = state.slotMap.get(key); - if (!slotEl.dataset.itemId) { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot equipped ${colorClass}`; - slotEl.dataset.itemId = item.item_id; - slotEl.appendChild(createInventorySlot(item)); - placed = true; - } - } - } - // If no exact match, find first matching bit in EQUIP_SLOTS - if (!placed) { - for (const m of Object.keys(EQUIP_SLOTS)) { - const slotMask = parseInt(m); - if ((mask & slotMask) === slotMask) { - const slotDef = EQUIP_SLOTS[slotMask]; - const key = `${slotDef.row}-${slotDef.col}`; - if (state.slotMap.has(key)) { - const slotEl = state.slotMap.get(key); - if (!slotEl.dataset.itemId) { - slotEl.innerHTML = ''; - const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || ''; - slotEl.className = `inv-equip-slot equipped ${colorClass}`; - slotEl.dataset.itemId = item.item_id; - slotEl.appendChild(createInventorySlot(item)); - placed = true; - break; - } - } - } - } - } - } - } else { - // Non-equipped, non-container → pack item. Group by container_id. - let cid = item.container_id || 0; - // Items on the character body (not wielded) → treat as main backpack (cid=0) - if (bodyContainerId !== null && cid === bodyContainerId) cid = 0; - if (!packItems.has(cid)) packItems.set(cid, []); - packItems.get(cid).push(item); - } - }); - - const stats = characterStats[state.characterName] || {}; - const burdenUnits = Number(stats.burden_units || 0); - const encumbranceCapacity = Number(stats.encumbrance_capacity || 0); - const burdenPct = encumbranceCapacity > 0 - ? Math.max(0, Math.min(200, (burdenUnits / encumbranceCapacity) * 100)) - : 0; - const burdenDisplay = Math.floor(burdenPct); - state.burdenLabel.textContent = `${burdenDisplay}%`; - state.burdenLabel.title = burdenUnits > 0 && encumbranceCapacity > 0 - ? `${burdenUnits.toLocaleString()} / ${encumbranceCapacity.toLocaleString()}` - : ''; - // Fill height: map 0-200% burden onto 0-100% bar height - state.burdenFill.style.height = `${burdenPct / 2}%`; - // Color by threshold - state.burdenFill.style.backgroundColor = burdenPct > 150 - ? '#b7432c' - : burdenPct > 100 - ? '#d8a431' - : '#2e8b57'; - - // 4. Sort containers for stable sidebar order (by unsigned item_id) - containers.sort((a, b) => { - const ua = a.item_id >>> 0; - const ub = b.item_id >>> 0; - return ua - ub; - }); - - // 5. Render packs in sidebar - state.packList.innerHTML = ''; - - // Helper: compute icon URL from raw icon id - const iconUrl = (iconRaw) => { - if (!iconRaw) return '/icons/06001080.png'; - const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - return `/icons/${hex}.png`; - }; - - // --- Main backpack (container_id === 0, non-containers) --- - const mainPackEl = document.createElement('div'); - mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`; - const mainPackImg = document.createElement('img'); - mainPackImg.src = '/icons/0600127E.png'; - mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; }; - - const mainFillCont = document.createElement('div'); - mainFillCont.className = 'inv-pack-fill-container'; - const mainFill = document.createElement('div'); - mainFill.className = 'inv-pack-fill'; - - // Main backpack items = container_id 0, excluding container objects - const mainPackItems = packItems.get(0) || []; - const mainPct = Math.min(100, (mainPackItems.length / 102) * 100); - mainFill.style.height = `${mainPct}%`; - - mainFillCont.appendChild(mainFill); - mainPackEl.appendChild(mainPackImg); - mainPackEl.appendChild(mainFillCont); - - mainPackEl.onclick = () => { - state.activePack = null; - renderInventoryState(state); - }; - state.packList.appendChild(mainPackEl); - - // --- Sub-packs: each container object (object_class=10) --- - containers.forEach(container => { - const cid = container.item_id; // items inside this pack have container_id = this item_id - const packEl = document.createElement('div'); - packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`; - const packImg = document.createElement('img'); - // Use the container's actual icon from the API - packImg.src = iconUrl(container.icon); - packImg.onerror = function() { this.src = '/icons/06001080.png'; }; - - const fillCont = document.createElement('div'); - fillCont.className = 'inv-pack-fill-container'; - const fill = document.createElement('div'); - fill.className = 'inv-pack-fill'; - - const pItems = packItems.get(cid) || []; - const capacity = container.items_capacity || 24; // default pack capacity in AC - const pPct = Math.min(100, (pItems.length / capacity) * 100); - fill.style.height = `${pPct}%`; - - fillCont.appendChild(fill); - packEl.appendChild(packImg); - packEl.appendChild(fillCont); - - packEl.onclick = () => { - state.activePack = cid; - renderInventoryState(state); - }; - state.packList.appendChild(packEl); - }); - - // 6. Render item grid - state.itemGrid.innerHTML = ''; - let itemsToShow = []; - if (state.activePack === null) { - // Main backpack: non-container items with container_id === 0 - itemsToShow = mainPackItems; - state.contentsHeader.textContent = 'Contents of Backpack'; - } else { - // Sub-pack: items with matching container_id - itemsToShow = packItems.get(state.activePack) || []; - // Use the container's name for the header - const activeContainer = containers.find(c => c.item_id === state.activePack); - state.contentsHeader.textContent = activeContainer - ? `Contents of ${activeContainer.name}` - : 'Contents of Pack'; - } - - const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6); - for (let i = 0; i < numCells; i++) { - const cell = document.createElement('div'); - if (i < itemsToShow.length) { - cell.className = 'inv-item-slot occupied'; - const itemNode = createInventorySlot(itemsToShow[i]); - cell.appendChild(itemNode); - } else { - cell.className = 'inv-item-slot'; - } - state.itemGrid.appendChild(cell); - } - - renderInventoryManaPanel(state); -} - -function getManaTrackedItems(state) { - if (!state || !state.items) return []; - - const overlayItems = equipmentCantripStates[state.characterName]?.items; - const overlayMap = new Map(); - if (Array.isArray(overlayItems)) { - overlayItems.forEach(item => { - if (item && item.item_id != null) { - overlayMap.set(Number(item.item_id), item); - } - }); - } - - const snapshotMs = Date.now(); - return state.items - .filter(item => (item.current_wielded_location || 0) > 0) - .filter(item => overlayMap.has(Number(item.item_id))) - .map(item => { - const result = { ...item }; - const overlay = overlayMap.get(Number(item.item_id)) || {}; - result.mana_state = overlay.state || 'unknown'; - if (overlay.current_mana !== undefined && overlay.current_mana !== null) { - result.current_mana = overlay.current_mana; - } - if (overlay.max_mana !== undefined && overlay.max_mana !== null) { - result.max_mana = overlay.max_mana; - } - if (overlay.mana_time_remaining_seconds !== undefined && overlay.mana_time_remaining_seconds !== null) { - result.mana_time_remaining_seconds = overlay.mana_time_remaining_seconds; - result.mana_snapshot_utc = equipmentCantripStates[state.characterName]?.timestamp || null; - } - - if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) { - const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN; - if (!Number.isNaN(snapshotUtc)) { - const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000)); - result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0); - } else { - result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds; - } - } else { - result.live_mana_time_remaining_seconds = null; - } - return result; - }) - .sort((a, b) => { - const aRemaining = a.live_mana_time_remaining_seconds; - const bRemaining = b.live_mana_time_remaining_seconds; - if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || ''); - if (aRemaining === null) return 1; - if (bRemaining === null) return -1; - if (aRemaining !== bRemaining) return aRemaining - bRemaining; - return (a.name || '').localeCompare(b.name || ''); - }); -} - -function formatManaRemaining(totalSeconds) { - if (totalSeconds === null || totalSeconds === undefined) return '--'; - const safeSeconds = Math.max(0, Math.floor(totalSeconds)); - const hours = Math.floor(safeSeconds / 3600); - const minutes = Math.floor((safeSeconds % 3600) / 60); - return `${hours}h${String(minutes).padStart(2, '0')}m`; -} - -function renderInventoryManaPanel(state) { - if (!state || !state.manaListBody || !state.manaSummary) return; - - const items = getManaTrackedItems(state); - state.manaListBody.innerHTML = ''; - - if (items.length === 0) { - const empty = document.createElement('div'); - empty.className = 'inv-mana-empty'; - empty.textContent = 'No equipped mana-bearing items'; - state.manaListBody.appendChild(empty); - state.manaSummary.textContent = 'Mana: 0 tracked'; - return; - } - - const activeCount = items.filter(item => item.mana_state === 'active').length; - const lowCount = items.filter(item => (item.live_mana_time_remaining_seconds || 0) > 0 && item.live_mana_time_remaining_seconds <= 7200).length; - state.manaSummary.textContent = `Mana: ${items.length} tracked, ${activeCount} active, ${lowCount} low`; - - items.forEach(item => { - const row = document.createElement('div'); - row.className = 'inv-mana-row'; - - const iconWrap = document.createElement('div'); - iconWrap.className = 'inv-mana-icon'; - const iconSlot = createInventorySlot(item); - iconSlot.classList.add('mana-slot'); - iconWrap.appendChild(iconSlot); - - const nameEl = document.createElement('div'); - nameEl.className = 'inv-mana-name'; - nameEl.textContent = item.name || item.Name || 'Unknown Item'; - - const stateEl = document.createElement('div'); - const stateName = item.mana_state || 'unknown'; - stateEl.className = `inv-mana-state-dot mana-state-${stateName}`; - stateEl.title = stateName.replace(/_/g, ' '); - - const manaEl = document.createElement('div'); - manaEl.className = 'inv-mana-value'; - if (item.current_mana !== undefined && item.max_mana !== undefined) { - manaEl.textContent = `${item.current_mana} / ${item.max_mana}`; - } else if (item.mana_display) { - manaEl.textContent = item.mana_display; - } else { - manaEl.textContent = '--'; - } - - const timeEl = document.createElement('div'); - timeEl.className = 'inv-mana-time'; - timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds); - - row.appendChild(iconWrap); - row.appendChild(nameEl); - row.appendChild(stateEl); - row.appendChild(manaEl); - row.appendChild(timeEl); - state.manaListBody.appendChild(row); - }); - -} - -function showInventoryWindow(name) { - debugLog('showInventoryWindow called for:', name); - const windowId = `inventoryWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Inventory: ${name}`, 'inventory-window' - ); - - if (!isNew) { - debugLog('Existing inventory window found, showing it'); - return; - } - - win.dataset.character = name; - inventoryWindows[name] = win; - - const loading = document.createElement('div'); - loading.className = 'inventory-loading'; - loading.textContent = 'Loading inventory...'; - content.appendChild(loading); - - win.style.width = '572px'; - win.style.height = '720px'; - - const invContent = document.createElement('div'); - invContent.className = 'inventory-content'; - invContent.style.display = 'none'; - content.appendChild(invContent); - - const equipGrid = document.createElement('div'); - equipGrid.className = 'inv-equipment-grid'; - - const slotMap = new Map(); - const createdSlots = new Set(); - - Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => { - const key = `${slotDef.row}-${slotDef.col}`; - if (!createdSlots.has(key)) { - createdSlots.add(key); - const slotEl = document.createElement('div'); - const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue'; - slotEl.className = `inv-equip-slot empty ${colorClass}`; - slotEl.style.left = `${(slotDef.col - 1) * 44}px`; - slotEl.style.top = `${(slotDef.row - 1) * 44}px`; - slotEl.dataset.pos = key; - equipGrid.appendChild(slotEl); - slotMap.set(key, slotEl); - } - }); - - const sidebar = document.createElement('div'); - sidebar.className = 'inv-sidebar'; - - const manaPanel = document.createElement('div'); - manaPanel.className = 'inv-mana-panel'; - const manaHeader = document.createElement('div'); - manaHeader.className = 'inv-mana-header'; - manaHeader.textContent = 'Mana'; - const manaSummary = document.createElement('div'); - manaSummary.className = 'inv-mana-summary'; - manaSummary.textContent = 'Mana: loading'; - const manaListBody = document.createElement('div'); - manaListBody.className = 'inv-mana-list'; - manaPanel.appendChild(manaHeader); - manaPanel.appendChild(manaSummary); - manaPanel.appendChild(manaListBody); - - const burdenContainer = document.createElement('div'); - burdenContainer.className = 'inv-burden-bar'; - const burdenFill = document.createElement('div'); - burdenFill.className = 'inv-burden-fill'; - const burdenLabel = document.createElement('div'); - burdenLabel.className = 'inv-burden-label'; - burdenLabel.textContent = 'Burden'; - burdenContainer.appendChild(burdenFill); - sidebar.appendChild(burdenLabel); - sidebar.appendChild(burdenContainer); - - const packList = document.createElement('div'); - packList.className = 'inv-pack-list'; - sidebar.appendChild(packList); - - const leftColumn = document.createElement('div'); - leftColumn.className = 'inv-left-column'; - - const contentsHeader = document.createElement('div'); - contentsHeader.className = 'inv-contents-header'; - contentsHeader.textContent = 'Contents of Backpack'; - - const itemGrid = document.createElement('div'); - itemGrid.className = 'inv-item-grid'; - - leftColumn.appendChild(equipGrid); - leftColumn.appendChild(contentsHeader); - leftColumn.appendChild(itemGrid); - - invContent.appendChild(leftColumn); - invContent.appendChild(sidebar); - invContent.appendChild(manaPanel); - - const resizeGrip = document.createElement('div'); - resizeGrip.className = 'inv-resize-grip'; - win.appendChild(resizeGrip); - - let resizing = false; - let startY, startH; - - resizeGrip.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - resizing = true; - startY = e.clientY; - startH = win.offsetHeight; - document.body.style.cursor = 'ns-resize'; - document.body.style.userSelect = 'none'; - }); - - document.addEventListener('mousemove', (e) => { - if (!resizing) return; - const newH = Math.max(400, startH + (e.clientY - startY)); - win.style.height = newH + 'px'; - }); - - document.addEventListener('mouseup', () => { - if (!resizing) return; - resizing = false; - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }); - - win._inventoryState = { - windowEl: win, - items: [], - activePack: null, - slotMap: slotMap, - equipGrid: equipGrid, - itemGrid: itemGrid, - packList: packList, - burdenFill: burdenFill, - burdenLabel: burdenLabel, - contentsHeader: contentsHeader, - manaPanel: manaPanel, - manaSummary: manaSummary, - manaListBody: manaListBody, - characterName: name - }; - - fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`) - .then(response => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); - }) - .then(data => { - loading.style.display = 'none'; - invContent.style.display = 'flex'; - - data.items.forEach(i => normalizeInventoryItem(i)); - win._inventoryState.items = data.items; - - renderInventoryState(win._inventoryState); - }) - .catch(err => { - handleError('Inventory', err, true); - loading.textContent = `Failed to load inventory: ${err.message}`; - }); - - if (!characterStats[name]) { - fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - characterStats[name] = data; - if (win._inventoryState) { - renderInventoryState(win._inventoryState); - } - } - }) - .catch(() => {}); - } - - if (!equipmentCantripStates[name]) { - fetch(`${API_BASE}/equipment-cantrip-state/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - equipmentCantripStates[name] = data; - if (win._inventoryState) { - renderInventoryState(win._inventoryState); - } - } - }) - .catch(() => {}); - } - - debugLog('Inventory window created for:', name); -} - -// === TreeStats Property ID Mappings === -const TS_AUGMENTATIONS = { - 218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement", - 221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will", - 224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence", - 227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule", - 230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment", - 233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune", - 236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance", - 239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner", - 241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler", - 244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless", - 294: "Master of the Steel Circle", 295: "Master of the Focused Eye", - 296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer", - 298: "Iron Skin of the Invincible", 299: "Jack of All Trades", - 300: "Infused Void Magic", 301: "Infused War Magic", - 302: "Infused Life Magic", 309: "Infused Item Magic", - 310: "Infused Creature Magic", 326: "Clutch of the Miser", - 328: "Enduring Enchantment" -}; -const TS_AURAS = { - 333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution", - 336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow", - 340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization", - 365: "World" -}; -const TS_RATINGS = { - 370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance", - 374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost", - 379: "Vitality" -}; -const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" }; -const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" }; -const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" }; -const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" }; - -function _tsSocietyRank(v) { - if (v >= 1001) return "Master"; - if (v >= 301) return "Lord"; - if (v >= 151) return "Knight"; - if (v >= 31) return "Adept"; - return "Initiate"; -} - -function _tsSetupTabs(container) { - const tabs = container.querySelectorAll('.ts-tab'); - const boxes = container.querySelectorAll('.ts-box'); - tabs.forEach((tab, i) => { - tab.addEventListener('click', () => { - tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); }); - boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); }); - tab.classList.remove('inactive'); tab.classList.add('active'); - if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); } - }); - }); -} - -function showCharacterWindow(name) { - debugLog('showCharacterWindow called for:', name); - const windowId = `characterWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Character: ${name}`, 'character-window' - ); - - if (!isNew) { - debugLog('Existing character window found, showing it'); - return; - } - - win.dataset.character = name; - characterWindows[name] = win; - - const esc = CSS.escape(name); - content.innerHTML = ` -
-

${name}

-
Awaiting character data...
-
-
-
Total XP: \u2014
-
Unassigned XP: \u2014
-
Luminance: \u2014
-
Deaths: \u2014
-
-
-
-
-
Attributes
-
Skills
-
Titles
-
-
-
-
- Health -
- \u2014 / \u2014 -
-
- Stamina -
- \u2014 / \u2014 -
-
- Mana -
- \u2014 / \u2014 -
-
- - - - - - - - -
AttributeCreationBase
Strength\u2014\u2014
Endurance\u2014\u2014
Coordination\u2014\u2014
Quickness\u2014\u2014
Focus\u2014\u2014
Self\u2014\u2014
- - - - - -
VitalBase
Health\u2014
Stamina\u2014
Mana\u2014
- - -
Skill Credits\u2014
-
-
-
Awaiting data...
-
-
-
Awaiting data...
-
-
-
-
-
Augmentations
-
Ratings
-
Other
-
-
-
Awaiting data...
-
-
-
Awaiting data...
-
-
-
Awaiting data...
-
-
-
-
-
Allegiance
-
Awaiting data...
-
- `; - - // Wire up tab switching - const leftTabs = document.getElementById(`charTabLeft-${esc}`); - const rightTabs = document.getElementById(`charTabRight-${esc}`); - if (leftTabs) _tsSetupTabs(leftTabs); - if (rightTabs) _tsSetupTabs(rightTabs); - - // Fetch existing data from API - fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`) - .then(r => r.ok ? r.json() : null) - .then(data => { - if (data && !data.error) { - characterStats[name] = data; - updateCharacterWindow(name, data); - } - }) - .catch(err => handleError('Character stats', err)); - - // If we already have vitals from the live stream, apply them - if (characterVitals[name]) { - updateCharacterVitals(name, characterVitals[name]); - } -} - -function updateCharacterWindow(name, data) { - const esc = CSS.escape(name); - const fmt = n => n != null ? n.toLocaleString() : '\u2014'; - - // -- Header -- - const header = document.getElementById(`charHeader-${esc}`); - if (header) { - const level = data.level || '?'; - const race = data.race || ''; - const gender = data.gender || ''; - const parts = [gender, race].filter(Boolean).join(' '); - header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...'; - const levelSpan = header.querySelector('.ts-level'); - if (levelSpan) levelSpan.textContent = level; - } - - // -- XP / Luminance row -- - const xplum = document.getElementById(`charXpLum-${esc}`); - if (xplum) { - const divs = xplum.querySelectorAll('div'); - if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`; - if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`; - if (divs[2]) { - const lum = data.luminance_earned != null && data.luminance_total != null - ? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}` - : '\u2014'; - divs[2].textContent = `Luminance: ${lum}`; - } - if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`; - } - - // -- Attributes table -- - const attribTable = document.getElementById(`charAttribTable-${esc}`); - if (attribTable && data.attributes) { - const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self']; - const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)'); - order.forEach((attr, i) => { - if (rows[i] && data.attributes[attr]) { - const cells = rows[i].querySelectorAll('td'); - if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014'; - if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014'; - } - }); - } - - // -- Vitals table (base values) -- - const vitalsTable = document.getElementById(`charVitalsTable-${esc}`); - if (vitalsTable && data.vitals) { - const vOrder = ['health', 'stamina', 'mana']; - const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)'); - vOrder.forEach((v, i) => { - if (vRows[i] && data.vitals[v]) { - const cells = vRows[i].querySelectorAll('td'); - if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014'; - } - }); - } - - // -- Skill credits -- - const creditsTable = document.getElementById(`charCredits-${esc}`); - if (creditsTable) { - const cell = creditsTable.querySelector('td.ts-headerright'); - if (cell) cell.textContent = fmt(data.skill_credits); - } - - // -- Skills tab -- - const skillsBox = document.getElementById(`charSkills-${esc}`); - if (skillsBox && data.skills) { - const grouped = { Specialized: [], Trained: [] }; - for (const [skill, info] of Object.entries(data.skills)) { - const training = info.training || 'Untrained'; - if (training === 'Untrained' || training === 'Unusable') continue; - const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - if (grouped[training]) grouped[training].push({ name: displayName, base: info.base }); - } - for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name)); - - let html = ''; - html += ''; - if (grouped.Specialized.length) { - for (const s of grouped.Specialized) { - html += ``; - } - } - if (grouped.Trained.length) { - for (const s of grouped.Trained) { - html += ``; - } - } - html += '
SkillLevel
${s.name}${s.base}
${s.name}${s.base}
'; - skillsBox.innerHTML = html; - } - - // -- Titles tab -- - const titlesBox = document.getElementById(`charTitles-${esc}`); - if (titlesBox) { - const statsData = data.stats_data || data; - const titles = statsData.titles; - if (titles && titles.length > 0) { - let html = '
'; - for (const t of titles) html += `
${t}
`; - html += '
'; - titlesBox.innerHTML = html; - } else { - titlesBox.innerHTML = '
No titles data
'; - } - } - - // -- Properties-based tabs (Augmentations, Ratings, Other) -- - const statsData = data.stats_data || data; - const props = statsData.properties || {}; - - // Augmentations tab - const augsBox = document.getElementById(`charAugs-${esc}`); - if (augsBox) { - let augRows = [], auraRows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val }); - if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val }); - } - if (augRows.length || auraRows.length) { - let html = ''; - if (augRows.length) { - html += '
Augmentations
'; - html += ''; - for (const a of augRows) html += ``; - html += '
NameUses
${a.name}${a.uses}
'; - } - if (auraRows.length) { - html += '
Auras
'; - html += ''; - for (const a of auraRows) html += ``; - html += '
NameUses
${a.name}${a.uses}
'; - } - augsBox.innerHTML = html; - } else { - augsBox.innerHTML = '
No augmentation data
'; - } - } - - // Ratings tab - const ratingsBox = document.getElementById(`charRatings-${esc}`); - if (ratingsBox) { - let rows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val }); - } - if (rows.length) { - let html = ''; - for (const r of rows) html += ``; - html += '
RatingValue
${r.name}${r.value}
'; - ratingsBox.innerHTML = html; - } else { - ratingsBox.innerHTML = '
No rating data
'; - } - } - - // Other tab (General, Masteries, Society) - const otherBox = document.getElementById(`charOther-${esc}`); - if (otherBox) { - let html = ''; - - // General section - let generalRows = []; - if (data.birth) generalRows.push({ name: 'Birth', value: data.birth }); - if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) }); - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val }); - } - if (generalRows.length) { - html += '
General
'; - html += ''; - for (const r of generalRows) html += ``; - html += '
${r.name}${r.value}
'; - } - - // Masteries section - let masteryRows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_MASTERIES[nid]) { - const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`; - masteryRows.push({ name: TS_MASTERIES[nid], value: mName }); - } - } - if (masteryRows.length) { - html += '
Masteries
'; - html += ''; - for (const m of masteryRows) html += ``; - html += '
${m.name}${m.value}
'; - } - - // Society section - let societyRows = []; - for (const [id, val] of Object.entries(props)) { - const nid = parseInt(id); - if (TS_SOCIETY[nid] && val > 0) { - societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val }); - } - } - if (societyRows.length) { - html += '
Society
'; - html += ''; - for (const s of societyRows) html += ``; - html += '
${s.name}${s.rank} (${s.value})
'; - } - - otherBox.innerHTML = html || '
No additional data
'; - } - - // -- Allegiance section -- - const allegDiv = document.getElementById(`charAllegiance-${esc}`); - if (allegDiv && data.allegiance) { - const a = data.allegiance; - let html = '
Allegiance
'; - html += ''; - if (a.name) html += ``; - if (a.monarch) html += ``; - if (a.patron) html += ``; - if (a.rank !== undefined) html += ``; - if (a.followers !== undefined) html += ``; - html += '
Name${a.name}
Monarch${a.monarch.name || '\u2014'}
Patron${a.patron.name || '\u2014'}
Rank${a.rank}
Followers${a.followers}
'; - allegDiv.innerHTML = html; - } -} - -function updateCharacterVitals(name, vitals) { - const esc = CSS.escape(name); - const vitalsDiv = document.getElementById(`charVitals-${esc}`); - if (!vitalsDiv) return; - - const vitalElements = vitalsDiv.querySelectorAll('.ts-vital'); - - if (vitalElements[0]) { - const fill = vitalElements[0].querySelector('.ts-vital-fill'); - const txt = vitalElements[0].querySelector('.ts-vital-text'); - if (fill) fill.style.width = `${vitals.health_percentage || 0}%`; - if (txt && vitals.health_current !== undefined) { - txt.textContent = `${vitals.health_current} / ${vitals.health_max}`; - } - } - if (vitalElements[1]) { - const fill = vitalElements[1].querySelector('.ts-vital-fill'); - const txt = vitalElements[1].querySelector('.ts-vital-text'); - if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`; - if (txt && vitals.stamina_current !== undefined) { - txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`; - } - } - if (vitalElements[2]) { - const fill = vitalElements[2].querySelector('.ts-vital-fill'); - const txt = vitalElements[2].querySelector('.ts-vital-text'); - if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`; - if (txt && vitals.mana_current !== undefined) { - txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`; - } - } -} - -// Inventory tooltip functions -let inventoryTooltip = null; - -function showInventoryTooltip(e, slot) { - if (!inventoryTooltip) { - inventoryTooltip = document.createElement('div'); - inventoryTooltip.className = 'inventory-tooltip'; - document.body.appendChild(inventoryTooltip); - } - - const name = slot.dataset.name; - const value = parseInt(slot.dataset.value) || 0; - const burden = parseInt(slot.dataset.burden) || 0; - - // Build enhanced tooltip - let tooltipHTML = `
${name}
`; - - // Basic stats - tooltipHTML += `
`; - tooltipHTML += `
Value: ${value.toLocaleString()}
`; - tooltipHTML += `
Burden: ${burden}
`; - - // 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 += `
Workmanship: ${enhanced.workmanship_text}
`; - } - } catch (e) { - // Ignore parsing errors for this section - } - } - - tooltipHTML += `
`; - - // Enhanced data from inventory service - if (slot.dataset.enhancedData) { - try { - const enhanced = JSON.parse(slot.dataset.enhancedData); - - // Only show enhanced sections if we have enhanced data - if (Object.keys(enhanced).length > 0) { - - // Helper function to check for valid values - const isValid = (val) => val !== undefined && val !== null && val !== -1 && val !== -1.0; - - // Weapon-specific stats section (for weapons) - if (enhanced.damage_range || enhanced.speed_text || enhanced.equip_skill_name) { - tooltipHTML += `
`; - - // Skill requirement - if (enhanced.equip_skill_name) { - tooltipHTML += `
Skill: ${enhanced.equip_skill_name}
`; - } - - // Damage with type - if (enhanced.damage_range && enhanced.damage_type) { - const damageText = enhanced.damage_type !== 'Physical' ? - `${enhanced.damage_range}, ${enhanced.damage_type}` : - enhanced.damage_range; - tooltipHTML += `
Damage: ${damageText}
`; - } - - // Speed - if (enhanced.speed_text) { - tooltipHTML += `
Speed: ${enhanced.speed_text}
`; - } - - // Attack and defense bonuses (as percentages) - if (isValid(enhanced.attack_bonus)) { - const attackPercent = ((enhanced.attack_bonus - 1) * 100).toFixed(1); - if (attackPercent !== "0.0") { - tooltipHTML += `
Bonus to Attack Skill: ${attackPercent > 0 ? '+' : ''}${attackPercent}%
`; - } - } - - // Defense bonuses - if (enhanced.melee_defense_bonus && isValid(enhanced.melee_defense_bonus)) { - const defensePercent = ((enhanced.melee_defense_bonus - 1) * 100).toFixed(1); - if (defensePercent !== "0.0") { - tooltipHTML += `
Bonus to Melee Defense: ${defensePercent > 0 ? '+' : ''}${defensePercent}%
`; - } - } - - // Magic defense bonus - if (enhanced.magic_defense_bonus && isValid(enhanced.magic_defense_bonus)) { - const magicDefensePercent = ((enhanced.magic_defense_bonus - 1) * 100).toFixed(1); - if (magicDefensePercent !== "0.0") { - tooltipHTML += `
Bonus to Magic Defense: ${magicDefensePercent > 0 ? '+' : ''}${magicDefensePercent}%
`; - } - } - - // Elemental damage vs monsters - if (enhanced.elemental_damage_vs_monsters && isValid(enhanced.elemental_damage_vs_monsters)) { - const elementalPercent = ((enhanced.elemental_damage_vs_monsters - 1) * 100).toFixed(1); - if (elementalPercent !== "0.0") { - tooltipHTML += `
Elemental Damage vs Monsters: ${elementalPercent > 0 ? '+' : ''}${elementalPercent}%
`; - } - } - - tooltipHTML += `
`; - } - - // Traditional combat stats section (for non-weapons or additional stats) - const combatProps = []; - if (isValid(enhanced.armor_level)) combatProps.push(`Armor Level: ${enhanced.armor_level}`); - if (!enhanced.damage_range && isValid(enhanced.max_damage)) combatProps.push(`Max Damage: ${enhanced.max_damage}`); - if (!enhanced.attack_bonus && isValid(enhanced.damage_bonus)) combatProps.push(`Damage Bonus: ${enhanced.damage_bonus.toFixed(1)}`); - - if (combatProps.length > 0) { - tooltipHTML += `
Combat Stats
`; - combatProps.forEach(prop => { - tooltipHTML += `
${prop}
`; - }); - tooltipHTML += `
`; - } - - // Requirements section - const reqProps = []; - if (isValid(enhanced.wield_level)) reqProps.push(`Level Required: ${enhanced.wield_level}`); - if (isValid(enhanced.skill_level)) reqProps.push(`Skill Level: ${enhanced.skill_level}`); - if (enhanced.equip_skill_name) reqProps.push(`Skill: ${enhanced.equip_skill_name}`); - if (isValid(enhanced.lore_requirement)) reqProps.push(`Lore: ${enhanced.lore_requirement}`); - - if (reqProps.length > 0) { - tooltipHTML += `
Requirements
`; - reqProps.forEach(prop => { - tooltipHTML += `
${prop}
`; - }); - tooltipHTML += `
`; - } - - // Enhancement section - const enhanceProps = []; - if (enhanced.material_name) enhanceProps.push(`Material: ${enhanced.material_name}`); - if (enhanced.imbue) enhanceProps.push(`Imbue: ${enhanced.imbue}`); - if (enhanced.item_set) enhanceProps.push(`Set: ${enhanced.item_set}`); - if (isValid(enhanced.tinks)) enhanceProps.push(`Tinks: ${enhanced.tinks}`); - // Use workmanship_text if available, otherwise numeric value - if (enhanced.workmanship_text) { - enhanceProps.push(`Workmanship: ${enhanced.workmanship_text}`); - } else if (isValid(enhanced.workmanship)) { - enhanceProps.push(`Workmanship: ${enhanced.workmanship.toFixed(1)}`); - } - - if (enhanceProps.length > 0) { - tooltipHTML += `
Enhancements
`; - enhanceProps.forEach(prop => { - tooltipHTML += `
${prop}
`; - }); - tooltipHTML += `
`; - } - - // Ratings section - const ratingProps = []; - if (isValid(enhanced.damage_rating)) ratingProps.push(`Damage Rating: ${enhanced.damage_rating}`); - if (isValid(enhanced.crit_rating)) ratingProps.push(`Crit Rating: ${enhanced.crit_rating}`); - if (isValid(enhanced.heal_boost_rating)) ratingProps.push(`Heal Boost: ${enhanced.heal_boost_rating}`); - - if (ratingProps.length > 0) { - tooltipHTML += `
Ratings
`; - ratingProps.forEach(prop => { - tooltipHTML += `
${prop}
`; - }); - tooltipHTML += `
`; - } - - // Spells section (condensed list) - if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) { - const spellNames = enhanced.spells.spells.map(spell => spell.name).join(', '); - tooltipHTML += `
`; - tooltipHTML += `
Spells: ${spellNames}
`; - tooltipHTML += `
`; - } - - // Mana and Spellcraft section - if (enhanced.mana_display || enhanced.spellcraft) { - tooltipHTML += `
`; - if (enhanced.spellcraft) { - tooltipHTML += `
Spellcraft: ${enhanced.spellcraft}
`; - } - if (enhanced.mana_display) { - tooltipHTML += `
Mana: ${enhanced.mana_display}
`; - } - tooltipHTML += `
`; - } - - // Detailed Spell Descriptions section - if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) { - tooltipHTML += `
Spell Descriptions
`; - enhanced.spells.spells.forEach(spell => { - tooltipHTML += `
`; - tooltipHTML += `
${spell.name}
`; - if (spell.description) { - tooltipHTML += `
${spell.description}
`; - } - tooltipHTML += `
`; - }); - tooltipHTML += `
`; - } - - // Object class info - if (enhanced.object_class_name) { - tooltipHTML += `
`; - tooltipHTML += `
Type: ${enhanced.object_class_name}
`; - tooltipHTML += `
`; - } - - } // End of enhanced data check - - } catch (e) { - console.warn('Failed to parse enhanced tooltip data', e); - } - } - - inventoryTooltip.innerHTML = tooltipHTML; - - // Position tooltip near cursor - const x = e.clientX + 10; - const y = e.clientY + 10; - inventoryTooltip.style.left = `${x}px`; - inventoryTooltip.style.top = `${y}px`; - inventoryTooltip.style.display = 'block'; -} - -function hideInventoryTooltip() { - if (inventoryTooltip) { - inventoryTooltip.style.display = 'none'; - } -} - - -const applyTransform = () => - group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; - -function clampPan() { - if (!imgW) return; - const r = wrap.getBoundingClientRect(); - const vw = r.width, vh = r.height; - const mw = imgW * scale, mh = imgH * scale; - - offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX)); - offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY)); -} - -function updateView() { - clampPan(); - applyTransform(); - - // Throttled heat map re-rendering during pan/zoom - if (heatmapEnabled && heatmapData && !heatTimeout) { - heatTimeout = setTimeout(() => { - renderHeatmap(); - heatTimeout = null; - }, HEAT_THROTTLE); - } -} - -let pendingFrame = null; -function scheduleViewUpdate() { - if (!pendingFrame) { - pendingFrame = requestAnimationFrame(() => { - updateView(); - pendingFrame = null; - }); - } -} - -function fitToWindow() { - const r = wrap.getBoundingClientRect(); - scale = Math.min(r.width / imgW, r.height / imgH); - minScale = scale; - updateView(); -} - -/* ---------- tooltip handlers ------------------------------------ */ -function showTooltip(evt, p) { - tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`; - const r = wrap.getBoundingClientRect(); - tooltip.style.left = `${evt.clientX - r.left + 10}px`; - tooltip.style.top = `${evt.clientY - r.top + 10}px`; - tooltip.style.display = 'block'; -} -function hideTooltip() { - tooltip.style.display = 'none'; -} - -/* ---------- polling and initialization -------------------------- */ -async function pollLive() { - try { - const [liveRes, trailsRes] = await Promise.all([ - fetch(`${API_BASE}/live/`), - fetch(`${API_BASE}/trails/?seconds=600`), - ]); - const { players } = await liveRes.json(); - const { trails } = await trailsRes.json(); - currentPlayers = players; - renderTrails(trails); - renderList(); - } catch (e) { - handleError('Player update', e); - } -} - -async function pollTotalRares() { - try { - const response = await fetch(`${API_BASE}/total-rares/`); - const data = await response.json(); - updateTotalRaresDisplay(data); - } catch (e) { - handleError('Rare counter', e); - } -} - -function updateTotalRaresDisplay(data) { - const countElement = document.getElementById('totalRaresCount'); - if (countElement && data.all_time !== undefined && data.today !== undefined) { - const allTimeFormatted = data.all_time.toLocaleString(); - const todayFormatted = data.today.toLocaleString(); - countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`; - } -} - -async function pollTotalKills() { - try { - const response = await fetch(`${API_BASE}/total-kills/`); - const data = await response.json(); - updateTotalKillsDisplay(data); - } catch (e) { - handleError('Kill counter', e); - } -} - -function updateTotalKillsDisplay(data) { - const killsElement = document.getElementById('totalKillsCount'); - if (killsElement && data.total !== undefined) { - killsElement.textContent = data.total.toLocaleString(); - } -} - -async function pollServerHealth() { - try { - const response = await fetch(`${API_BASE}/server-health`); - const data = await response.json(); - updateServerStatusDisplay(data); - } catch (e) { - handleError('Server health', e); - updateServerStatusDisplay({ status: 'error' }); - } -} - -function updateServerStatusDisplay(data) { - const statusDot = document.getElementById('statusDot'); - const statusText = document.getElementById('statusText'); - const playerCount = document.getElementById('playerCount'); - const latencyMs = document.getElementById('latencyMs'); - const uptime = document.getElementById('uptime'); - const lastRestart = document.getElementById('lastRestart'); - - if (!statusDot || !statusText) return; - - // Update status indicator - const status = data.status || 'unknown'; - statusDot.className = `status-dot status-${status}`; - statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1); - - // Update player count - if (playerCount) { - playerCount.textContent = data.player_count !== null && data.player_count !== undefined ? data.player_count : '-'; - } - - // Update latency - if (latencyMs) { - latencyMs.textContent = data.latency_ms ? Math.round(data.latency_ms) : '-'; - } - - // Update uptime - if (uptime) { - uptime.textContent = data.uptime || '-'; - } - - // Update last restart with Stockholm timezone (24h format, no year) - if (lastRestart) { - if (data.last_restart) { - const restartDate = new Date(data.last_restart); - const formattedDate = restartDate.toLocaleString('sv-SE', { - timeZone: 'Europe/Stockholm', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }); - lastRestart.textContent = formattedDate; - } else { - lastRestart.textContent = 'Unknown'; - } - } -} - -function handleServerStatusUpdate(msg) { - // Handle real-time server status updates via WebSocket - if (msg.status === 'up' && msg.message) { - // Show notification for server coming back online - debugLog(`Server Status: ${msg.message}`); - } - - // Trigger an immediate server health poll to refresh the display - pollServerHealth(); -} - -function startPolling() { - // Clear any existing intervals first (prevents leak on re-init) - pollIntervals.forEach(id => clearInterval(id)); - pollIntervals.length = 0; - - // Initial fetches - pollLive(); - pollTotalRares(); - pollTotalKills(); - pollServerHealth(); - - // Set up recurring polls - pollIntervals.push(setInterval(pollLive, POLL_MS)); - pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS)); - pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS)); - pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS)); -} - -img.onload = () => { - imgW = img.naturalWidth; - imgH = img.naturalHeight; - // size the SVG trails container to match the map dimensions - if (trailsContainer) { - trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`); - trailsContainer.setAttribute('width', `${imgW}`); - trailsContainer.setAttribute('height', `${imgH}`); - } - fitToWindow(); - startPolling(); - initWebSocket(); - initHeatMap(); - initPortalMap(); -}; - -// Ensure server health polling starts regardless of image loading -document.addEventListener('DOMContentLoaded', () => { - // Start server health polling immediately on DOM ready - pollServerHealth(); -}); - -/* ---------- rendering sorted list & dots ------------------------ */ -/** - * Filter and sort the currentPlayers, then render them. - */ -function renderList() { - // Filter by name prefix - const filtered = currentPlayers.filter(p => - p.character_name.toLowerCase().startsWith(currentFilter) - ); - // Sort filtered list - filtered.sort(currentSort.comparator); - const sorted = filtered; - render(sorted); -} - -// Track when user might be interacting to avoid DOM manipulation during clicks -let userInteracting = false; -let interactionTimeout = null; - -// Add global mousedown/mouseup tracking to detect when user is clicking -document.addEventListener('mousedown', () => { - userInteracting = true; - if (interactionTimeout) clearTimeout(interactionTimeout); -}); - -document.addEventListener('mouseup', () => { - // Give a small buffer after mouseup to ensure click events complete - if (interactionTimeout) clearTimeout(interactionTimeout); - interactionTimeout = setTimeout(() => { - userInteracting = false; - }, 50); // 50ms buffer -}); - -function render(players) { - const startTime = performance.now(); - debugLog('🔄 RENDER STARTING:', new Date().toISOString()); - - // If user is actively clicking, defer this render briefly - if (userInteracting) { - debugLog('🔄 RENDER DEFERRED: User interaction detected'); - setTimeout(() => render(players), 100); - return; - } - - // Reset per-render stats - performanceStats.renderDotsCreated = 0; - performanceStats.renderDotsReused = 0; - performanceStats.renderListItemsCreated = 0; - performanceStats.renderListItemsReused = 0; - performanceStats.renderCount++; - - // Get existing elements and map them by player name for reuse - const existingDots = Array.from(dots.children); - const existingListItems = Array.from(list.children); - - // Create maps for efficient lookup by player name - const dotsByPlayer = new Map(); - const listItemsByPlayer = new Map(); - - existingDots.forEach(dot => { - if (dot.playerData && dot.playerData.character_name) { - dotsByPlayer.set(dot.playerData.character_name, dot); - } - }); - - existingListItems.forEach(li => { - if (li.playerData && li.playerData.character_name) { - listItemsByPlayer.set(li.playerData.character_name, li); - } - }); - - - // DON'T clear containers - we need to reuse elements - - // Update header with active player count - const header = document.getElementById('activePlayersHeader'); - if (header) { - header.textContent = `Active Mosswart Enjoyers (${players.length})`; - } - - // Calculate and update server KPH - const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0); - const kphElement = document.getElementById('serverKphCount'); - if (kphElement) { - // Format with commas and one decimal place for EPIC display - const formattedKPH = totalKPH.toLocaleString('en-US', { - minimumFractionDigits: 1, - maximumFractionDigits: 1 - }); - kphElement.textContent = formattedKPH; - - // Add extra epic effect for high KPH - const container = document.getElementById('serverKphCounter'); - if (container) { - if (totalKPH > 5000) { - container.classList.add('ultra-epic'); - } else { - container.classList.remove('ultra-epic'); - } - } - } - - // Total kills is now fetched from the /total-kills/ API endpoint - // (see pollTotalKills function) to include ALL characters, not just online ones - - players.forEach((p) => { - const { x, y } = worldToPx(p.ew, p.ns); - - // Reuse existing dot by player name or create new one - let dot = dotsByPlayer.get(p.character_name); - if (!dot) { - dot = createNewDot(); - dots.appendChild(dot); - } else { - performanceStats.dotsReused++; - performanceStats.renderDotsReused++; - // Remove from the map so we don't count it as unused later - dotsByPlayer.delete(p.character_name); - } - - // Update dot properties - dot.style.left = `${x}px`; - dot.style.top = `${y}px`; - dot.style.background = getColorFor(p.character_name); - dot.playerData = p; // Store for event handlers - - // Update highlight state - if (p.character_name === selected) { - dot.classList.add('highlight'); - } else { - dot.classList.remove('highlight'); - } - - // Reuse existing list item by player name or create new one - let li = listItemsByPlayer.get(p.character_name); - if (!li) { - li = createNewListItem(); - list.appendChild(li); - } else { - performanceStats.listItemsReused++; - performanceStats.renderListItemsReused++; - // Remove from the map so we don't count it as unused later - listItemsByPlayer.delete(p.character_name); - } - - const color = getColorFor(p.character_name); - li.style.borderLeftColor = color; - li.playerData = p; // Store for event handlers BEFORE any DOM movement - - // Also store playerData directly on buttons for more reliable access - if (li.chatBtn) li.chatBtn.playerData = p; - if (li.statsBtn) li.statsBtn.playerData = p; - if (li.inventoryBtn) li.inventoryBtn.playerData = p; - if (li.radarBtn) li.radarBtn.playerData = p; - - // Only reorder element if it's actually out of place for current sort order - // Check if this element needs to be moved to maintain sort order - const expectedIndex = players.indexOf(p); - const currentIndex = Array.from(list.children).indexOf(li); - - if (currentIndex !== expectedIndex && li.parentNode) { - // Find the correct position to insert - if (expectedIndex === players.length - 1) { - // Should be last - only move if it's not already last - if (li !== list.lastElementChild) { - list.appendChild(li); - } - } else { - // Should be at a specific position - const nextPlayer = players[expectedIndex + 1]; - const nextElement = Array.from(list.children).find(el => - el.playerData && el.playerData.character_name === nextPlayer.character_name - ); - if (nextElement && li.nextElementSibling !== nextElement) { - list.insertBefore(li, nextElement); - } - } - } - - // Calculate KPR (Kills Per Rare) - const playerTotalKills = p.total_kills || 0; - const totalRares = p.total_rares || 0; - const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞'; - - // Update only the grid content via innerHTML (buttons preserved) - li.gridContent.innerHTML = ` - ${p.character_name}${createVitaeIndicator(p.character_name)} ${loc(p.ns, p.ew)} - ${createVitalsHTML(p.character_name)} - ${p.kills} - ${p.total_kills || 0} - ${p.kills_per_hour} - ${p.session_rares}/${p.total_rares} - ${kpr} - ${p.vt_state} - ${p.onlinetime} - ${p.deaths}/${p.total_deaths || 0} - ${p.prismatic_taper_count || 0} - `; - - // Color the metastate pill according to its value - const metaSpan = li.querySelector('.stat.meta'); - if (metaSpan) { - const goodStates = ['default', 'default2', 'hunt', 'combat']; - const state = (p.vt_state || '').toString().toLowerCase(); - metaSpan.classList.remove('green', 'red'); // Clear previous - if (goodStates.includes(state)) { - metaSpan.classList.add('green'); - } else { - metaSpan.classList.add('red'); - } - } - - // Update selected state - if (p.character_name === selected) { - li.classList.add('selected'); - } else { - li.classList.remove('selected'); - } - }); - - // Remove unused elements (any elements left in the maps are unused) - // These are dots for players that are no longer in the current player list - dotsByPlayer.forEach((dot, playerName) => { - dots.removeChild(dot); - }); - - // These are list items for players that are no longer in the current player list - listItemsByPlayer.forEach((li, playerName) => { - list.removeChild(li); - }); - - // Update performance stats - performanceStats.lastRenderTime = performance.now() - startTime; - - // Determine optimization status - const totalCreatedThisRender = performanceStats.renderDotsCreated + performanceStats.renderListItemsCreated; - const totalReusedThisRender = performanceStats.renderDotsReused + performanceStats.renderListItemsReused; - const isOptimized = totalCreatedThisRender === 0 && totalReusedThisRender > 0; - const isPartiallyOptimized = totalCreatedThisRender < players.length && totalReusedThisRender > 0; - - // Choose icon and color - let statusIcon = '🚀'; - let colorStyle = ''; - if (isOptimized) { - statusIcon = '✨'; - colorStyle = 'background: #1a4a1a; color: #4ade80; font-weight: bold;'; - } else if (isPartiallyOptimized) { - statusIcon = '⚡'; - colorStyle = 'background: #4a3a1a; color: #fbbf24; font-weight: bold;'; - } else { - statusIcon = '🔥'; - colorStyle = 'background: #4a1a1a; color: #f87171; font-weight: bold;'; - } - - // Performance stats are tracked but not logged to keep console clean - // Optimization is achieving 100% element reuse consistently - - const renderTime = performance.now() - startTime; - debugLog('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms'); -} - -/* ---------- rendering trails ------------------------------- */ -function renderTrails(trailData) { - trailsContainer.innerHTML = ''; - // Build point strings directly - avoid intermediate arrays - const byChar = {}; - for (const pt of trailData) { - const { x, y } = worldToPx(pt.ew, pt.ns); - const key = pt.character_name; - if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 }; - else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; } - } - for (const name in byChar) { - if (byChar[name].count < 2) continue; - const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); - poly.setAttribute('points', byChar[name].points); - poly.setAttribute('stroke', getColorFor(name)); - poly.setAttribute('fill', 'none'); - poly.setAttribute('class', 'trail-path'); - trailsContainer.appendChild(poly); - } -} -/* ---------- selection centering, focus zoom & blink ------------ */ -function selectPlayer(p, x, y) { - selected = p.character_name; - // set focus zoom - scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM)); - // center on the player - const r = wrap.getBoundingClientRect(); - offX = r.width / 2 - x * scale; - offY = r.height / 2 - y * scale; - updateView(); - renderList(); // keep sorted + highlight -} - -/* - * ---------- Chat & Command WebSocket Handlers ------------------ - * Maintains a persistent WebSocket connection to the /ws/live endpoint - * for receiving chat messages and sending user commands to plugin clients. - * Reconnects automatically on close and logs errors. - */ -// Initialize WebSocket for chat and command streams -function initWebSocket() { - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`; - socket = new WebSocket(wsUrl); - socket.addEventListener('message', evt => { - let msg; - try { msg = JSON.parse(evt.data); } catch { return; } - if (msg.type === 'chat') { - appendChatMessage(msg); - } else if (msg.type === 'vitals') { - updateVitalsDisplay(msg); - } else if (msg.type === 'rare') { - triggerEpicRareNotification(msg.character_name, msg.name); - } else if (msg.type === 'character_stats') { - characterStats[msg.character_name] = msg; - updateCharacterWindow(msg.character_name, msg); - if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) { - renderInventoryState(inventoryWindows[msg.character_name]._inventoryState); - } - } else if (msg.type === 'equipment_cantrip_state') { - equipmentCantripStates[msg.character_name] = msg; - if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) { - renderInventoryState(inventoryWindows[msg.character_name]._inventoryState); - } - } else if (msg.type === 'inventory_delta') { - updateInventoryLive(msg); - } else if (msg.type === 'server_status') { - handleServerStatusUpdate(msg); - } else if (msg.type === 'nearby_objects') { - updateRadarWindow(msg); - } else if (msg.type === 'dungeon_map') { - if (msg.landblock) { - dungeonMapCache[msg.landblock] = msg; - // Update active radar window for this character - const rw = radarWindows[msg.character_name]; - if (rw) rw._radarDungeonLandblock = msg.landblock; - } - } else if (msg.type === 'combat_stats') { - liveCombatStats[msg.character_name] = msg; - updateCombatStatsWindows(msg.character_name); - } else if (msg.type === 'share_peer_removed') { - removeVitalSharingPeer(msg.character_name); - } else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) { - updateVitalSharingPeer(msg); - } - }); - socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); - socket.addEventListener('error', e => handleError('WebSocket', e)); -} - -// Display or create a chat window for a character -function showChatWindow(name) { - debugLog('showChatWindow called for:', name); - const windowId = `chatWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Chat: ${name}`, 'chat-window' - ); - - if (!isNew) { - debugLog('Existing chat window found, showing it'); - return; - } - - win.dataset.character = name; - chatWindows[name] = win; - - // Messages container - const msgs = document.createElement('div'); - msgs.className = 'chat-messages'; - content.appendChild(msgs); - - // Input form - const form = document.createElement('form'); - form.className = 'chat-form'; - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'chat-input'; - input.placeholder = 'Enter chat...'; - form.appendChild(input); - form.addEventListener('submit', e => { - e.preventDefault(); - const text = input.value.trim(); - if (!text) return; - // Send command envelope: player_name and command only - socket.send(JSON.stringify({ player_name: name, command: text })); - input.value = ''; - }); - content.appendChild(form); - - debugLog('Chat window created for:', name); -} - -// Append a chat message to the correct window -/** - * Append a chat message to the correct window, optionally coloring the text. - * msg: { type: 'chat', character_name, text, color? } - */ -function appendChatMessage(msg) { - const { character_name: name, text, color } = msg; - const win = chatWindows[name]; - if (!win) return; - const msgs = win.querySelector('.chat-messages'); - const p = document.createElement('div'); - if (color !== undefined) { - let c = color; - if (typeof c === 'number') { - // map numeric chat code to configured color, or fallback to raw hex - if (CHAT_COLOR_MAP.hasOwnProperty(c)) { - c = CHAT_COLOR_MAP[c]; - } else { - c = '#' + c.toString(16).padStart(6, '0'); - } - } - p.style.color = c; - } - p.textContent = text; - msgs.appendChild(p); - // Enforce max number of lines in scrollback - while (msgs.children.length > MAX_CHAT_LINES) { - msgs.removeChild(msgs.firstChild); - } - // Scroll to bottom - msgs.scrollTop = msgs.scrollHeight; -} - -/* ---------- pan & zoom handlers -------------------------------- */ -wrap.addEventListener('wheel', e => { - e.preventDefault(); - if (!imgW) return; - - const r = wrap.getBoundingClientRect(); - const mx = (e.clientX - r.left - offX) / scale; - const my = (e.clientY - r.top - offY) / scale; - const factor = e.deltaY > 0 ? 0.9 : 1.1; - let ns = scale * factor; - ns = Math.max(minScale, Math.min(MAX_Z, ns)); - - offX -= mx * (ns - scale); - offY -= my * (ns - scale); - scale = ns; - scheduleViewUpdate(); -}, { passive: false }); - -wrap.addEventListener('mousedown', e => { - dragging = true; sx = e.clientX; sy = e.clientY; - wrap.classList.add('dragging'); -}); -window.addEventListener('mousemove', e => { - if (!dragging) return; - offX += e.clientX - sx; offY += e.clientY - sy; - sx = e.clientX; sy = e.clientY; - scheduleViewUpdate(); -}); -window.addEventListener('mouseup', () => { - dragging = false; wrap.classList.remove('dragging'); -}); - -wrap.addEventListener('touchstart', e => { - if (e.touches.length !== 1) return; - dragging = true; - sx = e.touches[0].clientX; sy = e.touches[0].clientY; -}); -wrap.addEventListener('touchmove', e => { - if (!dragging || e.touches.length !== 1) return; - const t = e.touches[0]; - offX += t.clientX - sx; offY += t.clientY - sy; - sx = t.clientX; sy = t.clientY; - scheduleViewUpdate(); -}); -wrap.addEventListener('touchend', () => { - dragging = false; -}); - -/* ---------- coordinate display on hover ---------------------------- */ -wrap.addEventListener('mousemove', e => { - if (!imgW) return; - - const r = wrap.getBoundingClientRect(); - const x = e.clientX - r.left; - const y = e.clientY - r.top; - - const { ew, ns } = pxToWorld(x, y); - - // Display coordinates using the same format as the existing loc function - coordinates.textContent = loc(ns, ew); - coordinates.style.left = `${x + 10}px`; - coordinates.style.top = `${y + 10}px`; - coordinates.style.display = 'block'; -}); - -wrap.addEventListener('mouseleave', () => { - coordinates.style.display = 'none'; -}); - -/* ---------- vitals display functions ----------------------------- */ -// Store vitals data per character -const characterVitals = {}; -const characterStats = {}; -const characterWindows = {}; - -function updateVitalsDisplay(vitalsMsg) { - // Store the vitals data for this character - characterVitals[vitalsMsg.character_name] = { - health_percentage: vitalsMsg.health_percentage, - stamina_percentage: vitalsMsg.stamina_percentage, - mana_percentage: vitalsMsg.mana_percentage, - health_current: vitalsMsg.health_current, - health_max: vitalsMsg.health_max, - stamina_current: vitalsMsg.stamina_current, - stamina_max: vitalsMsg.stamina_max, - mana_current: vitalsMsg.mana_current, - mana_max: vitalsMsg.mana_max, - vitae: vitalsMsg.vitae - }; - - // Re-render the player list to update vitals in the UI - renderList(); - - // Also update character window if open - updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]); -} - -function createVitalsHTML(characterName) { - const vitals = characterVitals[characterName]; - if (!vitals) { - return ''; // No vitals data available - } - - return ` -
-
-
-
-
-
-
-
-
-
-
- `; -} - -function createVitaeIndicator(characterName) { - const vitals = characterVitals[characterName]; - if (!vitals || !vitals.vitae || vitals.vitae >= 100) { - return ''; // No vitae penalty - } - - return `⚰️ ${vitals.vitae}%`; -} - -function getVitalClass(percentage) { - if (percentage <= 25) { - return 'critical-vital'; - } else if (percentage <= 50) { - return 'low-vital'; - } - return ''; -} - -/* ---------- epic rare notification system ------------------------ */ -// Track previous rare count to detect increases -let lastRareCount = 0; -let notificationQueue = []; -let isShowingNotification = false; - -function triggerEpicRareNotification(characterName, rareName) { - // Add to queue - notificationQueue.push({ characterName, rareName }); - - // Process queue if not already showing a notification - if (!isShowingNotification) { - processNotificationQueue(); - } - - // Trigger fireworks immediately - createFireworks(); - - // Highlight the player in the list - highlightRareFinder(characterName); -} - -function processNotificationQueue() { - if (notificationQueue.length === 0) { - isShowingNotification = false; - return; - } - - isShowingNotification = true; - const notification = notificationQueue.shift(); - - // Create notification element - const container = document.getElementById('rareNotifications'); - const notifEl = document.createElement('div'); - notifEl.className = 'rare-notification'; - notifEl.innerHTML = ` -
🎆 LEGENDARY RARE! 🎆
-
${notification.rareName}
-
found by
-
⚔️ ${notification.characterName} ⚔️
- `; - - container.appendChild(notifEl); - - // Remove notification after display duration and process next - setTimeout(() => { - notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards'; - setTimeout(() => { - notifEl.remove(); - processNotificationQueue(); - }, 500); - }, NOTIFICATION_DURATION_MS); -} - -// Add slide out animation -const style = document.createElement('style'); -style.textContent = ` - @keyframes notification-slide-out { - to { - transform: translateY(-100px); - opacity: 0; - } - } -`; -document.head.appendChild(style); - -function createFireworks() { - const container = document.getElementById('fireworksContainer'); - const rareCounter = document.getElementById('totalRaresCounter'); - const rect = rareCounter.getBoundingClientRect(); - - // Create 30 particles - const particleCount = 30; - const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue']; - - for (let i = 0; i < particleCount; i++) { - const particle = document.createElement('div'); - particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`; - - // Start position at rare counter - particle.style.left = `${rect.left + rect.width / 2}px`; - particle.style.top = `${rect.top + rect.height / 2}px`; - - // Random explosion direction - const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5; - const velocity = 100 + Math.random() * 200; - const dx = Math.cos(angle) * velocity; - const dy = Math.sin(angle) * velocity - 50; // Slight upward bias - - // Create custom animation for this particle - const animName = `particle-${Date.now()}-${i}`; - const keyframes = ` - @keyframes ${animName} { - 0% { - transform: translate(0, 0) scale(1); - opacity: 1; - } - 100% { - transform: translate(${dx}px, ${dy + 200}px) scale(0); - opacity: 0; - } - } - `; - - const styleEl = document.createElement('style'); - styleEl.textContent = keyframes; - document.head.appendChild(styleEl); - - particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`; - - container.appendChild(particle); - - // Clean up particle and animation after completion - setTimeout(() => { - particle.remove(); - styleEl.remove(); - }, 2000); - } -} - -function highlightRareFinder(characterName) { - // Use element pool for O(1) lookup instead of querySelectorAll - for (const item of elementPools.activeListItems) { - if (item.playerData && item.playerData.character_name === characterName) { - item.classList.add('rare-finder-glow'); - setTimeout(() => { - item.classList.remove('rare-finder-glow'); - }, GLOW_DURATION_MS); - break; - } - } -} - -// Update total rares display to trigger fireworks on increase -const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay; -updateTotalRaresDisplay = function(data) { - originalUpdateTotalRaresDisplay(data); - - // Check if total increased - const newTotal = data.all_time || 0; - if (newTotal > lastRareCount && lastRareCount > 0) { - // Don't trigger on initial load - createFireworks(); - - // Check for milestones when count increases - if (newTotal > 0 && newTotal % 100 === 0) { - triggerMilestoneCelebration(newTotal); - } - } - lastRareCount = newTotal; -} - -function triggerMilestoneCelebration(rareNumber) { - debugLog(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`); - - // Create full-screen milestone overlay - const overlay = document.createElement('div'); - overlay.className = 'milestone-overlay'; - - overlay.innerHTML = ` -
-
#${rareNumber}
-
🏆 EPIC MILESTONE! 🏆
-
Server Achievement Unlocked
-
- `; - - document.body.appendChild(overlay); - - // Add screen shake effect - document.body.classList.add('screen-shake'); - - // Create massive firework explosion - createMilestoneFireworks(); - - // Remove milestone overlay after 5 seconds - setTimeout(() => { - overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse'; - document.body.classList.remove('screen-shake'); - setTimeout(() => { - overlay.remove(); - }, 500); - }, 5000); -} - -function createMilestoneFireworks() { - const container = document.getElementById('fireworksContainer'); - - // Create multiple bursts across the screen - const burstCount = 5; - const particlesPerBurst = 50; - const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00']; - - for (let burst = 0; burst < burstCount; burst++) { - setTimeout(() => { - // Random position for each burst - const x = Math.random() * window.innerWidth; - const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15); - - for (let i = 0; i < particlesPerBurst; i++) { - const particle = document.createElement('div'); - particle.className = 'milestone-particle'; - particle.style.background = colors[Math.floor(Math.random() * colors.length)]; - particle.style.boxShadow = `0 0 12px ${particle.style.background}`; - - // Start position - particle.style.left = `${x}px`; - particle.style.top = `${y}px`; - - // Random explosion direction - const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8; - const velocity = 200 + Math.random() * 300; - const dx = Math.cos(angle) * velocity; - const dy = Math.sin(angle) * velocity - 100; // Upward bias - - // Create custom animation - const animName = `milestone-particle-${Date.now()}-${burst}-${i}`; - const keyframes = ` - @keyframes ${animName} { - 0% { - transform: translate(0, 0) scale(1); - opacity: 1; - } - 100% { - transform: translate(${dx}px, ${dy + 400}px) scale(0); - opacity: 0; - } - } - `; - - const styleEl = document.createElement('style'); - styleEl.textContent = keyframes; - document.head.appendChild(styleEl); - - particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`; - - container.appendChild(particle); - - // Clean up - setTimeout(() => { - particle.remove(); - styleEl.remove(); - }, 3000); - } - }, burst * 200); // Stagger bursts - } -} - -/* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */ - -/** - * Opens the dedicated inventory search page in a new browser tab. - */ -function openInventorySearch() { - // Open the dedicated inventory search page in a new tab - window.open('/inventory.html', '_blank'); -} - -/** - * Opens the Suitbuilder interface in a new browser tab. - */ -function openSuitbuilder() { - // Open the Suitbuilder page in a new tab - window.open('/suitbuilder.html', '_blank'); -} - -/** - * Opens the Player Debug interface in a new browser tab. - */ -function openPlayerDebug() { - // Open the Player Debug page in a new tab - window.open('/debug.html', '_blank'); -} - -/** - * Opens the Quest Status interface in a new browser tab. - */ -function openQuestStatus() { - // Open the Quest Status page in a new tab - window.open('/quest-status.html', '_blank'); -} - -/** - * Opens the Player Dashboard interface in a new browser tab. - */ -function openPlayerDashboard() { - // Open the Player Dashboard page in a new tab - window.open('/player-dashboard.html', '_blank'); -} - -// ─── Radar Window ────────────────────────────────────────────────── - -const radarWindows = {}; // character_name -> window element -const dungeonMapCache = {}; // landblock hex string -> dungeon map data -let dungeonTileCanvases = null; // env_id -> processed offscreen canvas - -// UB default source colors (ARGB as signed int32 → R,G,B) -// These are the colors embedded in the tile images that get remapped -const UB_TILE_COLORS = { - walls: { r: 0, g: 0, b: 255 }, // -16777089 → #0000FF - innerWalls: { r: 127, g: 127, b: 255 }, // -8404993 → #7F7FFF - rampedWalls: { r: 77, g: 255, b: 255 }, // -11622657 → #4DFFFF (approx) - floors: { r: 0, g: 127, b: 255 }, // -16744513 → #007FFF - stairs: { r: 0, g: 63, b: 255 }, // -16760961 → #003FFF -}; - -// Target display colors (UB-like appearance on dark background) -const DUNGEON_DISPLAY_COLORS = { - walls: { r: 140, g: 140, b: 180 }, // light gray-blue walls - innerWalls: { r: 100, g: 100, b: 140 }, // darker inner walls - rampedWalls: { r: 120, g: 160, b: 120 }, // greenish ramps - floors: { r: 60, g: 80, b: 60 }, // dark green floors - stairs: { r: 180, g: 160, b: 80 }, // yellowish stairs -}; - -// Process a tile image: make white transparent, remap UB colors -function processTileImage(img) { - const c = document.createElement('canvas'); - c.width = 10; - c.height = 10; - const ctx = c.getContext('2d'); - ctx.drawImage(img, 0, 0, 10, 10); - const imageData = ctx.getImageData(0, 0, 10, 10); - const d = imageData.data; - - for (let i = 0; i < d.length; i += 4) { - const r = d[i], g = d[i+1], b = d[i+2]; - - // Make white (and near-white) transparent - if (r > 240 && g > 240 && b > 240) { - d[i+3] = 0; // alpha = 0 - continue; - } - - // Color remap: match source colors and replace with display colors - let matched = false; - for (const [key, src] of Object.entries(UB_TILE_COLORS)) { - if (Math.abs(r - src.r) < 15 && Math.abs(g - src.g) < 15 && Math.abs(b - src.b) < 15) { - const dst = DUNGEON_DISPLAY_COLORS[key]; - d[i] = dst.r; d[i+1] = dst.g; d[i+2] = dst.b; - matched = true; - break; - } - } - - // Make black semi-transparent (outlines) - if (!matched && r < 15 && g < 15 && b < 15) { - d[i+3] = 100; - } - } - - ctx.putImageData(imageData, 0, 0); - return c; -} - -// Load dungeon tile textures (614 tiles, ~287KB JSON) -function loadDungeonTiles() { - if (dungeonTileCanvases) return; // already loading/loaded - dungeonTileCanvases = {}; - fetch('dungeon_tiles.json') - .then(r => r.json()) - .then(data => { - let loaded = 0; - const total = Object.keys(data).length; - Object.entries(data).forEach(([envId, dataUrl]) => { - const img = new Image(); - img.onload = () => { - dungeonTileCanvases[envId] = processTileImage(img); - loaded++; - if (loaded === total) console.log(`Processed ${total} dungeon tiles`); - }; - img.src = dataUrl; - }); - }) - .catch(err => console.warn('Failed to load dungeon tiles:', err)); -} - -const RADAR_COLORS = { - Monster: '#ff4444', - Player: '#4488ff', - NPC: '#44cc44', - Vendor: '#44cc44', - Portal: '#aa44ff', - Corpse: '#ff8800', - Container: '#cccc44', - Door: '#888888', -}; - -const RADAR_CANVAS_SIZE = 300; -const RADAR_DEFAULT_RANGE = 0.5; // AC coordinate units (~120m) - -function showRadarWindow(name) { - const windowId = `radarWindow-${name}`; - - const { win, content, isNew } = createWindow( - windowId, `Radar: ${name}`, 'radar-window', { - onClose: () => { - // Send stop_radar command - if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ player_name: name, command: 'stop_radar' })); - } - delete radarWindows[name]; - } - } - ); - - if (!isNew) { - // Window was hidden, not destroyed — re-register and restart streaming - radarWindows[name] = win; - if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ player_name: name, command: 'start_radar' })); - } - return; - } - - radarWindows[name] = win; - win.dataset.character = name; - - // Radar controls - const controls = document.createElement('div'); - controls.className = 'radar-controls'; - - const rangeDisplay = document.createElement('span'); - rangeDisplay.className = 'radar-range-display'; - rangeDisplay.textContent = 'Range: ~120m'; - controls.appendChild(rangeDisplay); - - const zoomHint = document.createElement('span'); - zoomHint.className = 'radar-zoom-hint'; - zoomHint.textContent = 'Scroll to zoom'; - controls.appendChild(zoomHint); - - content.appendChild(controls); - - // Canvas - const canvas = document.createElement('canvas'); - canvas.className = 'radar-canvas'; - canvas.width = RADAR_CANVAS_SIZE; - canvas.height = RADAR_CANVAS_SIZE; - content.appendChild(canvas); - - // Entity list - const listContainer = document.createElement('div'); - listContainer.className = 'radar-entity-list'; - - const listHeader = document.createElement('div'); - listHeader.className = 'radar-entity-header'; - listHeader.innerHTML = 'NameTypeDistDir'; - listContainer.appendChild(listHeader); - - const listBody = document.createElement('div'); - listBody.className = 'radar-entity-body'; - listContainer.appendChild(listBody); - content.appendChild(listContainer); - - // Load map image for radar background - const radarMapImg = new Image(); - radarMapImg.src = 'dereth.png'; - - // Store refs on the window - win._radarCanvas = canvas; - win._radarListBody = listBody; - win._radarRange = RADAR_DEFAULT_RANGE; - win._radarRangeDisplay = rangeDisplay; - win._radarSelectedId = null; - win._radarLastObjects = []; // cache for click hit-testing - win._radarMapImg = radarMapImg; - win._radarIsDungeon = false; - win._radarDungeonLandblock = null; - - // Scroll-wheel zoom on canvas - canvas.addEventListener('wheel', (e) => { - e.preventDefault(); - const factor = e.deltaY > 0 ? 1.25 : 0.8; - win._radarRange = Math.max(0.02, Math.min(5.0, win._radarRange * factor)); - const meters = Math.round(win._radarRange * 240); - win._radarRangeDisplay.textContent = `Range: ~${meters}m`; - }); - - // Click canvas to select nearest object - canvas.addEventListener('click', (e) => { - const rect = canvas.getBoundingClientRect(); - const mx = (e.clientX - rect.left) * (canvas.width / rect.width); - const my = (e.clientY - rect.top) * (canvas.height / rect.height); - // Find closest rendered dot - let closest = null; - let closestDist = 20; // max pixel distance to select - (win._radarLastObjects || []).forEach(obj => { - if (obj._px === undefined) return; - const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py) ** 2); - if (d < closestDist) { closestDist = d; closest = obj; } - }); - win._radarSelectedId = closest ? closest.id : null; - }); - - // Load dungeon tiles on first radar open - loadDungeonTiles(); - - // Send start_radar command - if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ player_name: name, command: 'start_radar' })); - } -} - -function updateRadarWindow(msg) { - const name = msg.character_name; - const win = radarWindows[name]; - if (!win || win.style.display === 'none') return; - - const canvas = win._radarCanvas; - const listBody = win._radarListBody; - const range = win._radarRange || RADAR_DEFAULT_RANGE; - const selectedId = win._radarSelectedId; - const objects = msg.objects || []; - - const playerEW = msg.player_ew; - const playerNS = msg.player_ns; - const playerHeading = msg.player_heading || 0; - const isDungeon = msg.is_dungeon || false; - const landblock = msg.landblock || null; - const playerX = msg.player_x || 0; // raw physics X (dungeon) - const playerY = msg.player_y || 0; // raw physics Y (dungeon) - const playerRawZ = msg.player_raw_z || 0; - - // Update dungeon state - win._radarIsDungeon = isDungeon; - if (isDungeon && landblock) { - win._radarDungeonLandblock = landblock; - // Request dungeon map if not cached - if (!dungeonMapCache[landblock] && socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ type: 'request_dungeon_map', landblock })); - } - } - - // ─── Draw radar canvas ─── - const ctx = canvas.getContext('2d'); - const size = RADAR_CANVAS_SIZE; - const cx = size / 2; - const cy = size / 2; - // In dungeons, raw coords are game units; range is AC units (1 AC unit = 240 game units) - const scale = isDungeon ? (size / 2) / (range * 240) : (size / 2) / range; - - ctx.clearRect(0, 0, size, size); - - // Background - ctx.fillStyle = '#111'; - ctx.beginPath(); - ctx.arc(cx, cy, cx, 0, Math.PI * 2); - ctx.fill(); - - // Background: overworld map or dungeon cells - ctx.save(); - ctx.beginPath(); - ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2); - ctx.clip(); - - if (isDungeon && landblock && dungeonMapCache[landblock]) { - // ─── Dungeon cell background with tile textures ─── - const dmap = dungeonMapCache[landblock]; - const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6; - ctx.translate(cx, cy); - // UB applies (heading - 180) rotation for dungeons (line 1013 DungeonMaps.cs) - // The layer has Y increasing downward; the 180° offset flips N/S - ctx.rotate(-(playerHeading - 180) * Math.PI / 180); - const cellSize = 10 * scale; // each cell is 10 game units - const hasTiles = dungeonTileCanvases && Object.keys(dungeonTileCanvases).length > 0; - - // Normalize rotation value to radians (exact copy of UB's DungeonCell.cs) - function cellRotation(rot) { - if (rot === 1) return Math.PI; - if (rot < -0.70 && rot > -0.8) return Math.PI / 2; - if (rot > 0.70 && rot < 0.8) return -Math.PI / 2; - return 0; - } - - // Draw non-current floors first (dimmed), then current floor on top - const sortedLevels = (dmap.z_levels || []).slice().sort((a, b) => { - const aCur = a.z === playerRoundedZ ? 1 : 0; - const bCur = b.z === playerRoundedZ ? 1 : 0; - return aCur - bCur; // current floor drawn last (on top) - }); - - sortedLevels.forEach(level => { - const isCurrentFloor = (level.z === playerRoundedZ); - ctx.globalAlpha = isCurrentFloor ? 0.85 : 0.12; - - (level.cells || []).forEach(cell => { - // X mirrored (matches UB's layer rendering), Y direct - const dx = -(cell.x - playerX) * scale; - const dy = (cell.y - playerY) * scale; - const tileCanvas = hasTiles ? dungeonTileCanvases[String(cell.env_id)] : null; - - if (tileCanvas) { - // Draw processed tile with per-cell rotation - ctx.save(); - ctx.translate(dx, dy); - ctx.rotate(cellRotation(cell.rotation)); - ctx.drawImage(tileCanvas, -cellSize / 2, -cellSize / 2, cellSize, cellSize); - ctx.restore(); - } else { - // Fallback: colored rectangle - ctx.fillStyle = isCurrentFloor ? '#3a5a3a' : '#1a2a1a'; - ctx.fillRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize); - } - }); - }); - ctx.globalAlpha = 1.0; - ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform - } else if (!isDungeon) { - // ─── Overworld map background ─── - const mapImg = win._radarMapImg; - if (mapImg && mapImg.complete && mapImg.naturalWidth) { - const mapCoordRange = 204.2; - const pixPerCoord = mapImg.naturalWidth / mapCoordRange; - const mapPx = (playerEW + 102.1) * pixPerCoord; - const mapPy = (102.1 - playerNS) * pixPerCoord; - ctx.translate(cx, cy); - ctx.rotate(-playerHeading * Math.PI / 180); - ctx.globalAlpha = 0.4; - const srcSize = range * pixPerCoord * 2; - ctx.drawImage(mapImg, - mapPx - srcSize / 2, mapPy - srcSize / 2, srcSize, srcSize, - -cx, -cy, size, size - ); - ctx.globalAlpha = 1.0; - ctx.setTransform(1, 0, 0, 1, 0, 0); - } - } - ctx.restore(); - - // Range rings - ctx.strokeStyle = '#333'; - ctx.lineWidth = 1; - for (let i = 1; i <= 4; i++) { - ctx.beginPath(); - ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2); - ctx.stroke(); - } - - // Crosshairs - ctx.strokeStyle = '#333'; - ctx.beginPath(); - ctx.moveTo(cx, 0); ctx.lineTo(cx, size); - ctx.moveTo(0, cy); ctx.lineTo(size, cy); - ctx.stroke(); - - // Always heading-up: player facing direction = up on canvas. - // Compass labels rotate around the edge based on player heading. - // AC heading: 0=N, 90=E, 180=S, 270=W (standard clockwise) - const headingRad = playerHeading * Math.PI / 180; - const compassLabels = [ - { label: 'N', angle: 0 }, - { label: 'E', angle: Math.PI / 2 }, - { label: 'S', angle: Math.PI }, - { label: 'W', angle: -Math.PI / 2 }, - ]; - ctx.font = 'bold 12px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - compassLabels.forEach(({ label, angle }) => { - // Rotate compass direction by negative heading so labels move as player turns - const a = angle - headingRad; - const lx = cx + Math.sin(a) * (cx - 12); - const ly = cy - Math.cos(a) * (cx - 12); - ctx.fillStyle = label === 'N' ? '#cc4444' : '#888'; - ctx.fillText(label, lx, ly); - }); - - // Facing direction indicator (always points up from center) - ctx.strokeStyle = '#666'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(cx, cy); - ctx.lineTo(cx, cy - cx * 0.85); - ctx.stroke(); - - // Rotate world by heading so player facing = up on canvas - // In dungeons, use (PI - heading) to match UB's canvas rotation - const rotAngle = isDungeon ? (Math.PI - headingRad) : headingRad; - - // Draw objects and cache positions for click hit-testing - const cosA = Math.cos(rotAngle); - const sinA = Math.sin(rotAngle); - objects.forEach(obj => { - // Use raw physics coords in dungeons (X mirrored), EW/NS on surface - let dX, dY; - if (isDungeon && obj.raw_x !== undefined) { - dX = -(obj.raw_x - playerX); // X mirrored to match tile space - dY = (obj.raw_y - playerY); // Y direct (180° heading offset handles N/S) - } else { - dX = obj.ew - playerEW; - dY = obj.ns - playerNS; - } - - const dx = dX * cosA - dY * sinA; - // In dungeons, don't negate dy (180° heading offset handles N/S) - // In overworld, negate for north-up - const dy = isDungeon ? (dX * sinA + dY * cosA) : -(dX * sinA + dY * cosA); - - const px = cx + dx * scale; - const py = cy + dy * scale; - - // Store pixel position for click hit-testing - obj._px = px; - obj._py = py; - - // Clip to circle - const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2); - if (distFromCenter > cx - 4) return; - - const color = RADAR_COLORS[obj.object_class] || '#999'; - const isSelected = obj.id === selectedId; - const dotSize = isSelected ? 6 : ((obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3); - - // Selection ring - if (isSelected) { - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2); - ctx.stroke(); - } - - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(px, py, dotSize, 0, Math.PI * 2); - ctx.fill(); - - // Label for players, portals, and selected object - if (obj.object_class === 'Player' || obj.object_class === 'Portal' || isSelected) { - ctx.fillStyle = isSelected ? '#fff' : color; - ctx.font = '9px monospace'; - ctx.textAlign = 'left'; - ctx.fillText(obj.name, px + 6, py + 3); - } - }); - win._radarLastObjects = objects; - - // Player dot (center) - ctx.fillStyle = '#ffcc00'; - ctx.beginPath(); - ctx.arc(cx, cy, 5, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 1; - ctx.stroke(); - - // ─── Update entity list ─── - // Sort by distance - const withDist = objects.map(obj => { - let distMeters, dX, dY; - if (isDungeon && obj.raw_x !== undefined) { - dX = -(obj.raw_x - playerX); // X mirrored - dY = (obj.raw_y - playerY); // Y direct - distMeters = Math.sqrt(dX * dX + dY * dY); // raw coords are ~meters - } else { - dX = obj.ew - playerEW; - dY = obj.ns - playerNS; - distMeters = Math.sqrt(dX * dX + dY * dY) * 240; - } - - // Compass direction - const angle = Math.atan2(dX, dY) * 180 / Math.PI; - const dir = compassDir(angle); - - return { ...obj, distMeters, dir }; - }); - withDist.sort((a, b) => a.distMeters - b.distMeters); - - // Rebuild list with selection support - listBody.innerHTML = ''; - let selectedRow = null; - withDist.forEach(obj => { - const row = document.createElement('div'); - row.className = 'radar-entity-row'; - if (obj.id === selectedId) { - row.classList.add('radar-entity-selected'); - selectedRow = row; - } - - // Click row to select on canvas - row.addEventListener('click', () => { - win._radarSelectedId = (win._radarSelectedId === obj.id) ? null : obj.id; - }); - - const colorDot = document.createElement('span'); - colorDot.className = 're-color'; - colorDot.style.background = RADAR_COLORS[obj.object_class] || '#999'; - row.appendChild(colorDot); - - const nameSpan = document.createElement('span'); - nameSpan.className = 're-name'; - nameSpan.textContent = obj.name; - row.appendChild(nameSpan); - - const typeSpan = document.createElement('span'); - typeSpan.className = 're-type'; - typeSpan.textContent = obj.object_class; - row.appendChild(typeSpan); - - const distSpan = document.createElement('span'); - distSpan.className = 're-dist'; - distSpan.textContent = obj.distMeters < 1000 - ? `${Math.round(obj.distMeters)}m` - : `${(obj.distMeters / 1000).toFixed(1)}km`; - row.appendChild(distSpan); - - const dirSpan = document.createElement('span'); - dirSpan.className = 're-dir'; - dirSpan.textContent = obj.dir; - row.appendChild(dirSpan); - - listBody.appendChild(row); - }); - - // Scroll selected row into view - if (selectedRow) selectedRow.scrollIntoView({ block: 'nearest' }); -} - -function compassDir(angleDeg) { - // angleDeg: 0 = N, 90 = E, -90 = W, 180 = S - const a = ((angleDeg % 360) + 360) % 360; - const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - return dirs[Math.round(a / 45) % 8]; -} - -// ─── Version Display ──────────────────────────────────────────── - -fetch('/api-version').then(r => r.json()).then(d => { - const el = document.getElementById('versionDisplay'); - if (el) el.textContent = 'v' + d.version; -}).catch(() => {}); - -// ─── Current User Info ────────────────────────────────────────── -let _currentUser = null; - -fetch('/me').then(r => { - if (!r.ok) throw new Error('not authenticated'); - return r.json(); -}).then(data => { - _currentUser = data; - const userInfo = document.getElementById('userInfo'); - const nameEl = document.getElementById('currentUsername'); - const adminLink = document.getElementById('adminLink'); - if (userInfo && nameEl) { - nameEl.textContent = data.username; - userInfo.style.display = 'flex'; - } - if (adminLink && data.is_admin) { - adminLink.style.display = 'inline'; - } -}).catch(() => {}); - -// ─── Issues Board ─────────────────────────────────────────────── - -const ISSUE_CATEGORIES = { - plugin: { label: 'Plugin', color: '#8844cc' }, - overlord: { label: 'Overlord', color: '#4488cc' }, - nav: { label: 'Nav', color: '#44aa44' }, - macro: { label: 'Macro', color: '#cc8844' }, - other: { label: 'Other', color: '#888888' }, -}; - -function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - -function showIssuesWindow() { - const { win, content, isNew } = createWindow( - 'issuesWindow', 'Issues Board', 'issues-window' - ); - - if (!isNew) { - refreshIssuesList(win); - return; - } - - win.style.width = '540px'; - win.style.height = '520px'; - - // Issues list container - const listDiv = document.createElement('div'); - listDiv.className = 'issues-list'; - content.appendChild(listDiv); - win._issuesList = listDiv; - - // Add issue form - const form = document.createElement('div'); - form.className = 'issues-form'; - form.innerHTML = ` -
- - -
-
- - -
- `; - content.appendChild(form); - - // Add button handler - form.querySelector('#issueAddBtn').addEventListener('click', async () => { - const title = document.getElementById('issueTitle').value.trim(); - const desc = document.getElementById('issueDescription').value.trim(); - const cat = document.getElementById('issueCategory').value; - if (!title) return; - - await fetch('/issues', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, description: desc, category: cat }) - }); - document.getElementById('issueTitle').value = ''; - document.getElementById('issueDescription').value = ''; - refreshIssuesList(win); - }); - - refreshIssuesList(win); -} - -async function refreshIssuesList(win) { - const listDiv = win._issuesList; - if (!listDiv) return; - - try { - const resp = await fetch('/issues'); - const data = await resp.json(); - const issues = data.issues || []; - - if (issues.length === 0) { - listDiv.innerHTML = '
No open issues
'; - return; - } - - // Sort: unresolved first, then resolved - issues.sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0)); - - listDiv.innerHTML = ''; - issues.forEach(issue => { - const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; - const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : ''; - const isResolved = !!issue.resolved; - const author = issue.author || 'User'; - const comments = issue.comments || []; - - const row = document.createElement('div'); - row.className = 'issue-row' + (isResolved ? ' issue-resolved' : ''); - - // Header line - const headerDiv = document.createElement('div'); - headerDiv.className = 'issue-header'; - headerDiv.innerHTML = ` - ${cat.label} - ${escapeHtml(issue.title)} - by ${escapeHtml(author)} - ${date} - `; - row.appendChild(headerDiv); - - // Description - if (issue.description) { - const descDiv = document.createElement('div'); - descDiv.className = 'issue-description'; - descDiv.textContent = issue.description; - row.appendChild(descDiv); - } - - // Action buttons - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'issue-actions'; - - if (isResolved) { - const reopenBtn = document.createElement('button'); - reopenBtn.textContent = '\u21BA Reopen'; - reopenBtn.className = 'issue-reopen-btn'; - reopenBtn.addEventListener('click', async () => { - await fetch(`/issues/${issue.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ resolved: false }) - }); - refreshIssuesList(win); - }); - actionsDiv.appendChild(reopenBtn); - - const deleteBtn = document.createElement('button'); - deleteBtn.textContent = '\uD83D\uDDD1 Delete'; - deleteBtn.className = 'issue-delete-btn'; - deleteBtn.addEventListener('click', async () => { - if (!confirm(`Delete issue "${issue.title}"?`)) return; - await fetch(`/issues/${issue.id}`, { method: 'DELETE' }); - refreshIssuesList(win); - }); - actionsDiv.appendChild(deleteBtn); - } else { - const resolveBtn = document.createElement('button'); - resolveBtn.textContent = '\u2713 Resolve'; - resolveBtn.className = 'issue-resolve-btn'; - resolveBtn.addEventListener('click', async () => { - await fetch(`/issues/${issue.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ resolved: true }) - }); - refreshIssuesList(win); - }); - actionsDiv.appendChild(resolveBtn); - } - - // Edit button - const editBtn = document.createElement('button'); - editBtn.textContent = '\u270E Edit'; - editBtn.className = 'issue-edit-btn'; - editBtn.addEventListener('click', () => { - showEditIssueForm(row, issue, win); - }); - actionsDiv.appendChild(editBtn); - - row.appendChild(actionsDiv); - - // Comments section — always visible inline - showCommentsSection(row, issue, win); - - listDiv.appendChild(row); - }); - } catch (err) { - listDiv.innerHTML = '
Failed to load issues
'; - } -} - -function showEditIssueForm(row, issue, win) { - // Remove any existing edit form - const existing = row.querySelector('.issue-edit-form'); - if (existing) { existing.remove(); return; } - - const cat = issue.category || 'other'; - const form = document.createElement('div'); - form.className = 'issue-edit-form'; - form.innerHTML = ` -
- - -
-
- -
- - -
-
- `; - - form.querySelector('.edit-save-btn').addEventListener('click', async () => { - const title = form.querySelector('.edit-title').value.trim(); - const desc = form.querySelector('.edit-description').value.trim(); - const category = form.querySelector('.edit-category').value; - if (!title) return; - await fetch(`/issues/${issue.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, description: desc, category }) - }); - refreshIssuesList(win); - }); - - form.querySelector('.edit-cancel-btn').addEventListener('click', () => { - form.remove(); - }); - - row.appendChild(form); -} - -function showCommentsSection(row, issue, win) { - const section = document.createElement('div'); - section.className = 'issue-comments-section'; - - const comments = issue.comments || []; - - // Render existing comments - const commentsListDiv = document.createElement('div'); - commentsListDiv.className = 'issue-comments-list'; - if (comments.length === 0) { - commentsListDiv.innerHTML = '
No comments yet
'; - } else { - comments.forEach(c => { - const cDiv = document.createElement('div'); - cDiv.className = 'issue-comment'; - const cDate = c.created ? new Date(c.created).toLocaleDateString('sv-SE') : ''; - cDiv.innerHTML = `${escapeHtml(c.author || 'Anonymous')} ${cDate}`; - const textDiv = document.createElement('div'); - textDiv.className = 'comment-text'; - textDiv.textContent = c.text; - cDiv.appendChild(textDiv); - commentsListDiv.appendChild(cDiv); - }); - } - section.appendChild(commentsListDiv); - - // Add comment form - const addDiv = document.createElement('div'); - addDiv.className = 'issue-comment-form'; - addDiv.innerHTML = ` -
- - -
- `; - - addDiv.querySelector('.comment-add-btn').addEventListener('click', async () => { - const text = addDiv.querySelector('.comment-text-input').value.trim(); - if (!text) return; - await fetch(`/issues/${issue.id}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }) - }); - refreshIssuesList(win); - }); - - section.appendChild(addDiv); - row.appendChild(section); -} - -// ─── Vital Sharing NetworkUI ─────────────────────────────────────────────── -// Live state fed by share_* WebSocket messages AND by /vital-sharing/peers -// polling. Keyed by character_name. -const vitalSharingPeers = {}; -let _vitalSharingPollTimer = null; - -function removeVitalSharingPeer(name) { - if (!name) return; - if (vitalSharingPeers[name]) { - delete vitalSharingPeers[name]; - renderVitalSharingWindow(); - } -} - -function updateVitalSharingPeer(msg) { - const name = msg.character_name; - if (!name) return; - const entry = vitalSharingPeers[name] || (vitalSharingPeers[name] = { - character_name: name, - tags: [], - vitals: null, - position: null, - last_update: null, - connected: true, - }); - entry.last_update = msg.timestamp || new Date().toISOString(); - if (Array.isArray(msg.tags)) entry.tags = msg.tags; - if (msg.type === 'share_vital_update') { - entry.vitals = { - current_health: msg.current_health, - max_health: msg.max_health, - current_stamina: msg.current_stamina, - max_stamina: msg.max_stamina, - current_mana: msg.current_mana, - max_mana: msg.max_mana, - }; - } else if (msg.type === 'share_position_update') { - entry.position = { - ew: msg.ew, ns: msg.ns, z: msg.z, heading: msg.heading, - }; - } - renderVitalSharingWindow(); -} - -function showVitalSharingWindow() { - const { win, content, isNew } = createWindow( - 'vitalSharingWindow', 'Vital Sharing Network', 'issues-window', - { onClose: () => { - if (_vitalSharingPollTimer) { - clearInterval(_vitalSharingPollTimer); - _vitalSharingPollTimer = null; - } - } - } - ); - - if (!isNew) { - refreshVitalSharingPeers(); - return; - } - - win.style.width = '520px'; - win.style.height = '500px'; - - const listDiv = document.createElement('div'); - listDiv.className = 'vital-sharing-list'; - listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;'; - content.appendChild(listDiv); - win._vitalSharingList = listDiv; - - refreshVitalSharingPeers(); - if (_vitalSharingPollTimer) clearInterval(_vitalSharingPollTimer); - _vitalSharingPollTimer = setInterval(refreshVitalSharingPeers, 5000); -} - -async function refreshVitalSharingPeers() { - try { - const resp = await fetch('/vital-sharing/peers'); - if (!resp.ok) return; - const data = await resp.json(); - const serverNames = new Set(); - (data.peers || []).forEach(p => { - serverNames.add(p.character_name); - const existing = vitalSharingPeers[p.character_name] || {}; - vitalSharingPeers[p.character_name] = { ...existing, ...p }; - }); - // Prune any local entries the server no longer knows about (unsubscribed - // or disconnected). This catches any race where the share_peer_removed - // broadcast didn't arrive. - Object.keys(vitalSharingPeers).forEach(name => { - if (!serverNames.has(name)) delete vitalSharingPeers[name]; - }); - } catch (e) { - // ignore transient errors - } - renderVitalSharingWindow(); -} - -function renderVitalSharingWindow() { - const win = document.getElementById('vitalSharingWindow'); - if (!win || win.style.display === 'none') return; - const listDiv = win._vitalSharingList; - if (!listDiv) return; - - const peers = Object.values(vitalSharingPeers).sort((a, b) => - (a.character_name || '').localeCompare(b.character_name || '') - ); - - if (peers.length === 0) { - listDiv.innerHTML = '
No vital-sharing peers connected
'; - return; - } - - listDiv.innerHTML = ''; - peers.forEach(p => { - const row = document.createElement('div'); - row.className = 'vital-sharing-peer'; - row.style.cssText = 'border:1px solid #444;background:#1f1f1f;padding:6px 8px;margin-bottom:5px;border-radius:3px;font-size:0.78rem;color:#ddd;'; - - const dot = p.plugin_connected - ? '' - : ''; - const subscribed = p.subscribed ? ' [subscribed]' : ''; - const tags = (p.tags || []).join(', ') || 'no tags'; - - let vitalsHtml = '(no vitals yet)'; - if (p.vitals && p.vitals.max_health) { - const v = p.vitals; - const pct = (cur, max) => max > 0 ? Math.max(0, Math.min(100, (cur / max) * 100)) : 0; - const bar = (label, cur, max, color) => ` -
- ${label} -
-
-
- ${cur ?? 0}/${max ?? 0} -
`; - vitalsHtml = - bar('HP', v.current_health, v.max_health, 'linear-gradient(90deg, #ff4444, #ff6666)') + - bar('STA', v.current_stamina, v.max_stamina, 'linear-gradient(90deg, #ffaa00, #ffcc44)') + - bar('MANA', v.current_mana, v.max_mana, 'linear-gradient(90deg, #4488ff, #66aaff)'); - } - - let posHtml = ''; - if (p.position && p.position.ew !== undefined) { - const fmt = (n) => (typeof n === 'number' ? n.toFixed(1) : n); - posHtml = `
- ${fmt(p.position.ns)}N, ${fmt(p.position.ew)}E z=${fmt(p.position.z)} -
`; - } - - row.innerHTML = ` -
- ${dot} - ${p.character_name} - ${subscribed} -
-
tags: ${tags}
- ${vitalsHtml} - ${posHtml} - `; - listDiv.appendChild(row); - }); -} - -// ─── Combat Stats (Mag-Tools style) ─────────────────────────────────────── -const liveCombatStats = {}; -const combatStatsWindows = {}; -const DAMAGE_ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric']; - -function showCombatStatsWindow(charName) { - // If called without a name, show a picker of all characters with data - if (!charName) { - const chars = Object.keys(liveCombatStats).sort(); - if (chars.length === 0) { - // Try fetching from API - fetch('/combat-stats').then(r => r.json()).then(data => { - if (data.stats && data.stats.length > 0) { - data.stats.forEach(s => { liveCombatStats[s.character_name] = s; }); - showCombatStatsPickerWindow(); - } else { - alert('No combat stats available yet. Kill some monsters first!'); - } - }); - return; - } - showCombatStatsPickerWindow(); - return; - } - - const windowId = `combatStatsWindow-${charName}`; - const { win, content, isNew } = createWindow(windowId, `Combat: ${charName}`, 'issues-window'); - - if (!isNew) { - renderCombatStatsContent(win, charName); - return; - } - - win.style.width = '620px'; - win.style.height = '560px'; - combatStatsWindows[charName] = win; - - // Session/Lifetime toggle - const controls = document.createElement('div'); - controls.style.cssText = 'padding:4px 8px;display:flex;gap:6px;align-items:center;border-bottom:1px solid #444;'; - const sessionBtn = document.createElement('button'); - sessionBtn.textContent = 'Session'; - sessionBtn.className = 'combat-stats-toggle active'; - sessionBtn.onclick = () => { win._combatMode = 'session'; sessionBtn.classList.add('active'); lifetimeBtn.classList.remove('active'); renderCombatStatsContent(win, charName); }; - const lifetimeBtn = document.createElement('button'); - lifetimeBtn.textContent = 'Lifetime'; - lifetimeBtn.className = 'combat-stats-toggle'; - lifetimeBtn.onclick = () => { win._combatMode = 'lifetime'; lifetimeBtn.classList.add('active'); sessionBtn.classList.remove('active'); renderCombatStatsContent(win, charName); }; - controls.appendChild(sessionBtn); - controls.appendChild(lifetimeBtn); - content.appendChild(controls); - - // Monster list (top) - const monsterDiv = document.createElement('div'); - monsterDiv.className = 'combat-monster-list'; - monsterDiv.style.cssText = 'height:180px;overflow-y:auto;border-bottom:1px solid #444;'; - content.appendChild(monsterDiv); - win._monsterList = monsterDiv; - - // Damage breakdown grid (bottom) - const breakdownDiv = document.createElement('div'); - breakdownDiv.className = 'combat-breakdown'; - breakdownDiv.style.cssText = 'flex:1;overflow-y:auto;padding:4px 6px;'; - content.appendChild(breakdownDiv); - win._breakdownGrid = breakdownDiv; - - win._combatMode = 'session'; - win._selectedMonster = null; // null = "All" - - renderCombatStatsContent(win, charName); -} - -function showCombatStatsPickerWindow() { - const { win, content, isNew } = createWindow('combatStatsPicker', 'Combat Stats — Select Character', 'issues-window'); - if (!isNew) { renderCombatStatsPicker(win); return; } - win.style.width = '320px'; - win.style.height = '400px'; - const listDiv = document.createElement('div'); - listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;'; - content.appendChild(listDiv); - win._pickerList = listDiv; - renderCombatStatsPicker(win); -} - -function renderCombatStatsPicker(win) { - const listDiv = win._pickerList; - if (!listDiv) return; - const chars = Object.keys(liveCombatStats).sort(); - listDiv.innerHTML = ''; - if (chars.length === 0) { - listDiv.innerHTML = '
No combat data yet
'; - return; - } - chars.forEach(name => { - const row = document.createElement('div'); - row.style.cssText = 'padding:4px 8px;cursor:pointer;border-bottom:1px solid #333;color:#ddd;font-size:0.85rem;'; - row.textContent = name; - row.onmouseenter = () => row.style.background = '#333'; - row.onmouseleave = () => row.style.background = ''; - row.onclick = () => { showCombatStatsWindow(name); }; - listDiv.appendChild(row); - }); -} - -function updateCombatStatsWindows(charName) { - const win = combatStatsWindows[charName]; - if (win && win.style.display !== 'none') { - renderCombatStatsContent(win, charName); - } -} - -function renderCombatStatsContent(win, charName) { - const data = liveCombatStats[charName]; - if (!data) return; - - const mode = win._combatMode || 'session'; - const stateData = data[mode]; - if (!stateData) return; - - const monsters = stateData.monsters || {}; - const monsterNames = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort(); - - // ── Monster list ── - const monsterDiv = win._monsterList; - if (monsterDiv) { - monsterDiv.innerHTML = ''; - - // Header row - const hdr = document.createElement('div'); - hdr.style.cssText = 'display:flex;gap:4px;padding:2px 6px;font-size:0.72rem;color:#888;border-bottom:1px solid #333;font-weight:bold;'; - hdr.innerHTML = `MonsterKillsDmg RecvDmg Given`; - monsterDiv.appendChild(hdr); - - // "All" row - const allRow = createMonsterRow('*', 'All', stateData.total_kills, stateData.total_damage_received, stateData.total_damage_given, win._selectedMonster === null); - allRow.onclick = () => { win._selectedMonster = null; renderCombatStatsContent(win, charName); }; - monsterDiv.appendChild(allRow); - - // Per-monster rows - monsterNames.forEach(name => { - const m = monsters[name]; - const selected = win._selectedMonster === name; - const row = createMonsterRow('', name, m.kill_count, m.damage_received, m.damage_given, selected); - row.onclick = () => { win._selectedMonster = name; renderCombatStatsContent(win, charName); }; - monsterDiv.appendChild(row); - }); - } - - // ── Breakdown grid ── - const grid = win._breakdownGrid; - if (!grid) return; - - // Gather stats for the selected monster or all - let offense = {}, defense = {}; - let totalAethSurges = 0, totalCloakSurges = 0; - - if (win._selectedMonster === null) { - // Aggregate all monsters - monsterNames.forEach(name => { - const m = monsters[name]; - mergeAttackStats(offense, m.offense || {}); - mergeAttackStats(defense, m.defense || {}); - totalAethSurges += m.aetheria_surges || 0; - totalCloakSurges += m.cloak_surges || 0; - }); - // Add cloak surges from the synthetic key - if (monsters['__cloak_surges__']) { - totalCloakSurges += monsters['__cloak_surges__'].cloak_surges || 0; - } - } else { - const m = monsters[win._selectedMonster]; - if (m) { - offense = m.offense || {}; - defense = m.defense || {}; - totalAethSurges = m.aetheria_surges || 0; - totalCloakSurges = m.cloak_surges || 0; - } - } - - // Compute aggregates for the right-side stats - const offAll = flattenStats(offense); - const defAll = flattenStats(defense); - const defMM = flattenStatsForType(defense, 'MeleeMissile'); - const defMag = flattenStatsForType(defense, 'Magic'); - - const totalAttacks = offAll.totalAttacks; - const failedAttacks = offAll.failedAttacks; - const hitRate = totalAttacks > 0 ? ((totalAttacks - failedAttacks) / totalAttacks * 100).toFixed(0) : '0'; - - const totalMeleeDefends = defMM.totalAttacks; - const totalEvades = defMM.failedAttacks; - const evadeRate = totalMeleeDefends > 0 ? (totalEvades / totalMeleeDefends * 100).toFixed(0) : '0'; - - const totalMagicDefends = defMag.totalAttacks; - const totalResists = defMag.failedAttacks; - const resistRate = totalMagicDefends > 0 ? (totalResists / totalMagicDefends * 100).toFixed(0) : '0'; - - const aethRate = totalAttacks > 0 ? (totalAethSurges / totalAttacks * 100).toFixed(1) : '0.0'; - const totalDefHits = (totalMeleeDefends - totalEvades) + (totalMagicDefends - totalResists); - const cloakRate = totalDefHits > 0 ? (totalCloakSurges / totalDefHits * 100).toFixed(1) : '0.0'; - - const crits = offAll.crits; - const hitsNonKill = totalAttacks - failedAttacks; - const critRate = hitsNonKill > 0 ? (crits / hitsNonKill * 100).toFixed(1) : '0.0'; - const normalHits = hitsNonKill - crits; - const avgNormal = normalHits > 0 ? Math.round(offAll.totalNormalDamage / normalHits) : 0; - const avgCrit = crits > 0 ? Math.round(offAll.totalCritDamage / crits) : 0; - - // Damage by element — offense (given) and defense (received) - const offDmgMM = {}, offDmgMag = {}, defDmgMM = {}, defDmgMag = {}; - let totalOffMM = 0, totalOffMag = 0, totalDefMM = 0, totalDefMag = 0; - DAMAGE_ELEMENTS.forEach(el => { - offDmgMM[el] = getDefDmg(offense, 'MeleeMissile', el); - offDmgMag[el] = getDefDmg(offense, 'Magic', el); - defDmgMM[el] = getDefDmg(defense, 'MeleeMissile', el); - defDmgMag[el] = getDefDmg(defense, 'Magic', el); - totalOffMM += offDmgMM[el]; - totalOffMag += offDmgMag[el]; - totalDefMM += defDmgMM[el]; - totalDefMag += defDmgMag[el]; - }); - - const totalDmgGiven = offAll.totalNormalDamage + offAll.totalCritDamage; - - // Build the grid HTML - const fmtN = n => n === 0 ? '' : n.toLocaleString(); - const rightCell = (text) => `${text}`; - const labelCell = (text) => `${text}`; - const headerCell = (text) => `${text}`; - const statLabel = (text) => `${text}`; - const sepCell = ``; - - let html = ''; - - // Column headers: Element | Dmg Given (M/M, Mag) | Dmg Recv (M/M, Mag) | Stats - html += `${headerCell('Given M/M')}${headerCell('Given Mag')}${sepCell}${headerCell('Recv M/M')}${headerCell('Recv Mag')}${sepCell}${statLabel('Attacks')}${rightCell(totalAttacks > 0 ? `${fmtN(totalAttacks)} (${hitRate}%)` : '')}`; - - // Stats to show on the right side of each element row - const rightStats = [ - ['Evades', () => totalMeleeDefends > 0 ? `${fmtN(totalMeleeDefends)} (${evadeRate}%)` : ''], - ['Resists', () => totalMagicDefends > 0 ? `${fmtN(totalMagicDefends)} (${resistRate}%)` : ''], - ['A.Surges', () => totalAethSurges > 0 ? `${fmtN(totalAethSurges)} (${aethRate}%)` : ''], - ['C.Surges', () => totalCloakSurges > 0 ? `${fmtN(totalCloakSurges)} (${cloakRate}%)` : ''], - ['', () => ''], - ['', () => ''], - ['Av/Mx', () => avgNormal > 0 ? `${fmtN(avgNormal)} / ${fmtN(offAll.maxNormalDamage)}` : ''], - ['Crits', () => crits > 0 ? `${fmtN(crits)} (${critRate}%)` : ''], - ]; - - for (let i = 0; i < DAMAGE_ELEMENTS.length; i++) { - const el = DAMAGE_ELEMENTS[i]; - const rs = rightStats[i] || ['', () => '']; - html += `${labelCell(el)}${rightCell(fmtN(offDmgMM[el]))}${rightCell(fmtN(offDmgMag[el]))}${sepCell}${rightCell(fmtN(defDmgMM[el]))}${rightCell(fmtN(defDmgMag[el]))}${sepCell}${rs[0] ? statLabel(rs[0]) : ''}${rightCell(rs[1]())}`; - } - - // Crit Avg/Max row - html += `${sepCell}${sepCell}${statLabel('Av/Mx')}${rightCell(avgCrit > 0 ? `${fmtN(avgCrit)} / ${fmtN(offAll.maxCritDamage)}` : '')}`; - - // Blank row - html += ``; - - // Total row - html += `${labelCell('Total')}${rightCell(fmtN(totalOffMM))}${rightCell(fmtN(totalOffMag))}${sepCell}${rightCell(fmtN(totalDefMM))}${rightCell(fmtN(totalDefMag))}${sepCell}${statLabel('Total')}${rightCell(fmtN(totalDmgGiven))}`; - - html += '
'; - grid.innerHTML = html; -} - -function createMonsterRow(marker, name, kills, dmgRecv, dmgGiven, selected) { - const row = document.createElement('div'); - const bg = selected ? 'background:#2a3a4a;' : ''; - row.style.cssText = `display:flex;gap:4px;padding:2px 6px;font-size:0.78rem;cursor:pointer;border-bottom:1px solid #222;${bg}color:#ddd;`; - row.onmouseenter = () => { if (!selected) row.style.background = '#252525'; }; - row.onmouseleave = () => { if (!selected) row.style.background = ''; }; - const fmtN = n => (!n || n === 0) ? '' : Number(n).toLocaleString(); - row.innerHTML = `${marker}${name}${fmtN(kills)}${fmtN(dmgRecv)}${fmtN(dmgGiven)}`; - return row; -} - -// ── Helpers for aggregating nested combat data ── -function mergeAttackStats(target, source) { - for (const [atk, byEl] of Object.entries(source)) { - if (!target[atk]) target[atk] = {}; - for (const [el, stats] of Object.entries(byEl)) { - if (!target[atk][el]) target[atk][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 }; - const t = target[atk][el]; - t.total_attacks += stats.total_attacks || 0; - t.failed_attacks += stats.failed_attacks || 0; - t.crits += stats.crits || 0; - t.total_normal_damage += stats.total_normal_damage || 0; - t.max_normal_damage = Math.max(t.max_normal_damage, stats.max_normal_damage || 0); - t.total_crit_damage += stats.total_crit_damage || 0; - t.max_crit_damage = Math.max(t.max_crit_damage, stats.max_crit_damage || 0); - } - } -} - -function flattenStats(attackTypes) { - let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 }; - for (const byEl of Object.values(attackTypes)) { - for (const s of Object.values(byEl)) { - r.totalAttacks += s.total_attacks || 0; - r.failedAttacks += s.failed_attacks || 0; - r.crits += s.crits || 0; - r.totalNormalDamage += s.total_normal_damage || 0; - r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0); - r.totalCritDamage += s.total_crit_damage || 0; - r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0); - } - } - return r; -} - -function flattenStatsForType(attackTypes, type) { - let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 }; - const byEl = attackTypes[type]; - if (!byEl) return r; - for (const s of Object.values(byEl)) { - r.totalAttacks += s.total_attacks || 0; - r.failedAttacks += s.failed_attacks || 0; - r.crits += s.crits || 0; - r.totalNormalDamage += s.total_normal_damage || 0; - r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0); - r.totalCritDamage += s.total_crit_damage || 0; - r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0); - } - return r; -} - -function getDefDmg(defense, atkType, element) { - const byEl = defense[atkType]; - if (!byEl) return 0; - const s = byEl[element]; - if (!s) return 0; - return (s.total_normal_damage || 0) + (s.total_crit_damage || 0); -}