feat: combat stats backend + frontend (Mag-Tools style)

Backend:
- db_async.py: new combat_stats + combat_stats_sessions tables
- main.py: combat_stats message handler with DB upsert (lifetime +
  session snapshots), in-memory live_combat_stats dict, broadcast
  to browser clients.
- REST: GET /combat-stats and GET /combat-stats/{character_name}

Frontend:
- index.html: new "Combat Stats" sidebar link
- script.js: full Combat Stats window with two panels:
  - Top: monster list (name, kills, dmg recv, dmg given) with
    clickable rows and "All" aggregate, matching CombatTrackerGUI.cs
  - Bottom: damage breakdown grid matching CombatTrackerGUIInfo.cs
    layout — element × attack type matrix (Mel/Msl + Magic columns),
    Attacks (hit%), Evades (%), Resists (%), A.Surges (%), C.Surges (%),
    normal Avg/Max, Crits (%), Crit Avg/Max, Total Damage.
  - Session / Lifetime toggle button
- style.css: combat-stats-toggle styles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 09:42:11 +02:00
parent da4e840581
commit c03b1c19f2
5 changed files with 509 additions and 0 deletions

View file

@ -111,6 +111,13 @@
🤝 Vital Sharing
</a>
</div>
<!-- Combat Stats link -->
<div class="quest-status-link">
<a href="#" id="combatStatsBtn" onclick="showCombatStatsWindow()">
Combat Stats
</a>
</div>
<!-- Container for sort and filter controls -->
<div id="sortButtons" class="sort-buttons"></div>

View file

