"""Live per-voice-channel SQB tally tracking. A member connected to a voice channel claims an IGN or a squadron via the ``/tally-claim`` command. As SQB games finish involving that target, the bot updates the voice channel's Discord status to a running scoreline such as ``2W-1L: Win against ENEMY``. The count is fresh from the moment of claim and is independent of the cumulative standings in ``wl.py``. State lives in an in-memory registry keyed by ``(guild_id, channel_id)``, mirrored to ``STORAGE_DIR/TALLY.json`` so tallies survive restarts. Tallies are wiped on a 1h idle sweep or when the voice channel empties of human members. """ from __future__ import annotations import re from dataclasses import dataclass, field, asdict from typing import Optional from .utils import t IDLE_TIMEOUT = 3600 RECENT_SESSIONS_MAX = 50 @dataclass class Tally: guild_id: int channel_id: int mode: str # "player" | "squadron" target: str # IGN (player mode) or squadron short (squadron mode) display_target: str wins: int = 0 losses: int = 0 last_result_kind: Optional[str] = None # "win" | "loss" | "draw" last_opponent: Optional[str] = None last_update_ts: float = 0.0 claimed_by: int = 0 created_ts: float = 0.0 recent_sessions: list[str] = field(default_factory=list) def strip_tag(s: Optional[str]) -> str: """Strip leading/trailing non-alphanumerics (mirrors autologging._strip_tag).""" if not s: return "" return re.sub(r'^[^A-Za-z0-9]+|[^A-Za-z0-9]+$', '', s) or s def team_short(team: dict) -> str: """Bare short tag for a replay team dict.""" return strip_tag(team.get("squadron_short") or team.get("squadron")) def team_index_for(tally: Tally, teams: list[dict]) -> Optional[int]: """Return the index of the team the tracked entity is on, else None.""" for idx, team in enumerate(teams): if not team: continue if tally.mode == "squadron": if team_short(team).lower() == strip_tag(tally.target).lower(): return idx else: # player mode target = tally.target.strip().lower() for p in team.get("players", []): if str(p.get("nick") or "").strip().lower() == target: return idx return None def evaluate( tally: Tally, teams: list[dict], winner_short: Optional[str], is_draw: bool, session_id: str, ) -> Optional[str]: """Apply a finished session to the tally, mutating it in place. Returns the result kind applied ("win"|"loss"|"draw"), or None if the tracked entity was not in this game or the session was already counted. """ if session_id and session_id in tally.recent_sessions: return None idx = team_index_for(tally, teams) if idx is None: return None other = teams[1 - idx] if len(teams) == 2 and idx in (0, 1) else None opponent = team_short(other) if other else None if is_draw: kind = "draw" tally.losses += 1 elif winner_short and strip_tag(winner_short).lower() == team_short(teams[idx]).lower(): kind = "win" tally.wins += 1 else: kind = "loss" tally.losses += 1 tally.last_result_kind = kind tally.last_opponent = opponent if session_id: tally.recent_sessions.append(session_id) if len(tally.recent_sessions) > RECENT_SESSIONS_MAX: tally.recent_sessions = tally.recent_sessions[-RECENT_SESSIONS_MAX:] return kind def format_status(tally: Tally, lang: str) -> str: """Build the voice-channel status string for a tally.""" base = f"{tally.wins}W-{tally.losses}L" if tally.last_result_kind and tally.last_opponent: verb = t(lang, f"commands.tally.result_{tally.last_result_kind}") return t( lang, "commands.tally.status_line", base=base, verb=verb, opponent=tally.last_opponent, ) return base