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:
parent
d2c30b610b
commit
adb9d5feab
163 changed files with 2756 additions and 2910 deletions
69
main.py
69
main.py
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue