fix(v2): all reported issues — missing windows, broken features, layouts
Missing features (now added): 1. Vital Sharing window — polls /vital-sharing/peers, shows peer vitals with HP/STA/MANA bars, connection status, position, tags 2. Combat Stats window — full Mag-Tools style with monster list (left), damage breakdown grid (right), session/lifetime toggle, element matrix 3. Issues Board window — CRUD with categories, resolve/reopen, comments 4. Quest Status — links to /quest-status.html (separate page like v1) 5. Sidebar: added Issues + Vitals buttons, Quest link, Combat button per player row (6 buttons now: Chat/Stats/Inv/Char/Combat/Radar) Fixed functionality: 6. Radar — fixed command to "start_radar"/"stop_radar" (was wrong path) 7. Character window — redesigned with v1-style tabbed layout: Left tabs: Attributes (vitals bars + attribute grid) | Skills (specialized/trained grouped) | Titles Right tabs: Augs | Ratings | Other (allegiance) Header: level, race, gender, XP, luminance, deaths, skill credits 8. Stats window — proper Grafana iframe grid (4 panels 2x2) with time range selector (1H/6H/24H/7D) Color palette: expanded to 60 distinct colors (was 30) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b77450b6eb
commit
52e1bcd6b8
12 changed files with 710 additions and 226 deletions
197
frontend/src/components/windows/CombatStatsWindow.tsx
Normal file
197
frontend/src/components/windows/CombatStatsWindow.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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 */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333' }}>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue