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 = ({ id, charName, zIndex }) => { const [data, setData] = useState(null); const [mode, setMode] = useState<'session' | 'lifetime'>('session'); const [selected, setSelected] = useState(null); useEffect(() => { apiFetch(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {}); const iv = setInterval(() => { apiFetch(`/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 ( {/* Toggle + Clear */}
{mode === 'session' && ( )}
{/* Monster list (left) */}
Monster KillsDmg
{/* All row */}
setSelected(null)}> {selected === null ? '*' : ''} All {fmtN(state?.total_kills ?? 0)} {fmtN(state?.total_damage_given ?? 0)}
{names.map(n => { const m = monsters[n]; return (
setSelected(n)}> {selected === n ? '*' : ''} {n} {fmtN(m.kill_count)} {fmtN(m.damage_given)}
); })}
{/* Breakdown grid (right) */}
{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 ( ); })}
Given M/M Given Mag Recv M/M Recv Mag Stats
{el} {fmtN(getDmg(agg.offense, 'MeleeMissile', el))} {fmtN(getDmg(agg.offense, 'Magic', el))} {fmtN(getDmg(agg.defense, 'MeleeMissile', el))} {fmtN(getDmg(agg.defense, 'Magic', el))} {stats[0]} {stats[1]}
Total {fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'MeleeMissile', e), 0))} {fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'Magic', e), 0))} {fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'MeleeMissile', e), 0))} {fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'Magic', e), 0))} Total {fmtN(off.normalDmg + off.critDmg)}
); };