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

@ -83,6 +83,26 @@ rare_stats_sessions = Table(
Column("session_id", String, primary_key=True),
Column("session_rares", Integer, nullable=False, default=0),
)
# Per-character persistent combat stats (lifetime accumulation, Mag-Tools style)
combat_stats = Table(
"combat_stats",
metadata,
Column("character_name", String, primary_key=True),
Column("timestamp", DateTime(timezone=True), nullable=False),
Column("stats_data", JSON, nullable=False),
)
# Per-session combat stats snapshots (session history)
combat_stats_sessions = Table(
"combat_stats_sessions",
metadata,
Column("id", Integer, primary_key=True),
Column("character_name", String, nullable=False, index=True),
Column("session_id", String, nullable=False, index=True),
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
Column("stats_data", JSON, nullable=False),
)
# Table for recording spawn events (mob creates) for heatmap analysis
spawn_events = Table(
# Records individual mob spawn occurrences for heatmap and analysis

102
main.py
View file

@ -62,6 +62,8 @@ from db_async import (
portals,
server_health_checks,
server_status,
combat_stats,
combat_stats_sessions,
users,
init_db_async,
cleanup_old_portals,
@ -1733,6 +1735,56 @@ async def get_vital_sharing_peers():
}
@app.get("/combat-stats/{character_name}")
async def get_combat_stats(character_name: str):
"""Get lifetime combat stats for a character."""
# Prefer live in-memory data (more up-to-date), fall back to DB
live = live_combat_stats.get(character_name)
if live:
return {
"character_name": character_name,
"session": live.get("session"),
"lifetime": live.get("lifetime"),
}
row = await database.fetch_one(
"SELECT stats_data FROM combat_stats WHERE character_name = :name",
{"name": character_name},
)
if not row:
return {"character_name": character_name, "session": None, "lifetime": None}
return {
"character_name": character_name,
"session": None,
"lifetime": row["stats_data"],
}
@app.get("/combat-stats")
async def get_all_combat_stats():
"""Get combat stats for all characters with live data."""
results = []
seen = set()
# Live data first (most current)
for char, data in live_combat_stats.items():
seen.add(char)
results.append({
"character_name": char,
"session": data.get("session"),
"lifetime": data.get("lifetime"),
})
# Fill in from DB for characters not currently live
rows = await database.fetch_all("SELECT character_name, stats_data FROM combat_stats")
for r in rows:
if r["character_name"] not in seen:
results.append({
"character_name": r["character_name"],
"session": None,
"lifetime": r["stats_data"],
})
results.sort(key=lambda x: x["character_name"])
return {"stats": results}
@app.get("/server-health")
async def get_server_health():
"""Return current server health status."""
@ -2417,6 +2469,10 @@ _vital_sharing_subscribers: set[str] = set()
# and so the browser NetworkUI can populate without waiting for the next tick.
_vital_sharing_peer_state: Dict[str, dict] = {}
# --- Combat stats (Mag-Tools style per-character combat tracking) ----------
# Latest combat_stats payload per character for real-time display.
live_combat_stats: Dict[str, dict] = {}
async def _broadcast_share_to_plugin_clients(data: dict, origin: str) -> None:
"""Forward a share_* message to all opted-in plugin clients except origin.
@ -2877,6 +2933,52 @@ async def ws_receive_snapshots(
f"Broadcasted chat message from {data.get('character_name', 'unknown')}"
)
continue
# --- Combat stats: store + broadcast Mag-Tools style combat data ---
if msg_type == "combat_stats":
char = data.get("character_name")
if char:
live_combat_stats[char] = data
# Upsert lifetime stats into DB
try:
lifetime = data.get("lifetime")
if lifetime:
await database.execute(
combat_stats.delete().where(
combat_stats.c.character_name == char
)
)
await database.execute(
combat_stats.insert().values(
character_name=char,
timestamp=datetime.now(timezone.utc),
stats_data=lifetime,
)
)
# Store session snapshot (latest per session)
session_data = data.get("session")
session_id = data.get("session_id")
if session_data and session_id:
# Delete old snapshot for this session, then insert fresh
await database.execute(
combat_stats_sessions.delete().where(
(combat_stats_sessions.c.character_name == char)
& (combat_stats_sessions.c.session_id == session_id)
)
)
await database.execute(
combat_stats_sessions.insert().values(
character_name=char,
session_id=session_id,
timestamp=datetime.now(timezone.utc),
stats_data=session_data,
)
)
except Exception as e:
logger.error(f"Failed to store combat stats for {char}: {e}")
# Broadcast to browser clients for live display
await _broadcast_to_browser_clients(data)
continue
# --- Full inventory message: store complete inventory snapshot ---
if msg_type == "full_inventory":
payload = data.copy()

View file

@ -112,6 +112,13 @@
</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;
}