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 { useLiveData } from './hooks/useLiveData';
import './styles/global.css';
@ -15,6 +15,18 @@ export default function App() {
);
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 next = viewMode === 'map' ? 'dashboard' : 'map';
setViewMode(next);

View file

@ -26,8 +26,22 @@ export const RareNotification: React.FC<Props> = ({ recentRares }) => {
for (const r of newRares) {
const key = ++notifKey;
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
// Trigger fireworks
// Trigger fireworks + sound
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
setTimeout(() => {
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 { apiFetch } from '../../api/client';
import { WindowManagerProvider } from '../../contexts/WindowManagerContext';
import { WindowManagerProvider, useWindowManager } from '../../contexts/WindowManagerContext';
import { MapView } from './MapView';
import { Sidebar } from './Sidebar';
import { WindowRenderer } from '../windows/WindowRenderer';
@ -37,6 +37,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
setSelectedPlayer(prev => prev === name ? null : name);
}, []);
return (
<WindowManagerProvider>
<div className="ml-layout">
@ -54,6 +55,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
onToggleHeatmap={setShowHeatmap}
onTogglePortals={setShowPortals}
version={version}
selectedPlayer={selectedPlayer}
/>
<MapView
players={players}
@ -64,7 +66,8 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
selectedPlayer={selectedPlayer}
/>
<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} />
</div>
</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 { useWindowManager } from '../../contexts/WindowManagerContext';
import type { TelemetrySnapshot } from '../../types';
interface Props {
@ -13,6 +14,15 @@ interface Props {
}
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(() =>
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
...p,
@ -38,8 +48,36 @@ export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, ge
}}
onMouseLeave={() => onHover(null, 0, 0)}
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>
);
});

View file

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

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, useState, useCallback } from 'react';
import { PlayerRow } from './PlayerRow';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
@ -7,10 +7,20 @@ interface Props {
vitals: Map<string, VitalsMessage>;
getColor: (name: string) => string;
onSelect: (name: string) => void;
selectedPlayer?: string | null;
}
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect }) => (
<ul className="ml-player-list">
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect, selectedPlayer }) => {
const listRef = useRef<HTMLUListElement>(null);
const [showTop, setShowTop] = useState(false);
const handleScroll = useCallback(() => {
if (listRef.current) setShowTop(listRef.current.scrollTop > 200);
}, []);
return (
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
<ul className="ml-player-list" ref={listRef} onScroll={handleScroll}>
{players.map(p => (
<PlayerRow
key={p.character_name}
@ -18,7 +28,18 @@ export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelec
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;
color: string;
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 vtState = (p.vt_state || 'idle').toLowerCase();
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;
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}>
<span className="ml-pr-name">{name}</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 }) => {
const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
const [hasNewBelow, setHasNewBelow] = useState(false);
const historyRef = useRef<string[]>(loadHistory(charName));
const historyIndexRef = useRef(-1); // -1 = not browsing history
const savedInputRef = useRef(''); // preserves current input when browsing history
const historyIndexRef = useRef(-1);
const savedInputRef = useRef('');
const userScrolledRef = useRef(false);
// Auto-scroll only if user is already at the bottom
useEffect(() => {
const el = msgsRef.current;
if (!el) return;
if (!userScrolledRef.current) {
el.scrollTop = el.scrollHeight;
setHasNewBelow(false);
} else {
setHasNewBelow(true);
}
}, [messages.length]);
// Track whether user has scrolled up from bottom
const handleScroll = useCallback(() => {
const el = msgsRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
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) => {
@ -118,6 +126,11 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, so
</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}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder="Enter chat..." />

View file

@ -100,10 +100,17 @@ export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) =>
return (
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
{/* Toggle */}
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333' }}>
{/* Toggle + Clear */}
<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 === '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 style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Monster list (left) */}

View file

@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow';
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) ──
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 [loading, setLoading] = useState(true);
const [activePack, setActivePack] = useState<number | null>(null);
@ -175,7 +175,7 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
setItems(rawItems.map(normalizeItem));
setCharStats(stats);
}).finally(() => setLoading(false));
}, [charName]);
}, [charName, inventoryVersion]); // re-fetch when inventory_delta arrives
const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => {
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={{ 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' }}>
{Array.from(equippedMap.values())
.filter((i: any) => (i.current_mana > 0 || i.max_mana > 0))
.sort((a: any, b: any) => (a.current_mana ?? 999999) - (b.current_mana ?? 999999))
.map((item: any, i: number) => (
{(() => {
// Merge real cantrip state data if available
const cantripItems = equipmentCantrips?.items ?? [];
const cantripMap: Map<number, any> = new Map(cantripItems.map((c: any) => [c.item_id, c]));
const snapshotTime = equipmentCantrips?.timestamp ? new Date(equipmentCantrips.timestamp).getTime() : 0;
const elapsed = snapshotTime > 0 ? Math.max(0, (Date.now() - snapshotTime) / 1000) : 0;
return Array.from(equippedMap.values())
.map((item: any) => {
const cantrip = cantripMap.get(item.item_id);
const curMana = cantrip?.current_mana ?? item.current_mana ?? 0;
const maxMana = cantrip?.max_mana ?? item.max_mana ?? 0;
const rawRemaining = cantrip?.mana_time_remaining_seconds ?? null;
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: item.current_mana > 0 ? '#4c4' : '#c44', flexShrink: 0 }} />
<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 ?? 0}/{item.max_mana ?? 0}</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.max_mana > 0 ? formatManaTime(item.current_mana ?? 0, item.max_mana ?? 0) : ''}
{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 && (
<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 {
if (max <= 0 || current <= 0) return '0h00m';
// Rough estimate: ~20 mana per second drain rate for typical items
const seconds = current * 20;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `~${hours}h${String(minutes).padStart(2, '0')}m`;
function formatSeconds(totalSeconds: number): string {
if (totalSeconds <= 0) return '0h00m';
const s = Math.floor(totalSeconds);
const hours = Math.floor(s / 3600);
const minutes = Math.floor((s % 3600) / 60);
return `${hours}h${String(minutes).padStart(2, '0')}m`;
}

View file

@ -15,10 +15,12 @@ interface Props {
characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
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();
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}
vitals={characters.get(charName)?.vitals ?? undefined} />;
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':
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;

View file

@ -14,6 +14,8 @@ export interface DashboardState {
recentRares: RareMessage[];
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
nearbyObjects: Map<string, any>;
inventoryVersion: number;
equipmentCantrips: Map<string, any>;
socketRef: React.RefObject<WebSocket | null>;
}
@ -23,10 +25,11 @@ export function useLiveData(): DashboardState {
const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0);
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 [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 charsRef = useRef(characters);
charsRef.current = characters;
@ -57,6 +60,14 @@ export function useLiveData(): DashboardState {
} else if (msg.type === 'rare') {
const r = msg as RareMessage;
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') {
// Cache dungeon map data for radar rendering (stored on window for canvas access)
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
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;
}
.ml-player-row:hover { background: #252525; }
.ml-player-row.ml-player-selected { background: #2a3344; }
.ml-pr-name {
font-size: 0.82rem;

40
main.py
View file

@ -1242,6 +1242,13 @@ async def on_startup():
# Seed default users on first run
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")
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["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)
# For offense/defense, use latest from delta (nested merge is complex and not needed
# since the backend doesn't need per-element lifetime accuracy — session view has that)
if dm.get("offense"):
lm["offense"] = dm["offense"]
if dm.get("defense"):
lm["defense"] = dm["defense"]
# Deep-merge offense/defense per-element stats
for side_key in ("offense", "defense"):
delta_side = dm.get(side_key, {})
if not delta_side:
continue
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

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="/icons/0600127E.png" />
<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="stylesheet" crossorigin href="/assets/index-DODaoLcJ.css">
<link rel="stylesheet" crossorigin href="/assets/index-BZiKckBB.css">
</head>
<body>
<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="/icons/0600127E.png" />
<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="stylesheet" crossorigin href="/assets/index-DODaoLcJ.css">
<link rel="stylesheet" crossorigin href="/assets/index-BZiKckBB.css">
</head>
<body>
<div id="root"></div>