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>
204 lines
12 KiB
TypeScript
204 lines
12 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { DraggableWindow } from './DraggableWindow';
|
|
import { apiFetch } from '../../api/client';
|
|
|
|
interface Props { id: string; charName: string; zIndex: number; }
|
|
|
|
const ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
|
|
|
|
function getDmg(side: any, atkType: string, el: string): number {
|
|
return (side?.[atkType]?.[el]?.total_normal_damage ?? 0) + (side?.[atkType]?.[el]?.total_crit_damage ?? 0);
|
|
}
|
|
|
|
function flatten(side: any) {
|
|
let r = { attacks: 0, failed: 0, crits: 0, normalDmg: 0, maxNormal: 0, critDmg: 0, maxCrit: 0 };
|
|
if (!side) return r;
|
|
for (const byEl of Object.values(side) as any[]) {
|
|
for (const s of Object.values(byEl) as any[]) {
|
|
r.attacks += s.total_attacks ?? 0;
|
|
r.failed += s.failed_attacks ?? 0;
|
|
r.crits += s.crits ?? 0;
|
|
r.normalDmg += s.total_normal_damage ?? 0;
|
|
r.maxNormal = Math.max(r.maxNormal, s.max_normal_damage ?? 0);
|
|
r.critDmg += s.total_crit_damage ?? 0;
|
|
r.maxCrit = Math.max(r.maxCrit, s.max_crit_damage ?? 0);
|
|
}
|
|
}
|
|
return r;
|
|
}
|
|
|
|
function flattenType(side: any, type: string) {
|
|
let r = { attacks: 0, failed: 0 };
|
|
const byEl = side?.[type];
|
|
if (!byEl) return r;
|
|
for (const s of Object.values(byEl) as any[]) { r.attacks += s.total_attacks ?? 0; r.failed += s.failed_attacks ?? 0; }
|
|
return r;
|
|
}
|
|
|
|
export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
|
const [data, setData] = useState<any>(null);
|
|
const [mode, setMode] = useState<'session' | 'lifetime'>('session');
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
|
|
const iv = setInterval(() => {
|
|
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
|
|
}, 10000);
|
|
return () => clearInterval(iv);
|
|
}, [charName]);
|
|
|
|
const state = data?.[mode];
|
|
const monsters = state?.monsters ?? {};
|
|
const names = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
|
|
|
|
// Aggregate for selected or all
|
|
const agg = useMemo(() => {
|
|
let offense: any = {}, defense: any = {}, aeth = 0, cloak = 0;
|
|
const list = selected ? [monsters[selected]].filter(Boolean) : names.map(n => monsters[n]);
|
|
for (const m of list) {
|
|
if (!m) continue;
|
|
for (const [at, byEl] of Object.entries(m.offense ?? {})) {
|
|
if (!offense[at]) offense[at] = {};
|
|
for (const [el, s] of Object.entries(byEl as any)) {
|
|
if (!offense[at][el]) offense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
|
|
const t = offense[at][el]; const src = s as any;
|
|
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0; t.crits += src.crits ?? 0;
|
|
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
|
|
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
|
|
}
|
|
}
|
|
for (const [at, byEl] of Object.entries(m.defense ?? {})) {
|
|
if (!defense[at]) defense[at] = {};
|
|
for (const [el, s] of Object.entries(byEl as any)) {
|
|
if (!defense[at][el]) defense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
|
|
const t = defense[at][el]; const src = s as any;
|
|
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0;
|
|
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
|
|
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
|
|
}
|
|
}
|
|
aeth += m.aetheria_surges ?? 0;
|
|
cloak += m.cloak_surges ?? 0;
|
|
}
|
|
if (monsters['__cloak_surges__'] && !selected) cloak += monsters['__cloak_surges__'].cloak_surges ?? 0;
|
|
return { offense, defense, aeth, cloak };
|
|
}, [monsters, names, selected]);
|
|
|
|
const off = flatten(agg.offense);
|
|
const defMM = flattenType(agg.defense, 'MeleeMissile');
|
|
const defMag = flattenType(agg.defense, 'Magic');
|
|
const hitRate = off.attacks > 0 ? ((off.attacks - off.failed) / off.attacks * 100).toFixed(0) : '0';
|
|
const evadeRate = defMM.attacks > 0 ? (defMM.failed / defMM.attacks * 100).toFixed(0) : '0';
|
|
const resistRate = defMag.attacks > 0 ? (defMag.failed / defMag.attacks * 100).toFixed(0) : '0';
|
|
const hits = off.attacks - off.failed;
|
|
const normalHits = hits - off.crits;
|
|
const avgN = normalHits > 0 ? Math.round(off.normalDmg / normalHits) : 0;
|
|
const avgC = off.crits > 0 ? Math.round(off.critDmg / off.crits) : 0;
|
|
const critPct = hits > 0 ? (off.crits / hits * 100).toFixed(1) : '0';
|
|
const fmtN = (n: number) => n === 0 ? '' : n.toLocaleString();
|
|
|
|
return (
|
|
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
|
|
{/* 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) */}
|
|
<div style={{ width: 240, borderRight: '1px solid #333', overflowY: 'auto', fontSize: '0.72rem' }}>
|
|
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem', fontWeight: 600 }}>
|
|
<span style={{ width: 14 }}></span><span style={{ flex: 1 }}>Monster</span>
|
|
<span style={{ width: 40, textAlign: 'right' }}>Kills</span><span style={{ width: 55, textAlign: 'right' }}>Dmg</span>
|
|
</div>
|
|
{/* All row */}
|
|
<div style={{ display: 'flex', padding: '3px 6px', cursor: 'pointer', background: selected === null ? '#2a3a4a' : '', borderBottom: '1px solid #222', color: '#ddd' }}
|
|
onClick={() => setSelected(null)}>
|
|
<span style={{ width: 14, color: '#888' }}>{selected === null ? '*' : ''}</span>
|
|
<span style={{ flex: 1 }}>All</span>
|
|
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(state?.total_kills ?? 0)}</span>
|
|
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(state?.total_damage_given ?? 0)}</span>
|
|
</div>
|
|
{names.map(n => {
|
|
const m = monsters[n];
|
|
return (
|
|
<div key={n} style={{ display: 'flex', padding: '2px 6px', cursor: 'pointer', background: selected === n ? '#2a3a4a' : '',
|
|
borderBottom: '1px solid #1a1a1a', color: '#ccc' }} onClick={() => setSelected(n)}>
|
|
<span style={{ width: 14, color: '#888' }}>{selected === n ? '*' : ''}</span>
|
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n}</span>
|
|
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(m.kill_count)}</span>
|
|
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(m.damage_given)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{/* Breakdown grid (right) */}
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.72rem' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ color: '#777', fontSize: '0.65rem' }}>
|
|
<th style={{ textAlign: 'left', padding: '1px 4px' }}></th>
|
|
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given M/M</th>
|
|
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given Mag</th>
|
|
<th style={{ width: 4 }}></th>
|
|
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv M/M</th>
|
|
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv Mag</th>
|
|
<th style={{ width: 4 }}></th>
|
|
<th style={{ textAlign: 'left', padding: '1px 3px' }}>Stats</th>
|
|
<th style={{ textAlign: 'right', padding: '1px 3px' }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ELEMENTS.map((el, i) => {
|
|
const stats = [
|
|
['Evades', defMM.attacks > 0 ? `${fmtN(defMM.attacks)} (${evadeRate}%)` : ''],
|
|
['Resists', defMag.attacks > 0 ? `${fmtN(defMag.attacks)} (${resistRate}%)` : ''],
|
|
['A.Surges', agg.aeth > 0 ? `${fmtN(agg.aeth)}` : ''],
|
|
['C.Surges', agg.cloak > 0 ? `${fmtN(agg.cloak)}` : ''],
|
|
['', ''], ['', ''],
|
|
['Av/Mx', avgN > 0 ? `${fmtN(avgN)} / ${fmtN(off.maxNormal)}` : ''],
|
|
['Crits', off.crits > 0 ? `${fmtN(off.crits)} (${critPct}%)` : ''],
|
|
][i] ?? ['', ''];
|
|
return (
|
|
<tr key={el}>
|
|
<td style={{ padding: '1px 4px', color: '#888' }}>{el}</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'MeleeMissile', el))}</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'Magic', el))}</td>
|
|
<td></td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'MeleeMissile', el))}</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'Magic', el))}</td>
|
|
<td></td>
|
|
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>{stats[0]}</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{stats[1]}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
<tr>
|
|
<td colSpan={9} style={{ height: 4 }}></td>
|
|
</tr>
|
|
<tr>
|
|
<td style={{ padding: '1px 4px', color: '#888', fontWeight: 600 }}>Total</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'MeleeMissile', e), 0))}</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'Magic', e), 0))}</td>
|
|
<td></td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'MeleeMissile', e), 0))}</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'Magic', e), 0))}</td>
|
|
<td></td>
|
|
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>Total</td>
|
|
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(off.normalDmg + off.critDmg)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</DraggableWindow>
|
|
);
|
|
};
|