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:
parent
da4e840581
commit
c03b1c19f2
5 changed files with 509 additions and 0 deletions
20
db_async.py
20
db_async.py
|
|
@ -83,6 +83,26 @@ rare_stats_sessions = Table(
|
||||||
Column("session_id", String, primary_key=True),
|
Column("session_id", String, primary_key=True),
|
||||||
Column("session_rares", Integer, nullable=False, default=0),
|
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
|
# Table for recording spawn events (mob creates) for heatmap analysis
|
||||||
spawn_events = Table(
|
spawn_events = Table(
|
||||||
# Records individual mob spawn occurrences for heatmap and analysis
|
# Records individual mob spawn occurrences for heatmap and analysis
|
||||||
|
|
|
||||||
102
main.py
102
main.py
|
|
@ -62,6 +62,8 @@ from db_async import (
|
||||||
portals,
|
portals,
|
||||||
server_health_checks,
|
server_health_checks,
|
||||||
server_status,
|
server_status,
|
||||||
|
combat_stats,
|
||||||
|
combat_stats_sessions,
|
||||||
users,
|
users,
|
||||||
init_db_async,
|
init_db_async,
|
||||||
cleanup_old_portals,
|
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")
|
@app.get("/server-health")
|
||||||
async def get_server_health():
|
async def get_server_health():
|
||||||
"""Return current server health status."""
|
"""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.
|
# and so the browser NetworkUI can populate without waiting for the next tick.
|
||||||
_vital_sharing_peer_state: Dict[str, dict] = {}
|
_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:
|
async def _broadcast_share_to_plugin_clients(data: dict, origin: str) -> None:
|
||||||
"""Forward a share_* message to all opted-in plugin clients except origin.
|
"""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')}"
|
f"Broadcasted chat message from {data.get('character_name', 'unknown')}"
|
||||||
)
|
)
|
||||||
continue
|
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 ---
|
# --- Full inventory message: store complete inventory snapshot ---
|
||||||
if msg_type == "full_inventory":
|
if msg_type == "full_inventory":
|
||||||
payload = data.copy()
|
payload = data.copy()
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,13 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 -->
|
<!-- Container for sort and filter controls -->
|
||||||
<div id="sortButtons" class="sort-buttons"></div>
|
<div id="sortButtons" class="sort-buttons"></div>
|
||||||
|
|
||||||
|
|
|
||||||
364
static/script.js
364
static/script.js
|
|
@ -3074,6 +3074,9 @@ function initWebSocket() {
|
||||||
const rw = radarWindows[msg.character_name];
|
const rw = radarWindows[msg.character_name];
|
||||||
if (rw) rw._radarDungeonLandblock = msg.landblock;
|
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') {
|
} else if (msg.type === 'share_peer_removed') {
|
||||||
removeVitalSharingPeer(msg.character_name);
|
removeVitalSharingPeer(msg.character_name);
|
||||||
} else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) {
|
} else if (typeof msg.type === 'string' && msg.type.startsWith('share_')) {
|
||||||
|
|
@ -4598,3 +4601,364 @@ function renderVitalSharingWindow() {
|
||||||
listDiv.appendChild(row);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2872,3 +2872,19 @@ table.ts-allegiance td:first-child {
|
||||||
.user-info-logout:hover {
|
.user-info-logout:hover {
|
||||||
color: #ff6b6b;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue