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 { 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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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..." />
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
40
main.py
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
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="/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>
|
||||
|
|
|
|||
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="/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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue