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