feat(combat): backend-side lifetime accumulation from session deltas

Plugin now sends only session data (lifetime=null). Backend computes
the delta between consecutive session snapshots (new - previous) and
merges it into a persisted lifetime record in the combat_stats table.

- _combat_last_session: tracks last-seen session per char:session_id
- _combat_lifetime_cache: in-memory lifetime per character
- _combat_session_delta(): computes diff between two cumulative snapshots
- _combat_merge_into_lifetime(): adds delta totals into lifetime record
- First snapshot for a new session = entire session is the delta
- Lifetime loaded from DB on first message, cached in memory after
- Broadcast enriched with computed lifetime so frontend gets both

This means session resets on login but lifetime persists in DB across
all sessions. The two will now show different data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 10:58:36 +02:00
parent 2cd68d0368
commit 0a0fdc5b3d

140
main.py
View file

@ -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