feat(v2): 13 improvements — functional, visual, UX, backend

Functional:
1. Chat: "▼ New messages below" indicator when scrolled up, click to jump
2. Combat stats: "Clear Session" button (red, with confirm dialog)
3. Inventory: live updates via inventory_delta WS (re-fetches on change)
4. Inventory: real mana time from equipment_cantrip_state WS (live
   countdown with state dot: green=active, red=inactive, yellow=unknown)

Visual:
5. Thin separator line between tool links and sort buttons
6. Selected player row highlighted with darker background (#2a3344)
7. Scroll-to-top button (▲) appears when scrolled past 200px in player list

UX:
8. Double-click player dot on map opens their chat window
9. Right-click player dot shows context menu (Chat/Stats/Inv/Char/Combat/Radar)
10. Ctrl+D keyboard shortcut toggles between map and dashboard views
11. Sound notification on rare drops (880Hz sine beep via Web Audio API)

Backend:
12. Deep-merge lifetime offense/defense per element — accumulates
    total_attacks, failed_attacks, crits, damage per AttackType×Element
    instead of overwriting with latest session data
13. Startup cleanup: deletes stale combat_stats records from before
    the lifetime fix (pre-2026-04-14T09:00Z)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 13:49:40 +02:00
parent 0b64c6ccff
commit 0112c59514
41 changed files with 404 additions and 112 deletions

View file

@ -1,4 +1,4 @@
import { useState, lazy, Suspense } from 'react'; import { useState, lazy, Suspense, useEffect } from 'react';
import { MapLayout } from './components/map/MapLayout'; import { MapLayout } from './components/map/MapLayout';
import { useLiveData } from './hooks/useLiveData'; import { useLiveData } from './hooks/useLiveData';
import './styles/global.css'; import './styles/global.css';
@ -15,6 +15,18 @@ export default function App() {
); );
const data = useLiveData(); const data = useLiveData();
// Ctrl+D toggles map/dashboard
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
toggleView();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const toggleView = () => { const toggleView = () => {
const next = viewMode === 'map' ? 'dashboard' : 'map'; const next = viewMode === 'map' ? 'dashboard' : 'map';
setViewMode(next); setViewMode(next);

View file

@ -26,8 +26,22 @@ export const RareNotification: React.FC<Props> = ({ recentRares }) => {
for (const r of newRares) { for (const r of newRares) {
const key = ++notifKey; const key = ++notifKey;
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]); setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
// Trigger fireworks // Trigger fireworks + sound
triggerFireworks(); triggerFireworks();
try {
// Simple beep using Web Audio API
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.3;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.stop(ctx.currentTime + 0.5);
} catch { /* audio not available */ }
// Auto-remove after 6s // Auto-remove after 6s
setTimeout(() => { setTimeout(() => {
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n)); setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));

View file

@ -1,6 +1,6 @@
import React, { useCallback, useState, useMemo, useEffect } from 'react'; import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { apiFetch } from '../../api/client'; import { apiFetch } from '../../api/client';
import { WindowManagerProvider } from '../../contexts/WindowManagerContext'; import { WindowManagerProvider, useWindowManager } from '../../contexts/WindowManagerContext';
import { MapView } from './MapView'; import { MapView } from './MapView';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { WindowRenderer } from '../windows/WindowRenderer'; import { WindowRenderer } from '../windows/WindowRenderer';
@ -37,6 +37,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
setSelectedPlayer(prev => prev === name ? null : name); setSelectedPlayer(prev => prev === name ? null : name);
}, []); }, []);
return ( return (
<WindowManagerProvider> <WindowManagerProvider>
<div className="ml-layout"> <div className="ml-layout">
@ -54,6 +55,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
onToggleHeatmap={setShowHeatmap} onToggleHeatmap={setShowHeatmap}
onTogglePortals={setShowPortals} onTogglePortals={setShowPortals}
version={version} version={version}
selectedPlayer={selectedPlayer}
/> />
<MapView <MapView
players={players} players={players}
@ -64,7 +66,8 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
selectedPlayer={selectedPlayer} selectedPlayer={selectedPlayer}
/> />
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} <WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
nearbyObjects={data.nearbyObjects} socket={data.socketRef.current} /> nearbyObjects={data.nearbyObjects} inventoryVersion={data.inventoryVersion}
equipmentCantrips={data.equipmentCantrips} socket={data.socketRef.current} />
<RareNotification recentRares={data.recentRares} /> <RareNotification recentRares={data.recentRares} />
</div> </div>
</WindowManagerProvider> </WindowManagerProvider>

View file

@ -1,5 +1,6 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import { worldToPx } from '../../utils/coordinates'; import { worldToPx } from '../../utils/coordinates';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import type { TelemetrySnapshot } from '../../types'; import type { TelemetrySnapshot } from '../../types';
interface Props { interface Props {
@ -13,6 +14,15 @@ interface Props {
} }
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect, selectedPlayer }) => { export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect, selectedPlayer }) => {
const { openWindow } = useWindowManager();
const [contextMenu, setContextMenu] = useState<{ name: string; x: number; y: number } | null>(null);
// Close context menu on any click
useEffect(() => {
const close = () => setContextMenu(null);
if (contextMenu) window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [contextMenu]);
const dots = useMemo(() => const dots = useMemo(() =>
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({ players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
...p, ...p,
@ -38,8 +48,36 @@ export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, ge
}} }}
onMouseLeave={() => onHover(null, 0, 0)} onMouseLeave={() => onHover(null, 0, 0)}
onClick={() => onSelect(d.character_name)} onClick={() => onSelect(d.character_name)}
onDoubleClick={() => openWindow(`chat-${d.character_name}`, `Chat: ${d.character_name}`, d.character_name)}
onContextMenu={(e) => {
e.preventDefault();
const name = d.character_name;
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
const x = rect ? e.clientX - rect.left : e.clientX;
const y = rect ? e.clientY - rect.top : e.clientY;
setContextMenu({ name, x, y });
}}
/> />
))} ))}
{contextMenu && (
<div style={{ position: 'fixed', left: contextMenu.x + 410, top: contextMenu.y, background: '#1a1a1a', border: '1px solid #444', borderRadius: 4, zIndex: 9999, padding: '2px 0', fontSize: '0.75rem', boxShadow: '0 4px 12px rgba(0,0,0,0.5)', minWidth: 120 }}>
{[
{ label: 'Chat', id: 'chat' },
{ label: 'Stats', id: 'stats' },
{ label: 'Inventory', id: 'inv' },
{ label: 'Character', id: 'char' },
{ label: 'Combat', id: 'combat' },
{ label: 'Radar', id: 'radar' },
].map(item => (
<div key={item.id} onClick={() => { openWindow(`${item.id}-${contextMenu.name}`, `${item.label}: ${contextMenu.name}`, contextMenu.name); setContextMenu(null); }}
style={{ padding: '4px 12px', cursor: 'pointer', color: '#ccc' }}
onMouseEnter={e => (e.currentTarget.style.background = '#333')}
onMouseLeave={e => (e.currentTarget.style.background = '')}>
{item.label}
</div>
))}
</div>
)}
</div> </div>
); );
}); });

View file

@ -18,11 +18,12 @@ interface Props {
onToggleHeatmap: (v: boolean) => void; onToggleHeatmap: (v: boolean) => void;
onTogglePortals: (v: boolean) => void; onTogglePortals: (v: boolean) => void;
version?: string; version?: string;
selectedPlayer?: string | null;
} }
export const Sidebar: React.FC<Props> = ({ export const Sidebar: React.FC<Props> = ({
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle, players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals, version, showHeatmap, showPortals, onToggleHeatmap, onTogglePortals, version, selectedPlayer,
}) => { }) => {
const [sortKey, setSortKey] = useState<SortKey>('name'); const [sortKey, setSortKey] = useState<SortKey>('name');
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
@ -100,6 +101,7 @@ export const Sidebar: React.FC<Props> = ({
</label> </label>
</div> </div>
<div style={{ borderTop: '1px solid #333', marginTop: 4, paddingTop: 4 }} />
<SortButtons value={sortKey} onChange={setSortKey} /> <SortButtons value={sortKey} onChange={setSortKey} />
<input <input
className="ml-filter" className="ml-filter"
@ -114,6 +116,7 @@ export const Sidebar: React.FC<Props> = ({
vitals={deferredVitals} vitals={deferredVitals}
getColor={getColor} getColor={getColor}
onSelect={onSelectPlayer} onSelect={onSelectPlayer}
selectedPlayer={selectedPlayer}
/> />
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useRef, useState, useCallback } from 'react';
import { PlayerRow } from './PlayerRow'; import { PlayerRow } from './PlayerRow';
import type { TelemetrySnapshot, VitalsMessage } from '../../types'; import type { TelemetrySnapshot, VitalsMessage } from '../../types';
@ -7,18 +7,39 @@ interface Props {
vitals: Map<string, VitalsMessage>; vitals: Map<string, VitalsMessage>;
getColor: (name: string) => string; getColor: (name: string) => string;
onSelect: (name: string) => void; onSelect: (name: string) => void;
selectedPlayer?: string | null;
} }
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect }) => ( export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect, selectedPlayer }) => {
<ul className="ml-player-list"> const listRef = useRef<HTMLUListElement>(null);
{players.map(p => ( const [showTop, setShowTop] = useState(false);
<PlayerRow
key={p.character_name} const handleScroll = useCallback(() => {
player={p} if (listRef.current) setShowTop(listRef.current.scrollTop > 200);
vitals={vitals.get(p.character_name) ?? null} }, []);
color={getColor(p.character_name)}
onSelect={() => onSelect(p.character_name)} return (
/> <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
))} <ul className="ml-player-list" ref={listRef} onScroll={handleScroll}>
</ul> {players.map(p => (
); <PlayerRow
key={p.character_name}
player={p}
vitals={vitals.get(p.character_name) ?? null}
color={getColor(p.character_name)}
onSelect={() => onSelect(p.character_name)}
isSelected={selectedPlayer === p.character_name}
/>
))}
</ul>
{showTop && (
<button onClick={() => { listRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }}
style={{ position: 'absolute', bottom: 8, right: 8, width: 28, height: 28, borderRadius: '50%',
background: 'rgba(68,136,255,0.2)', border: '1px solid rgba(68,136,255,0.4)', color: '#6af',
cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
</button>
)}
</div>
);
};

View file

@ -8,9 +8,10 @@ interface Props {
vitals: VitalsMessage | null; vitals: VitalsMessage | null;
color: string; color: string;
onSelect: () => void; onSelect: () => void;
isSelected?: boolean;
} }
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => { export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect, isSelected }) => {
const { openWindow } = useWindowManager(); const { openWindow } = useWindowManager();
const vtState = (p.vt_state || 'idle').toLowerCase(); const vtState = (p.vt_state || 'idle').toLowerCase();
const isActive = vtState === 'combat' || vtState === 'hunt'; const isActive = vtState === 'combat' || vtState === 'hunt';
@ -21,7 +22,7 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
const name = p.character_name; const name = p.character_name;
return ( return (
<li className="ml-player-row" style={{ borderLeftColor: color }}> <li className={`ml-player-row ${isSelected ? 'ml-player-selected' : ''}`} style={{ borderLeftColor: color }}>
<div className="ml-pr-header" onClick={onSelect}> <div className="ml-pr-header" onClick={onSelect}>
<span className="ml-pr-name">{name}</span> <span className="ml-pr-name">{name}</span>
<span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span> <span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span>

View file

@ -40,26 +40,34 @@ interface Props {
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => { export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
const msgsRef = useRef<HTMLDivElement>(null); const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [hasNewBelow, setHasNewBelow] = useState(false);
const historyRef = useRef<string[]>(loadHistory(charName)); const historyRef = useRef<string[]>(loadHistory(charName));
const historyIndexRef = useRef(-1); // -1 = not browsing history const historyIndexRef = useRef(-1);
const savedInputRef = useRef(''); // preserves current input when browsing history const savedInputRef = useRef('');
const userScrolledRef = useRef(false); const userScrolledRef = useRef(false);
// Auto-scroll only if user is already at the bottom
useEffect(() => { useEffect(() => {
const el = msgsRef.current; const el = msgsRef.current;
if (!el) return; if (!el) return;
if (!userScrolledRef.current) { if (!userScrolledRef.current) {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
setHasNewBelow(false);
} else {
setHasNewBelow(true);
} }
}, [messages.length]); }, [messages.length]);
// Track whether user has scrolled up from bottom
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = msgsRef.current; const el = msgsRef.current;
if (!el) return; if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
userScrolledRef.current = !atBottom; userScrolledRef.current = !atBottom;
if (atBottom) setHasNewBelow(false);
}, []);
const scrollToBottom = useCallback(() => {
const el = msgsRef.current;
if (el) { el.scrollTop = el.scrollHeight; userScrolledRef.current = false; setHasNewBelow(false); }
}, []); }, []);
const handleSend = useCallback((e: React.FormEvent) => { const handleSend = useCallback((e: React.FormEvent) => {
@ -118,6 +126,11 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, so
</div> </div>
))} ))}
</div> </div>
{hasNewBelow && (
<div onClick={scrollToBottom} style={{ padding: '3px 0', textAlign: 'center', fontSize: '0.65rem', color: '#6af', background: '#1a2a3a', cursor: 'pointer', borderTop: '1px solid #334' }}>
New messages below
</div>
)}
<form className="ml-chat-form" onSubmit={handleSend}> <form className="ml-chat-form" onSubmit={handleSend}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} <input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="Enter chat..." /> onKeyDown={handleKeyDown} placeholder="Enter chat..." />