@ -3074,6 +3074,9 @@ function initWebSocket() {
const rw = radarWindows[msg.character_name];
if (rw) rw._radarDungeonLandblock = msg.landblock;
}
} else if (msg.type === 'combat_stats') {
liveCombatStats[msg.character_name] = msg;
updateCombatStatsWindows(msg.character_name);
} else if (msg.type === 'share_peer_removed') {
removeVitalSharingPeer(msg.character_name);
} else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) {
@ -4598,3 +4601,364 @@ function renderVitalSharingWindow() {
listDiv.appendChild(row);
});
}
// ─── Combat Stats (Mag-Tools style) ───────────────────────────────────────
const liveCombatStats = {};
const combatStatsWindows = {};
const DAMAGE_ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
function showCombatStatsWindow(charName) {
// If called without a name, show a picker of all characters with data
if (!charName) {
const chars = Object.keys(liveCombatStats).sort();
if (chars.length === 0) {
// Try fetching from API
fetch('/combat-stats').then(r => r.json()).then(data => {
if (data.stats && data.stats.length > 0) {
data.stats.forEach(s => { liveCombatStats[s.character_name] = s; });
showCombatStatsPickerWindow();
} else {
alert('No combat stats available yet. Kill some monsters first!');
}
});
return;
}
showCombatStatsPickerWindow();
return;
}
const windowId = `combatStatsWindow-${charName}`;
const { win, content, isNew } = createWindow(windowId, `Combat: ${charName}`, 'issues-window');
if (!isNew) {
renderCombatStatsContent(win, charName);
return;
}
win.style.width = '620px';
win.style.height = '560px';
combatStatsWindows[charName] = win;
// Session/Lifetime toggle
const controls = document.createElement('div');
controls.style.cssText = 'padding:4px 8px;display:flex;gap:6px;align-items:center;border-bottom:1px solid #444;';
const sessionBtn = document.createElement('button');
sessionBtn.textContent = 'Session';
sessionBtn.className = 'combat-stats-toggle active';
sessionBtn.onclick = () => { win._combatMode = 'session'; sessionBtn.classList.add('active'); lifetimeBtn.classList.remove('active'); renderCombatStatsContent(win, charName); };
const lifetimeBtn = document.createElement('button');
lifetimeBtn.textContent = 'Lifetime';
lifetimeBtn.className = 'combat-stats-toggle';
lifetimeBtn.onclick = () => { win._combatMode = 'lifetime'; lifetimeBtn.classList.add('active'); sessionBtn.classList.remove('active'); renderCombatStatsContent(win, charName); };
controls.appendChild(sessionBtn);
controls.appendChild(lifetimeBtn);
content.appendChild(controls);
// Monster list (top)
const monsterDiv = document.createElement('div');
monsterDiv.className = 'combat-monster-list';
monsterDiv.style.cssText = 'height:180px;overflow-y:auto;border-bottom:1px solid #444;';
content.appendChild(monsterDiv);
win._monsterList = monsterDiv;
// Damage breakdown grid (bottom)
const breakdownDiv = document.createElement('div');
breakdownDiv.className = 'combat-breakdown';
breakdownDiv.style.cssText = 'flex:1;overflow-y:auto;padding:4px 6px;';
content.appendChild(breakdownDiv);
win._breakdownGrid = breakdownDiv;
win._combatMode = 'session';
win._selectedMonster = null; // null = "All"
renderCombatStatsContent(win, charName);
}
function showCombatStatsPickerWindow() {
const { win, content, isNew } = createWindow('combatStatsPicker', 'Combat Stats — Select Character', 'issues-window');
if (!isNew) { renderCombatStatsPicker(win); return; }
win.style.width = '320px';
win.style.height = '400px';
const listDiv = document.createElement('div');
listDiv.style.cssText = 'padding:6px;overflow-y:auto;flex:1;';
content.appendChild(listDiv);
win._pickerList = listDiv;
renderCombatStatsPicker(win);
}
function renderCombatStatsPicker(win) {
const listDiv = win._pickerList;
if (!listDiv) return;
const chars = Object.keys(liveCombatStats).sort();
listDiv.innerHTML = '';
if (chars.length === 0) {
listDiv.innerHTML = '<div style="padding:10px;color:#888;text-align:center;">No combat data yet</div>';
return;
}
chars.forEach(name => {
const row = document.createElement('div');
row.style.cssText = 'padding:4px 8px;cursor:pointer;border-bottom:1px solid #333;color:#ddd;font-size:0.85rem;';
row.textContent = name;
row.onmouseenter = () => row.style.background = '#333';
row.onmouseleave = () => row.style.background = '';
row.onclick = () => { showCombatStatsWindow(name); };
listDiv.appendChild(row);
});
}
function updateCombatStatsWindows(charName) {
const win = combatStatsWindows[charName];
if (win && win.style.display !== 'none') {
renderCombatStatsContent(win, charName);
}
}
function renderCombatStatsContent(win, charName) {
const data = liveCombatStats[charName];
if (!data) return;
const mode = win._combatMode || 'session';
const stateData = data[mode];
if (!stateData) return;
const monsters = stateData.monsters || {};
const monsterNames = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
// ── Monster list ──
const monsterDiv = win._monsterList;
if (monsterDiv) {
monsterDiv.innerHTML = '';
// Header row
const hdr = document.createElement('div');
hdr.style.cssText = 'display:flex;gap:4px;padding:2px 6px;font-size:0.72rem;color:#888;border-bottom:1px solid #333;font-weight:bold;';
hdr.innerHTML = `<span style="width:14px"></span><span style="flex:1">Monster</span><span style="width:50px;text-align:right">Kills</span><span style="width:70px;text-align:right">Dmg Recv</span><span style="width:70px;text-align:right">Dmg Given</span>`;
monsterDiv.appendChild(hdr);
// "All" row
const allRow = createMonsterRow('*', 'All', stateData.total_kills, stateData.total_damage_received, stateData.total_damage_given, win._selectedMonster === null);
allRow.onclick = () => { win._selectedMonster = null; renderCombatStatsContent(win, charName); };
monsterDiv.appendChild(allRow);
// Per-monster rows
monsterNames.forEach(name => {
const m = monsters[name];
const selected = win._selectedMonster === name;
const row = createMonsterRow('', name, m.kill_count, m.damage_received, m.damage_given, selected);
row.onclick = () => { win._selectedMonster = name; renderCombatStatsContent(win, charName); };
monsterDiv.appendChild(row);
});
}
// ── Breakdown grid ──
const grid = win._breakdownGrid;
if (!grid) return;
// Gather stats for the selected monster or all
let offense = {}, defense = {};
let totalAethSurges = 0, totalCloakSurges = 0;
if (win._selectedMonster === null) {
// Aggregate all monsters
monsterNames.forEach(name => {
const m = monsters[name];
mergeAttackStats(offense, m.offense || {});
mergeAttackStats(defense, m.defense || {});
totalAethSurges += m.aetheria_surges || 0;
totalCloakSurges += m.cloak_surges || 0;
});
// Add cloak surges from the synthetic key
if (monsters['__cloak_surges__']) {
totalCloakSurges += monsters['__cloak_surges__'].cloak_surges || 0;
}
} else {
const m = monsters[win._selectedMonster];
if (m) {
offense = m.offense || {};
defense = m.defense || {};
totalAethSurges = m.aetheria_surges || 0;
totalCloakSurges = m.cloak_surges || 0;
}
}
// Compute aggregates for the right-side stats
const offAll = flattenStats(offense);
const defAll = flattenStats(defense);
const defMM = flattenStatsForType(defense, 'MeleeMissile');
const defMag = flattenStatsForType(defense, 'Magic');
const totalAttacks = offAll.totalAttacks;
const failedAttacks = offAll.failedAttacks;
const hitRate = totalAttacks > 0 ? ((totalAttacks - failedAttacks) / totalAttacks * 100).toFixed(0) : '0';
const totalMeleeDefends = defMM.totalAttacks;
const totalEvades = defMM.failedAttacks;
const evadeRate = totalMeleeDefends > 0 ? (totalEvades / totalMeleeDefends * 100).toFixed(0) : '0';
const totalMagicDefends = defMag.totalAttacks;
const totalResists = defMag.failedAttacks;
const resistRate = totalMagicDefends > 0 ? (totalResists / totalMagicDefends * 100).toFixed(0) : '0';
const aethRate = totalAttacks > 0 ? (totalAethSurges / totalAttacks * 100).toFixed(1) : '0.0';
const totalDefHits = (totalMeleeDefends - totalEvades) + (totalMagicDefends - totalResists);
const cloakRate = totalDefHits > 0 ? (totalCloakSurges / totalDefHits * 100).toFixed(1) : '0.0';
const crits = offAll.crits;
const hitsNonKill = totalAttacks - failedAttacks;
const critRate = hitsNonKill > 0 ? (crits / hitsNonKill * 100).toFixed(1) : '0.0';
const normalHits = hitsNonKill - crits;
const avgNormal = normalHits > 0 ? Math.round(offAll.totalNormalDamage / normalHits) : 0;
const avgCrit = crits > 0 ? Math.round(offAll.totalCritDamage / crits) : 0;
// Damage received by element
const defDmgMM = {}, defDmgMag = {};
let totalDefMM = 0, totalDefMag = 0;
DAMAGE_ELEMENTS.forEach(el => {
defDmgMM[el] = getDefDmg(defense, 'MeleeMissile', el);
defDmgMag[el] = getDefDmg(defense, 'Magic', el);
totalDefMM += defDmgMM[el];
totalDefMag += defDmgMag[el];
});
const totalDmgGiven = offAll.totalNormalDamage + offAll.totalCritDamage;
// Build the grid HTML — matches Mag-Tools CombatTrackerGUIInfo layout
const fmtN = n => n === 0 ? '' : n.toLocaleString();
const rightCell = (text) => `<td style="text-align:right;padding:1px 4px;font-size:0.75rem;color:#ccc;">${text}</td>`;
const labelCell = (text) => `<td style="padding:1px 4px;font-size:0.75rem;color:#aaa;">${text}</td>`;
const headerCell = (text) => `<td style="text-align:right;padding:1px 4px;font-size:0.72rem;color:#888;font-weight:bold;">${text}</td>`;
const statLabel = (text) => `<td style="padding:1px 4px;font-size:0.72rem;color:#888;font-weight:bold;">${text}</td>`;
let html = '<table style="width:100%;border-collapse:collapse;">';
// Header
html += `<tr><td></td>${headerCell('Mel/Msl')}${headerCell('Magic')}${statLabel('Attacks')}${rightCell(totalAttacks > 0 ? `${fmtN(totalAttacks)} (${hitRate}%)` : '')}</tr>`;
const statRows = [
['Typeless', 'Attacks', () => totalAttacks > 0 ? `${fmtN(totalAttacks)} (${hitRate}%)` : ''],
['Slash', 'Evades', () => totalMeleeDefends > 0 ? `${fmtN(totalMeleeDefends)} (${evadeRate}%)` : ''],
['Pierce', 'Resists', () => totalMagicDefends > 0 ? `${fmtN(totalMagicDefends)} (${resistRate}%)` : ''],
['Bludgeon', 'A.Surges', () => totalAethSurges > 0 ? `${fmtN(totalAethSurges)} (${aethRate}%)` : ''],
['Fire', 'C.Surges', () => totalCloakSurges > 0 ? `${fmtN(totalCloakSurges)} (${cloakRate}%)` : ''],
['Cold', '', () => ''],
['Acid', 'Av/Mx', () => avgNormal > 0 ? `${fmtN(avgNormal)} / ${fmtN(offAll.maxNormalDamage)}` : ''],
['Electric', 'Crits', () => crits > 0 ? `${fmtN(crits)} (${critRate}%)` : ''],
];
// First row already printed above as header. Now element rows:
for (let i = 0; i < DAMAGE_ELEMENTS.length; i++) {
const el = DAMAGE_ELEMENTS[i];
const sr = statRows[i];
let statLabelText = sr ? sr[1] : '';
let statValue = sr ? sr[2]() : '';
// Skip the duplicate "Attacks" row — it's already in the header
if (i === 0) {
statLabelText = 'Evades';
statValue = totalMeleeDefends > 0 ? `${fmtN(totalMeleeDefends)} (${evadeRate}%)` : '';
} else if (i === 1) {
statLabelText = 'Resists';
statValue = totalMagicDefends > 0 ? `${fmtN(totalMagicDefends)} (${resistRate}%)` : '';
} else if (i === 2) {
statLabelText = 'A.Surges';
statValue = totalAethSurges > 0 ? `${fmtN(totalAethSurges)} (${aethRate}%)` : '';
} else if (i === 3) {
statLabelText = 'C.Surges';
statValue = totalCloakSurges > 0 ? `${fmtN(totalCloakSurges)} (${cloakRate}%)` : '';
} else if (i === 4) {
statLabelText = '';
statValue = '';
} else if (i === 5) {
statLabelText = '';
statValue = '';
} else if (i === 6) {
statLabelText = 'Av/Mx';
statValue = avgNormal > 0 ? `${fmtN(avgNormal)} / ${fmtN(offAll.maxNormalDamage)}` : '';
} else if (i === 7) {
statLabelText = 'Crits';
statValue = crits > 0 ? `${fmtN(crits)} (${critRate}%)` : '';
}
html += `<tr>${labelCell(el)}${rightCell(fmtN(defDmgMM[el]))}${rightCell(fmtN(defDmgMag[el]))}${statLabelText ? statLabel(statLabelText) : '<td></td>'}${rightCell(statValue)}</tr>`;
}
// Crit Avg/Max row
html += `<tr><td></td><td></td><td></td>${statLabel('Av/Mx')}${rightCell(avgCrit > 0 ? `${fmtN(avgCrit)} / ${fmtN(offAll.maxCritDamage)}` : '')}</tr>`;
// Blank row
html += '<tr><td colspan="5" style="height:6px;"></td></tr>';
// Total row
html += `<tr>${labelCell('Total')}${rightCell(fmtN(totalDefMM))}${rightCell(fmtN(totalDefMag))}${statLabel('Total')}${rightCell(fmtN(totalDmgGiven))}</tr>`;
html += '</table>';
grid.innerHTML = html;
}
function createMonsterRow(marker, name, kills, dmgRecv, dmgGiven, selected) {
const row = document.createElement('div');
const bg = selected ? 'background:#2a3a4a;' : '';
row.style.cssText = `display:flex;gap:4px;padding:2px 6px;font-size:0.78rem;cursor:pointer;border-bottom:1px solid #222;${bg}color:#ddd;`;
row.onmouseenter = () => { if (!selected) row.style.background = '#252525'; };
row.onmouseleave = () => { if (!selected) row.style.background = ''; };
const fmtN = n => (!n || n === 0) ? '' : Number(n).toLocaleString();
row.innerHTML = `<span style="width:14px;color:#888;">${marker}</span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${name}</span><span style="width:50px;text-align:right;">${fmtN(kills)}</span><span style="width:70px;text-align:right;">${fmtN(dmgRecv)}</span><span style="width:70px;text-align:right;">${fmtN(dmgGiven)}</span>`;
return row;
}
// ── Helpers for aggregating nested combat data ──
function mergeAttackStats(target, source) {
for (const [atk, byEl] of Object.entries(source)) {
if (!target[atk]) target[atk] = {};
for (const [el, stats] of Object.entries(byEl)) {
if (!target[atk][el]) target[atk][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 = target[atk][el];
t.total_attacks += stats.total_attacks || 0;
t.failed_attacks += stats.failed_attacks || 0;
t.crits += stats.crits || 0;
t.total_normal_damage += stats.total_normal_damage || 0;
t.max_normal_damage = Math.max(t.max_normal_damage, stats.max_normal_damage || 0);
t.total_crit_damage += stats.total_crit_damage || 0;
t.max_crit_damage = Math.max(t.max_crit_damage, stats.max_crit_damage || 0);
}
}
}
function flattenStats(attackTypes) {
let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 };
for (const byEl of Object.values(attackTypes)) {
for (const s of Object.values(byEl)) {
r.totalAttacks += s.total_attacks || 0;
r.failedAttacks += s.failed_attacks || 0;
r.crits += s.crits || 0;
r.totalNormalDamage += s.total_normal_damage || 0;
r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0);
r.totalCritDamage += s.total_crit_damage || 0;
r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0);
}
}
return r;
}
function flattenStatsForType(attackTypes, type) {
let r = { totalAttacks:0, failedAttacks:0, crits:0, totalNormalDamage:0, maxNormalDamage:0, totalCritDamage:0, maxCritDamage:0 };
const byEl = attackTypes[type];
if (!byEl) return r;
for (const s of Object.values(byEl)) {
r.totalAttacks += s.total_attacks || 0;
r.failedAttacks += s.failed_attacks || 0;
r.crits += s.crits || 0;
r.totalNormalDamage += s.total_normal_damage || 0;
r.maxNormalDamage = Math.max(r.maxNormalDamage, s.max_normal_damage || 0);
r.totalCritDamage += s.total_crit_damage || 0;
r.maxCritDamage = Math.max(r.maxCritDamage, s.max_crit_damage || 0);
}
return r;
}
function getDefDmg(defense, atkType, element) {
const byEl = defense[atkType];
if (!byEl) return 0;
const s = byEl[element];
if (!s) return 0;
return (s.total_normal_damage || 0) + (s.total_crit_damage || 0);
}

View file

@ -2872,3 +2872,19 @@ table.ts-allegiance td:first-child {
.user-info-logout:hover {
color: #ff6b6b;
}
/* ---------- Combat Stats window ---------- */
.combat-stats-toggle {
padding: 2px 10px;
font-size: 0.75rem;
background: #2a2a2a;
color: #aaa;
border: 1px solid #555;
cursor: pointer;
border-radius: 3px;
}
.combat-stats-toggle.active {
background: #3a5070;
color: #fff;
border-color: #5588bb;
}