diff --git a/BOT/autologging.py b/BOT/autologging.py index db49367..3fa2c1f 100644 --- a/BOT/autologging.py +++ b/BOT/autologging.py @@ -67,6 +67,7 @@ from .utils import ( WILDCARD_KEYS, ) from .wl import record_result, record_draw, get_standings +from . import tally # ============================================================================ @@ -1131,6 +1132,14 @@ async def process_session( else: new_wl = get_standings(squadrons_clean) + # Update any live voice-channel tallies for this guild against this game. + try: + await tally.on_session_processed( + guild_id, teams, winner, is_draw, session_id + ) + except Exception as e: + logging.error(f"[TALLY] hook failed for session {session_id}: {e}") + # Scoreboard Build lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock()) async with lock: diff --git a/BOT/botscript.py b/BOT/botscript.py index 0c535ea..6077e0a 100644 --- a/BOT/botscript.py +++ b/BOT/botscript.py @@ -33,7 +33,7 @@ import matplotlib.dates as mdates # noqa: E402 import matplotlib.pyplot as plt # noqa: E402 import numpy as np from discord import Color, Embed, app_commands -from discord.ext import commands +from discord.ext import commands, tasks from discord.ui import Select, View, button from dotenv import load_dotenv import matplotlib.patheffects as path_effects # noqa: E402 @@ -144,6 +144,7 @@ from .utils import ( replay_session_dir, ) from .wl import wl_bootstrap +from . import tally # Load environment variables from .env file load_dotenv() @@ -253,6 +254,14 @@ async def on_ready(): # Start all background tasks from tasks.py await cast(Callable[[], Awaitable[None]], start_all_tasks)() + try: + tally.load_from_disk() + if not tally_sweep_loop.is_running(): + tally_sweep_loop.start() + logging.info("Initialized tally tracking") + except Exception as e: + logging.error(f"Error initializing tally tracking: {e}") + # Start the W/L queue consumer wl_bootstrap() @@ -263,6 +272,34 @@ async def on_ready(): logging.error(f"Error starting tasks in startup: {e}") +@tasks.loop(minutes=2) +async def tally_sweep_loop(): + """Periodically wipe idle voice-channel tallies.""" + try: + await tally.sweep_idle() + except Exception as e: + logging.error(f"[TALLY] sweep loop error: {e}") + + +@tally_sweep_loop.before_loop +async def _before_tally_sweep(): + await bot.wait_until_ready() + + +@bot.event +async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): + """Wipe a VC's tally the moment it empties of human members.""" + channel = before.channel + if channel is None or (after.channel is not None and after.channel.id == channel.id): + return + guild_id = channel.guild.id + if tally.get(guild_id, channel.id) is None: + return + if all(m.bot for m in channel.members): + tally.wipe(guild_id, channel.id) + await tally.clear_status(channel.id) + + @bot.event async def on_guild_join(guild): """Handle joining a new guild: update presence and send setup hint. @@ -4204,6 +4241,181 @@ async def set_player_perm_error(interaction, error): await permission_fail(interaction, error) +# ═══════════════════════════════════════════════════════════════════════════ +# /tally-claim · /tally-transfer · /tally-wipe — live VC scoreline tracking +# ═══════════════════════════════════════════════════════════════════════════ + +def _invoker_voice_channel(interaction: discord.Interaction): + """Return the voice channel the invoker is connected to, or None.""" + member = interaction.user + if isinstance(member, discord.Member) and member.voice and member.voice.channel: + return member.voice.channel + return None + + +@bot.tree.command( + name="tally-claim", + description=command_locale( + "Track a live SQB scoreline on your current voice channel", + "commands.tally.description_claim", + ), +) +@app_commands.describe( + ign=command_locale("The player IGN to track", "commands.tally.ign"), + squadron_short=command_locale("The squadron short name to track", "commands.tally.squadron_short"), +) +@discord.app_commands.autocomplete(ign=player_autocomplete, squadron_short=squadron_autocomplete) +async def tally_claim(interaction: discord.Interaction, ign: str = "", squadron_short: str = ""): + lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" + if not interaction.guild_id or not await is_guild_entitled(interaction.guild_id): + await interaction.response.send_message(t(lang, "commands.tally.premium_required"), ephemeral=True) + return + vc = _invoker_voice_channel(interaction) + if vc is None: + await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) + return + ign = ign.strip() + squadron_short = squadron_short.strip() + if bool(ign) == bool(squadron_short): # neither or both + await interaction.response.send_message(t(lang, "commands.tally.need_one_input"), ephemeral=True) + return + + existing = tally.get(interaction.guild_id, vc.id) + if existing is not None: + await interaction.response.send_message( + t(lang, "commands.tally.already_active", channel=vc.name, target=existing.display_target), + ephemeral=True, + ) + return + + if ign: + mode, target, display = "player", ign, ign + else: + mode, target, display = "squadron", squadron_short, squadron_short + + tly = tally.claim(interaction.guild_id, vc.id, mode, target, display, interaction.user.id) + await tally.push_status(tly) + await interaction.response.send_message( + t(lang, "commands.tally.claimed", target=display, channel=vc.name), ephemeral=True + ) + + +@bot.tree.command( + name="tally-transfer", + description=command_locale( + "Transfer the active voice-channel tally to a different player", + "commands.tally.description_transfer", + ), +) +@app_commands.describe(ign=command_locale("The player IGN to track", "commands.tally.ign")) +@discord.app_commands.autocomplete(ign=player_autocomplete) +async def tally_transfer(interaction: discord.Interaction, ign: str): + lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" + if not interaction.guild_id or not await is_guild_entitled(interaction.guild_id): + await interaction.response.send_message(t(lang, "commands.tally.premium_required"), ephemeral=True) + return + vc = _invoker_voice_channel(interaction) + if vc is None: + await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) + return + if tally.get(interaction.guild_id, vc.id) is None: + await interaction.response.send_message( + t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True + ) + return + ign = ign.strip() + tly = tally.transfer(interaction.guild_id, vc.id, ign, ign) + if tly is None: + await interaction.response.send_message( + t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True + ) + return + await tally.push_status(tly) + await interaction.response.send_message( + t(lang, "commands.tally.transferred", channel=vc.name, target=ign, + base=f"{tly.wins}W-{tly.losses}L"), + ephemeral=True, + ) + + +@bot.tree.command( + name="tally-wipe", + description=command_locale( + "Clear the active tally on your current voice channel", + "commands.tally.description_wipe", + ), +) +async def tally_wipe(interaction: discord.Interaction): + lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" + vc = _invoker_voice_channel(interaction) + if vc is None: + await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) + return + if not interaction.guild_id or not tally.wipe(interaction.guild_id, vc.id): + await interaction.response.send_message( + t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True + ) + return + await tally.clear_status(vc.id) + await interaction.response.send_message( + t(lang, "commands.tally.wiped", channel=vc.name), ephemeral=True + ) + + +@bot.tree.command( + name="dev-tally", + description="[DEV] Manually attribute a win or loss to the tally in your voice channel", +) +@app_commands.describe( + result="Whether to record a win or a loss", + username="The player to attribute it to (use alone, not with squadron)", + squadron="The squadron to attribute it to (use alone, not with username)", +) +@app_commands.choices(result=[ + app_commands.Choice(name="Win", value="win"), + app_commands.Choice(name="Loss", value="loss"), +]) +@discord.app_commands.autocomplete(username=player_autocomplete, squadron=squadron_autocomplete) +async def dev_tally(interaction: discord.Interaction, result: app_commands.Choice[str], + username: str = "", squadron: str = ""): + await collect_command_stats(interaction) + if not await is_dev_team(interaction): + await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) + return + lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" + vc = _invoker_voice_channel(interaction) + if vc is None: + await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) + return + username = username.strip() + squadron = squadron.strip() + if username and squadron: + await interaction.response.send_message( + "Use `username` or `squadron` separately — don't pass both.", ephemeral=True + ) + return + if not username and not squadron: + await interaction.response.send_message( + "Provide a `username` or a `squadron` to attribute the result to.", ephemeral=True + ) + return + if not interaction.guild_id: + await interaction.response.send_message(t(lang, "commands.tally.not_in_vc"), ephemeral=True) + return + tly = tally.apply_manual_result(interaction.guild_id, vc.id, result.value) + if tly is None: + await interaction.response.send_message( + t(lang, "commands.tally.no_active", channel=vc.name), ephemeral=True + ) + return + await tally.push_status(tly) + await interaction.response.send_message( + f"Recorded a **{result.name}** for **{username or squadron}** in **{vc.name}** " + f"(`{tly.wins}W-{tly.losses}L`).", + ephemeral=True, + ) + + # ═══════════════════════════════════════════════════════════════════════════ # /view-player-games — Last 20 games for a player # ═══════════════════════════════════════════════════════════════════════════ diff --git a/BOT/tally.py b/BOT/tally.py index 6b73083..620376f 100644 --- a/BOT/tally.py +++ b/BOT/tally.py @@ -56,13 +56,23 @@ def team_short(team: dict) -> str: return strip_tag(team.get("squadron_short") or team.get("squadron")) +def team_identities(team: dict) -> set[str]: + """All lowercased, tag-stripped squadron identifiers for a replay team.""" + out = set() + for key in ("squadron_short", "squadron", "squadron_tagged"): + v = strip_tag(team.get(key)) + if v: + out.add(v.lower()) + return out + + 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(): + if strip_tag(tally.target).lower() in team_identities(team): return idx else: # player mode target = tally.target.strip().lower() @@ -96,7 +106,7 @@ def evaluate( if is_draw: kind = "draw" tally.losses += 1 - elif winner_short and strip_tag(winner_short).lower() == team_short(teams[idx]).lower(): + elif winner_short and strip_tag(winner_short).lower() in team_identities(teams[idx]): kind = "win" tally.wins += 1 else: @@ -207,6 +217,28 @@ def wipe(guild_id: int, channel_id: int) -> bool: return True +def apply_manual_result(guild_id: int, channel_id: int, kind: str, + opponent: str = "DEV") -> Optional["Tally"]: + """Manually bump a VC's active tally by a win or loss (dev/testing). + + ``kind`` is "win" or "loss". Mirrors what a finished game would do but with + no real opponent, so ``last_opponent`` is set to a fixed label. Returns the + updated tally, or None if the VC has no active tally. + """ + tly = _REGISTRY.get((guild_id, channel_id)) + if tly is None: + return None + if kind == "win": + tly.wins += 1 + else: + tly.losses += 1 + tly.last_result_kind = kind + tly.last_opponent = opponent + tly.last_update_ts = time.time() + _save() + return tly + + # --------------------------------------------------------------------------- # Voice-status HTTP helper # --------------------------------------------------------------------------- @@ -254,3 +286,21 @@ async def on_session_processed(guild_id: int, teams: list[dict], await push_status(tly) except Exception as e: logging.error(f"[TALLY] on_session_processed error (guild={guild_id}): {e}") + + +# --------------------------------------------------------------------------- +# Idle sweep +# --------------------------------------------------------------------------- + +async def sweep_idle() -> None: + """Wipe tallies that are idle >= IDLE_TIMEOUT or whose channel is gone.""" + now = time.time() + bot = get_bot() + for (g, c), tly in list(_REGISTRY.items()): + channel_gone = bot is None or bot.get_channel(c) is None + if channel_gone: + wipe(g, c) # channel gone: drop without an HTTP clear + continue + if now - tly.last_update_ts >= IDLE_TIMEOUT: + wipe(g, c) + await clear_status(c) diff --git a/BOT/tests/test_tally_logic.py b/BOT/tests/test_tally_logic.py index 6e9d3fa..ee9d2df 100644 --- a/BOT/tests/test_tally_logic.py +++ b/BOT/tests/test_tally_logic.py @@ -14,7 +14,7 @@ Path(os.environ["STORAGE_VOL_PATH"]).mkdir(parents=True, exist_ok=True) sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # repo root for `BOT` package -from BOT.tally import Tally, team_index_for, evaluate, format_status, strip_tag +from BOT.tally import Tally, team_index_for, evaluate, format_status, strip_tag, team_identities def _teams(): @@ -111,6 +111,47 @@ def test_persistence_roundtrip(tmp_path_env=None): assert got.mode == "player" and got.target == "bravo" and got.claimed_by == 99 +def _teams_resolved_mismatch(): + # squadron_short was resolved to "DSPLALPHA" but the winning_team_squadron + # stripped value is the bare "DSPL". + return [ + {"squadron_short": "DSPLALPHA", "squadron": "DSPL", "squadron_tagged": "-DSPL-", + "players": [{"nick": "Alpha"}]}, + {"squadron_short": "ENEMY", "squadron": "ENEMY", "squadron_tagged": "=ENEMY=", + "players": [{"nick": "Echo"}]}, + ] + +def test_evaluate_win_when_winner_matches_bare_squadron_not_resolved_short(): + t = Tally(guild_id=1, channel_id=2, mode="squadron", target="DSPL", display_target="DSPL") + kind = evaluate(t, _teams_resolved_mismatch(), winner_short="DSPL", is_draw=False, session_id="s1") + assert kind == "win" # must NOT be scored as a loss + assert (t.wins, t.losses) == (1, 0) + +def test_squadron_mode_matches_bare_name_despite_resolved_short(): + t = Tally(guild_id=1, channel_id=2, mode="squadron", target="DSPL", display_target="DSPL") + from BOT.tally import team_index_for + assert team_index_for(t, _teams_resolved_mismatch()) == 0 + + +def test_apply_manual_result_win_and_loss(): + import tempfile + import BOT.tally as tal + with tempfile.TemporaryDirectory() as d: + tal.TALLY_PATH = Path(d) / "TALLY.json" + tal._REGISTRY.clear() + tal.claim(1, 2, "player", "bravo", "Bravo", user_id=7) + win = tal.apply_manual_result(1, 2, "win") + assert win is not None + assert (win.wins, win.losses) == (1, 0) + assert win.last_result_kind == "win" and win.last_opponent == "DEV" + loss = tal.apply_manual_result(1, 2, "loss") + assert loss is not None + assert (loss.wins, loss.losses) == (1, 1) + assert loss.last_result_kind == "loss" and loss.last_opponent == "DEV" + # No active tally in a different VC → None + assert tal.apply_manual_result(1, 999, "win") is None + + def _run(): fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] for fn in fns: