feat(tally): core model, evaluation and status formatting (#1337)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user