diff --git a/main.py b/main.py index d0e621b6..4100c0b7 100644 --- a/main.py +++ b/main.py @@ -2472,6 +2472,74 @@ _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] = {} +_combat_last_session: Dict[str, dict] = {} # key = "char:session_id" → last session snapshot +_combat_lifetime_cache: Dict[str, dict] = {} # char → accumulated lifetime stats + + +def _combat_session_delta(new: dict, old: dict) -> dict: + """Compute the difference between two cumulative session snapshots.""" + delta = { + "total_damage_given": (new.get("total_damage_given", 0) or 0) - (old.get("total_damage_given", 0) or 0), + "total_damage_received": (new.get("total_damage_received", 0) or 0) - (old.get("total_damage_received", 0) or 0), + "total_kills": (new.get("total_kills", 0) or 0) - (old.get("total_kills", 0) or 0), + "total_aetheria_surges": (new.get("total_aetheria_surges", 0) or 0) - (old.get("total_aetheria_surges", 0) or 0), + "total_cloak_surges": (new.get("total_cloak_surges", 0) or 0) - (old.get("total_cloak_surges", 0) or 0), + "monsters": {}, + } + new_monsters = new.get("monsters", {}) + old_monsters = old.get("monsters", {}) + for name, nm in new_monsters.items(): + om = old_monsters.get(name, {}) + dm = { + "name": name, + "kill_count": (nm.get("kill_count", 0) or 0) - (om.get("kill_count", 0) or 0), + "damage_given": (nm.get("damage_given", 0) or 0) - (om.get("damage_given", 0) or 0), + "damage_received": (nm.get("damage_received", 0) or 0) - (om.get("damage_received", 0) or 0), + "aetheria_surges": (nm.get("aetheria_surges", 0) or 0) - (om.get("aetheria_surges", 0) or 0), + "cloak_surges": (nm.get("cloak_surges", 0) or 0) - (om.get("cloak_surges", 0) or 0), + "offense": nm.get("offense", {}), # offense/defense are complex nested — just use latest + "defense": nm.get("defense", {}), + } + # Only include if there's actual change + if dm["kill_count"] > 0 or dm["damage_given"] > 0 or dm["damage_received"] > 0: + delta["monsters"][name] = dm + return delta + + +def _combat_merge_into_lifetime(lifetime: dict, delta: dict) -> dict: + """Merge a session delta into the accumulated lifetime stats.""" + if not lifetime: + lifetime = { + "total_damage_given": 0, "total_damage_received": 0, + "total_kills": 0, "total_aetheria_surges": 0, "total_cloak_surges": 0, + "monsters": {}, + } + lifetime["total_damage_given"] = (lifetime.get("total_damage_given", 0) or 0) + (delta.get("total_damage_given", 0) or 0) + lifetime["total_damage_received"] = (lifetime.get("total_damage_received", 0) or 0) + (delta.get("total_damage_received", 0) or 0) + lifetime["total_kills"] = (lifetime.get("total_kills", 0) or 0) + (delta.get("total_kills", 0) or 0) + lifetime["total_aetheria_surges"] = (lifetime.get("total_aetheria_surges", 0) or 0) + (delta.get("total_aetheria_surges", 0) or 0) + lifetime["total_cloak_surges"] = (lifetime.get("total_cloak_surges", 0) or 0) + (delta.get("total_cloak_surges", 0) or 0) + + lt_monsters = lifetime.setdefault("monsters", {}) + for name, dm in delta.get("monsters", {}).items(): + if name not in lt_monsters: + lt_monsters[name] = { + "name": name, "kill_count": 0, "damage_given": 0, "damage_received": 0, + "aetheria_surges": 0, "cloak_surges": 0, "offense": {}, "defense": {}, + } + lm = lt_monsters[name] + lm["kill_count"] = (lm.get("kill_count", 0) or 0) + (dm.get("kill_count", 0) or 0) + lm["damage_given"] = (lm.get("damage_given", 0) or 0) + (dm.get("damage_given", 0) or 0) + lm["damage_received"] = (lm.get("damage_received", 0) or 0) + (dm.get("damage_received", 0) or 0) + lm["aetheria_surges"] = (lm.get("aetheria_surges", 0) or 0) + (dm.get("aetheria_surges", 0) or 0) + lm["cloak_surges"] = (lm.get("cloak_surges", 0) or 0) + (dm.get("cloak_surges", 0) or 0) + # For offense/defense, use latest from delta (nested merge is complex and not needed + # since the backend doesn't need per-element lifetime accuracy — session view has that) + if dm.get("offense"): + lm["offense"] = dm["offense"] + if dm.get("defense"): + lm["defense"] = dm["defense"] + return lifetime async def _broadcast_share_to_plugin_clients(data: dict, origin: str) -> None: @@ -2937,11 +3005,37 @@ async def ws_receive_snapshots( if msg_type == "combat_stats": char = data.get("character_name") if char: - live_combat_stats[char] = data - # Upsert lifetime stats into DB + session_data = data.get("session") + session_id = data.get("session_id") or "" try: - lifetime = data.get("lifetime") - if lifetime: + if session_data: + # ── Compute delta and accumulate into lifetime ── + # The plugin sends cumulative session totals. We track + # the last-seen totals and add the difference to lifetime. + prev_key = f"{char}:{session_id}" + prev = _combat_last_session.get(prev_key) + _combat_last_session[prev_key] = session_data + + if prev is not None: + delta = _combat_session_delta(session_data, prev) + else: + # First snapshot for this session — entire session is the delta + delta = session_data + + # Load existing lifetime from DB (or in-memory cache) + if char not in _combat_lifetime_cache: + row = await database.fetch_one( + "SELECT stats_data FROM combat_stats WHERE character_name = :n", + {"n": char}, + ) + _combat_lifetime_cache[char] = row["stats_data"] if row else {} + + lifetime = _combat_merge_into_lifetime( + _combat_lifetime_cache[char], delta + ) + _combat_lifetime_cache[char] = lifetime + + # Persist lifetime to DB await database.execute( combat_stats.delete().where( combat_stats.c.character_name == char @@ -2954,28 +3048,30 @@ async def ws_receive_snapshots( 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) + + # Store session snapshot + if session_id: + 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, + await database.execute( + combat_stats_sessions.insert().values( + character_name=char, + session_id=session_id, + timestamp=datetime.now(timezone.utc), + stats_data=session_data, + ) ) - ) + + # Enrich the broadcast with computed lifetime + data["lifetime"] = lifetime except Exception as e: logger.error(f"Failed to store combat stats for {char}: {e}") - # Broadcast to browser clients for live display + + live_combat_stats[char] = data await _broadcast_to_browser_clients(data) continue