From 732595433a7706f84fd856802e611167e0ed3848 Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:20:31 -0700 Subject: [PATCH] feat(tally): registry, JSON persistence, voice-status HTTP, session hook (#1338) Co-authored-by: Claude Opus 4.8 --- BOT/tally.py | 136 +++++++++++++++++++++++++++++++++- BOT/tests/test_tally_logic.py | 15 ++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/BOT/tally.py b/BOT/tally.py index 276e373..6b73083 100644 --- a/BOT/tally.py +++ b/BOT/tally.py @@ -12,11 +12,16 @@ wiped on a 1h idle sweep or when the voice channel empties of human members. """ from __future__ import annotations +import json +import logging import re +import time from dataclasses import dataclass, field, asdict from typing import Optional -from .utils import t +import discord + +from .utils import t, STORAGE_DIR, get_bot, guild_lang IDLE_TIMEOUT = 3600 RECENT_SESSIONS_MAX = 50 @@ -120,3 +125,132 @@ def format_status(tally: Tally, lang: str) -> str: opponent=tally.last_opponent, ) return base + + +# --------------------------------------------------------------------------- +# Registry and persistence +# --------------------------------------------------------------------------- + +TALLY_PATH = STORAGE_DIR / "TALLY.json" + +_REGISTRY: dict[tuple[int, int], "Tally"] = {} + + +def _save() -> None: + """Persist the registry to disk (best-effort).""" + try: + data = [asdict(tly) for tly in _REGISTRY.values()] + tmp = TALLY_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + tmp.replace(TALLY_PATH) + except Exception as e: + logging.error(f"[TALLY] failed to save state: {e}") + + +def load_from_disk() -> None: + """Load persisted tallies into the in-memory registry.""" + try: + raw = json.loads(TALLY_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + return + except Exception as e: + logging.error(f"[TALLY] failed to load state: {e}") + return + _REGISTRY.clear() + for d in raw: + try: + tly = Tally(**d) + _REGISTRY[(tly.guild_id, tly.channel_id)] = tly + except Exception as e: + logging.error(f"[TALLY] skipping bad record {d}: {e}") + + +def get(guild_id: int, channel_id: int) -> Optional["Tally"]: + return _REGISTRY.get((guild_id, channel_id)) + + +def tallies_for_guild(guild_id: int) -> list["Tally"]: + return [tly for (g, _c), tly in _REGISTRY.items() if g == guild_id] + + +def claim(guild_id: int, channel_id: int, mode: str, target: str, + display_target: str, user_id: int) -> "Tally": + now = time.time() + tly = Tally( + guild_id=guild_id, channel_id=channel_id, mode=mode, target=target, + display_target=display_target, last_update_ts=now, claimed_by=user_id, + created_ts=now, + ) + _REGISTRY[(guild_id, channel_id)] = tly + _save() + return tly + + +def transfer(guild_id: int, channel_id: int, target: str, + display_target: str) -> Optional["Tally"]: + """Switch an active tally to player mode on a new IGN, carrying the count.""" + tly = _REGISTRY.get((guild_id, channel_id)) + if tly is None: + return None + tly.mode = "player" + tly.target = target + tly.display_target = display_target + tly.last_update_ts = time.time() + _save() + return tly + + +def wipe(guild_id: int, channel_id: int) -> bool: + if _REGISTRY.pop((guild_id, channel_id), None) is None: + return False + _save() + return True + + +# --------------------------------------------------------------------------- +# Voice-status HTTP helper +# --------------------------------------------------------------------------- + +async def set_voice_status(channel_id: int, text: str) -> None: + """Set a voice channel's Discord status via the raw HTTP endpoint. + + discord.py 2.7.1 has no native voice-status API, so call the route + directly: PUT /channels/{id}/voice-status {"status": text}. + """ + bot = get_bot() + route = discord.http.Route( # type: ignore[attr-defined] + "PUT", "/channels/{channel_id}/voice-status", channel_id=channel_id + ) + try: + await bot.http.request(route, json={"status": text}) + except Exception as e: + logging.error(f"[TALLY] set_voice_status failed for {channel_id}: {e}") + + +async def push_status(tly: "Tally") -> None: + lang = await guild_lang(tly.guild_id) + await set_voice_status(tly.channel_id, format_status(tly, lang)) + + +async def clear_status(channel_id: int) -> None: + await set_voice_status(channel_id, "") + + +# --------------------------------------------------------------------------- +# Session hook +# --------------------------------------------------------------------------- + +async def on_session_processed(guild_id: int, teams: list[dict], + winner_short: Optional[str], is_draw: bool, + session_id: str) -> None: + """Update every active tally in a guild against one finished session.""" + for tly in tallies_for_guild(guild_id): + try: + kind = evaluate(tly, teams, winner_short, is_draw, session_id) + if kind is None: + continue + tly.last_update_ts = time.time() + _save() + await push_status(tly) + except Exception as e: + logging.error(f"[TALLY] on_session_processed error (guild={guild_id}): {e}") diff --git a/BOT/tests/test_tally_logic.py b/BOT/tests/test_tally_logic.py index 4e643e7..6e9d3fa 100644 --- a/BOT/tests/test_tally_logic.py +++ b/BOT/tests/test_tally_logic.py @@ -96,6 +96,21 @@ def test_format_status_after_draw(): assert format_status(t, "en") == "0W-1L: Draw against ENEMY" +def test_persistence_roundtrip(tmp_path_env=None): + import tempfile, os, importlib + import BOT.tally as tal + with tempfile.TemporaryDirectory() as d: + tal.TALLY_PATH = Path(d) / "TALLY.json" + tal._REGISTRY.clear() + tal.claim(10, 20, "player", "bravo", "Bravo", user_id=99) + # reload into a clean registry from disk + tal._REGISTRY.clear() + tal.load_from_disk() + got = tal.get(10, 20) + assert got is not None + assert got.mode == "player" and got.target == "bravo" and got.claimed_by == 99 + + def _run(): fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] for fn in fns: