From c03b1c19f206a5cc11c00cdc9dd17763f288851c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 09:42:11 +0200 Subject: [PATCH] feat: combat stats backend + frontend (Mag-Tools style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- db_async.py | 20 +++ main.py | 102 +++++++++++++ static/index.html | 7 + static/script.js | 364 ++++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 16 ++ 5 files changed, 509 insertions(+) diff --git a/db_async.py b/db_async.py index d79c88ce..4d15dc9a 100644 --- a/db_async.py +++ b/db_async.py @@ -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 diff --git a/main.py b/main.py index ca0aae51..d0e621b6 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/static/index.html b/static/index.html index c6df10a5..3cde6e95 100644 --- a/static/index.html +++ b/static/index.html @@ -111,6 +111,13 @@ 🤝 Vital Sharing + + +
diff --git a/static/script.js b/static/script.js index a23197fb..da359a1a 100644 --- a/static/script.js +++ b/static/script.js @@ -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 = '
No combat data yet
'; + 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 = `MonsterKillsDmg RecvDmg Given`; + 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) => `${text}`; + const labelCell = (text) => `${text}`; + const headerCell = (text) => `${text}`; + const statLabel = (text) => `${text}`; + + let html = ''; + // Header + html += `${headerCell('Mel/Msl')}${headerCell('Magic')}${statLabel('Attacks')}${rightCell(totalAttacks > 0 ? `${fmtN(totalAttacks)} (${hitRate}%)` : '')}`; + + 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 += `${labelCell(el)}${rightCell(fmtN(defDmgMM[el]))}${rightCell(fmtN(defDmgMag[el]))}${statLabelText ? statLabel(statLabelText) : ''}${rightCell(statValue)}`; + } + + // Crit Avg/Max row + html += `${statLabel('Av/Mx')}${rightCell(avgCrit > 0 ? `${fmtN(avgCrit)} / ${fmtN(offAll.maxCritDamage)}` : '')}`; + + // Blank row + html += ''; + + // Total row + html += `${labelCell('Total')}${rightCell(fmtN(totalDefMM))}${rightCell(fmtN(totalDefMag))}${statLabel('Total')}${rightCell(fmtN(totalDmgGiven))}`; + + html += '
'; + 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 = `${marker}${name}${fmtN(kills)}${fmtN(dmgRecv)}${fmtN(dmgGiven)}`; + 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); +} diff --git a/static/style.css b/static/style.css index e774768c..471da663 100644 --- a/static/style.css +++ b/static/style.css @@ -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; +}