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

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()