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:
parent
0b64c6ccff
commit
0112c59514
41 changed files with 404 additions and 112 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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..." />
|
||||||
|
|
|
||||||
|
|
@ -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) */}
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
40
main.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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};
|
||||||
1
static/assets/CombatStatsWindow-ByrFHRs-.js
Normal file
1
static/assets/CombatStatsWindow-ByrFHRs-.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/assets/InventoryWindow-B5rfMh1P.js
Normal file
1
static/assets/InventoryWindow-B5rfMh1P.js
Normal file
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
|
|
@ -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};
|
||||||
|
|
@ -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
34
static/assets/index-D34zgfM7.js
Normal file
34
static/assets/index-D34zgfM7.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
1
static/v2/assets/CharacterWindow-W8tJ-i8K.js
Normal file
1
static/v2/assets/CharacterWindow-W8tJ-i8K.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/CombatPickerWindow-CNplxP8v.js
Normal file
1
static/v2/assets/CombatPickerWindow-CNplxP8v.js
Normal 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};
|
||||||
1
static/v2/assets/CombatStatsWindow-ByrFHRs-.js
Normal file
1
static/v2/assets/CombatStatsWindow-ByrFHRs-.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
72
static/v2/assets/DashboardView-MLBmpIrL.js
Normal file
72
static/v2/assets/DashboardView-MLBmpIrL.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/InventoryWindow-B5rfMh1P.js
Normal file
1
static/v2/assets/InventoryWindow-B5rfMh1P.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/v2/assets/IssuesWindow-BcJOqoTW.js
Normal file
1
static/v2/assets/IssuesWindow-BcJOqoTW.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/RadarWindow-Dy3k6CPo.js
Normal file
1
static/v2/assets/RadarWindow-Dy3k6CPo.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/StatsWindow-Ujc9Sd75.js
Normal file
1
static/v2/assets/StatsWindow-Ujc9Sd75.js
Normal 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};
|
||||||
1
static/v2/assets/VitalSharingWindow-X0sye0vl.js
Normal file
1
static/v2/assets/VitalSharingWindow-X0sye0vl.js
Normal 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
1
static/v2/assets/index-BZiKckBB.css
Normal file
1
static/v2/assets/index-BZiKckBB.css
Normal file
File diff suppressed because one or more lines are too long
34
static/v2/assets/index-D34zgfM7.js
Normal file
34
static/v2/assets/index-D34zgfM7.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue