MosswartOverlord/frontend/src/components/windows/CombatStatsWindow.tsx
Erik 0112c59514 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>
2026-04-14 13:49:40 +02:00

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>
);
};