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;
+}