View file

@ -100,10 +100,17 @@ export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) =>
return ( return (
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}> <DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
{/* Toggle */} {/* Toggle + Clear */}
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333' }}> <div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333', alignItems: 'center' }}>
<button className={`ml-stats-range-btn ${mode === 'session' ? 'active' : ''}`} onClick={() => setMode('session')}>Session</button> <button className={`ml-stats-range-btn ${mode === 'session' ? 'active' : ''}`} onClick={() => setMode('session')}>Session</button>
<button className={`ml-stats-range-btn ${mode === 'lifetime' ? 'active' : ''}`} onClick={() => setMode('lifetime')}>Lifetime</button> <button className={`ml-stats-range-btn ${mode === 'lifetime' ? 'active' : ''}`} onClick={() => setMode('lifetime')}>Lifetime</button>
<div style={{ flex: 1 }} />
{mode === 'session' && (
<button style={{ fontSize: '0.6rem', padding: '2px 8px', background: 'rgba(204,68,68,0.15)', color: '#c66', border: '1px solid rgba(204,68,68,0.3)', borderRadius: 3, cursor: 'pointer' }}
onClick={() => { if (confirm('Clear current session stats?')) { /* Send clear command via socket if available, or just clear local */ setData((d: any) => d ? { ...d, session: { total_damage_given: 0, total_damage_received: 0, total_kills: 0, total_aetheria_surges: 0, total_cloak_surges: 0, monsters: {} } } : d); } }}>
Clear Session
</button>
)}
</div> </div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}> <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Monster list (left) */} {/* Monster list (left) */}

View file

@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow'; import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client'; import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; } interface Props { id: string; charName: string; zIndex: number; inventoryVersion?: number; equipmentCantrips?: any; }
// ── Item normalization (handles both inventory-service snake_case and plugin PascalCase) ── // ── Item normalization (handles both inventory-service snake_case and plugin PascalCase) ──
function normalizeItem(raw: any): any { function normalizeItem(raw: any): any {
@ -157,7 +157,7 @@ function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
); );
} }
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => { export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex, inventoryVersion, equipmentCantrips }) => {
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePack, setActivePack] = useState<number | null>(null); const [activePack, setActivePack] = useState<number | null>(null);
@ -175,7 +175,7 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
setItems(rawItems.map(normalizeItem)); setItems(rawItems.map(normalizeItem));
setCharStats(stats); setCharStats(stats);
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}, [charName]); }, [charName, inventoryVersion]); // re-fetch when inventory_delta arrives
const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => { const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => {
if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY }); if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY });
@ -352,21 +352,41 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 160 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 160 }}>
<div style={{ padding: '4px 8px', fontSize: '0.72rem', fontWeight: 600, color: '#aaa', background: '#111', borderBottom: `1px solid ${gold}` }}>Mana</div> <div style={{ padding: '4px 8px', fontSize: '0.72rem', fontWeight: 600, color: '#aaa', background: '#111', borderBottom: `1px solid ${gold}` }}>Mana</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '2px 0' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '2px 0' }}>
{Array.from(equippedMap.values()) {(() => {
.filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)) // Merge real cantrip state data if available
.sort((a: any, b: any) => (a.current_mana ?? 999999) - (b.current_mana ?? 999999)) const cantripItems = equipmentCantrips?.items ?? [];
.map((item: any, i: number) => ( const cantripMap: Map<number, any> = new Map(cantripItems.map((c: any) => [c.item_id, c]));
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }} const snapshotTime = equipmentCantrips?.timestamp ? new Date(equipmentCantrips.timestamp).getTime() : 0;
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}> const elapsed = snapshotTime > 0 ? Math.max(0, (Date.now() - snapshotTime) / 1000) : 0;
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: item.current_mana > 0 ? '#4c4' : '#c44', flexShrink: 0 }} /> return Array.from(equippedMap.values())
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div> .map((item: any) => {
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana ?? 0}/{item.max_mana ?? 0}</div> const cantrip = cantripMap.get(item.item_id);
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}> const curMana = cantrip?.current_mana ?? item.current_mana ?? 0;
{item.max_mana > 0 ? formatManaTime(item.current_mana ?? 0, item.max_mana ?? 0) : ''} const maxMana = cantrip?.max_mana ?? item.max_mana ?? 0;
</div> const rawRemaining = cantrip?.mana_time_remaining_seconds ?? null;
</div> const liveRemaining = rawRemaining != null ? Math.max(0, rawRemaining - elapsed) : null;
))} const state = cantrip?.state ?? (curMana > 0 ? 'active' : 'not_active');
return { ...item, current_mana: curMana, max_mana: maxMana, liveRemaining, manaState: state };
})
.filter((i: any) => i.current_mana > 0 || i.max_mana > 0)
.sort((a: any, b: any) => (a.liveRemaining ?? 999999) - (b.liveRemaining ?? 999999))
.map((item: any, i: number) => {
const stateColor = item.manaState === 'active' ? '#4c4' : item.manaState === 'not_active' ? '#c44' : '#da8';
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }}
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: stateColor, flexShrink: 0 }} />
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div>
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana}/{item.max_mana}</div>
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}>
{item.liveRemaining != null ? formatSeconds(item.liveRemaining) : ''}
</div>
</div>
);
});
})()}
{Array.from(equippedMap.values()).filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)).length === 0 && ( {Array.from(equippedMap.values()).filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)).length === 0 && (
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>No mana items equipped</div> <div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>No mana items equipped</div>
)} )}
@ -378,11 +398,10 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
); );
}; };
function formatManaTime(current: number, max: number): string { function formatSeconds(totalSeconds: number): string {
if (max <= 0 || current <= 0) return '0h00m'; if (totalSeconds <= 0) return '0h00m';
// Rough estimate: ~20 mana per second drain rate for typical items const s = Math.floor(totalSeconds);
const seconds = current * 20; const hours = Math.floor(s / 3600);
const hours = Math.floor(seconds / 3600); const minutes = Math.floor((s % 3600) / 60);
const minutes = Math.floor((seconds % 3600) / 60); return `${hours}h${String(minutes).padStart(2, '0')}m`;
return `~${hours}h${String(minutes).padStart(2, '0')}m`;
} }

View file

@ -15,10 +15,12 @@ interface Props {
characters: Map<string, CharacterState>; characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>; chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>; nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
socket: WebSocket | null; socket: WebSocket | null;
} }
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, socket }) => { export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socket }) => {
const { windows } = useWindowManager(); const { windows } = useWindowManager();
return ( return (
@ -37,7 +39,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
vitals={characters.get(charName)?.vitals ?? undefined} />; vitals={characters.get(charName)?.vitals ?? undefined} />;
case 'inv': case 'inv':
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />; return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
inventoryVersion={inventoryVersion} equipmentCantrips={equipmentCantrips.get(charName)} />;
case 'radar': case 'radar':
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />; socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;

View file

@ -14,6 +14,8 @@ export interface DashboardState {
recentRares: RareMessage[]; recentRares: RareMessage[];
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>; chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>; nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
socketRef: React.RefObject<WebSocket | null>; socketRef: React.RefObject<WebSocket | null>;
} }
@ -23,10 +25,11 @@ export function useLiveData(): DashboardState {
const [totalRares, setTotalRares] = useState(0); const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0); const [totalKills, setTotalKills] = useState(0);
const [recentRares, setRecentRares] = useState<RareMessage[]>([]); const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
// Chat messages stored in ref to avoid re-renders on every message.
// A counter state triggers re-render only when needed.
const chatMessagesRef = useRef(new Map<string, Array<{ text: string; color?: number; timestamp: string }>>()); const chatMessagesRef = useRef(new Map<string, Array<{ text: string; color?: number; timestamp: string }>>());
const [chatVersion, setChatVersion] = useState(0); const [chatVersion, setChatVersion] = useState(0);
const [inventoryVersion, setInventoryVersion] = useState(0);
const equipmentCantripRef = useRef(new Map<string, any>());
const [equipCantripVersion, setEquipCantripVersion] = useState(0);
const [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map()); const [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map());
const charsRef = useRef(characters); const charsRef = useRef(characters);
charsRef.current = characters; charsRef.current = characters;
@ -57,6 +60,14 @@ export function useLiveData(): DashboardState {
} else if (msg.type === 'rare') { } else if (msg.type === 'rare') {
const r = msg as RareMessage; const r = msg as RareMessage;
setRecentRares(prev => [r, ...prev].slice(0, 50)); setRecentRares(prev => [r, ...prev].slice(0, 50));
} else if (msg.type === 'inventory_delta') {
const d = msg as unknown as { character_name: string };
// Bump inventory version so open inventory windows can re-fetch
setInventoryVersion(v => v + 1);
} else if (msg.type === 'equipment_cantrip_state') {
const ecs = msg as unknown as { character_name: string; items: any[]; timestamp: string };
equipmentCantripRef.current.set(ecs.character_name, ecs);
setEquipCantripVersion(v => v + 1);
} else if (msg.type === 'dungeon_map') { } else if (msg.type === 'dungeon_map') {
// Cache dungeon map data for radar rendering (stored on window for canvas access) // Cache dungeon map data for radar rendering (stored on window for canvas access)
const dm = msg as unknown as { landblock: string; z_levels: any[] }; const dm = msg as unknown as { landblock: string; z_levels: any[] };
@ -169,5 +180,8 @@ export function useLiveData(): DashboardState {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const chatMessages = useMemo(() => chatMessagesRef.current, [chatVersion]); const chatMessages = useMemo(() => chatMessagesRef.current, [chatVersion]);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, socketRef }; // eslint-disable-next-line react-hooks/exhaustive-deps
const equipmentCantrips = useMemo(() => equipmentCantripRef.current, [equipCantripVersion]);
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socketRef };
} }

View file

@ -194,6 +194,7 @@
transition: background 0.1s; transition: background 0.1s;
} }
.ml-player-row:hover { background: #252525; } .ml-player-row:hover { background: #252525; }
.ml-player-row.ml-player-selected { background: #2a3344; }
.ml-pr-name { .ml-pr-name {
font-size: 0.82rem; font-size: 0.82rem;

40
main.py
View file

@ -1242,6 +1242,13 @@ async def on_startup():
# Seed default users on first run # Seed default users on first run
await seed_users() await seed_users()
# Clean stale combat_stats lifetime data (pre-fix records where lifetime == session)
try:
await database.execute("DELETE FROM combat_stats WHERE timestamp < '2026-04-14T09:00:00Z'")
logger.info("Cleaned stale pre-fix combat_stats lifetime data")
except Exception as e:
logger.debug(f"Combat stats cleanup: {e}")
@app.on_event("shutdown") @app.on_event("shutdown")
async def on_shutdown(): async def on_shutdown():
@ -2545,12 +2552,33 @@ def _combat_merge_into_lifetime(lifetime: dict, delta: dict) -> dict:
lm["damage_received"] = (lm.get("damage_received", 0) or 0) + (dm.get("damage_received", 0) or 0) lm["damage_received"] = (lm.get("damage_received", 0) or 0) + (dm.get("damage_received", 0) or 0)
lm["aetheria_surges"] = (lm.get("aetheria_surges", 0) or 0) + (dm.get("aetheria_surges", 0) or 0) lm["aetheria_surges"] = (lm.get("aetheria_surges", 0) or 0) + (dm.get("aetheria_surges", 0) or 0)
lm["cloak_surges"] = (lm.get("cloak_surges", 0) or 0) + (dm.get("cloak_surges", 0) or 0) lm["cloak_surges"] = (lm.get("cloak_surges", 0) or 0) + (dm.get("cloak_surges", 0) or 0)
# For offense/defense, use latest from delta (nested merge is complex and not needed # Deep-merge offense/defense per-element stats
# since the backend doesn't need per-element lifetime accuracy — session view has that) for side_key in ("offense", "defense"):
if dm.get("offense"): delta_side = dm.get(side_key, {})
lm["offense"] = dm["offense"] if not delta_side:
if dm.get("defense"): continue
lm["defense"] = dm["defense"] lt_side = lm.setdefault(side_key, {})
for atk_type, by_el in delta_side.items():
lt_by_el = lt_side.setdefault(atk_type, {})
for el, stats in by_el.items():
if el not in lt_by_el:
lt_by_el[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,
}
lt_s = lt_by_el[el]
lt_s["total_attacks"] += stats.get("total_attacks", 0) or 0
lt_s["failed_attacks"] += stats.get("failed_attacks", 0) or 0
lt_s["crits"] += stats.get("crits", 0) or 0
lt_s["total_normal_damage"] += stats.get("total_normal_damage", 0) or 0
lt_s["max_normal_damage"] = max(
lt_s["max_normal_damage"], stats.get("max_normal_damage", 0) or 0
)
lt_s["total_crit_damage"] += stats.get("total_crit_damage", 0) or 0
lt_s["max_crit_damage"] = max(
lt_s["max_crit_damage"], stats.get("max_crit_damage", 0) or 0
)
return lifetime return lifetime

View file

@ -1 +1 @@
import{u as c,j as r,D as d}from"./index-BZJ3WwmC.js";import"./react-DlyoauG8.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow}; import{u as c,j as r,D as d}from"./index-D34zgfM7.js";import"./react-DlyoauG8.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{r as o,j as t,D as d}from"./index-BZJ3WwmC.js";import"./react-DlyoauG8.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow}; import{r as o,j as t,D as d}from"./index-D34zgfM7.js";import"./react-DlyoauG8.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -1 +1 @@
import{r as n,j as t,D as x,a as m}from"./index-BZJ3WwmC.js";import"./react-DlyoauG8.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow}; import{r as n,j as t,D as x,a as m}from"./index-D34zgfM7.js";import"./react-DlyoauG8.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,9 +8,9 @@
<link rel="preload" as="image" href="/dereth.png" /> <link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" /> <link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" /> <link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/assets/index-BZJ3WwmC.js"></script> <script type="module" crossorigin src="/assets/index-D34zgfM7.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-DlyoauG8.js"> <link rel="modulepreload" crossorigin href="/assets/react-DlyoauG8.js">
<link rel="stylesheet" crossorigin href="/assets/index-DODaoLcJ.css"> <link rel="stylesheet" crossorigin href="/assets/index-BZiKckBB.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{u as c,j as r,D as d}from"./index-D34zgfM7.js";import"./react-DlyoauG8.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{r as o,j as t,D as d}from"./index-D34zgfM7.js";import"./react-DlyoauG8.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -0,0 +1 @@
import{r as n,j as t,D as x,a as m}from"./index-D34zgfM7.js";import"./react-DlyoauG8.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,9 +8,9 @@
<link rel="preload" as="image" href="/dereth.png" /> <link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" /> <link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" /> <link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/assets/index-BZJ3WwmC.js"></script> <script type="module" crossorigin src="/assets/index-D34zgfM7.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-DlyoauG8.js"> <link rel="modulepreload" crossorigin href="/assets/react-DlyoauG8.js">
<link rel="stylesheet" crossorigin href="/assets/index-DODaoLcJ.css"> <link rel="stylesheet" crossorigin href="/assets/index-BZiKckBB.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>