Auto merge dev → main (#1339)

* feat(tally): /tally-claim, /tally-transfer, /tally-wipe commands

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tally): idle sweep, startup load, and empty-VC expiry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(tally): parenthesize voice-state guard for clarity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tally): update live tallies when sessions finish

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tally): robust winner matching + cleanup of deleted-VC tallies

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tally): /dev-tally to manually attribute a win/loss in your VC

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-06-19 01:19:19 -07:00
committed by GitHub
parent 732595433a
commit 9222f7c53f
4 changed files with 316 additions and 4 deletions
+9
View File
@@ -67,6 +67,7 @@ from .utils import (
WILDCARD_KEYS, WILDCARD_KEYS,
) )
from .wl import record_result, record_draw, get_standings from .wl import record_result, record_draw, get_standings
from . import tally
# ============================================================================ # ============================================================================
@@ -1131,6 +1132,14 @@ async def process_session(
else: else:
new_wl = get_standings(squadrons_clean) 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 # Scoreboard Build
lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock()) lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock())
async with lock: async with lock:
+213 -1
View File
@@ -33,7 +33,7 @@ import matplotlib.dates as mdates # noqa: E402
import matplotlib.pyplot as plt # noqa: E402 import matplotlib.pyplot as plt # noqa: E402
import numpy as np import numpy as np
from discord import Color, Embed, app_commands 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 discord.ui import Select, View, button
from dotenv import load_dotenv from dotenv import load_dotenv
import matplotlib.patheffects as path_effects # noqa: E402 import matplotlib.patheffects as path_effects # noqa: E402
@@ -144,6 +144,7 @@ from .utils import (
replay_session_dir, replay_session_dir,
) )
from .wl import wl_bootstrap from .wl import wl_bootstrap
from . import tally
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
@@ -253,6 +254,14 @@ async def on_ready():
# Start all background tasks from tasks.py # Start all background tasks from tasks.py
await cast(Callable[[], Awaitable[None]], start_all_tasks)() 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 # Start the W/L queue consumer
wl_bootstrap() wl_bootstrap()
@@ -263,6 +272,34 @@ async def on_ready():
logging.error(f"Error starting tasks in startup: {e}") 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 @bot.event
async def on_guild_join(guild): async def on_guild_join(guild):
"""Handle joining a new guild: update presence and send setup hint. """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) 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 # /view-player-games — Last 20 games for a player
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
+52 -2
View File
@@ -56,13 +56,23 @@ def team_short(team: dict) -> str:
return strip_tag(team.get("squadron_short") or team.get("squadron")) 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]: def team_index_for(tally: Tally, teams: list[dict]) -> Optional[int]:
"""Return the index of the team the tracked entity is on, else None.""" """Return the index of the team the tracked entity is on, else None."""
for idx, team in enumerate(teams): for idx, team in enumerate(teams):
if not team: if not team:
continue continue
if tally.mode == "squadron": 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 return idx
else: # player mode else: # player mode
target = tally.target.strip().lower() target = tally.target.strip().lower()
@@ -96,7 +106,7 @@ def evaluate(
if is_draw: if is_draw:
kind = "draw" kind = "draw"
tally.losses += 1 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" kind = "win"
tally.wins += 1 tally.wins += 1
else: else:
@@ -207,6 +217,28 @@ def wipe(guild_id: int, channel_id: int) -> bool:
return True 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 # Voice-status HTTP helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -254,3 +286,21 @@ async def on_session_processed(guild_id: int, teams: list[dict],
await push_status(tly) await push_status(tly)
except Exception as e: except Exception as e:
logging.error(f"[TALLY] on_session_processed error (guild={guild_id}): {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)
+42 -1
View File
@@ -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 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(): 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 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(): def _run():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in fns: for fn in fns: