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,
)
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:
+213 -1
View File
@@ -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
# ═══════════════════════════════════════════════════════════════════════════
+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"))
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)
+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
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: