feat: major cleanup + death alerts + idle detection + Discord webhooks

Cleanup:
- Removed 109 stale asset files from static/assets/ (was 122, now 13)
- Removed static/v2/ entirely (was duplicate of root assets)
- Removed dead dashboard code: DashboardView, Layout, GlobalStats,
  CharacterCard, CharacterGrid, VitalBar, TabContainer, CombatTab,
  RaresTab, MapTab, InventoryTab, global.css, MapTransformContext
- Removed recharts dependency (425KB chunk eliminated)
- CSS reduced from 17KB to 10KB
- Added deploy-frontend.sh script for one-command build+deploy
- Updated CLAUDE.md with combat_stats, share_*, dungeon_map events
  and React frontend architecture

Death alerts (frontend + backend):
- Frontend: DeathNotification component with red banner + sawtooth
  sound when vitae goes from 0 to >0
- Backend: detects vitae transition in vitals handler, sends Discord
  webhook to #aclog with "☠️ CHARACTER died! (vitae: X%)"
- Rate-limited: max 1 Discord alert per character per 5 minutes

Idle detection (backend):
- Background task runs every 60 seconds
- Detects: vt_state "default"/"idle" OR kph=0 while in combat/hunt
- Sends Discord webhook: "⚠️ CHARACTER appears idle (state: X, KPH: 0)"
- Auto-clears alert when character becomes active again
- No duplicate alerts for same idle period

Discord integration:
- DISCORD_ACLOG_WEBHOOK env var for webhook URL
- Used by both death alerts and idle detection
- Graceful fallback when not configured

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 16:32:14 +02:00
parent d2c30b610b
commit adb9d5feab
163 changed files with 2756 additions and 2910 deletions

69
main.py
View file

@ -1236,8 +1236,9 @@ async def on_startup():
_rares_cache_task = asyncio.create_task(_refresh_total_rares_cache())
_server_health_task = asyncio.create_task(monitor_server_health())
_cleanup_task = asyncio.create_task(cleanup_connections_loop())
_idle_detection_task = asyncio.create_task(_idle_detection_loop())
logger.info(
"Background cache refresh, server monitoring, and connection cleanup tasks started"
"Background cache refresh, server monitoring, connection cleanup, and idle detection tasks started"
)
# Seed default users on first run
await seed_users()
@ -2491,6 +2492,60 @@ _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] = {}
# --- Idle detection + Discord alerts ----------
DISCORD_ACLOG_WEBHOOK = os.getenv("DISCORD_ACLOG_WEBHOOK", "")
_idle_alerted: set[str] = set() # chars we've already alerted for this idle period
_death_alerted: Dict[str, float] = {} # char → last death alert timestamp
async def _send_discord_aclog(message: str):
"""Send a message to the #aclog Discord channel via webhook."""
if not DISCORD_ACLOG_WEBHOOK:
return
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(DISCORD_ACLOG_WEBHOOK, json={"content": message})
except Exception as e:
logger.debug(f"Discord webhook failed: {e}")
async def _idle_detection_loop():
"""Check for idle/dead characters every 60 seconds and alert via Discord."""
await asyncio.sleep(30) # initial delay to let telemetry arrive
while True:
try:
# Check all characters in the live telemetry cache
if hasattr(_cached_live, "get"):
players = _cached_live.get("players", [])
else:
players = []
for p in players:
name = p.get("character_name", "")
vt_state = (p.get("vt_state") or "idle").lower()
kph = int(p.get("kills_per_hour", 0) or 0)
# Idle = state is "default" or "idle" or KPH is 0 for an active character
is_idle = vt_state in ("default", "idle", "") or (
vt_state in ("combat", "hunt") and kph == 0
)
if is_idle and name not in _idle_alerted:
_idle_alerted.add(name)
state_text = p.get("vt_state") or "idle"
await _send_discord_aclog(
f"⚠️ **{name}** appears idle (state: {state_text}, KPH: {kph})"
)
logger.info(f"IDLE_ALERT: {name} state={state_text} kph={kph}")
elif not is_idle and name in _idle_alerted:
# Character recovered — clear alert
_idle_alerted.discard(name)
except Exception as e:
logger.debug(f"Idle detection error: {e}")
await asyncio.sleep(60)
_combat_last_session: Dict[str, dict] = {} # key = "char:session_id" → last session snapshot
_combat_lifetime_cache: Dict[str, dict] = {} # char → accumulated lifetime stats
@ -3197,6 +3252,18 @@ async def ws_receive_snapshots(
payload.pop("type", None)
try:
vitals_msg = VitalsMessage.parse_obj(payload)
# Detect death: vitae went from 0 to > 0
prev = live_vitals.get(vitals_msg.character_name, {})
prev_vitae = prev.get("vitae", 0) or 0
new_vitae = vitals_msg.vitae or 0
if prev_vitae == 0 and new_vitae > 0:
now = asyncio.get_event_loop().time()
last_alert = _death_alerted.get(vitals_msg.character_name, 0)
if now - last_alert > 300: # max 1 death alert per 5 min per char
_death_alerted[vitals_msg.character_name] = now
asyncio.create_task(_send_discord_aclog(
f"☠️ **{vitals_msg.character_name}** died! (vitae: {new_vitae}%)"
))
live_vitals[vitals_msg.character_name] = vitals_msg.dict()
await _broadcast_to_browser_clients(data)
logger.debug(