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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user