From 14103e48b75a9588944ebfc7c622978a734b3f93 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 08:13:31 -0600 Subject: [PATCH 01/12] feat: Add Scouting feature (Wonder Pick-style social pack opening) When a player opens a pack, a scout opportunity is posted to #pack-openings with face-down card buttons. Other players can blind-pick one card using daily scout tokens (2/day), receiving a copy. The opener keeps all cards. New files: - discord_ui/scout_view.py: ScoutView with dynamic buttons and claim logic - helpers/scouting.py: create_scout_opportunity() and embed builder - cogs/economy_new/scouting.py: /scout-tokens command and cleanup task Modified: - helpers/main.py: Hook into open_st_pr_packs() after display_cards() - paperdynasty.py: Register scouting cog Requires new API endpoints in paper-dynasty-database (scout_opportunities). Tracks #44. Co-Authored-By: Claude Opus 4.6 --- cogs/economy_new/scouting.py | 104 +++++++++++ discord_ui/__init__.py | 35 ++-- discord_ui/scout_view.py | 339 +++++++++++++++++++++++++++++++++++ helpers/__init__.py | 9 +- helpers/main.py | 46 +++-- helpers/scouting.py | 173 ++++++++++++++++++ paperdynasty.py | 69 +++---- 7 files changed, 716 insertions(+), 59 deletions(-) create mode 100644 cogs/economy_new/scouting.py create mode 100644 discord_ui/scout_view.py create mode 100644 helpers/scouting.py diff --git a/cogs/economy_new/scouting.py b/cogs/economy_new/scouting.py new file mode 100644 index 0000000..9e895e5 --- /dev/null +++ b/cogs/economy_new/scouting.py @@ -0,0 +1,104 @@ +""" +Scouting Cog — Scout token management and expired opportunity cleanup. +""" + +import datetime +import logging + +import discord +from discord import app_commands +from discord.ext import commands, tasks + +from api_calls import db_get +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.main import get_team_by_owner +from helpers.constants import PD_SEASON, IMAGES + +logger = logging.getLogger("discord_app") + +SCOUT_TOKENS_PER_DAY = 2 + + +class Scouting(commands.Cog): + """Scout token tracking and expired opportunity cleanup.""" + + def __init__(self, bot): + self.bot = bot + self.cleanup_expired.start() + + async def cog_unload(self): + self.cleanup_expired.cancel() + + @app_commands.command( + name="scout-tokens", + description="Check how many scout tokens you have left today", + ) + async def scout_tokens_command(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + team = await get_team_by_owner(interaction.user.id) + if not team: + await interaction.followup.send( + "You need a Paper Dynasty team first!", + ephemeral=True, + ) + return + + now = datetime.datetime.now() + midnight = int_timestamp( + datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + ) + + used_today = await db_get( + "rewards", + params=[ + ("name", "Scout Token"), + ("team_id", team["id"]), + ("created_after", midnight), + ], + ) + tokens_used = used_today["count"] if used_today else 0 + tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used) + + embed = get_team_embed(title="Scout Tokens", team=team) + embed.description = ( + f"**{tokens_remaining}** of **{SCOUT_TOKENS_PER_DAY}** tokens remaining today.\n\n" + f"Tokens reset at midnight Central." + ) + + if tokens_remaining == 0: + embed.description += "\n\nYou've used all your tokens! Check back tomorrow." + + await interaction.followup.send(embed=embed, ephemeral=True) + + @tasks.loop(minutes=15) + async def cleanup_expired(self): + """Log expired unclaimed scout opportunities. + + This is a safety net — the ScoutView's on_timeout handles the UI side. + If the bot restarted mid-scout, those views are lost; this just logs it. + """ + try: + now = int_timestamp(datetime.datetime.now()) + expired = await db_get( + "scout_opportunities", + params=[ + ("claimed", False), + ("expired_before", now), + ], + ) + if expired and expired.get("count", 0) > 0: + logger.info( + f"Found {expired['count']} expired unclaimed scout opportunities" + ) + except Exception as e: + logger.debug(f"Scout cleanup check failed (API may not be ready): {e}") + + @cleanup_expired.before_loop + async def before_cleanup(self): + await self.bot.wait_until_ready() + + +async def setup(bot): + await bot.add_cog(Scouting(bot)) diff --git a/discord_ui/__init__.py b/discord_ui/__init__.py index 6e40231..88a3f9d 100644 --- a/discord_ui/__init__.py +++ b/discord_ui/__init__.py @@ -7,17 +7,32 @@ This package contains all Discord UI classes and components used throughout the from .confirmations import Question, Confirm, ButtonOptions from .pagination import Pagination from .selectors import ( - SelectChoicePackTeam, SelectOpenPack, SelectPaperdexCardset, - SelectPaperdexTeam, SelectBuyPacksCardset, SelectBuyPacksTeam, - SelectUpdatePlayerTeam, SelectView + SelectChoicePackTeam, + SelectOpenPack, + SelectPaperdexCardset, + SelectPaperdexTeam, + SelectBuyPacksCardset, + SelectBuyPacksTeam, + SelectUpdatePlayerTeam, + SelectView, ) from .dropdowns import Dropdown, DropdownView +from .scout_view import ScoutView __all__ = [ - 'Question', 'Confirm', 'ButtonOptions', - 'Pagination', - 'SelectChoicePackTeam', 'SelectOpenPack', 'SelectPaperdexCardset', - 'SelectPaperdexTeam', 'SelectBuyPacksCardset', 'SelectBuyPacksTeam', - 'SelectUpdatePlayerTeam', 'SelectView', - 'Dropdown', 'DropdownView' -] \ No newline at end of file + "Question", + "Confirm", + "ButtonOptions", + "Pagination", + "SelectChoicePackTeam", + "SelectOpenPack", + "SelectPaperdexCardset", + "SelectPaperdexTeam", + "SelectBuyPacksCardset", + "SelectBuyPacksTeam", + "SelectUpdatePlayerTeam", + "SelectView", + "Dropdown", + "DropdownView", + "ScoutView", +] diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py new file mode 100644 index 0000000..2e7dfb8 --- /dev/null +++ b/discord_ui/scout_view.py @@ -0,0 +1,339 @@ +""" +Scout View — Face-down card button UI for the Scouting feature. + +When a player opens a pack, a ScoutView is posted with one button per card. +Other players can click a button to "scout" (blind-pick) one card, receiving +a copy. The opener keeps all their cards. Multiple players can scout different +cards from the same pack — each costs one scout token. +""" + +import datetime +import logging + +import discord + +from api_calls import db_get, db_post, db_patch +from helpers.main import get_team_by_owner, get_card_embeds +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.constants import IMAGES, PD_SEASON + +logger = logging.getLogger("discord_app") + +SCOUT_TOKENS_PER_DAY = 2 + + +class ScoutView(discord.ui.View): + """Displays face-down card buttons for a scout opportunity. + + - One button per card, labeled "Card 1" ... "Card N" + - Any player EXCEPT the pack opener can interact + - Each card can be scouted once; multiple players can scout different cards + - One scout per player per pack + - Timeout: 30 minutes + """ + + def __init__( + self, + scout_opp_id: int, + cards: list[dict], + opener_team: dict, + opener_user_id: int, + bot, + ): + super().__init__(timeout=1800.0) + self.scout_opp_id = scout_opp_id + self.cards = cards + self.opener_team = opener_team + self.opener_user_id = opener_user_id + self.bot = bot + self.message: discord.Message | None = None + self.card_lines: list[tuple[int, str]] = [] + + # Per-card claim tracking: position -> scouter team name + self.claimed_positions: dict[int, str] = {} + # Per-user lock: user IDs who have already scouted this pack + self.scouted_users: set[int] = set() + # Positions currently being processed (prevent double-click race) + self.processing: set[int] = set() + + for i, card in enumerate(cards): + button = ScoutButton( + card=card, + position=i, + scout_view=self, + ) + self.add_item(button) + + @property + def all_claimed(self) -> bool: + return len(self.claimed_positions) >= len(self.cards) + + async def update_message(self): + """Refresh the embed with current claim state.""" + if not self.message: + return + + from helpers.scouting import build_scouted_card_list + + scouted_ids = {} + for pos, team_name in self.claimed_positions.items(): + player_id = self.cards[pos]["player"]["player_id"] + scouted_ids[player_id] = team_name + + card_list = build_scouted_card_list(self.card_lines, scouted_ids) + claim_count = len(self.claimed_positions) + + if self.all_claimed: + title = "Fully Scouted!" + footer_text = f"Paper Dynasty Season {PD_SEASON} \u2022 All cards scouted" + else: + title = f"Scout Opportunity! ({claim_count}/{len(self.cards)} scouted)" + footer_text = ( + f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player" + ) + + embed = get_team_embed(title=title, team=self.opener_team) + embed.description = ( + f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}\n\n" + ) + if not self.all_claimed: + embed.description += ( + "Pick a card — but which is which?\n" + "Costs 1 Scout Token (2 per day, resets at midnight Central)." + ) + + embed.set_footer(text=footer_text, icon_url=IMAGES["logo"]) + + try: + await self.message.edit(embed=embed, view=self) + except Exception as e: + logger.error(f"Failed to update scout message: {e}") + + async def on_timeout(self): + """Disable all buttons and update the embed when the window expires.""" + for item in self.children: + item.disabled = True + + if self.message: + try: + from helpers.scouting import build_scouted_card_list + + scouted_ids = {} + for pos, team_name in self.claimed_positions.items(): + player_id = self.cards[pos]["player"]["player_id"] + scouted_ids[player_id] = team_name + + card_list = build_scouted_card_list(self.card_lines, scouted_ids) + claim_count = len(self.claimed_positions) + + if claim_count > 0: + title = ( + f"Scout Window Closed ({claim_count}/{len(self.cards)} scouted)" + ) + else: + title = "Scout Window Closed" + + embed = get_team_embed(title=title, team=self.opener_team) + embed.description = ( + f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}" + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", + icon_url=IMAGES["logo"], + ) + await self.message.edit(embed=embed, view=self) + except Exception as e: + logger.error(f"Failed to edit expired scout message: {e}") + + +class ScoutButton(discord.ui.Button): + """A single face-down card button in a ScoutView.""" + + def __init__(self, card: dict, position: int, scout_view: ScoutView): + super().__init__( + label=f"Card {position + 1}", + style=discord.ButtonStyle.secondary, + row=0, + ) + self.card = card + self.position = position + self.scout_view: ScoutView = scout_view + + async def callback(self, interaction: discord.Interaction): + view = self.scout_view + + # Block the opener + if interaction.user.id == view.opener_user_id: + await interaction.response.send_message( + "You can't scout your own pack!", + ephemeral=True, + ) + return + + # One scout per player per pack + if interaction.user.id in view.scouted_users: + await interaction.response.send_message( + "You already scouted a card from this pack!", + ephemeral=True, + ) + return + + # This card already taken + if self.position in view.claimed_positions: + await interaction.response.send_message( + "This card was already scouted! Try a different one.", + ephemeral=True, + ) + return + + # Prevent double-click race on same card + if self.position in view.processing: + await interaction.response.send_message( + "Hold on, someone's claiming this card right now...", + ephemeral=True, + ) + return + + view.processing.add(self.position) + await interaction.response.defer(ephemeral=True) + + try: + # Get scouting player's team + scouter_team = await get_team_by_owner(interaction.user.id) + if not scouter_team: + await interaction.followup.send( + "You need a Paper Dynasty team to scout! Ask an admin to set one up.", + ephemeral=True, + ) + return + + # Check scout token balance + now = datetime.datetime.now() + midnight = int_timestamp( + datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + ) + used_today = await db_get( + "rewards", + params=[ + ("name", "Scout Token"), + ("team_id", scouter_team["id"]), + ("created_after", midnight), + ], + ) + tokens_used = used_today["count"] if used_today else 0 + + if tokens_used >= SCOUT_TOKENS_PER_DAY: + await interaction.followup.send( + "You're out of scout tokens for today! You get 2 per day, resetting at midnight Central.", + ephemeral=True, + ) + return + + # Record the claim in the database + try: + await db_post( + "scout_claims", + payload={ + "scout_opportunity_id": view.scout_opp_id, + "card_id": self.card["id"], + "claimed_by_team_id": scouter_team["id"], + }, + ) + except Exception as e: + logger.error(f"Failed to record scout claim: {e}") + await interaction.followup.send( + "Something went wrong claiming this scout. Try again!", + ephemeral=True, + ) + return + + # Consume a scout token + current = await db_get("current") + await db_post( + "rewards", + payload={ + "name": "Scout Token", + "team_id": scouter_team["id"], + "season": current["season"] if current else PD_SEASON, + "week": current["week"] if current else 1, + "created": int_timestamp(now), + }, + ) + + # Create a copy of the card for the scouter + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": self.card["player"]["player_id"], + "team_id": scouter_team["id"], + } + ], + }, + ) + + # Track the claim + view.claimed_positions[self.position] = scouter_team["lname"] + view.scouted_users.add(interaction.user.id) + + # Update this button + self.disabled = True + self.style = discord.ButtonStyle.success + self.label = "Scouted!" + + # If all cards claimed, disable remaining buttons and stop + if view.all_claimed: + for item in view.children: + item.disabled = True + view.stop() + + # Update the shared embed + await view.update_message() + + # Send the scouter their card details (ephemeral) + player_name = self.card["player"]["p_name"] + rarity_name = self.card["player"]["rarity"]["name"] + + card_for_embed = { + "player": self.card["player"], + "team": scouter_team, + } + card_embeds = await get_card_embeds(card_for_embed) + await interaction.followup.send( + content=f"You scouted a **{rarity_name}** {player_name}!", + embeds=card_embeds, + ephemeral=True, + ) + + # Notify for shiny scouts (rarity >= 5) + if self.card["player"]["rarity"]["value"] >= 5: + try: + from helpers.discord_utils import send_to_channel + + notif_embed = get_team_embed(title="Rare Scout!", team=scouter_team) + notif_embed.description = ( + f"**{scouter_team['lname']}** scouted a " + f"**{rarity_name}** {player_name}!" + ) + notif_embed.set_thumbnail( + url=self.card["player"].get("headshot", IMAGES["logo"]) + ) + await send_to_channel( + view.bot, "pd-network-news", embed=notif_embed + ) + except Exception as e: + logger.error(f"Failed to send shiny scout notification: {e}") + + except Exception as e: + logger.error(f"Unexpected error in scout callback: {e}", exc_info=True) + try: + await interaction.followup.send( + "Something went wrong. Please try again.", + ephemeral=True, + ) + except Exception: + pass + finally: + view.processing.discard(self.position) diff --git a/helpers/__init__.py b/helpers/__init__.py index 4b62f4e..8c7e39d 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -6,7 +6,7 @@ The package is organized into logical modules for better maintainability. Modules: - constants: Application constants and configuration -- utils: General utility functions +- utils: General utility functions - random_content: Random content generators - search_utils: Search and fuzzy matching functionality - discord_utils: Discord helper functions @@ -21,9 +21,10 @@ Modules: # This allows existing code to continue working during the migration from helpers.main import * -# Import from migrated modules +# Import from migrated modules from .constants import * from .utils import * from .random_content import * -from .search_utils import * -from .discord_utils import * \ No newline at end of file +from .search_utils import * +from .discord_utils import * +from .scouting import * diff --git a/helpers/main.py b/helpers/main.py index ed16a3b..4dfc659 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -8,7 +8,7 @@ import traceback import discord import pygsheets -import requests +import aiohttp from discord.ext import commands from api_calls import * @@ -43,17 +43,21 @@ async def get_player_photo(player): ) try: - resp = requests.get(req_url, timeout=0.5) - except Exception as e: + async with aiohttp.ClientSession() as session: + async with session.get( + req_url, timeout=aiohttp.ClientTimeout(total=0.5) + ) as resp: + if resp.status == 200: + data = await resp.json() + if data["player"] and data["player"][0]["strSport"] == "Baseball": + await db_patch( + "players", + object_id=player["player_id"], + params=[("headshot", data["player"][0]["strThumb"])], + ) + return data["player"][0]["strThumb"] + except Exception: return None - if resp.status_code == 200 and resp.json()["player"]: - if resp.json()["player"][0]["strSport"] == "Baseball": - await db_patch( - "players", - object_id=player["player_id"], - params=[("headshot", resp.json()["player"][0]["strThumb"])], - ) - return resp.json()["player"][0]["strThumb"] return None @@ -1681,9 +1685,9 @@ async def paperdex_team_embed(team: dict, mlb_team: dict) -> list[discord.Embed] for cardset_id in coll_data: if cardset_id != "total_owned": if coll_data[cardset_id]["players"]: - coll_data[cardset_id]["embeds"][0].description = ( - f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}" - ) + coll_data[cardset_id]["embeds"][ + 0 + ].description = f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}" coll_data[cardset_id]["embeds"][0].add_field( name="# Collected / # Total Cards", value=f"{coll_data[cardset_id]['owned']} / {len(coll_data[cardset_id]['players'])}", @@ -1749,6 +1753,8 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): all_cards = [] for p_id in pack_ids: new_cards = await db_get("cards", params=[("pack_id", p_id)]) + for card in new_cards["cards"]: + card.setdefault("pack_id", p_id) all_cards.extend(new_cards["cards"]) if not all_cards: @@ -1764,6 +1770,18 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): await context.channel.send(content=f"Let's head down to {pack_channel.mention}!") await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover) + # Create scout opportunities for each pack + from helpers.scouting import create_scout_opportunity + + for p_id in pack_ids: + pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] + if pack_cards: + await create_scout_opportunity( + pack_cards, team, pack_channel, author, context + ) + if len(pack_ids) > 1: + await asyncio.sleep(2) + async def get_choice_from_cards( interaction: discord.Interaction, diff --git a/helpers/scouting.py b/helpers/scouting.py new file mode 100644 index 0000000..32536fc --- /dev/null +++ b/helpers/scouting.py @@ -0,0 +1,173 @@ +""" +Scouting Helper Functions + +Handles creation of scout opportunities after pack openings +and embed formatting for the scouting feature. +""" + +import asyncio +import datetime +import logging +import random + +import discord + +from api_calls import db_post +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.constants import IMAGES, PD_SEASON + +logger = logging.getLogger("discord_app") + +SCOUT_WINDOW_SECONDS = 1800 # 30 minutes + +# Rarity value → display symbol +RARITY_SYMBOLS = { + 8: "\U0001f7e1", # HoF — yellow + 5: "\U0001f7e3", # MVP — purple + 3: "\U0001f535", # All-Star — blue + 2: "\U0001f7e2", # Starter — green + 1: "\u26aa", # Reserve — white + 0: "\u26ab", # Replacement — black +} + + +def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: + """Build a shuffled list of (player_id, display_line) tuples.""" + lines = [] + for card in cards: + player = card["player"] + rarity_val = player["rarity"]["value"] + symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab") + lines.append( + ( + player["player_id"], + f"{symbol} {player['rarity']['name']} — {player['p_name']}", + ) + ) + random.shuffle(lines) + return lines + + +def build_scout_embed( + opener_team: dict, + cards: list[dict], + card_lines: list[tuple[int, str]] = None, +) -> discord.Embed: + """Build the embed shown above the scout buttons. + + Shows a shuffled list of cards (rarity + player name) so scouters + know what's in the pack but not which button maps to which card. + Returns (embed, card_lines) so the view can store the shuffled order. + """ + embed = get_team_embed(title="Scout Opportunity!", team=opener_team) + + if card_lines is None: + card_lines = _build_card_lines(cards) + + card_list = "\n".join(line for _, line in card_lines) + + embed.description = ( + f"**{opener_team['lname']}** just opened a pack!\n\n" + f"**Cards in this pack:**\n{card_list}\n\n" + f"Pick a card — but which is which?\n" + f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" + f"This window closes in **30 minutes**." + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack", + icon_url=IMAGES["logo"], + ) + return embed, card_lines + + +def build_scouted_card_list( + card_lines: list[tuple[int, str]], + scouted_cards: dict[int, str], +) -> str: + """Rebuild the card list marking scouted cards with the scouter's team name. + + Parameters + ---------- + card_lines : shuffled list of (player_id, display_line) tuples + scouted_cards : {player_id: scouter_team_name} for each claimed card + """ + result = [] + for player_id, line in card_lines: + if player_id in scouted_cards: + team_name = scouted_cards[player_id] + result.append(f"{line} \u2014 \u2714\ufe0f *{team_name}*") + else: + result.append(line) + return "\n".join(result) + + +async def create_scout_opportunity( + pack_cards: list[dict], + opener_team: dict, + channel: discord.TextChannel, + opener_user, + context, +) -> None: + """Create a scout opportunity and post the ScoutView to the channel. + + Called after display_cards() completes in open_st_pr_packs(). + Wrapped in try/except so scouting failures never crash pack opening. + + Parameters + ---------- + pack_cards : list of card dicts from a single pack + opener_team : team dict for the pack opener + channel : the #pack-openings channel + opener_user : discord.Member or discord.User who opened the pack + context : the command context (Context or Interaction), used to get bot + """ + from discord_ui.scout_view import ScoutView + + # Only create scout opportunities in the pack-openings channel + if not channel or channel.name != "pack-openings": + return + + if not pack_cards: + return + + now = datetime.datetime.now() + expires_at = int_timestamp(now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)) + created = int_timestamp(now) + + card_ids = [c["id"] for c in pack_cards] + + try: + scout_opp = await db_post( + "scout_opportunities", + payload={ + "pack_id": pack_cards[0].get("pack_id"), + "opener_team_id": opener_team["id"], + "card_ids": card_ids, + "expires_at": expires_at, + "created": created, + }, + ) + except Exception as e: + logger.error(f"Failed to create scout opportunity: {e}") + return + + embed, card_lines = build_scout_embed(opener_team, pack_cards) + + # Get bot reference from context + bot = getattr(context, "bot", None) or getattr(context, "client", None) + + view = ScoutView( + scout_opp_id=scout_opp["id"], + cards=pack_cards, + opener_team=opener_team, + opener_user_id=opener_user.id, + bot=bot, + ) + view.card_lines = card_lines + + try: + msg = await channel.send(embed=embed, view=view) + view.message = msg + except Exception as e: + logger.error(f"Failed to post scout opportunity message: {e}") diff --git a/paperdynasty.py b/paperdynasty.py index d7d9ea1..951654a 100644 --- a/paperdynasty.py +++ b/paperdynasty.py @@ -12,12 +12,12 @@ from in_game.gameplay_queries import get_channel_game_or_none from health_server import run_health_server from notify_restart import send_restart_notification -raw_log_level = os.getenv('LOG_LEVEL') -if raw_log_level == 'DEBUG': +raw_log_level = os.getenv("LOG_LEVEL") +if raw_log_level == "DEBUG": log_level = logging.DEBUG -elif raw_log_level == 'INFO': +elif raw_log_level == "INFO": log_level = logging.INFO -elif raw_log_level == 'WARN': +elif raw_log_level == "WARN": log_level = logging.WARNING else: log_level = logging.ERROR @@ -29,17 +29,17 @@ else: # level=log_level # ) # logger.getLogger('discord.http').setLevel(logger.INFO) -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") logger.setLevel(log_level) handler = RotatingFileHandler( - filename='logs/discord.log', + filename="logs/discord.log", # encoding='utf-8', maxBytes=32 * 1024 * 1024, # 32 MiB backupCount=5, # Rotate through 5 files ) -formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler.setFormatter(formatter) # dt_fmt = '%Y-%m-%d %H:%M:%S' @@ -48,27 +48,30 @@ handler.setFormatter(formatter) logger.addHandler(handler) COGS = [ - 'cogs.owner', - 'cogs.admins', - 'cogs.economy', - 'cogs.players', - 'cogs.gameplay', + "cogs.owner", + "cogs.admins", + "cogs.economy", + "cogs.players", + "cogs.gameplay", + "cogs.economy_new.scouting", ] intents = discord.Intents.default() intents.members = True intents.message_content = True -bot = commands.Bot(command_prefix='.', - intents=intents, - # help_command=None, - description='The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.', - case_insensitive=True, - owner_id=258104532423147520) +bot = commands.Bot( + command_prefix=".", + intents=intents, + # help_command=None, + description="The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.", + case_insensitive=True, + owner_id=258104532423147520, +) @bot.event async def on_ready(): - logger.info('Logged in as:') + logger.info("Logged in as:") logger.info(bot.user.name) logger.info(bot.user.id) @@ -77,9 +80,11 @@ async def on_ready(): @bot.tree.error -async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError): +async def on_app_command_error( + interaction: discord.Interaction, error: discord.app_commands.AppCommandError +): """Global error handler for all app commands (slash commands).""" - logger.error(f'App command error in {interaction.command}: {error}', exc_info=error) + logger.error(f"App command error in {interaction.command}: {error}", exc_info=error) # CRITICAL: Release play lock if command failed during gameplay # This prevents permanent user lockouts when exceptions occur @@ -97,22 +102,23 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord. session.add(current_play) session.commit() except Exception as lock_error: - logger.error(f'Failed to release play lock after error: {lock_error}', exc_info=lock_error) + logger.error( + f"Failed to release play lock after error: {lock_error}", + exc_info=lock_error, + ) # Try to respond to the user try: if not interaction.response.is_done(): await interaction.response.send_message( - f'❌ An error occurred: {str(error)}', - ephemeral=True + f"❌ An error occurred: {str(error)}", ephemeral=True ) else: await interaction.followup.send( - f'❌ An error occurred: {str(error)}', - ephemeral=True + f"❌ An error occurred: {str(error)}", ephemeral=True ) except Exception as e: - logger.error(f'Failed to send error message to user: {e}') + logger.error(f"Failed to send error message to user: {e}") async def main(): @@ -120,10 +126,10 @@ async def main(): for c in COGS: try: await bot.load_extension(c) - logger.info(f'Loaded cog: {c}') + logger.info(f"Loaded cog: {c}") except Exception as e: - logger.error(f'Failed to load cog: {c}') - logger.error(f'{e}') + logger.error(f"Failed to load cog: {c}") + logger.error(f"{e}") # Start health server and bot concurrently async with bot: @@ -132,7 +138,7 @@ async def main(): try: # Start bot (this blocks until bot stops) - await bot.start(os.environ.get('BOT_TOKEN', 'NONE')) + await bot.start(os.environ.get("BOT_TOKEN", "NONE")) finally: # Cleanup: cancel health server when bot stops health_task.cancel() @@ -141,4 +147,5 @@ async def main(): except asyncio.CancelledError: pass + asyncio.run(main()) -- 2.25.1 From d538c679c319be12ab3a99aca171ed5e7801f959 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 19:29:44 -0600 Subject: [PATCH 02/12] refactor: Consolidate scouting utilities, add test suite, use Discord timestamps - Consolidate SCOUT_TOKENS_PER_DAY and get_scout_tokens_used() into helpers/scouting.py (was duplicated across 3 files) - Add midnight_timestamp() utility to helpers/utils.py - Remove _build_scouted_ids() wrapper, use self.claims directly - Fix build_scout_embed return type annotation - Use Discord relative timestamps for scout window countdown - Add 66-test suite covering helpers, ScoutView, and cog Co-Authored-By: Claude Opus 4.6 --- cogs/economy_new/scouting.py | 18 +- discord_ui/__init__.py | 6 +- discord_ui/scout_view.py | 137 +-- helpers/scouting.py | 53 +- helpers/utils.py | 115 +-- tests/scouting/__init__.py | 0 tests/scouting/conftest.py | 160 ++++ tests/scouting/test_scout_view.py | 1029 +++++++++++++++++++++++ tests/scouting/test_scouting_cog.py | 269 ++++++ tests/scouting/test_scouting_helpers.py | 374 ++++++++ 10 files changed, 1987 insertions(+), 174 deletions(-) create mode 100644 tests/scouting/__init__.py create mode 100644 tests/scouting/conftest.py create mode 100644 tests/scouting/test_scout_view.py create mode 100644 tests/scouting/test_scouting_cog.py create mode 100644 tests/scouting/test_scouting_helpers.py diff --git a/cogs/economy_new/scouting.py b/cogs/economy_new/scouting.py index 9e895e5..118927a 100644 --- a/cogs/economy_new/scouting.py +++ b/cogs/economy_new/scouting.py @@ -10,6 +10,7 @@ from discord import app_commands from discord.ext import commands, tasks from api_calls import db_get +from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used from helpers.utils import int_timestamp from helpers.discord_utils import get_team_embed from helpers.main import get_team_by_owner @@ -17,8 +18,6 @@ from helpers.constants import PD_SEASON, IMAGES logger = logging.getLogger("discord_app") -SCOUT_TOKENS_PER_DAY = 2 - class Scouting(commands.Cog): """Scout token tracking and expired opportunity cleanup.""" @@ -45,20 +44,7 @@ class Scouting(commands.Cog): ) return - now = datetime.datetime.now() - midnight = int_timestamp( - datetime.datetime(now.year, now.month, now.day, 0, 0, 0) - ) - - used_today = await db_get( - "rewards", - params=[ - ("name", "Scout Token"), - ("team_id", team["id"]), - ("created_after", midnight), - ], - ) - tokens_used = used_today["count"] if used_today else 0 + tokens_used = await get_scout_tokens_used(team["id"]) tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used) embed = get_team_embed(title="Scout Tokens", team=team) diff --git a/discord_ui/__init__.py b/discord_ui/__init__.py index 88a3f9d..ffc1390 100644 --- a/discord_ui/__init__.py +++ b/discord_ui/__init__.py @@ -17,7 +17,10 @@ from .selectors import ( SelectView, ) from .dropdowns import Dropdown, DropdownView -from .scout_view import ScoutView + +# ScoutView intentionally NOT imported here to avoid circular import: +# helpers.main → discord_ui → scout_view → helpers.main +# Import directly: from discord_ui.scout_view import ScoutView __all__ = [ "Question", @@ -34,5 +37,4 @@ __all__ = [ "SelectView", "Dropdown", "DropdownView", - "ScoutView", ] diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 2e7dfb8..11ce77a 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -3,32 +3,30 @@ Scout View — Face-down card button UI for the Scouting feature. When a player opens a pack, a ScoutView is posted with one button per card. Other players can click a button to "scout" (blind-pick) one card, receiving -a copy. The opener keeps all their cards. Multiple players can scout different -cards from the same pack — each costs one scout token. +a copy. The opener keeps all their cards. Multiple players can scout the same +card — each gets their own copy. """ -import datetime import logging import discord -from api_calls import db_get, db_post, db_patch +from api_calls import db_get, db_post from helpers.main import get_team_by_owner, get_card_embeds +from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used from helpers.utils import int_timestamp from helpers.discord_utils import get_team_embed from helpers.constants import IMAGES, PD_SEASON logger = logging.getLogger("discord_app") -SCOUT_TOKENS_PER_DAY = 2 - class ScoutView(discord.ui.View): """Displays face-down card buttons for a scout opportunity. - One button per card, labeled "Card 1" ... "Card N" - Any player EXCEPT the pack opener can interact - - Each card can be scouted once; multiple players can scout different cards + - Any card can be scouted multiple times by different players - One scout per player per pack - Timeout: 30 minutes """ @@ -40,6 +38,7 @@ class ScoutView(discord.ui.View): opener_team: dict, opener_user_id: int, bot, + expires_unix: int = None, ): super().__init__(timeout=1800.0) self.scout_opp_id = scout_opp_id @@ -47,15 +46,18 @@ class ScoutView(discord.ui.View): self.opener_team = opener_team self.opener_user_id = opener_user_id self.bot = bot + self.expires_unix = expires_unix self.message: discord.Message | None = None self.card_lines: list[tuple[int, str]] = [] - # Per-card claim tracking: position -> scouter team name - self.claimed_positions: dict[int, str] = {} + # Per-card claim tracking: player_id -> list of scouter team names + self.claims: dict[int, list[str]] = {} # Per-user lock: user IDs who have already scouted this pack self.scouted_users: set[int] = set() - # Positions currently being processed (prevent double-click race) - self.processing: set[int] = set() + # Users currently being processed (prevent double-click race) + self.processing_users: set[int] = set() + # Total scout count + self.total_scouts = 0 for i, card in enumerate(cards): button = ScoutButton( @@ -65,10 +67,6 @@ class ScoutView(discord.ui.View): ) self.add_item(button) - @property - def all_claimed(self) -> bool: - return len(self.claimed_positions) >= len(self.cards) - async def update_message(self): """Refresh the embed with current claim state.""" if not self.message: @@ -76,34 +74,26 @@ class ScoutView(discord.ui.View): from helpers.scouting import build_scouted_card_list - scouted_ids = {} - for pos, team_name in self.claimed_positions.items(): - player_id = self.cards[pos]["player"]["player_id"] - scouted_ids[player_id] = team_name - - card_list = build_scouted_card_list(self.card_lines, scouted_ids) - claim_count = len(self.claimed_positions) - - if self.all_claimed: - title = "Fully Scouted!" - footer_text = f"Paper Dynasty Season {PD_SEASON} \u2022 All cards scouted" - else: - title = f"Scout Opportunity! ({claim_count}/{len(self.cards)} scouted)" - footer_text = ( - f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player" - ) + card_list = build_scouted_card_list(self.card_lines, self.claims) + title = f"Scout Opportunity! ({self.total_scouts} scouted)" embed = get_team_embed(title=title, team=self.opener_team) - embed.description = ( - f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}\n\n" - ) - if not self.all_claimed: - embed.description += ( - "Pick a card — but which is which?\n" - "Costs 1 Scout Token (2 per day, resets at midnight Central)." - ) + if self.expires_unix: + time_line = f"Scout window closes ." + else: + time_line = "Scout window closes in **30 minutes**." - embed.set_footer(text=footer_text, icon_url=IMAGES["logo"]) + embed.description = ( + f"**{self.opener_team['lname']}**'s pack\n\n" + f"{card_list}\n\n" + f"Pick a card — but which is which?\n" + f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" + f"{time_line}" + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player", + icon_url=IMAGES["logo"], + ) try: await self.message.edit(embed=embed, view=self) @@ -119,18 +109,10 @@ class ScoutView(discord.ui.View): try: from helpers.scouting import build_scouted_card_list - scouted_ids = {} - for pos, team_name in self.claimed_positions.items(): - player_id = self.cards[pos]["player"]["player_id"] - scouted_ids[player_id] = team_name + card_list = build_scouted_card_list(self.card_lines, self.claims) - card_list = build_scouted_card_list(self.card_lines, scouted_ids) - claim_count = len(self.claimed_positions) - - if claim_count > 0: - title = ( - f"Scout Window Closed ({claim_count}/{len(self.cards)} scouted)" - ) + if self.total_scouts > 0: + title = f"Scout Window Closed ({self.total_scouts} scouted)" else: title = "Scout Window Closed" @@ -179,23 +161,11 @@ class ScoutButton(discord.ui.Button): ) return - # This card already taken - if self.position in view.claimed_positions: - await interaction.response.send_message( - "This card was already scouted! Try a different one.", - ephemeral=True, - ) + # Prevent double-click race for same user + if interaction.user.id in view.processing_users: return - # Prevent double-click race on same card - if self.position in view.processing: - await interaction.response.send_message( - "Hold on, someone's claiming this card right now...", - ephemeral=True, - ) - return - - view.processing.add(self.position) + view.processing_users.add(interaction.user.id) await interaction.response.defer(ephemeral=True) try: @@ -209,19 +179,7 @@ class ScoutButton(discord.ui.Button): return # Check scout token balance - now = datetime.datetime.now() - midnight = int_timestamp( - datetime.datetime(now.year, now.month, now.day, 0, 0, 0) - ) - used_today = await db_get( - "rewards", - params=[ - ("name", "Scout Token"), - ("team_id", scouter_team["id"]), - ("created_after", midnight), - ], - ) - tokens_used = used_today["count"] if used_today else 0 + tokens_used = await get_scout_tokens_used(scouter_team["id"]) if tokens_used >= SCOUT_TOKENS_PER_DAY: await interaction.followup.send( @@ -257,7 +215,7 @@ class ScoutButton(discord.ui.Button): "team_id": scouter_team["id"], "season": current["season"] if current else PD_SEASON, "week": current["week"] if current else 1, - "created": int_timestamp(now), + "created": int_timestamp(), }, ) @@ -275,19 +233,12 @@ class ScoutButton(discord.ui.Button): ) # Track the claim - view.claimed_positions[self.position] = scouter_team["lname"] + player_id = self.card["player"]["player_id"] + if player_id not in view.claims: + view.claims[player_id] = [] + view.claims[player_id].append(scouter_team["lname"]) view.scouted_users.add(interaction.user.id) - - # Update this button - self.disabled = True - self.style = discord.ButtonStyle.success - self.label = "Scouted!" - - # If all cards claimed, disable remaining buttons and stop - if view.all_claimed: - for item in view.children: - item.disabled = True - view.stop() + view.total_scouts += 1 # Update the shared embed await view.update_message() @@ -336,4 +287,4 @@ class ScoutButton(discord.ui.Button): except Exception: pass finally: - view.processing.discard(self.position) + view.processing_users.discard(interaction.user.id) diff --git a/helpers/scouting.py b/helpers/scouting.py index 32536fc..ab2d2c2 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -12,13 +12,14 @@ import random import discord -from api_calls import db_post -from helpers.utils import int_timestamp +from api_calls import db_get, db_post +from helpers.utils import int_timestamp, midnight_timestamp from helpers.discord_utils import get_team_embed from helpers.constants import IMAGES, PD_SEASON logger = logging.getLogger("discord_app") +SCOUT_TOKENS_PER_DAY = 2 SCOUT_WINDOW_SECONDS = 1800 # 30 minutes # Rarity value → display symbol @@ -32,6 +33,19 @@ RARITY_SYMBOLS = { } +async def get_scout_tokens_used(team_id: int) -> int: + """Return how many scout tokens a team has used today.""" + used_today = await db_get( + "rewards", + params=[ + ("name", "Scout Token"), + ("team_id", team_id), + ("created_after", midnight_timestamp()), + ], + ) + return used_today["count"] if used_today else 0 + + def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: """Build a shuffled list of (player_id, display_line) tuples.""" lines = [] @@ -53,7 +67,8 @@ def build_scout_embed( opener_team: dict, cards: list[dict], card_lines: list[tuple[int, str]] = None, -) -> discord.Embed: + expires_unix: int = None, +) -> tuple[discord.Embed, list[tuple[int, str]]]: """Build the embed shown above the scout buttons. Shows a shuffled list of cards (rarity + player name) so scouters @@ -67,12 +82,17 @@ def build_scout_embed( card_list = "\n".join(line for _, line in card_lines) + if expires_unix: + time_line = f"Scout window closes ." + else: + time_line = "Scout window closes in **30 minutes**." + embed.description = ( f"**{opener_team['lname']}** just opened a pack!\n\n" f"**Cards in this pack:**\n{card_list}\n\n" f"Pick a card — but which is which?\n" f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" - f"This window closes in **30 minutes**." + f"{time_line}" ) embed.set_footer( text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack", @@ -83,20 +103,25 @@ def build_scout_embed( def build_scouted_card_list( card_lines: list[tuple[int, str]], - scouted_cards: dict[int, str], + scouted_cards: dict[int, list[str]], ) -> str: - """Rebuild the card list marking scouted cards with the scouter's team name. + """Rebuild the card list marking scouted cards with scouter team names. Parameters ---------- card_lines : shuffled list of (player_id, display_line) tuples - scouted_cards : {player_id: scouter_team_name} for each claimed card + scouted_cards : {player_id: [team_name, ...]} for each claimed card """ result = [] for player_id, line in card_lines: - if player_id in scouted_cards: - team_name = scouted_cards[player_id] - result.append(f"{line} \u2014 \u2714\ufe0f *{team_name}*") + teams = scouted_cards.get(player_id) + if teams: + count = len(teams) + names = ", ".join(f"*{t}*" for t in teams) + if count == 1: + result.append(f"{line} \u2014 \u2714\ufe0f {names}") + else: + result.append(f"{line} \u2014 \u2714\ufe0f x{count} ({names})") else: result.append(line) return "\n".join(result) @@ -152,7 +177,12 @@ async def create_scout_opportunity( logger.error(f"Failed to create scout opportunity: {e}") return - embed, card_lines = build_scout_embed(opener_team, pack_cards) + expires_unix = int( + (now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)).timestamp() + ) + embed, card_lines = build_scout_embed( + opener_team, pack_cards, expires_unix=expires_unix + ) # Get bot reference from context bot = getattr(context, "bot", None) or getattr(context, "client", None) @@ -163,6 +193,7 @@ async def create_scout_opportunity( opener_team=opener_team, opener_user_id=opener_user.id, bot=bot, + expires_unix=expires_unix, ) view.card_lines = card_lines diff --git a/helpers/utils.py b/helpers/utils.py index 9fc70e3..7535bf7 100644 --- a/helpers/utils.py +++ b/helpers/utils.py @@ -4,6 +4,7 @@ General Utilities This module contains standalone utility functions with minimal dependencies, including timestamp conversion, position abbreviations, and simple helpers. """ + import datetime from typing import Optional import discord @@ -16,48 +17,55 @@ def int_timestamp(datetime_obj: Optional[datetime.datetime] = None): return int(datetime.datetime.now().timestamp()) +def midnight_timestamp() -> int: + """Return today's midnight (00:00:00) as an integer millisecond timestamp.""" + now = datetime.datetime.now() + midnight = datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + return int_timestamp(midnight) + + def get_pos_abbrev(field_pos: str) -> str: """Convert position name to standard abbreviation.""" - if field_pos.lower() == 'catcher': - return 'C' - elif field_pos.lower() == 'first baseman': - return '1B' - elif field_pos.lower() == 'second baseman': - return '2B' - elif field_pos.lower() == 'third baseman': - return '3B' - elif field_pos.lower() == 'shortstop': - return 'SS' - elif field_pos.lower() == 'left fielder': - return 'LF' - elif field_pos.lower() == 'center fielder': - return 'CF' - elif field_pos.lower() == 'right fielder': - return 'RF' + if field_pos.lower() == "catcher": + return "C" + elif field_pos.lower() == "first baseman": + return "1B" + elif field_pos.lower() == "second baseman": + return "2B" + elif field_pos.lower() == "third baseman": + return "3B" + elif field_pos.lower() == "shortstop": + return "SS" + elif field_pos.lower() == "left fielder": + return "LF" + elif field_pos.lower() == "center fielder": + return "CF" + elif field_pos.lower() == "right fielder": + return "RF" else: - return 'P' + return "P" def position_name_to_abbrev(position_name): """Convert position name to abbreviation (alternate format).""" - if position_name == 'Catcher': - return 'C' - elif position_name == 'First Base': - return '1B' - elif position_name == 'Second Base': - return '2B' - elif position_name == 'Third Base': - return '3B' - elif position_name == 'Shortstop': - return 'SS' - elif position_name == 'Left Field': - return 'LF' - elif position_name == 'Center Field': - return 'CF' - elif position_name == 'Right Field': - return 'RF' - elif position_name == 'Pitcher': - return 'P' + if position_name == "Catcher": + return "C" + elif position_name == "First Base": + return "1B" + elif position_name == "Second Base": + return "2B" + elif position_name == "Third Base": + return "3B" + elif position_name == "Shortstop": + return "SS" + elif position_name == "Left Field": + return "LF" + elif position_name == "Center Field": + return "CF" + elif position_name == "Right Field": + return "RF" + elif position_name == "Pitcher": + return "P" else: return position_name @@ -67,13 +75,13 @@ def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool: for x in user.roles: if x.name == role_name: return True - + return False def get_roster_sheet_legacy(team): """Get legacy roster sheet URL for a team.""" - return f'https://docs.google.com/spreadsheets/d/{team.gsheet}/edit' + return f"https://docs.google.com/spreadsheets/d/{team.gsheet}/edit" def get_roster_sheet(team): @@ -83,13 +91,15 @@ def get_roster_sheet(team): Handles both dict and Team object formats. """ # Handle both dict (team["gsheet"]) and object (team.gsheet) formats - gsheet = team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None) - return f'https://docs.google.com/spreadsheets/d/{gsheet}/edit' + gsheet = ( + team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None) + ) + return f"https://docs.google.com/spreadsheets/d/{gsheet}/edit" def get_player_url(team, player) -> str: """Generate player URL for SBA or Baseball Reference.""" - if team.get('league') == 'SBA': + if team.get("league") == "SBA": return f'https://statsplus.net/super-baseball-association/player/{player["player_id"]}' else: return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml' @@ -101,7 +111,7 @@ def owner_only(ctx) -> bool: owners = [287463767924137994, 1087936030899347516] # Handle both Context (has .author) and Interaction (has .user) objects - user = getattr(ctx, 'user', None) or getattr(ctx, 'author', None) + user = getattr(ctx, "user", None) or getattr(ctx, "author", None) if user and user.id in owners: return True @@ -121,35 +131,36 @@ def get_context_user(ctx): discord.User or discord.Member: The user who invoked the command """ # Handle both Context (has .author) and Interaction (has .user) objects - return getattr(ctx, 'user', None) or getattr(ctx, 'author', None) + return getattr(ctx, "user", None) or getattr(ctx, "author", None) def get_cal_user(ctx): """Get the Cal user from context. Always returns an object with .mention attribute.""" import logging - logger = logging.getLogger('discord_app') - + + logger = logging.getLogger("discord_app") + # Define placeholder user class first class PlaceholderUser: def __init__(self): self.mention = "<@287463767924137994>" self.id = 287463767924137994 - + # Handle both Context and Interaction objects - if hasattr(ctx, 'bot'): # Context object + if hasattr(ctx, "bot"): # Context object bot = ctx.bot logger.debug("get_cal_user: Using Context object") - elif hasattr(ctx, 'client'): # Interaction object + elif hasattr(ctx, "client"): # Interaction object bot = ctx.client logger.debug("get_cal_user: Using Interaction object") else: logger.error("get_cal_user: No bot or client found in context") return PlaceholderUser() - + if not bot: logger.error("get_cal_user: bot is None") return PlaceholderUser() - + logger.debug(f"get_cal_user: Searching among members") try: for user in bot.get_all_members(): @@ -158,7 +169,7 @@ def get_cal_user(ctx): return user except Exception as e: logger.error(f"get_cal_user: Exception in get_all_members: {e}") - + # Fallback: try to get user directly by ID logger.debug("get_cal_user: User not found in get_all_members, trying get_user") try: @@ -170,7 +181,7 @@ def get_cal_user(ctx): logger.debug("get_cal_user: get_user returned None") except Exception as e: logger.error(f"get_cal_user: Exception in get_user: {e}") - + # Last resort: return a placeholder user object with mention logger.debug("get_cal_user: Using placeholder user") - return PlaceholderUser() \ No newline at end of file + return PlaceholderUser() diff --git a/tests/scouting/__init__.py b/tests/scouting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scouting/conftest.py b/tests/scouting/conftest.py new file mode 100644 index 0000000..523e38f --- /dev/null +++ b/tests/scouting/conftest.py @@ -0,0 +1,160 @@ +"""Shared fixtures for scouting feature tests.""" + +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock, Mock + +import discord +from discord.ext import commands + +# --------------------------------------------------------------------------- +# Sample data factories +# --------------------------------------------------------------------------- + + +def _make_player(player_id, name, rarity_name, rarity_value, headshot=None): + """Build a minimal player dict matching the API shape used by scouting.""" + return { + "player_id": player_id, + "p_name": name, + "rarity": {"name": rarity_name, "value": rarity_value, "color": "ffffff"}, + "headshot": headshot or "https://example.com/headshot.jpg", + } + + +def _make_card(card_id, player): + """Wrap a player dict inside a card dict (as returned by the cards API).""" + return {"id": card_id, "player": player} + + +@pytest.fixture +def sample_players(): + """Five players spanning different rarities for a realistic pack.""" + return [ + _make_player(101, "Mike Trout", "MVP", 5), + _make_player(102, "Juan Soto", "All-Star", 3), + _make_player(103, "Marcus Semien", "Starter", 2), + _make_player(104, "Willy Adames", "Reserve", 1), + _make_player(105, "Generic Bench", "Replacement", 0), + ] + + +@pytest.fixture +def sample_cards(sample_players): + """Five card dicts wrapping the sample players.""" + return [_make_card(i + 1, p) for i, p in enumerate(sample_players)] + + +@pytest.fixture +def opener_team(): + """Team dict for the pack opener.""" + return { + "id": 10, + "abbrev": "OPN", + "sname": "Openers", + "lname": "Opening Squad", + "gm_id": 99999, + "gmname": "Opener GM", + "color": "a6ce39", + "logo": "https://example.com/logo.png", + "season": 4, + } + + +@pytest.fixture +def scouter_team(): + """Team dict for a player who scouts a card.""" + return { + "id": 20, + "abbrev": "SCT", + "sname": "Scouts", + "lname": "Scouting Squad", + "gm_id": 88888, + "gmname": "Scout GM", + "color": "3498db", + "logo": "https://example.com/scout_logo.png", + "season": 4, + } + + +@pytest.fixture +def scouter_team_2(): + """Second scouter team for multi-scout tests.""" + return { + "id": 30, + "abbrev": "SC2", + "sname": "Scouts2", + "lname": "Second Scouts", + "gm_id": 77777, + "gmname": "Scout GM 2", + "color": "e74c3c", + "logo": "https://example.com/scout2_logo.png", + "season": 4, + } + + +# --------------------------------------------------------------------------- +# Discord mocks +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_bot(): + """Mock Discord bot.""" + bot = AsyncMock(spec=commands.Bot) + bot.get_cog = Mock(return_value=None) + bot.add_cog = AsyncMock() + bot.wait_until_ready = AsyncMock() + + # Mock guild / channel lookup for send_to_channel + channel_mock = AsyncMock(spec=discord.TextChannel) + channel_mock.send = AsyncMock() + guild_mock = Mock(spec=discord.Guild) + guild_mock.text_channels = [channel_mock] + channel_mock.name = "pd-network-news" + bot.guilds = [guild_mock] + return bot + + +@pytest.fixture +def mock_interaction(): + """Mock Discord interaction for slash commands.""" + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.response.is_done = Mock(return_value=False) + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + + interaction.user = Mock(spec=discord.Member) + interaction.user.id = 12345 + interaction.user.mention = "<@12345>" + + interaction.channel = Mock(spec=discord.TextChannel) + interaction.channel.name = "pack-openings" + interaction.channel.send = AsyncMock() + + return interaction + + +@pytest.fixture +def mock_channel(): + """Mock #pack-openings channel.""" + channel = AsyncMock(spec=discord.TextChannel) + channel.name = "pack-openings" + channel.send = AsyncMock() + return channel + + +# --------------------------------------------------------------------------- +# Logging suppression +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def silence_logging(): + """Suppress log noise during tests.""" + import logging + + logging.getLogger("discord_app").setLevel(logging.CRITICAL) diff --git a/tests/scouting/test_scout_view.py b/tests/scouting/test_scout_view.py new file mode 100644 index 0000000..10853a0 --- /dev/null +++ b/tests/scouting/test_scout_view.py @@ -0,0 +1,1029 @@ +"""Tests for discord_ui/scout_view.py — ScoutView and ScoutButton behavior. + +Covers view initialization, button callbacks (guard rails, claim flow, +token checks, multi-scout), embed updates, and timeout handling. + +Note: All tests that instantiate ScoutView must be async because +discord.ui.View.__init__ requires a running event loop. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import discord + +from discord_ui.scout_view import ScoutView, ScoutButton, SCOUT_TOKENS_PER_DAY + +# --------------------------------------------------------------------------- +# ScoutView initialization +# --------------------------------------------------------------------------- + + +class TestScoutViewInit: + """Tests for ScoutView construction and initial state.""" + + @pytest.mark.asyncio + async def test_creates_one_button_per_card( + self, sample_cards, opener_team, mock_bot + ): + """Should add exactly one button per card in the pack.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + buttons = [c for c in view.children if isinstance(c, discord.ui.Button)] + assert len(buttons) == len(sample_cards) + + @pytest.mark.asyncio + async def test_buttons_labeled_sequentially( + self, sample_cards, opener_team, mock_bot + ): + """Buttons should be labeled 'Card 1', 'Card 2', etc.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + labels = [c.label for c in view.children if isinstance(c, discord.ui.Button)] + expected = [f"Card {i + 1}" for i in range(len(sample_cards))] + assert labels == expected + + @pytest.mark.asyncio + async def test_buttons_are_secondary_style( + self, sample_cards, opener_team, mock_bot + ): + """All buttons should start with the gray/secondary style (face-down).""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + for btn in view.children: + if isinstance(btn, discord.ui.Button): + assert btn.style == discord.ButtonStyle.secondary + + @pytest.mark.asyncio + async def test_initial_state_is_clean(self, sample_cards, opener_team, mock_bot): + """Claims, scouted_users, and processing_users should all start empty.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + assert view.claims == {} + assert view.scouted_users == set() + assert view.processing_users == set() + assert view.total_scouts == 0 + + @pytest.mark.asyncio + async def test_timeout_is_30_minutes(self, sample_cards, opener_team, mock_bot): + """The view timeout should be 1800 seconds (30 minutes).""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + assert view.timeout == 1800.0 + + +# --------------------------------------------------------------------------- +# ScoutButton callback — guard rails +# --------------------------------------------------------------------------- + + +class TestScoutButtonGuards: + """Tests for the access control checks in ScoutButton.callback.""" + + def _make_view(self, sample_cards, opener_team, mock_bot): + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + return view + + @pytest.mark.asyncio + async def test_opener_blocked(self, sample_cards, opener_team, mock_bot): + """The pack opener should be rejected with an ephemeral message.""" + view = self._make_view(sample_cards, opener_team, mock_bot) + button = view.children[0] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 99999 # same as opener + + await button.callback(interaction) + + interaction.response.send_message.assert_called_once() + call_kwargs = interaction.response.send_message.call_args[1] + assert call_kwargs["ephemeral"] is True + assert "own pack" in interaction.response.send_message.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_already_scouted_blocked(self, sample_cards, opener_team, mock_bot): + """A user who already scouted this pack should be rejected.""" + view = self._make_view(sample_cards, opener_team, mock_bot) + view.scouted_users.add(12345) + button = view.children[0] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await button.callback(interaction) + + interaction.response.send_message.assert_called_once() + assert ( + "already scouted" + in interaction.response.send_message.call_args[0][0].lower() + ) + + @pytest.mark.asyncio + async def test_double_click_silently_ignored( + self, sample_cards, opener_team, mock_bot + ): + """If a user is already being processed, the click should be silently dropped.""" + view = self._make_view(sample_cards, opener_team, mock_bot) + view.processing_users.add(12345) + button = view.children[0] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.user = Mock() + interaction.user.id = 12345 + + await button.callback(interaction) + + # Should not have called defer or send_message + interaction.response.defer.assert_not_called() + + +# --------------------------------------------------------------------------- +# ScoutButton callback — successful scout flow +# --------------------------------------------------------------------------- + + +class TestScoutButtonSuccess: + """Tests for the happy-path scout claim flow.""" + + def _make_view_with_message(self, sample_cards, opener_team, mock_bot): + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + return view + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_successful_scout_creates_card_copy( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """A valid scout should POST a scout_claim, consume a token, and create a card copy.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + button = view.children[0] + await button.callback(interaction) + + # Should have deferred + interaction.response.defer.assert_called_once_with(ephemeral=True) + + # db_post should be called 3 times: scout_claims, rewards, cards + assert mock_db_post.call_count == 3 + + # Verify scout_claims POST + claim_call = mock_db_post.call_args_list[0] + assert claim_call[0][0] == "scout_claims" + + # Verify rewards POST (token consumption) + reward_call = mock_db_post.call_args_list[1] + assert reward_call[0][0] == "rewards" + assert reward_call[1]["payload"]["name"] == "Scout Token" + + # Verify cards POST (card copy) + card_call = mock_db_post.call_args_list[2] + assert card_call[0][0] == "cards" + + # User should be marked as scouted + assert 12345 in view.scouted_users + assert view.total_scouts == 1 + + # Ephemeral follow-up with card details + interaction.followup.send.assert_called() + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_no_team_rejects( + self, + mock_get_team, + mock_db_post, + sample_cards, + opener_team, + mock_bot, + ): + """A user without a PD team should be rejected with an ephemeral message.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_team.return_value = None + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + button = view.children[0] + await button.callback(interaction) + + interaction.followup.send.assert_called_once() + msg = interaction.followup.send.call_args[0][0] + assert "team" in msg.lower() + assert mock_db_post.call_count == 0 + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_out_of_tokens_rejects( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """A user who has used all daily tokens should be rejected.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY # all used + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + button = view.children[0] + await button.callback(interaction) + + interaction.followup.send.assert_called_once() + msg = interaction.followup.send.call_args[0][0] + assert "out of scout tokens" in msg.lower() + assert mock_db_post.call_count == 0 + + +# --------------------------------------------------------------------------- +# Multi-scout behavior +# --------------------------------------------------------------------------- + + +class TestMultiScout: + """Tests for the multi-scout-per-card design. + + Any card can be scouted by multiple different players, but each player + can only scout one card per pack. + """ + + def _make_view_with_message(self, sample_cards, opener_team, mock_bot): + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + return view + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_two_users_can_scout_same_card( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + scouter_team_2, + mock_bot, + ): + """Two different users should both be able to scout the same card.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + button = view.children[0] # both pick the same card + + # First scouter + mock_get_team.return_value = scouter_team + interaction1 = AsyncMock(spec=discord.Interaction) + interaction1.response = AsyncMock() + interaction1.response.defer = AsyncMock() + interaction1.followup = AsyncMock() + interaction1.followup.send = AsyncMock() + interaction1.user = Mock() + interaction1.user.id = 11111 + + await button.callback(interaction1) + assert 11111 in view.scouted_users + assert view.total_scouts == 1 + + # Second scouter — same card + mock_get_team.return_value = scouter_team_2 + interaction2 = AsyncMock(spec=discord.Interaction) + interaction2.response = AsyncMock() + interaction2.response.defer = AsyncMock() + interaction2.followup = AsyncMock() + interaction2.followup.send = AsyncMock() + interaction2.user = Mock() + interaction2.user.id = 22222 + + await button.callback(interaction2) + assert 22222 in view.scouted_users + assert view.total_scouts == 2 + + # Claims should track both teams under the same player_id + player_id = sample_cards[0]["player"]["player_id"] + assert player_id in view.claims + assert len(view.claims[player_id]) == 2 + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_same_user_cannot_scout_twice( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """The same user should be blocked from scouting a second card.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + # First scout succeeds + interaction1 = AsyncMock(spec=discord.Interaction) + interaction1.response = AsyncMock() + interaction1.response.defer = AsyncMock() + interaction1.followup = AsyncMock() + interaction1.followup.send = AsyncMock() + interaction1.user = Mock() + interaction1.user.id = 12345 + + await view.children[0].callback(interaction1) + assert view.total_scouts == 1 + + # Second scout by same user is blocked + interaction2 = AsyncMock(spec=discord.Interaction) + interaction2.response = AsyncMock() + interaction2.response.send_message = AsyncMock() + interaction2.user = Mock() + interaction2.user.id = 12345 + + await view.children[1].callback(interaction2) + + interaction2.response.send_message.assert_called_once() + assert ( + "already scouted" + in interaction2.response.send_message.call_args[0][0].lower() + ) + assert view.total_scouts == 1 # unchanged + + @pytest.mark.asyncio + async def test_buttons_never_disabled_after_scout( + self, sample_cards, opener_team, mock_bot + ): + """All buttons should remain enabled regardless of how many scouts happen. + + This verifies the 'unlimited scouts per card' design — buttons + only disable on timeout, not on individual claims. + """ + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + # Simulate claims on every card + for card in sample_cards: + pid = card["player"]["player_id"] + view.claims[pid] = ["Team A", "Team B"] + + for btn in view.children: + if isinstance(btn, discord.ui.Button): + assert not btn.disabled + + +# --------------------------------------------------------------------------- +# ScoutView.on_timeout +# --------------------------------------------------------------------------- + + +class TestScoutViewTimeout: + """Tests for the timeout handler that closes the scout window.""" + + @pytest.mark.asyncio + async def test_timeout_disables_all_buttons( + self, sample_cards, opener_team, mock_bot + ): + """After timeout, every button should be disabled.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + await view.on_timeout() + + for btn in view.children: + if isinstance(btn, discord.ui.Button): + assert btn.disabled + + @pytest.mark.asyncio + async def test_timeout_updates_embed_title( + self, sample_cards, opener_team, mock_bot + ): + """The embed title should change to 'Scout Window Closed' on timeout.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + await view.on_timeout() + + view.message.edit.assert_called_once() + call_kwargs = view.message.edit.call_args[1] + embed = call_kwargs["embed"] + assert "closed" in embed.title.lower() + + @pytest.mark.asyncio + async def test_timeout_with_scouts_shows_count( + self, sample_cards, opener_team, mock_bot + ): + """When there were scouts, the closed title should include the count.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.total_scouts = 5 + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + await view.on_timeout() + + embed = view.message.edit.call_args[1]["embed"] + assert "5" in embed.title + + @pytest.mark.asyncio + async def test_timeout_without_message_is_safe( + self, sample_cards, opener_team, mock_bot + ): + """Timeout should not crash if the message reference is None.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.message = None + + # Should not raise + await view.on_timeout() + + +# --------------------------------------------------------------------------- +# Processing user cleanup +# --------------------------------------------------------------------------- + + +class TestProcessingUserCleanup: + """Verify the processing_users set is cleaned up in all code paths.""" + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_processing_cleared_on_success( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """After a successful scout, the user should be removed from processing_users.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + assert 12345 not in view.processing_users + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_processing_cleared_on_claim_db_failure( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """If db_post('scout_claims') raises, processing_users should still be cleared.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.side_effect = Exception("DB down") + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + assert 12345 not in view.processing_users + # Scout should not have been recorded + assert view.total_scouts == 0 + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_processing_cleared_on_no_team( + self, + mock_get_team, + sample_cards, + opener_team, + mock_bot, + ): + """If the user has no team, they should still be removed from processing_users.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + mock_get_team.return_value = None + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + assert 12345 not in view.processing_users + + +# --------------------------------------------------------------------------- +# db_get("current") fallback +# --------------------------------------------------------------------------- + + +class TestCurrentSeasonFallback: + """Tests for the fallback when db_get('current') returns None.""" + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_current_returns_none_uses_fallback( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """When db_get('current') returns None, rewards should use PD_SEASON fallback.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = None # db_get("current") returns None + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + # Should still complete successfully + assert view.total_scouts == 1 + assert 12345 in view.scouted_users + + # Verify the rewards POST used fallback values + from helpers.constants import PD_SEASON + + reward_call = mock_db_post.call_args_list[1] + assert reward_call[1]["payload"]["season"] == PD_SEASON + assert reward_call[1]["payload"]["week"] == 1 + + +# --------------------------------------------------------------------------- +# Shiny scout notification +# --------------------------------------------------------------------------- + + +class TestShinyScoutNotification: + """Tests for the rare-card notification path (rarity >= 5).""" + + @pytest.mark.asyncio + @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_shiny_card_sends_notification( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + mock_send_to_channel, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """Scouting a card with rarity >= 5 should post to #pd-network-news.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, # card 0 is MVP (rarity 5) + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + # Card 0 is MVP (rarity value 5) — should trigger notification + await view.children[0].callback(interaction) + + mock_send_to_channel.assert_called_once() + call_args = mock_send_to_channel.call_args + assert call_args[0][1] == "pd-network-news" + + @pytest.mark.asyncio + @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_non_shiny_card_no_notification( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + mock_send_to_channel, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """Scouting a card with rarity < 5 should NOT post a notification.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, # card 2 is Starter (rarity 2) + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + # Card 2 is Starter (rarity value 2) — no notification + await view.children[2].callback(interaction) + + mock_send_to_channel.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_shiny_notification_failure_does_not_crash( + self, + mock_get_team, + mock_get_tokens, + mock_db_get, + mock_db_post, + mock_card_embeds, + mock_send_to_channel, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """If sending the shiny notification fails, the scout should still succeed.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + mock_send_to_channel.side_effect = Exception("Channel not found") + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + # Should not raise even though notification fails + await view.children[0].callback(interaction) + + # Scout should still complete + assert view.total_scouts == 1 + assert 12345 in view.scouted_users + + +# --------------------------------------------------------------------------- +# update_message edge cases +# --------------------------------------------------------------------------- + + +class TestUpdateMessage: + """Tests for ScoutView.update_message edge cases.""" + + @pytest.mark.asyncio + async def test_update_message_with_no_message_is_noop( + self, sample_cards, opener_team, mock_bot + ): + """update_message should silently return if self.message is None.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = None + + # Should not raise + await view.update_message() + + @pytest.mark.asyncio + async def test_update_message_edit_failure_is_caught( + self, sample_cards, opener_team, mock_bot + ): + """If message.edit raises, it should be caught and logged, not re-raised.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock( + side_effect=discord.HTTPException(Mock(status=500), "Server error") + ) + + # Should not raise + await view.update_message() diff --git a/tests/scouting/test_scouting_cog.py b/tests/scouting/test_scouting_cog.py new file mode 100644 index 0000000..5bca155 --- /dev/null +++ b/tests/scouting/test_scouting_cog.py @@ -0,0 +1,269 @@ +"""Tests for cogs/economy_new/scouting.py — the Scouting cog. + +Covers the /scout-tokens command and the cleanup_expired background task. + +Note: Scouting.__init__ calls self.cleanup_expired.start() which requires +a running event loop. All tests that instantiate the cog must be async. +""" + +import datetime + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import discord +from discord.ext import commands + +from cogs.economy_new.scouting import Scouting, SCOUT_TOKENS_PER_DAY + + +def _make_team(): + return { + "id": 1, + "lname": "Test Team", + "color": "a6ce39", + "logo": "https://example.com/logo.png", + "season": 4, + } + + +# --------------------------------------------------------------------------- +# Cog setup +# --------------------------------------------------------------------------- + + +class TestScoutingCogSetup: + """Tests for cog initialization and lifecycle.""" + + @pytest.mark.asyncio + async def test_cog_initializes(self, mock_bot): + """The Scouting cog should initialize without errors.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + assert cog.bot is mock_bot + + @pytest.mark.asyncio + async def test_cleanup_task_starts(self, mock_bot): + """The cleanup_expired loop task should be started on init.""" + cog = Scouting(mock_bot) + assert cog.cleanup_expired.is_running() + cog.cleanup_expired.cancel() + + @pytest.mark.asyncio + async def test_cog_unload_calls_cancel(self, mock_bot): + """Unloading the cog should call cancel on the cleanup task.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + # Verify cog_unload runs without error + await cog.cog_unload() + + +# --------------------------------------------------------------------------- +# /scout-tokens command +# --------------------------------------------------------------------------- + + +class TestScoutTokensCommand: + """Tests for the /scout-tokens slash command.""" + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_shows_remaining_tokens( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """Should display the correct number of remaining tokens.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = 1 # 1 used today + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + interaction.response.defer.assert_called_once_with(ephemeral=True) + interaction.followup.send.assert_called_once() + + call_kwargs = interaction.followup.send.call_args[1] + embed = call_kwargs["embed"] + remaining = SCOUT_TOKENS_PER_DAY - 1 + assert str(remaining) in embed.description + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_no_team_rejects(self, mock_get_team, mock_get_tokens, mock_bot): + """A user without a PD team should get a rejection message.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = None + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + msg = interaction.followup.send.call_args[0][0] + assert "team" in msg.lower() + mock_get_tokens.assert_not_called() + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_all_tokens_used_shows_zero( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """When all tokens are used, should show 0 remaining with extra message.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + assert "0" in embed.description + assert ( + "used all" in embed.description.lower() + or "tomorrow" in embed.description.lower() + ) + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_no_tokens_used_shows_full( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """When no tokens have been used, should show the full daily allowance.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = 0 + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + assert str(SCOUT_TOKENS_PER_DAY) in embed.description + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_db_get_returns_none(self, mock_get_team, mock_get_tokens, mock_bot): + """If get_scout_tokens_used returns 0 (API failure handled internally), should show full tokens.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = ( + 0 # get_scout_tokens_used handles None internally + ) + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + assert str(SCOUT_TOKENS_PER_DAY) in embed.description + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_over_limit_tokens_shows_zero( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """If somehow more tokens than the daily limit were used, should show 0 not negative.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = 5 # more than SCOUT_TOKENS_PER_DAY + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + # Should show "0" not "-3" + assert "0" in embed.description + assert "-" not in embed.description.split("remaining")[0] + + +# --------------------------------------------------------------------------- +# cleanup_expired task +# --------------------------------------------------------------------------- + + +class TestCleanupExpired: + """Tests for the background cleanup task.""" + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock) + async def test_cleanup_logs_expired_opportunities(self, mock_db_get, mock_bot): + """The cleanup task should query for expired unclaimed opportunities.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_db_get.return_value = {"count": 3} + + # Call the coroutine directly (not via the loop) + await cog.cleanup_expired.coro(cog) + + mock_db_get.assert_called_once() + call_args = mock_db_get.call_args + assert call_args[0][0] == "scout_opportunities" + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock) + async def test_cleanup_handles_api_failure(self, mock_db_get, mock_bot): + """Cleanup should not crash if the API is unavailable.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_db_get.side_effect = Exception("API not ready") + + # Should not raise + await cog.cleanup_expired.coro(cog) diff --git a/tests/scouting/test_scouting_helpers.py b/tests/scouting/test_scouting_helpers.py new file mode 100644 index 0000000..dc635da --- /dev/null +++ b/tests/scouting/test_scouting_helpers.py @@ -0,0 +1,374 @@ +"""Tests for helpers/scouting.py — embed builders and scout opportunity creation. + +Covers the pure functions (_build_card_lines, build_scout_embed, +build_scouted_card_list) and the async create_scout_opportunity flow. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import discord + +from helpers.scouting import ( + _build_card_lines, + build_scout_embed, + build_scouted_card_list, + create_scout_opportunity, + RARITY_SYMBOLS, +) + +# --------------------------------------------------------------------------- +# _build_card_lines +# --------------------------------------------------------------------------- + + +class TestBuildCardLines: + """Tests for the shuffled card line builder.""" + + def test_returns_correct_count(self, sample_cards): + """Should produce one line per card in the pack.""" + lines = _build_card_lines(sample_cards) + assert len(lines) == len(sample_cards) + + def test_each_line_contains_player_id(self, sample_cards): + """Each tuple's first element should be the player_id from the card.""" + lines = _build_card_lines(sample_cards) + ids = {pid for pid, _ in lines} + expected_ids = {c["player"]["player_id"] for c in sample_cards} + assert ids == expected_ids + + def test_each_line_contains_player_name(self, sample_cards): + """The display string should include the player's name.""" + lines = _build_card_lines(sample_cards) + for pid, display in lines: + card = next(c for c in sample_cards if c["player"]["player_id"] == pid) + assert card["player"]["p_name"] in display + + def test_each_line_contains_rarity_name(self, sample_cards): + """The display string should include the rarity tier name.""" + lines = _build_card_lines(sample_cards) + for pid, display in lines: + card = next(c for c in sample_cards if c["player"]["player_id"] == pid) + assert card["player"]["rarity"]["name"] in display + + def test_rarity_symbol_present(self, sample_cards): + """Each line should start with the appropriate rarity emoji.""" + lines = _build_card_lines(sample_cards) + for pid, display in lines: + card = next(c for c in sample_cards if c["player"]["player_id"] == pid) + rarity_val = card["player"]["rarity"]["value"] + expected_symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab") + assert display.startswith(expected_symbol) + + def test_output_is_shuffled(self, sample_cards): + """Over many runs, the order should not always match the input order. + + We run 20 iterations — if it comes out sorted every time, the shuffle + is broken (probability ~1/20! per run, effectively zero). + """ + input_order = [c["player"]["player_id"] for c in sample_cards] + saw_different = False + for _ in range(20): + lines = _build_card_lines(sample_cards) + output_order = [pid for pid, _ in lines] + if output_order != input_order: + saw_different = True + break + assert saw_different, "Card lines were never shuffled across 20 runs" + + def test_empty_cards(self): + """Empty input should produce an empty list.""" + assert _build_card_lines([]) == [] + + def test_unknown_rarity_uses_fallback_symbol(self): + """A rarity value not in RARITY_SYMBOLS should get the black circle fallback.""" + card = { + "id": 99, + "player": { + "player_id": 999, + "p_name": "Unknown Rarity", + "rarity": {"name": "Legendary", "value": 99, "color": "gold"}, + }, + } + lines = _build_card_lines([card]) + assert lines[0][1].startswith("\u26ab") # black circle fallback + + +# --------------------------------------------------------------------------- +# build_scout_embed +# --------------------------------------------------------------------------- + + +class TestBuildScoutEmbed: + """Tests for the embed builder shown above scout buttons.""" + + def test_returns_embed_and_card_lines(self, opener_team, sample_cards): + """Should return a (discord.Embed, list) tuple.""" + embed, card_lines = build_scout_embed(opener_team, sample_cards) + assert isinstance(embed, discord.Embed) + assert isinstance(card_lines, list) + assert len(card_lines) == len(sample_cards) + + def test_embed_description_contains_team_name(self, opener_team, sample_cards): + """The embed body should mention the opener's team name.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + assert opener_team["lname"] in embed.description + + def test_embed_description_contains_all_player_names( + self, opener_team, sample_cards + ): + """Every player name from the pack should appear in the embed.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + for card in sample_cards: + assert card["player"]["p_name"] in embed.description + + def test_embed_mentions_token_cost(self, opener_team, sample_cards): + """The embed should tell users about the scout token cost.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + assert "Scout Token" in embed.description + + def test_embed_mentions_time_limit(self, opener_team, sample_cards): + """The embed should mention the 30-minute window.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + assert "30 minutes" in embed.description + + def test_prebuilt_card_lines_are_reused(self, opener_team, sample_cards): + """When card_lines are passed in, they should be reused (not rebuilt).""" + prebuilt = [(101, "Custom Line 1"), (102, "Custom Line 2")] + embed, returned_lines = build_scout_embed( + opener_team, sample_cards, card_lines=prebuilt + ) + assert returned_lines is prebuilt + assert "Custom Line 1" in embed.description + assert "Custom Line 2" in embed.description + + +# --------------------------------------------------------------------------- +# build_scouted_card_list +# --------------------------------------------------------------------------- + + +class TestBuildScoutedCardList: + """Tests for the card list formatter that marks scouted cards.""" + + def test_no_scouts_returns_plain_lines(self): + """With no scouts, output should match the raw card lines.""" + card_lines = [ + (101, "\U0001f7e3 MVP — Mike Trout"), + (102, "\U0001f535 All-Star — Juan Soto"), + ] + result = build_scouted_card_list(card_lines, {}) + assert result == "\U0001f7e3 MVP — Mike Trout\n\U0001f535 All-Star — Juan Soto" + + def test_single_scout_shows_team_name(self): + """A card scouted once should show a checkmark and the team name.""" + card_lines = [ + (101, "\U0001f7e3 MVP — Mike Trout"), + (102, "\U0001f535 All-Star — Juan Soto"), + ] + scouted = {101: ["Scouting Squad"]} + result = build_scouted_card_list(card_lines, scouted) + assert "\u2714\ufe0f" in result # checkmark + assert "*Scouting Squad*" in result + # Unscouted card should appear plain + lines = result.split("\n") + assert "\u2714" not in lines[1] + + def test_multiple_scouts_shows_count_and_names(self): + """A card scouted multiple times should show the count and all team names.""" + card_lines = [(101, "\U0001f7e3 MVP — Mike Trout")] + scouted = {101: ["Team A", "Team B", "Team C"]} + result = build_scouted_card_list(card_lines, scouted) + assert "x3" in result + assert "*Team A*" in result + assert "*Team B*" in result + assert "*Team C*" in result + + def test_mixed_scouted_and_unscouted(self): + """Only scouted cards should have marks; unscouted cards stay plain.""" + card_lines = [ + (101, "Line A"), + (102, "Line B"), + (103, "Line C"), + ] + scouted = {102: ["Some Team"]} + result = build_scouted_card_list(card_lines, scouted) + lines = result.split("\n") + assert "\u2714" not in lines[0] + assert "\u2714" in lines[1] + assert "\u2714" not in lines[2] + + def test_empty_input(self): + """Empty card lines should produce an empty string.""" + assert build_scouted_card_list([], {}) == "" + + def test_two_scouts_shows_count(self): + """Two scouts on the same card should show x2.""" + card_lines = [(101, "Line A")] + scouted = {101: ["Team X", "Team Y"]} + result = build_scouted_card_list(card_lines, scouted) + assert "x2" in result + + +# --------------------------------------------------------------------------- +# create_scout_opportunity +# --------------------------------------------------------------------------- + + +class TestCreateScoutOpportunity: + """Tests for the async scout opportunity creation flow.""" + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_posts_to_api_and_sends_message( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """Should POST to scout_opportunities and send a message to the channel.""" + mock_db_post.return_value = {"id": 42} + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + # API was called to create the opportunity + mock_db_post.assert_called_once() + call_args = mock_db_post.call_args + assert call_args[0][0] == "scout_opportunities" + assert call_args[1]["payload"]["opener_team_id"] == opener_team["id"] + + # Message was sent to the channel + mock_channel.send.assert_called_once() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_skips_wrong_channel( + self, mock_db_post, sample_cards, opener_team, mock_bot + ): + """Should silently return when the channel is not #pack-openings.""" + channel = AsyncMock(spec=discord.TextChannel) + channel.name = "general" + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, channel, opener_user, context + ) + + mock_db_post.assert_not_called() + channel.send.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_skips_empty_pack( + self, mock_db_post, opener_team, mock_channel, mock_bot + ): + """Should silently return when pack_cards is empty.""" + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + [], opener_team, mock_channel, opener_user, context + ) + + mock_db_post.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_skips_none_channel( + self, mock_db_post, sample_cards, opener_team, mock_bot + ): + """Should handle None channel without crashing.""" + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, None, opener_user, context + ) + + mock_db_post.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_api_failure_does_not_raise( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """Scout creation failure must never crash the pack opening flow.""" + mock_db_post.side_effect = Exception("API down") + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + # Should not raise + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_channel_send_failure_does_not_raise( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """If the channel.send fails, it should be caught gracefully.""" + mock_db_post.return_value = {"id": 42} + mock_channel.send.side_effect = discord.HTTPException( + Mock(status=500), "Server error" + ) + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + # Should not raise + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_context_client_fallback( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """When context.bot is None, should fall back to context.client for the bot ref.""" + mock_db_post.return_value = {"id": 42} + opener_user = Mock() + opener_user.id = 99999 + context = Mock(spec=[]) # empty spec — no .bot attribute + context.client = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + mock_channel.send.assert_called_once() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_view_message_is_assigned( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """The message returned by channel.send should be assigned to view.message. + + This linkage is required for update_message and on_timeout to work. + """ + mock_db_post.return_value = {"id": 42} + sent_msg = AsyncMock(spec=discord.Message) + mock_channel.send.return_value = sent_msg + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) -- 2.25.1 From d569e9190507bdbc0ea1603e2976f8935d19f724 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 19:39:43 -0600 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20Address=20PR=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20two=20bugs=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix int_timestamp() no-arg path returning seconds instead of milliseconds, which would silently break the daily scout token cap against the real API - Acknowledge double-click interactions with ephemeral message instead of silently returning (Discord requires all interactions to be acked) - Reorder scout flow: create card copy before consuming token so a failure doesn't cost the player a token for nothing - Move build_scouted_card_list import to top of scout_view.py - Remove unused asyncio import from helpers/scouting.py - Fix footer text inconsistency ("One scout per player" everywhere) - Update tests for new operation order and double-click behavior Co-Authored-By: Claude Opus 4.6 --- discord_ui/scout_view.py | 39 ++++++++++++++++++------------- helpers/scouting.py | 3 +-- helpers/utils.py | 11 +++++---- tests/scouting/test_scout_view.py | 31 +++++++++++++++--------- 4 files changed, 51 insertions(+), 33 deletions(-) diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 11ce77a..0d5a12f 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -13,7 +13,11 @@ import discord from api_calls import db_get, db_post from helpers.main import get_team_by_owner, get_card_embeds -from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used +from helpers.scouting import ( + SCOUT_TOKENS_PER_DAY, + build_scouted_card_list, + get_scout_tokens_used, +) from helpers.utils import int_timestamp from helpers.discord_utils import get_team_embed from helpers.constants import IMAGES, PD_SEASON @@ -72,8 +76,6 @@ class ScoutView(discord.ui.View): if not self.message: return - from helpers.scouting import build_scouted_card_list - card_list = build_scouted_card_list(self.card_lines, self.claims) title = f"Scout Opportunity! ({self.total_scouts} scouted)" @@ -163,6 +165,10 @@ class ScoutButton(discord.ui.Button): # Prevent double-click race for same user if interaction.user.id in view.processing_users: + await interaction.response.send_message( + "Your scout is already being processed!", + ephemeral=True, + ) return view.processing_users.add(interaction.user.id) @@ -206,6 +212,20 @@ class ScoutButton(discord.ui.Button): ) return + # Create a copy of the card for the scouter (before consuming token + # so a failure here doesn't cost the player a token for nothing) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": self.card["player"]["player_id"], + "team_id": scouter_team["id"], + } + ], + }, + ) + # Consume a scout token current = await db_get("current") await db_post( @@ -219,19 +239,6 @@ class ScoutButton(discord.ui.Button): }, ) - # Create a copy of the card for the scouter - await db_post( - "cards", - payload={ - "cards": [ - { - "player_id": self.card["player"]["player_id"], - "team_id": scouter_team["id"], - } - ], - }, - ) - # Track the claim player_id = self.card["player"]["player_id"] if player_id not in view.claims: diff --git a/helpers/scouting.py b/helpers/scouting.py index ab2d2c2..a21b9f9 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -5,7 +5,6 @@ Handles creation of scout opportunities after pack openings and embed formatting for the scouting feature. """ -import asyncio import datetime import logging import random @@ -95,7 +94,7 @@ def build_scout_embed( f"{time_line}" ) embed.set_footer( - text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack", + text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player", icon_url=IMAGES["logo"], ) return embed, card_lines diff --git a/helpers/utils.py b/helpers/utils.py index 7535bf7..8b091ab 100644 --- a/helpers/utils.py +++ b/helpers/utils.py @@ -11,10 +11,13 @@ import discord def int_timestamp(datetime_obj: Optional[datetime.datetime] = None): - """Convert current datetime to integer timestamp.""" - if datetime_obj: - return int(datetime.datetime.timestamp(datetime_obj) * 1000) - return int(datetime.datetime.now().timestamp()) + """Convert a datetime to an integer millisecond timestamp. + + If no argument is given, uses the current time. + """ + if datetime_obj is None: + datetime_obj = datetime.datetime.now() + return int(datetime.datetime.timestamp(datetime_obj) * 1000) def midnight_timestamp() -> int: diff --git a/tests/scouting/test_scout_view.py b/tests/scouting/test_scout_view.py index 10853a0..906bb97 100644 --- a/tests/scouting/test_scout_view.py +++ b/tests/scouting/test_scout_view.py @@ -162,19 +162,26 @@ class TestScoutButtonGuards: async def test_double_click_silently_ignored( self, sample_cards, opener_team, mock_bot ): - """If a user is already being processed, the click should be silently dropped.""" + """If a user is already being processed, they should get an ephemeral rejection.""" view = self._make_view(sample_cards, opener_team, mock_bot) view.processing_users.add(12345) button = view.children[0] interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() interaction.user = Mock() interaction.user.id = 12345 await button.callback(interaction) - # Should not have called defer or send_message - interaction.response.defer.assert_not_called() + interaction.response.send_message.assert_called_once() + call_kwargs = interaction.response.send_message.call_args[1] + assert call_kwargs["ephemeral"] is True + assert ( + "already being processed" + in interaction.response.send_message.call_args[0][0].lower() + ) # --------------------------------------------------------------------------- @@ -242,22 +249,22 @@ class TestScoutButtonSuccess: # Should have deferred interaction.response.defer.assert_called_once_with(ephemeral=True) - # db_post should be called 3 times: scout_claims, rewards, cards + # db_post should be called 3 times: scout_claims, cards, rewards assert mock_db_post.call_count == 3 # Verify scout_claims POST claim_call = mock_db_post.call_args_list[0] assert claim_call[0][0] == "scout_claims" - # Verify rewards POST (token consumption) - reward_call = mock_db_post.call_args_list[1] + # Verify cards POST (card copy — created before token consumption) + card_call = mock_db_post.call_args_list[1] + assert card_call[0][0] == "cards" + + # Verify rewards POST (token consumption — after card is safely created) + reward_call = mock_db_post.call_args_list[2] assert reward_call[0][0] == "rewards" assert reward_call[1]["payload"]["name"] == "Scout Token" - # Verify cards POST (card copy) - card_call = mock_db_post.call_args_list[2] - assert card_call[0][0] == "cards" - # User should be marked as scouted assert 12345 in view.scouted_users assert view.total_scouts == 1 @@ -797,9 +804,11 @@ class TestCurrentSeasonFallback: assert 12345 in view.scouted_users # Verify the rewards POST used fallback values + # Order: scout_claims (0), cards (1), rewards (2) from helpers.constants import PD_SEASON - reward_call = mock_db_post.call_args_list[1] + reward_call = mock_db_post.call_args_list[2] + assert reward_call[0][0] == "rewards" assert reward_call[1]["payload"]["season"] == PD_SEASON assert reward_call[1]["payload"]["week"] == 1 -- 2.25.1 From 89f80727bd2655ac4eb65553ca119b766bb4674b Mon Sep 17 00:00:00 2001 From: cal Date: Thu, 5 Mar 2026 03:12:20 +0000 Subject: [PATCH 04/12] Update .gitea/workflows/docker-build.yml --- .gitea/workflows/docker-build.yml | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index a4a0ceb..f286fc9 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -12,6 +12,7 @@ on: push: branches: - main + - next-release pull_request: branches: - main @@ -39,35 +40,25 @@ jobs: id: calver uses: cal/gitea-actions/calver@main - # Dev build: push with dev + dev-SHA tags (PR/feature branches) - - name: Build Docker image (dev) - if: github.ref != 'refs/heads/main' - uses: https://github.com/docker/build-push-action@v5 + - name: Resolve Docker tags + id: tags + uses: cal/gitea-actions/docker-tags@main with: - context: . - push: true - tags: | - manticorum67/paper-dynasty-discordapp:dev - manticorum67/paper-dynasty-discordapp:dev-${{ steps.calver.outputs.sha_short }} - cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache - cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max + image: manticorum67/paper-dynasty-discordapp + version: ${{ steps.calver.outputs.version }} + sha_short: ${{ steps.calver.outputs.sha_short }} - # Production build: push with latest + CalVer tags (main only) - - name: Build Docker image (production) - if: github.ref == 'refs/heads/main' + - name: Build and push Docker image uses: https://github.com/docker/build-push-action@v5 with: context: . push: true - tags: | - manticorum67/paper-dynasty-discordapp:latest - manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }} - manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }} + tags: ${{ steps.tags.outputs.tags }} cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max - name: Tag release - if: success() && github.ref == 'refs/heads/main' + if: success() && steps.tags.outputs.channel == 'stable' uses: cal/gitea-actions/gitea-tag@main with: version: ${{ steps.calver.outputs.version }} @@ -96,7 +87,7 @@ jobs: fi - name: Discord Notification - Success - if: success() && github.ref == 'refs/heads/main' + if: success() && steps.tags.outputs.channel != 'dev' uses: cal/gitea-actions/discord-notify@main with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} -- 2.25.1 From 75b9968149c7d8a178f3f638ed1239ded5f6e471 Mon Sep 17 00:00:00 2001 From: cal Date: Thu, 5 Mar 2026 03:15:37 +0000 Subject: [PATCH 05/12] Update .gitea/workflows/docker-build.yml --- .gitea/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index f286fc9..f409d19 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -99,7 +99,7 @@ jobs: timestamp: ${{ steps.calver.outputs.timestamp }} - name: Discord Notification - Failure - if: failure() && github.ref == 'refs/heads/main' + if: failure() && steps.tags.outputs.channel != 'dev' uses: cal/gitea-actions/discord-notify@main with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} -- 2.25.1 From 0ce0707e3ead65396973a4033e239cd5e9372045 Mon Sep 17 00:00:00 2001 From: cal Date: Thu, 5 Mar 2026 03:16:36 +0000 Subject: [PATCH 06/12] Update .gitea/workflows/docker-build.yml --- .gitea/workflows/docker-build.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index f409d19..9fae8dc 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -68,23 +68,20 @@ jobs: run: | echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY + IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}" + for tag in "${TAG_ARRAY[@]}"; do + echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY + done echo "" >> $GITHUB_STEP_SUMMARY echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ github.ref }}" == "refs/heads/main" ]; then - echo "Pushed to Docker Hub!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY - else - echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY - fi + echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY - name: Discord Notification - Success if: success() && steps.tags.outputs.channel != 'dev' -- 2.25.1 From ed00a97c0d66d44b8f0570dfc1bffd309ccd95f9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 15:57:25 -0600 Subject: [PATCH 07/12] fix: update owner_only to use Cal's correct Discord ID Co-Authored-By: Claude Opus 4.6 --- helpers/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/helpers/utils.py b/helpers/utils.py index 8b091ab..6b6185f 100644 --- a/helpers/utils.py +++ b/helpers/utils.py @@ -111,7 +111,8 @@ def get_player_url(team, player) -> str: def owner_only(ctx) -> bool: """Check if user is the bot owner.""" # ID for discord User Cal - owners = [287463767924137994, 1087936030899347516] + owners = [258104532423147520] + # owners += [287463767924137994, 1087936030899347516] # Handle both Context (has .author) and Interaction (has .user) objects user = getattr(ctx, "user", None) or getattr(ctx, "author", None) -- 2.25.1 From 77c3f3004c3788531373e4d9c3c66a833369865d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 13:03:15 -0600 Subject: [PATCH 08/12] fix: align scouting rarity symbols with system colors Co-Authored-By: Claude Opus 4.6 --- helpers/scouting.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/helpers/scouting.py b/helpers/scouting.py index a21b9f9..421ec32 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -23,12 +23,12 @@ SCOUT_WINDOW_SECONDS = 1800 # 30 minutes # Rarity value → display symbol RARITY_SYMBOLS = { - 8: "\U0001f7e1", # HoF — yellow - 5: "\U0001f7e3", # MVP — purple - 3: "\U0001f535", # All-Star — blue - 2: "\U0001f7e2", # Starter — green - 1: "\u26aa", # Reserve — white - 0: "\u26ab", # Replacement — black + 8: "\U0001f7e3", # HoF — purple (#751cea) + 5: "\U0001f535", # MVP — cyan/blue (#56f1fa) + 3: "\U0001f7e1", # All-Star — gold (#FFD700) + 2: "\u26aa", # Starter — silver (#C0C0C0) + 1: "\U0001f7e4", # Reserve — bronze (#CD7F32) + 0: "\u26ab", # Replacement — dark gray (#454545) } -- 2.25.1 From 8e605c2140c5d696a54b9f2ada43860065e3a7df Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 13:22:45 -0600 Subject: [PATCH 09/12] fix: add pack_id to scouted card creation, enhance embed with card links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include pack_id in db_post("cards") payload (API requires it) - Player names now link to card image URLs in scout embed - Display format: "🟡 All-Star — [2023 Mike Trout](card_image_url)" Co-Authored-By: Claude Opus 4.6 --- discord_ui/scout_view.py | 1 + helpers/scouting.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 0d5a12f..83712e6 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -221,6 +221,7 @@ class ScoutButton(discord.ui.Button): { "player_id": self.card["player"]["player_id"], "team_id": scouter_team["id"], + "pack_id": self.card["pack"]["id"], } ], }, diff --git a/helpers/scouting.py b/helpers/scouting.py index 421ec32..6666b94 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -52,10 +52,17 @@ def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: player = card["player"] rarity_val = player["rarity"]["value"] symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab") + desc = player.get("description", "") + image_url = player.get("image", "") + name_display = ( + f"[{desc} {player['p_name']}]({image_url})" + if image_url + else f"{desc} {player['p_name']}" + ) lines.append( ( player["player_id"], - f"{symbol} {player['rarity']['name']} — {player['p_name']}", + f"{symbol} {player['rarity']['name']} — {name_display}", ) ) random.shuffle(lines) -- 2.25.1 From e160be4137794dd43523ae90dfb1b56b76827c71 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 18:47:52 -0600 Subject: [PATCH 10/12] fix: add missing pack, description, image fields to scouting test fixtures Co-Authored-By: Claude Opus 4.6 --- tests/scouting/conftest.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/scouting/conftest.py b/tests/scouting/conftest.py index 523e38f..22164fd 100644 --- a/tests/scouting/conftest.py +++ b/tests/scouting/conftest.py @@ -12,19 +12,29 @@ from discord.ext import commands # --------------------------------------------------------------------------- -def _make_player(player_id, name, rarity_name, rarity_value, headshot=None): +def _make_player( + player_id, + name, + rarity_name, + rarity_value, + headshot=None, + description="2023", + image=None, +): """Build a minimal player dict matching the API shape used by scouting.""" return { "player_id": player_id, "p_name": name, "rarity": {"name": rarity_name, "value": rarity_value, "color": "ffffff"}, "headshot": headshot or "https://example.com/headshot.jpg", + "description": description, + "image": image or f"https://example.com/cards/{player_id}/battingcard.png", } -def _make_card(card_id, player): +def _make_card(card_id, player, pack_id=100): """Wrap a player dict inside a card dict (as returned by the cards API).""" - return {"id": card_id, "player": player} + return {"id": card_id, "player": player, "pack": {"id": pack_id}} @pytest.fixture -- 2.25.1 From da55cbe4d49488540ad9c0b46185a1b38583107f Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 21:12:46 -0600 Subject: [PATCH 11/12] feat: limit scouting to Standard/Premium packs, simplify scout view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SCOUTABLE_PACK_TYPES env var (default: Standard,Premium) to control which pack types offer scout opportunities - Unify embed construction into build_scout_embed() — removes 3 near-duplicate embed builders across scout_view.py and scouting.py - Replace manual total_scouts counter with derived property from claims dict - Remove redundant db_get("current") API call per scout click — use PD_SEASON - Remove duplicate expiry computation in create_scout_opportunity - Move send_to_channel to top-level import, remove redundant local import - Update tests to match simplified code Co-Authored-By: Claude Opus 4.6 --- discord_ui/scout_view.py | 66 ++++++++----------------- helpers/main.py | 18 ++++--- helpers/scouting.py | 81 ++++++++++++++++++++++--------- tests/scouting/test_scout_view.py | 47 +++++------------- 4 files changed, 99 insertions(+), 113 deletions(-) diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 83712e6..a5a64ba 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -11,15 +11,15 @@ import logging import discord -from api_calls import db_get, db_post +from api_calls import db_post from helpers.main import get_team_by_owner, get_card_embeds from helpers.scouting import ( SCOUT_TOKENS_PER_DAY, - build_scouted_card_list, + build_scout_embed, get_scout_tokens_used, ) from helpers.utils import int_timestamp -from helpers.discord_utils import get_team_embed +from helpers.discord_utils import get_team_embed, send_to_channel from helpers.constants import IMAGES, PD_SEASON logger = logging.getLogger("discord_app") @@ -60,8 +60,6 @@ class ScoutView(discord.ui.View): self.scouted_users: set[int] = set() # Users currently being processed (prevent double-click race) self.processing_users: set[int] = set() - # Total scout count - self.total_scouts = 0 for i, card in enumerate(cards): button = ScoutButton( @@ -71,30 +69,21 @@ class ScoutView(discord.ui.View): ) self.add_item(button) + @property + def total_scouts(self) -> int: + return sum(len(v) for v in self.claims.values()) + async def update_message(self): """Refresh the embed with current claim state.""" if not self.message: return - card_list = build_scouted_card_list(self.card_lines, self.claims) - - title = f"Scout Opportunity! ({self.total_scouts} scouted)" - embed = get_team_embed(title=title, team=self.opener_team) - if self.expires_unix: - time_line = f"Scout window closes ." - else: - time_line = "Scout window closes in **30 minutes**." - - embed.description = ( - f"**{self.opener_team['lname']}**'s pack\n\n" - f"{card_list}\n\n" - f"Pick a card — but which is which?\n" - f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" - f"{time_line}" - ) - embed.set_footer( - text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player", - icon_url=IMAGES["logo"], + embed, _ = build_scout_embed( + self.opener_team, + card_lines=self.card_lines, + expires_unix=self.expires_unix, + claims=self.claims, + total_scouts=self.total_scouts, ) try: @@ -109,22 +98,12 @@ class ScoutView(discord.ui.View): if self.message: try: - from helpers.scouting import build_scouted_card_list - - card_list = build_scouted_card_list(self.card_lines, self.claims) - - if self.total_scouts > 0: - title = f"Scout Window Closed ({self.total_scouts} scouted)" - else: - title = "Scout Window Closed" - - embed = get_team_embed(title=title, team=self.opener_team) - embed.description = ( - f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}" - ) - embed.set_footer( - text=f"Paper Dynasty Season {PD_SEASON}", - icon_url=IMAGES["logo"], + embed, _ = build_scout_embed( + self.opener_team, + card_lines=self.card_lines, + claims=self.claims, + total_scouts=self.total_scouts, + closed=True, ) await self.message.edit(embed=embed, view=self) except Exception as e: @@ -228,14 +207,12 @@ class ScoutButton(discord.ui.Button): ) # Consume a scout token - current = await db_get("current") await db_post( "rewards", payload={ "name": "Scout Token", "team_id": scouter_team["id"], - "season": current["season"] if current else PD_SEASON, - "week": current["week"] if current else 1, + "season": PD_SEASON, "created": int_timestamp(), }, ) @@ -246,7 +223,6 @@ class ScoutButton(discord.ui.Button): view.claims[player_id] = [] view.claims[player_id].append(scouter_team["lname"]) view.scouted_users.add(interaction.user.id) - view.total_scouts += 1 # Update the shared embed await view.update_message() @@ -269,8 +245,6 @@ class ScoutButton(discord.ui.Button): # Notify for shiny scouts (rarity >= 5) if self.card["player"]["rarity"]["value"] >= 5: try: - from helpers.discord_utils import send_to_channel - notif_embed = get_team_embed(title="Rare Scout!", team=scouter_team) notif_embed.description = ( f"**{scouter_team['lname']}** scouted a " diff --git a/helpers/main.py b/helpers/main.py index 4dfc659..0b989a5 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -1770,15 +1770,17 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): await context.channel.send(content=f"Let's head down to {pack_channel.mention}!") await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover) - # Create scout opportunities for each pack - from helpers.scouting import create_scout_opportunity + # Create scout opportunities for each pack (Standard/Premium only) + from helpers.scouting import create_scout_opportunity, SCOUTABLE_PACK_TYPES - for p_id in pack_ids: - pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] - if pack_cards: - await create_scout_opportunity( - pack_cards, team, pack_channel, author, context - ) + pack_type_name = all_packs[0].get("pack_type", {}).get("name") + if pack_type_name in SCOUTABLE_PACK_TYPES: + for p_id in pack_ids: + pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] + if pack_cards: + await create_scout_opportunity( + pack_cards, team, pack_channel, author, context + ) if len(pack_ids) > 1: await asyncio.sleep(2) diff --git a/helpers/scouting.py b/helpers/scouting.py index 6666b94..6c926bb 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -7,6 +7,7 @@ and embed formatting for the scouting feature. import datetime import logging +import os import random import discord @@ -20,6 +21,8 @@ logger = logging.getLogger("discord_app") SCOUT_TOKENS_PER_DAY = 2 SCOUT_WINDOW_SECONDS = 1800 # 30 minutes +_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium") +SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()} # Rarity value → display symbol RARITY_SYMBOLS = { @@ -71,39 +74,70 @@ def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: def build_scout_embed( opener_team: dict, - cards: list[dict], + cards: list[dict] = None, card_lines: list[tuple[int, str]] = None, expires_unix: int = None, + claims: dict[int, list[str]] = None, + total_scouts: int = 0, + closed: bool = False, ) -> tuple[discord.Embed, list[tuple[int, str]]]: """Build the embed shown above the scout buttons. Shows a shuffled list of cards (rarity + player name) so scouters know what's in the pack but not which button maps to which card. Returns (embed, card_lines) so the view can store the shuffled order. - """ - embed = get_team_embed(title="Scout Opportunity!", team=opener_team) - if card_lines is None: + Parameters + ---------- + closed : if True, renders the "Scout Window Closed" variant + claims : scouted card tracking dict for build_scouted_card_list + total_scouts : number of scouts so far (for title display) + """ + if card_lines is None and cards is not None: card_lines = _build_card_lines(cards) - card_list = "\n".join(line for _, line in card_lines) - - if expires_unix: - time_line = f"Scout window closes ." + if claims and card_lines: + card_list = build_scouted_card_list(card_lines, claims) + elif card_lines: + card_list = "\n".join(line for _, line in card_lines) else: - time_line = "Scout window closes in **30 minutes**." + card_list = "" - embed.description = ( - f"**{opener_team['lname']}** just opened a pack!\n\n" - f"**Cards in this pack:**\n{card_list}\n\n" - f"Pick a card — but which is which?\n" - f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" - f"{time_line}" - ) - embed.set_footer( - text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player", - icon_url=IMAGES["logo"], - ) + if closed: + if total_scouts > 0: + title = f"Scout Window Closed ({total_scouts} scouted)" + else: + title = "Scout Window Closed" + elif total_scouts > 0: + title = f"Scout Opportunity! ({total_scouts} scouted)" + else: + title = "Scout Opportunity!" + + embed = get_team_embed(title=title, team=opener_team) + + if closed: + embed.description = f"**{opener_team['lname']}**'s pack\n\n" f"{card_list}" + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", + icon_url=IMAGES["logo"], + ) + else: + if expires_unix: + time_line = f"Scout window closes ." + else: + time_line = "Scout window closes in **30 minutes**." + + embed.description = ( + f"**{opener_team['lname']}**'s pack\n\n" + f"{card_list}\n\n" + f"Pick a card — but which is which?\n" + f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" + f"{time_line}" + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player", + icon_url=IMAGES["logo"], + ) return embed, card_lines @@ -163,7 +197,9 @@ async def create_scout_opportunity( return now = datetime.datetime.now() - expires_at = int_timestamp(now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)) + expires_dt = now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS) + expires_at = int_timestamp(expires_dt) + expires_unix = int(expires_dt.timestamp()) created = int_timestamp(now) card_ids = [c["id"] for c in pack_cards] @@ -183,9 +219,6 @@ async def create_scout_opportunity( logger.error(f"Failed to create scout opportunity: {e}") return - expires_unix = int( - (now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)).timestamp() - ) embed, card_lines = build_scout_embed( opener_team, pack_cards, expires_unix=expires_unix ) diff --git a/tests/scouting/test_scout_view.py b/tests/scouting/test_scout_view.py index 906bb97..186c0b1 100644 --- a/tests/scouting/test_scout_view.py +++ b/tests/scouting/test_scout_view.py @@ -210,14 +210,12 @@ class TestScoutButtonSuccess: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_successful_scout_creates_card_copy( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -230,7 +228,6 @@ class TestScoutButtonSuccess: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -369,14 +366,12 @@ class TestMultiScout: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_two_users_can_scout_same_card( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -388,7 +383,6 @@ class TestMultiScout: """Two different users should both be able to scout the same card.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -430,14 +424,12 @@ class TestMultiScout: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_same_user_cannot_scout_twice( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -449,7 +441,6 @@ class TestMultiScout: view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -579,7 +570,9 @@ class TestScoutViewTimeout: view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] - view.total_scouts = 5 + # Set up claims so total_scouts property returns 5 + pid = sample_cards[0]["player"]["player_id"] + view.claims[pid] = ["Team A", "Team B", "Team C", "Team D", "Team E"] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() @@ -617,14 +610,12 @@ class TestProcessingUserCleanup: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_processing_cleared_on_success( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -648,7 +639,6 @@ class TestProcessingUserCleanup: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -744,24 +734,22 @@ class TestProcessingUserCleanup: # --------------------------------------------------------------------------- -# db_get("current") fallback +# Rewards use PD_SEASON constant # --------------------------------------------------------------------------- -class TestCurrentSeasonFallback: - """Tests for the fallback when db_get('current') returns None.""" +class TestRewardsSeason: + """Tests that reward records always use the PD_SEASON constant.""" @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) - async def test_current_returns_none_uses_fallback( + async def test_rewards_use_pd_season( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -769,7 +757,7 @@ class TestCurrentSeasonFallback: scouter_team, mock_bot, ): - """When db_get('current') returns None, rewards should use PD_SEASON fallback.""" + """Reward records should always use the PD_SEASON constant for season.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, @@ -785,7 +773,6 @@ class TestCurrentSeasonFallback: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = None # db_get("current") returns None mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -803,14 +790,13 @@ class TestCurrentSeasonFallback: assert view.total_scouts == 1 assert 12345 in view.scouted_users - # Verify the rewards POST used fallback values + # Verify the rewards POST uses PD_SEASON # Order: scout_claims (0), cards (1), rewards (2) from helpers.constants import PD_SEASON reward_call = mock_db_post.call_args_list[2] assert reward_call[0][0] == "rewards" assert reward_call[1]["payload"]["season"] == PD_SEASON - assert reward_call[1]["payload"]["week"] == 1 # --------------------------------------------------------------------------- @@ -822,17 +808,15 @@ class TestShinyScoutNotification: """Tests for the rare-card notification path (rarity >= 5).""" @pytest.mark.asyncio - @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_shiny_card_sends_notification( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, @@ -857,7 +841,6 @@ class TestShinyScoutNotification: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -877,17 +860,15 @@ class TestShinyScoutNotification: assert call_args[0][1] == "pd-network-news" @pytest.mark.asyncio - @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_non_shiny_card_no_notification( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, @@ -912,7 +893,6 @@ class TestShinyScoutNotification: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -930,17 +910,15 @@ class TestShinyScoutNotification: mock_send_to_channel.assert_not_called() @pytest.mark.asyncio - @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_shiny_notification_failure_does_not_crash( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, @@ -965,7 +943,6 @@ class TestShinyScoutNotification: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] mock_send_to_channel.side_effect = Exception("Channel not found") -- 2.25.1 From a9943ec57f4e8e4841735a537b67e3c9987c023d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 03:04:50 -0600 Subject: [PATCH 12/12] fix: log and handle ZeroDivisionError in gauntlet draft (#31) Both callers of gauntlets.run_draft() silently swallowed ZeroDivisionError (the intentional user-cancellation signal) with a bare return and no logging. Add logger.info() to both except blocks so cancellations are visible in the log stream. Co-Authored-By: Claude Sonnet 4.6 --- cogs/players.py | 1359 +++++++++++++++++++++------------- cogs/players_new/gauntlet.py | 232 +++--- 2 files changed, 998 insertions(+), 593 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index 4dfc233..093e254 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -3,7 +3,7 @@ import math import os import random -import requests +import aiohttp import discord import pygsheets @@ -19,6 +19,7 @@ from sqlmodel import Session import gauntlets import helpers + # import in_game.data_cache # import in_game.simulations # import in_game @@ -28,210 +29,389 @@ from in_game.gameplay_queries import get_team_or_none from in_game.simulations import get_pos_embeds, get_result from in_game.gameplay_models import Lineup, Play, Session, engine from api_calls import db_get, db_post, db_patch, get_team_by_abbrev -from helpers import ACTIVE_EVENT_LITERAL, PD_PLAYERS_ROLE_NAME, IMAGES, PD_SEASON, random_conf_gif, fuzzy_player_search, ALL_MLB_TEAMS, \ - fuzzy_search, get_channel, display_cards, get_card_embeds, get_team_embed, cardset_search, get_blank_team_card, \ - get_team_by_owner, get_rosters, get_roster_sheet, legal_channel, app_legal_channel, random_conf_word, embed_pagination, get_cal_user, \ - team_summary_embed, SelectView, SelectPaperdexCardset, SelectPaperdexTeam, get_context_user +from helpers import ( + ACTIVE_EVENT_LITERAL, + PD_PLAYERS_ROLE_NAME, + IMAGES, + PD_SEASON, + random_conf_gif, + fuzzy_player_search, + ALL_MLB_TEAMS, + fuzzy_search, + get_channel, + display_cards, + get_card_embeds, + get_team_embed, + cardset_search, + get_blank_team_card, + get_team_by_owner, + get_rosters, + get_roster_sheet, + legal_channel, + app_legal_channel, + random_conf_word, + embed_pagination, + get_cal_user, + team_summary_embed, + SelectView, + SelectPaperdexCardset, + SelectPaperdexTeam, + get_context_user, +) from utilities.buttons import ask_with_buttons from utilities.autocomplete import cardset_autocomplete, player_autocomplete - -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") def get_ai_records(short_games, long_games): all_results = { - 'ARI': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'ATL': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'BAL': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'BOS': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'CHC': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'CHW': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'CIN': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'CLE': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'COL': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'DET': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'HOU': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'KCR': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'LAA': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'LAD': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'MIA': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'MIL': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'MIN': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'NYM': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'NYY': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'OAK': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'PHI': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'PIT': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'SDP': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'SEA': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'SFG': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'STL': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'TBR': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'TEX': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'TOR': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, - 'WSN': { - 'short': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'minor': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, - 'major': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}, 'hof': {'w': 0, 'l': 0, 'rd': 0, 'points': 0}}, + "ARI": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "ATL": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "BAL": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "BOS": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "CHC": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "CHW": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "CIN": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "CLE": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "COL": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "DET": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "HOU": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "KCR": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "LAA": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "LAD": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "MIA": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "MIL": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "MIN": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "NYM": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "NYY": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "OAK": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "PHI": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "PIT": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "SDP": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "SEA": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "SFG": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "STL": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "TBR": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "TEX": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "TOR": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, + "WSN": { + "short": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "minor": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "major": {"w": 0, "l": 0, "rd": 0, "points": 0}, + "hof": {"w": 0, "l": 0, "rd": 0, "points": 0}, + }, } - logger.debug(f'running short games...') + logger.debug(f"running short games...") for line in short_games: - home_win = True if line['home_score'] > line['away_score'] else False + home_win = True if line["home_score"] > line["away_score"] else False - if line['away_team']['is_ai']: - all_results[line['away_team']['abbrev']]['short']['w'] += 1 if home_win else 0 - all_results[line['away_team']['abbrev']]['short']['l'] += 1 if not home_win else 0 - all_results[line['away_team']['abbrev']]['short']['points'] += 2 if home_win else 1 - all_results[line['away_team']['abbrev']]['short']['rd'] += line['home_score'] - line['away_score'] - elif line['home_team']['is_ai']: - all_results[line['home_team']['abbrev']]['short']['w'] += 1 if not home_win else 0 - all_results[line['home_team']['abbrev']]['short']['l'] += 1 if home_win else 0 - all_results[line['home_team']['abbrev']]['short']['points'] += 2 if not home_win else 1 - all_results[line['home_team']['abbrev']]['short']['rd'] += line['away_score'] - line['home_score'] - logger.debug(f'done short games') + if line["away_team"]["is_ai"]: + all_results[line["away_team"]["abbrev"]]["short"]["w"] += ( + 1 if home_win else 0 + ) + all_results[line["away_team"]["abbrev"]]["short"]["l"] += ( + 1 if not home_win else 0 + ) + all_results[line["away_team"]["abbrev"]]["short"]["points"] += ( + 2 if home_win else 1 + ) + all_results[line["away_team"]["abbrev"]]["short"]["rd"] += ( + line["home_score"] - line["away_score"] + ) + elif line["home_team"]["is_ai"]: + all_results[line["home_team"]["abbrev"]]["short"]["w"] += ( + 1 if not home_win else 0 + ) + all_results[line["home_team"]["abbrev"]]["short"]["l"] += ( + 1 if home_win else 0 + ) + all_results[line["home_team"]["abbrev"]]["short"]["points"] += ( + 2 if not home_win else 1 + ) + all_results[line["home_team"]["abbrev"]]["short"]["rd"] += ( + line["away_score"] - line["home_score"] + ) + logger.debug(f"done short games") - logger.debug(f'running league games...') - league = {None: 'minor', 'minor-league': 'minor', 'major-league': 'major', 'hall-of-fame': 'hof'} + logger.debug(f"running league games...") + league = { + None: "minor", + "minor-league": "minor", + "major-league": "major", + "hall-of-fame": "hof", + } for line in long_games: - home_win = True if line['home_score'] > line['away_score'] else False + home_win = True if line["home_score"] > line["away_score"] else False - if line['away_team']['is_ai']: - all_results[line['away_team']['abbrev']][league[line['game_type']]]['w'] += 1 if home_win else 0 - all_results[line['away_team']['abbrev']][league[line['game_type']]]['l'] += 1 if not home_win else 0 - all_results[line['away_team']['abbrev']][league[line['game_type']]]['points'] += 2 if home_win else 1 - all_results[line['away_team']['abbrev']][league[line['game_type']]]['rd'] += \ - line['home_score'] - line['away_score'] - elif line['home_team']['is_ai']: - all_results[line['home_team']['abbrev']][league[line['game_type']]]['w'] += 1 if not home_win else 0 - all_results[line['home_team']['abbrev']][league[line['game_type']]]['l'] += 1 if home_win else 0 - all_results[line['home_team']['abbrev']][league[line['game_type']]]['points'] += 2 if not home_win else 1 - all_results[line['home_team']['abbrev']][league[line['game_type']]]['rd'] += \ - line['away_score'] - line['home_score'] - logger.debug(f'done league games') + if line["away_team"]["is_ai"]: + all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ + "w" + ] += (1 if home_win else 0) + all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ + "l" + ] += (1 if not home_win else 0) + all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ + "points" + ] += (2 if home_win else 1) + all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ + "rd" + ] += (line["home_score"] - line["away_score"]) + elif line["home_team"]["is_ai"]: + all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ + "w" + ] += (1 if not home_win else 0) + all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ + "l" + ] += (1 if home_win else 0) + all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ + "points" + ] += (2 if not home_win else 1) + all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ + "rd" + ] += (line["away_score"] - line["home_score"]) + logger.debug(f"done league games") return all_results def get_record_embed_legacy(embed: discord.Embed, results: dict, league: str): - ale_points = results["BAL"][league]["points"] + results["BOS"][league]["points"] + \ - results["NYY"][league]["points"] + results["TBR"][league]["points"] + results["TOR"][league]["points"] - alc_points = results["CLE"][league]["points"] + results["CHW"][league]["points"] + \ - results["DET"][league]["points"] + results["KCR"][league]["points"] + results["MIN"][league]["points"] - alw_points = results["HOU"][league]["points"] + results["LAA"][league]["points"] + \ - results["OAK"][league]["points"] + results["SEA"][league]["points"] + results["TEX"][league]["points"] - nle_points = results["ATL"][league]["points"] + results["MIA"][league]["points"] + \ - results["NYM"][league]["points"] + results["PHI"][league]["points"] + results["WSN"][league]["points"] - nlc_points = results["CHC"][league]["points"] + results["CIN"][league]["points"] + \ - results["MIL"][league]["points"] + results["PIT"][league]["points"] + results["STL"][league]["points"] - nlw_points = results["ARI"][league]["points"] + results["COL"][league]["points"] + \ - results["LAD"][league]["points"] + results["SDP"][league]["points"] + results["SFG"][league]["points"] + ale_points = ( + results["BAL"][league]["points"] + + results["BOS"][league]["points"] + + results["NYY"][league]["points"] + + results["TBR"][league]["points"] + + results["TOR"][league]["points"] + ) + alc_points = ( + results["CLE"][league]["points"] + + results["CHW"][league]["points"] + + results["DET"][league]["points"] + + results["KCR"][league]["points"] + + results["MIN"][league]["points"] + ) + alw_points = ( + results["HOU"][league]["points"] + + results["LAA"][league]["points"] + + results["OAK"][league]["points"] + + results["SEA"][league]["points"] + + results["TEX"][league]["points"] + ) + nle_points = ( + results["ATL"][league]["points"] + + results["MIA"][league]["points"] + + results["NYM"][league]["points"] + + results["PHI"][league]["points"] + + results["WSN"][league]["points"] + ) + nlc_points = ( + results["CHC"][league]["points"] + + results["CIN"][league]["points"] + + results["MIL"][league]["points"] + + results["PIT"][league]["points"] + + results["STL"][league]["points"] + ) + nlw_points = ( + results["ARI"][league]["points"] + + results["COL"][league]["points"] + + results["LAD"][league]["points"] + + results["SDP"][league]["points"] + + results["SFG"][league]["points"] + ) embed.add_field( - name=f'AL East ({ale_points} pts)', + name=f"AL East ({ale_points} pts)", value=f'BAL: {results["BAL"][league]["w"]} - {results["BAL"][league]["l"]} ({results["BAL"][league]["rd"]} RD)\n' - f'BOS: {results["BOS"][league]["w"]} - {results["BOS"][league]["l"]} ({results["BOS"][league]["rd"]} RD)\n' - f'NYY: {results["NYY"][league]["w"]} - {results["NYY"][league]["l"]} ({results["NYY"][league]["rd"]} RD)\n' - f'TBR: {results["TBR"][league]["w"]} - {results["TBR"][league]["l"]} ({results["TBR"][league]["rd"]} RD)\n' - f'TOR: {results["TOR"][league]["w"]} - {results["TOR"][league]["l"]} ({results["TOR"][league]["rd"]} RD)\n' + f'BOS: {results["BOS"][league]["w"]} - {results["BOS"][league]["l"]} ({results["BOS"][league]["rd"]} RD)\n' + f'NYY: {results["NYY"][league]["w"]} - {results["NYY"][league]["l"]} ({results["NYY"][league]["rd"]} RD)\n' + f'TBR: {results["TBR"][league]["w"]} - {results["TBR"][league]["l"]} ({results["TBR"][league]["rd"]} RD)\n' + f'TOR: {results["TOR"][league]["w"]} - {results["TOR"][league]["l"]} ({results["TOR"][league]["rd"]} RD)\n', ) embed.add_field( - name=f'AL Central ({alc_points} pts)', + name=f"AL Central ({alc_points} pts)", value=f'CLE: {results["CLE"][league]["w"]} - {results["CLE"][league]["l"]} ({results["CLE"][league]["rd"]} RD)\n' - f'CHW: {results["CHW"][league]["w"]} - {results["CHW"][league]["l"]} ({results["CHW"][league]["rd"]} RD)\n' - f'DET: {results["DET"][league]["w"]} - {results["DET"][league]["l"]} ({results["DET"][league]["rd"]} RD)\n' - f'KCR: {results["KCR"][league]["w"]} - {results["KCR"][league]["l"]} ({results["KCR"][league]["rd"]} RD)\n' - f'MIN: {results["MIN"][league]["w"]} - {results["MIN"][league]["l"]} ({results["MIN"][league]["rd"]} RD)\n' + f'CHW: {results["CHW"][league]["w"]} - {results["CHW"][league]["l"]} ({results["CHW"][league]["rd"]} RD)\n' + f'DET: {results["DET"][league]["w"]} - {results["DET"][league]["l"]} ({results["DET"][league]["rd"]} RD)\n' + f'KCR: {results["KCR"][league]["w"]} - {results["KCR"][league]["l"]} ({results["KCR"][league]["rd"]} RD)\n' + f'MIN: {results["MIN"][league]["w"]} - {results["MIN"][league]["l"]} ({results["MIN"][league]["rd"]} RD)\n', ) embed.add_field( - name=f'AL West ({alw_points} pts)', + name=f"AL West ({alw_points} pts)", value=f'HOU: {results["HOU"][league]["w"]} - {results["HOU"][league]["l"]} ({results["HOU"][league]["rd"]} RD)\n' - f'LAA: {results["LAA"][league]["w"]} - {results["LAA"][league]["l"]} ({results["LAA"][league]["rd"]} RD)\n' - f'OAK: {results["OAK"][league]["w"]} - {results["OAK"][league]["l"]} ({results["OAK"][league]["rd"]} RD)\n' - f'SEA: {results["SEA"][league]["w"]} - {results["SEA"][league]["l"]} ({results["SEA"][league]["rd"]} RD)\n' - f'TEX: {results["TEX"][league]["w"]} - {results["TEX"][league]["l"]} ({results["TEX"][league]["rd"]} RD)\n' + f'LAA: {results["LAA"][league]["w"]} - {results["LAA"][league]["l"]} ({results["LAA"][league]["rd"]} RD)\n' + f'OAK: {results["OAK"][league]["w"]} - {results["OAK"][league]["l"]} ({results["OAK"][league]["rd"]} RD)\n' + f'SEA: {results["SEA"][league]["w"]} - {results["SEA"][league]["l"]} ({results["SEA"][league]["rd"]} RD)\n' + f'TEX: {results["TEX"][league]["w"]} - {results["TEX"][league]["l"]} ({results["TEX"][league]["rd"]} RD)\n', ) embed.add_field( - name=f'NL East ({nle_points} pts)', + name=f"NL East ({nle_points} pts)", value=f'ATL: {results["ATL"][league]["w"]} - {results["ATL"][league]["l"]} ({results["ATL"][league]["rd"]} RD)\n' - f'MIA: {results["MIA"][league]["w"]} - {results["MIA"][league]["l"]} ({results["MIA"][league]["rd"]} RD)\n' - f'NYM: {results["NYM"][league]["w"]} - {results["NYM"][league]["l"]} ({results["NYM"][league]["rd"]} RD)\n' - f'PHI: {results["PHI"][league]["w"]} - {results["PHI"][league]["l"]} ({results["PHI"][league]["rd"]} RD)\n' - f'WSN: {results["WSN"][league]["w"]} - {results["WSN"][league]["l"]} ({results["WSN"][league]["rd"]} RD)\n' + f'MIA: {results["MIA"][league]["w"]} - {results["MIA"][league]["l"]} ({results["MIA"][league]["rd"]} RD)\n' + f'NYM: {results["NYM"][league]["w"]} - {results["NYM"][league]["l"]} ({results["NYM"][league]["rd"]} RD)\n' + f'PHI: {results["PHI"][league]["w"]} - {results["PHI"][league]["l"]} ({results["PHI"][league]["rd"]} RD)\n' + f'WSN: {results["WSN"][league]["w"]} - {results["WSN"][league]["l"]} ({results["WSN"][league]["rd"]} RD)\n', ) embed.add_field( - name=f'NL Central ({nlc_points} pts)', + name=f"NL Central ({nlc_points} pts)", value=f'CHC: {results["CHC"][league]["w"]} - {results["CHC"][league]["l"]} ({results["CHC"][league]["rd"]} RD)\n' - f'CHW: {results["CIN"][league]["w"]} - {results["CIN"][league]["l"]} ({results["CIN"][league]["rd"]} RD)\n' - f'MIL: {results["MIL"][league]["w"]} - {results["MIL"][league]["l"]} ({results["MIL"][league]["rd"]} RD)\n' - f'PIT: {results["PIT"][league]["w"]} - {results["PIT"][league]["l"]} ({results["PIT"][league]["rd"]} RD)\n' - f'STL: {results["STL"][league]["w"]} - {results["STL"][league]["l"]} ({results["STL"][league]["rd"]} RD)\n' + f'CHW: {results["CIN"][league]["w"]} - {results["CIN"][league]["l"]} ({results["CIN"][league]["rd"]} RD)\n' + f'MIL: {results["MIL"][league]["w"]} - {results["MIL"][league]["l"]} ({results["MIL"][league]["rd"]} RD)\n' + f'PIT: {results["PIT"][league]["w"]} - {results["PIT"][league]["l"]} ({results["PIT"][league]["rd"]} RD)\n' + f'STL: {results["STL"][league]["w"]} - {results["STL"][league]["l"]} ({results["STL"][league]["rd"]} RD)\n', ) embed.add_field( - name=f'NL West ({nlw_points} pts)', + name=f"NL West ({nlw_points} pts)", value=f'ARI: {results["ARI"][league]["w"]} - {results["ARI"][league]["l"]} ({results["ARI"][league]["rd"]} RD)\n' - f'COL: {results["COL"][league]["w"]} - {results["COL"][league]["l"]} ({results["COL"][league]["rd"]} RD)\n' - f'LAD: {results["LAD"][league]["w"]} - {results["LAD"][league]["l"]} ({results["LAD"][league]["rd"]} RD)\n' - f'SDP: {results["SDP"][league]["w"]} - {results["SDP"][league]["l"]} ({results["SDP"][league]["rd"]} RD)\n' - f'SFG: {results["SFG"][league]["w"]} - {results["SFG"][league]["l"]} ({results["SFG"][league]["rd"]} RD)\n' + f'COL: {results["COL"][league]["w"]} - {results["COL"][league]["l"]} ({results["COL"][league]["rd"]} RD)\n' + f'LAD: {results["LAD"][league]["w"]} - {results["LAD"][league]["l"]} ({results["LAD"][league]["rd"]} RD)\n' + f'SDP: {results["SDP"][league]["w"]} - {results["SDP"][league]["l"]} ({results["SDP"][league]["rd"]} RD)\n' + f'SFG: {results["SFG"][league]["w"]} - {results["SFG"][league]["l"]} ({results["SFG"][league]["rd"]} RD)\n', ) return embed @@ -240,52 +420,52 @@ def get_record_embed_legacy(embed: discord.Embed, results: dict, league: str): def get_record_embed(team: dict, results: dict, league: str): embed = get_team_embed(league, team) embed.add_field( - name=f'AL East', + name=f"AL East", value=f'BAL: {results["BAL"][0]} - {results["BAL"][1]} ({results["BAL"][2]} RD)\n' - f'BOS: {results["BOS"][0]} - {results["BOS"][1]} ({results["BOS"][2]} RD)\n' - f'NYY: {results["NYY"][0]} - {results["NYY"][1]} ({results["NYY"][2]} RD)\n' - f'TBR: {results["TBR"][0]} - {results["TBR"][1]} ({results["TBR"][2]} RD)\n' - f'TOR: {results["TOR"][0]} - {results["TOR"][1]} ({results["TOR"][2]} RD)\n' + f'BOS: {results["BOS"][0]} - {results["BOS"][1]} ({results["BOS"][2]} RD)\n' + f'NYY: {results["NYY"][0]} - {results["NYY"][1]} ({results["NYY"][2]} RD)\n' + f'TBR: {results["TBR"][0]} - {results["TBR"][1]} ({results["TBR"][2]} RD)\n' + f'TOR: {results["TOR"][0]} - {results["TOR"][1]} ({results["TOR"][2]} RD)\n', ) embed.add_field( - name=f'AL Central', + name=f"AL Central", value=f'CLE: {results["CLE"][0]} - {results["CLE"][1]} ({results["CLE"][2]} RD)\n' - f'CHW: {results["CHW"][0]} - {results["CHW"][1]} ({results["CHW"][2]} RD)\n' - f'DET: {results["DET"][0]} - {results["DET"][1]} ({results["DET"][2]} RD)\n' - f'KCR: {results["KCR"][0]} - {results["KCR"][1]} ({results["KCR"][2]} RD)\n' - f'MIN: {results["MIN"][0]} - {results["MIN"][1]} ({results["MIN"][2]} RD)\n' + f'CHW: {results["CHW"][0]} - {results["CHW"][1]} ({results["CHW"][2]} RD)\n' + f'DET: {results["DET"][0]} - {results["DET"][1]} ({results["DET"][2]} RD)\n' + f'KCR: {results["KCR"][0]} - {results["KCR"][1]} ({results["KCR"][2]} RD)\n' + f'MIN: {results["MIN"][0]} - {results["MIN"][1]} ({results["MIN"][2]} RD)\n', ) embed.add_field( - name=f'AL West', + name=f"AL West", value=f'HOU: {results["HOU"][0]} - {results["HOU"][1]} ({results["HOU"][2]} RD)\n' - f'LAA: {results["LAA"][0]} - {results["LAA"][1]} ({results["LAA"][2]} RD)\n' - f'OAK: {results["OAK"][0]} - {results["OAK"][1]} ({results["OAK"][2]} RD)\n' - f'SEA: {results["SEA"][0]} - {results["SEA"][1]} ({results["SEA"][2]} RD)\n' - f'TEX: {results["TEX"][0]} - {results["TEX"][1]} ({results["TEX"][2]} RD)\n' + f'LAA: {results["LAA"][0]} - {results["LAA"][1]} ({results["LAA"][2]} RD)\n' + f'OAK: {results["OAK"][0]} - {results["OAK"][1]} ({results["OAK"][2]} RD)\n' + f'SEA: {results["SEA"][0]} - {results["SEA"][1]} ({results["SEA"][2]} RD)\n' + f'TEX: {results["TEX"][0]} - {results["TEX"][1]} ({results["TEX"][2]} RD)\n', ) embed.add_field( - name=f'NL East', + name=f"NL East", value=f'ATL: {results["ATL"][0]} - {results["ATL"][1]} ({results["ATL"][2]} RD)\n' - f'MIA: {results["MIA"][0]} - {results["MIA"][1]} ({results["MIA"][2]} RD)\n' - f'NYM: {results["NYM"][0]} - {results["NYM"][1]} ({results["NYM"][2]} RD)\n' - f'PHI: {results["PHI"][0]} - {results["PHI"][1]} ({results["PHI"][2]} RD)\n' - f'WSN: {results["WSN"][0]} - {results["WSN"][1]} ({results["WSN"][2]} RD)\n' + f'MIA: {results["MIA"][0]} - {results["MIA"][1]} ({results["MIA"][2]} RD)\n' + f'NYM: {results["NYM"][0]} - {results["NYM"][1]} ({results["NYM"][2]} RD)\n' + f'PHI: {results["PHI"][0]} - {results["PHI"][1]} ({results["PHI"][2]} RD)\n' + f'WSN: {results["WSN"][0]} - {results["WSN"][1]} ({results["WSN"][2]} RD)\n', ) embed.add_field( - name=f'NL Central', + name=f"NL Central", value=f'CHC: {results["CHC"][0]} - {results["CHC"][1]} ({results["CHC"][2]} RD)\n' - f'CIN: {results["CIN"][0]} - {results["CIN"][1]} ({results["CIN"][2]} RD)\n' - f'MIL: {results["MIL"][0]} - {results["MIL"][1]} ({results["MIL"][2]} RD)\n' - f'PIT: {results["PIT"][0]} - {results["PIT"][1]} ({results["PIT"][2]} RD)\n' - f'STL: {results["STL"][0]} - {results["STL"][1]} ({results["STL"][2]} RD)\n' + f'CIN: {results["CIN"][0]} - {results["CIN"][1]} ({results["CIN"][2]} RD)\n' + f'MIL: {results["MIL"][0]} - {results["MIL"][1]} ({results["MIL"][2]} RD)\n' + f'PIT: {results["PIT"][0]} - {results["PIT"][1]} ({results["PIT"][2]} RD)\n' + f'STL: {results["STL"][0]} - {results["STL"][1]} ({results["STL"][2]} RD)\n', ) embed.add_field( - name=f'NL West', + name=f"NL West", value=f'ARI: {results["ARI"][0]} - {results["ARI"][1]} ({results["ARI"][2]} RD)\n' - f'COL: {results["COL"][0]} - {results["COL"][1]} ({results["COL"][2]} RD)\n' - f'LAD: {results["LAD"][0]} - {results["LAD"][1]} ({results["LAD"][2]} RD)\n' - f'SDP: {results["SDP"][0]} - {results["SDP"][1]} ({results["SDP"][2]} RD)\n' - f'SFG: {results["SFG"][0]} - {results["SFG"][1]} ({results["SFG"][2]} RD)\n' + f'COL: {results["COL"][0]} - {results["COL"][1]} ({results["COL"][2]} RD)\n' + f'LAD: {results["LAD"][0]} - {results["LAD"][1]} ({results["LAD"][2]} RD)\n' + f'SDP: {results["SDP"][0]} - {results["SDP"][1]} ({results["SDP"][2]} RD)\n' + f'SFG: {results["SFG"][0]} - {results["SFG"][1]} ({results["SFG"][2]} RD)\n', ) return embed @@ -304,15 +484,17 @@ class Players(commands.Cog): @tasks.loop(hours=1) async def weekly_loop(self): - current = await db_get('current') + current = await db_get("current") now = datetime.datetime.now() - logger.debug(f'Datetime: {now} / weekday: {now.weekday()}') + logger.debug(f"Datetime: {now} / weekday: {now.weekday()}") # Begin Freeze # if now.weekday() == 0 and now.hour == 5: # Spring/Summer if now.weekday() == 0 and now.hour == 0: # Fall/Winter - current['week'] += 1 - await db_patch('current', object_id=current['id'], params=[('week', current['week'])]) + current["week"] += 1 + await db_patch( + "current", object_id=current["id"], params=[("week", current["week"])] + ) # End Freeze # elif now.weekday() == 5 and now.hour == 5 and current['freeze']: # Spring/Summer @@ -324,19 +506,28 @@ class Players(commands.Cog): await self.bot.wait_until_ready() async def cog_command_error(self, ctx, error): - await ctx.send(f'{error}') + await ctx.send(f"{error}") @tasks.loop(hours=18) async def build_player_list(self): - all_players = await db_get('players', params=[('flat', True), ('inc_dex', False)], timeout=25) - all_cardsets = await db_get('cardsets', params=[('flat', True)]) + all_players = await db_get( + "players", params=[("flat", True), ("inc_dex", False)], timeout=25 + ) + all_cardsets = await db_get("cardsets", params=[("flat", True)]) - [self.player_list.append(x['p_name'].lower()) for x in all_players['players'] if x['p_name'].lower() - not in self.player_list] - logger.info(f'There are now {len(self.player_list)} player names in the fuzzy search list.') + [ + self.player_list.append(x["p_name"].lower()) + for x in all_players["players"] + if x["p_name"].lower() not in self.player_list + ] + logger.info( + f"There are now {len(self.player_list)} player names in the fuzzy search list." + ) - self.cardset_list = [x['name'].lower() for x in all_cardsets['cardsets']] - logger.info(f'There are now {len(self.cardset_list)} cardsets in the fuzzy search list.') + self.cardset_list = [x["name"].lower() for x in all_cardsets["cardsets"]] + logger.info( + f"There are now {len(self.cardset_list)} cardsets in the fuzzy search list." + ) @build_player_list.before_loop async def before_player_list(self): @@ -413,109 +604,135 @@ class Players(commands.Cog): # db.close() # return return_embeds - @commands.command(name='build_list', help='Mod: Synchronize fuzzy player list') + @commands.command(name="build_list", help="Mod: Synchronize fuzzy player list") async def build_player_command(self, ctx): self.build_player_list.stop() self.build_player_list.start() - await ctx.send(f'Just kicked off the build...') + await ctx.send(f"Just kicked off the build...") await asyncio.sleep(10) - await ctx.send(f'There are now {len(self.player_list)} player names in the fuzzy search list.') + await ctx.send( + f"There are now {len(self.player_list)} player names in the fuzzy search list." + ) - @commands.command(name='player', help='For specific cardset, run /player', aliases=['show', 'card']) + @commands.command( + name="player", + help="For specific cardset, run /player", + aliases=["show", "card"], + ) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def player_card_command(self, ctx, *, player_name: str): this_player = fuzzy_search(player_name, self.player_list) if not this_player: - await ctx.send(f'No clue who that is.') + await ctx.send(f"No clue who that is.") return - all_players = await db_get('players', params=[('name', this_player)]) + all_players = await db_get("players", params=[("name", this_player)]) all_cards = [ - {'player': x, 'team': {'lname': 'Paper Dynasty', 'logo': IMAGES['logo'], 'season': PD_SEASON}} - for x in all_players['players'] + { + "player": x, + "team": { + "lname": "Paper Dynasty", + "logo": IMAGES["logo"], + "season": PD_SEASON, + }, + } + for x in all_players["players"] ] - all_cards.sort(key=lambda x: x['player']['rarity']['value'], reverse=True) + all_cards.sort(key=lambda x: x["player"]["rarity"]["value"], reverse=True) all_embeds = [] for x in all_cards: all_embeds.extend(await get_card_embeds(x)) await ctx.send(content=None, embeds=all_embeds) - @app_commands.command(name='player', description='Display one or more of the player\'s cards') + @app_commands.command( + name="player", description="Display one or more of the player's cards" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - @app_commands.autocomplete(player_name=player_autocomplete, cardset=cardset_autocomplete) + @app_commands.autocomplete( + player_name=player_autocomplete, cardset=cardset_autocomplete + ) async def player_slash_command( - self, interaction: discord.Interaction, player_name: str, - cardset: str = 'All'): + self, interaction: discord.Interaction, player_name: str, cardset: str = "All" + ): ephemeral = False - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker']: + if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]: ephemeral = True await interaction.response.defer(ephemeral=ephemeral) this_player = fuzzy_search(player_name, self.player_list) if not this_player: - await interaction.response.send_message(f'No clue who that is.') + await interaction.response.send_message(f"No clue who that is.") return - if cardset and cardset != 'All': + if cardset and cardset != "All": this_cardset = await cardset_search(cardset, self.cardset_list) if this_cardset: - all_params = [('name', this_player), ('cardset_id', this_cardset['id'])] + all_params = [("name", this_player), ("cardset_id", this_cardset["id"])] else: - await interaction.edit_original_response(content=f'I couldn\'t find {cardset} cardset.') + await interaction.edit_original_response( + content=f"I couldn't find {cardset} cardset." + ) return else: - all_params = [('name', this_player)] + all_params = [("name", this_player)] - all_players = await db_get('players', params=all_params) - if all_players['count'] == 0: - await interaction.edit_original_response(content='No players found') + all_players = await db_get("players", params=all_params) + if all_players["count"] == 0: + await interaction.edit_original_response(content="No players found") return - all_cards = [get_blank_team_card(x) for x in all_players['players']] - all_cards.sort(key=lambda x: x['player']['rarity']['value'], reverse=True) + all_cards = [get_blank_team_card(x) for x in all_players["players"]] + all_cards.sort(key=lambda x: x["player"]["rarity"]["value"], reverse=True) all_embeds = [] for x in all_cards: all_embeds.extend(await get_card_embeds(x, include_stats=True)) - logger.debug(f'embeds: {all_embeds}') + logger.debug(f"embeds: {all_embeds}") if len(all_embeds) > 1: - await interaction.edit_original_response(content=f'# {all_players["players"][0]["p_name"]}') + await interaction.edit_original_response( + content=f'# {all_players["players"][0]["p_name"]}' + ) await embed_pagination( all_embeds, interaction.channel, interaction.user, timeout=20, - start_page=0 + start_page=0, ) else: await interaction.edit_original_response(content=None, embed=all_embeds[0]) - @app_commands.command(name='update-player', description='Update a player\'s card to a specific MLB team') + @app_commands.command( + name="update-player", + description="Update a player's card to a specific MLB team", + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def update_player_team(self, interaction: discord.Interaction, player_id: int): + async def update_player_team( + self, interaction: discord.Interaction, player_id: int + ): owner_team = await get_team_by_owner(interaction.user.id) if not owner_team: await interaction.response.send_message( - 'Thank you for offering to help - if you sign up for a team with /newteam I can let you post updates.', - ephemeral=True + "Thank you for offering to help - if you sign up for a team with /newteam I can let you post updates.", + ephemeral=True, ) return - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker']: + if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]: await interaction.response.send_message( - f'Slide on down to #pd-bot-hole to run updates - thanks!', - ephemeral=True + f"Slide on down to #pd-bot-hole to run updates - thanks!", + ephemeral=True, ) await interaction.response.defer() - this_player = await db_get('players', object_id=player_id) + this_player = await db_get("players", object_id=player_id) if not this_player: - await interaction.response.send_message(f'No clue who that is.') + await interaction.response.send_message(f"No clue who that is.") return embed = await get_card_embeds(get_blank_team_card(this_player)) @@ -523,536 +740,660 @@ class Players(commands.Cog): view = helpers.Confirm(responders=[interaction.user]) question = await interaction.channel.send( - content='Is this the player you want to update?', - view=view + content="Is this the player you want to update?", view=view ) await view.wait() if not view.value: - await question.edit(content='Okay, we\'ll leave it be.', view=None) + await question.edit(content="Okay, we'll leave it be.", view=None) return else: await question.delete() - view = SelectView([ - helpers.SelectUpdatePlayerTeam('AL', this_player, owner_team, self.bot), - helpers.SelectUpdatePlayerTeam('NL', this_player, owner_team, self.bot) - ]) + view = SelectView( + [ + helpers.SelectUpdatePlayerTeam("AL", this_player, owner_team, self.bot), + helpers.SelectUpdatePlayerTeam("NL", this_player, owner_team, self.bot), + ] + ) await interaction.channel.send(content=None, view=view) - @app_commands.command(name='record', description='Display team record against AI teams') + @app_commands.command( + name="record", description="Display team record against AI teams" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def record_slash_command( - self, interaction: discord.Interaction, - league: Literal['All', 'Minor League', 'Major League', 'Flashback', 'Hall of Fame'], - team_abbrev: Optional[str] = None): + self, + interaction: discord.Interaction, + league: Literal[ + "All", "Minor League", "Major League", "Flashback", "Hall of Fame" + ], + team_abbrev: Optional[str] = None, + ): ephemeral = False - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker']: + if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]: ephemeral = True if team_abbrev: - t_query = await db_get('teams', params=[('abbrev', team_abbrev)]) + t_query = await db_get("teams", params=[("abbrev", team_abbrev)]) else: - t_query = await db_get('teams', params=[('gm_id', interaction.user.id)]) + t_query = await db_get("teams", params=[("gm_id", interaction.user.id)]) - if t_query['count'] == 0: + if t_query["count"] == 0: await interaction.response.send_message( - f'Hmm...I can\'t find the team you looking for.', ephemeral=ephemeral + f"Hmm...I can't find the team you looking for.", ephemeral=ephemeral ) return - team = t_query['teams'][0] - current = await db_get('current') + team = t_query["teams"][0] + current = await db_get("current") await interaction.response.send_message( f'I\'m tallying the {team["lname"]} results now...', ephemeral=ephemeral ) - st_query = await db_get(f'teams/{team["id"]}/season-record', object_id=current["season"]) + st_query = await db_get( + f'teams/{team["id"]}/season-record', object_id=current["season"] + ) - minor_embed = get_record_embed(team, st_query['minor-league'], 'Minor League') - major_embed = get_record_embed(team, st_query['major-league'], 'Major League') - flashback_embed = get_record_embed(team, st_query['flashback'], 'Flashback') - hof_embed = get_record_embed(team, st_query['hall-of-fame'], 'Hall of Fame') + minor_embed = get_record_embed(team, st_query["minor-league"], "Minor League") + major_embed = get_record_embed(team, st_query["major-league"], "Major League") + flashback_embed = get_record_embed(team, st_query["flashback"], "Flashback") + hof_embed = get_record_embed(team, st_query["hall-of-fame"], "Hall of Fame") - if league == 'All': + if league == "All": start_page = 0 - elif league == 'Minor League': + elif league == "Minor League": start_page = 0 - elif league == 'Major League': + elif league == "Major League": start_page = 1 - elif league == 'Flashback': + elif league == "Flashback": start_page = 2 else: start_page = 3 - await interaction.edit_original_response(content=f'Here are the {team["lname"]} campaign records') + await interaction.edit_original_response( + content=f'Here are the {team["lname"]} campaign records' + ) await embed_pagination( [minor_embed, major_embed, flashback_embed, hof_embed], interaction.channel, interaction.user, timeout=20, - start_page=start_page + start_page=start_page, ) - @app_commands.command(name='team', description='Show team overview and rosters') + @app_commands.command(name="team", description="Show team overview and rosters") @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) @app_legal_channel() - async def team_command(self, interaction: discord.Interaction, team_abbrev: Optional[str] = None): + async def team_command( + self, interaction: discord.Interaction, team_abbrev: Optional[str] = None + ): await interaction.response.defer() if team_abbrev: - t_query = await db_get('teams', params=[('abbrev', team_abbrev)]) + t_query = await db_get("teams", params=[("abbrev", team_abbrev)]) else: - t_query = await db_get('teams', params=[('gm_id', interaction.user.id)]) + t_query = await db_get("teams", params=[("gm_id", interaction.user.id)]) - if t_query['count'] == 0: + if t_query["count"] == 0: await interaction.edit_original_response( - content=f'Hmm...I can\'t find the team you looking for.' + content=f"Hmm...I can't find the team you looking for." ) return - team = t_query['teams'][0] + team = t_query["teams"][0] embed = await team_summary_embed(team, interaction) await interaction.edit_original_response(content=None, embed=embed) - group_lookup = app_commands.Group(name='lookup', description='Search for cards or players by ID') + group_lookup = app_commands.Group( + name="lookup", description="Search for cards or players by ID" + ) - @group_lookup.command(name='card-id', description='Look up individual card by ID') + @group_lookup.command(name="card-id", description="Look up individual card by ID") @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def card_lookup_command(self, interaction: discord.Interaction, card_id: int): await interaction.response.defer() - c_query = await db_get('cards', object_id=card_id) + c_query = await db_get("cards", object_id=card_id) if c_query: - c_string = f'Card ID {card_id} is a {helpers.player_desc(c_query["player"])}' - if c_query['team'] is not None: + c_string = ( + f'Card ID {card_id} is a {helpers.player_desc(c_query["player"])}' + ) + if c_query["team"] is not None: c_string += f' owned by the {c_query["team"]["sname"]}' if c_query["pack"] is not None: - c_string += f' pulled from a {c_query["pack"]["pack_type"]["name"]} pack.' + c_string += ( + f' pulled from a {c_query["pack"]["pack_type"]["name"]} pack.' + ) else: - c_query['team'] = c_query["pack"]["team"] - c_string += f' used by the {c_query["pack"]["team"]["sname"]} in a gauntlet' + c_query["team"] = c_query["pack"]["team"] + c_string += ( + f' used by the {c_query["pack"]["team"]["sname"]} in a gauntlet' + ) await interaction.edit_original_response( - content=c_string, - embeds=await get_card_embeds(c_query) + content=c_string, embeds=await get_card_embeds(c_query) ) return - await interaction.edit_original_response(content=f'There is no card with ID {card_id}') + await interaction.edit_original_response( + content=f"There is no card with ID {card_id}" + ) - @group_lookup.command(name='player-id', description='Look up an individual player by ID') + @group_lookup.command( + name="player-id", description="Look up an individual player by ID" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def player_lookup_command(self, interaction: discord.Interaction, player_id: int): + async def player_lookup_command( + self, interaction: discord.Interaction, player_id: int + ): await interaction.response.defer() - p_query = await db_get('players', object_id=player_id) + p_query = await db_get("players", object_id=player_id) if p_query: p_card = get_blank_team_card(p_query) await interaction.edit_original_response( - content=None, - embeds=await get_card_embeds(p_card) + content=None, embeds=await get_card_embeds(p_card) ) return - await interaction.edit_original_response(content=f'There is no player with ID {player_id}.') + await interaction.edit_original_response( + content=f"There is no player with ID {player_id}." + ) - @commands.hybrid_command(name='branding-pd', help='Update your team branding') + @commands.hybrid_command(name="branding-pd", help="Update your team branding") @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def branding_command( - self, ctx, team_logo_url: str = None, color: str = None, short_name: str = None, full_name: str = None): + self, + ctx, + team_logo_url: str = None, + color: str = None, + short_name: str = None, + full_name: str = None, + ): owner_team = await get_team_by_owner(get_context_user(ctx).id) if not owner_team: - await ctx.send(f'Hmm...I don\'t see a team for you, yet. You can create one with `/newteam`!') + await ctx.send( + f"Hmm...I don't see a team for you, yet. You can create one with `/newteam`!" + ) return params = [] if team_logo_url is not None: - params.append(('logo', team_logo_url)) + params.append(("logo", team_logo_url)) if color is not None: - params.append(('color', color)) + params.append(("color", color)) if short_name is not None: - params.append(('sname', short_name)) + params.append(("sname", short_name)) if full_name is not None: - params.append(('lname', full_name)) + params.append(("lname", full_name)) if not params: - await ctx.send(f'You keep thinking on it - I can\'t make updates if you don\'t provide them.') + await ctx.send( + f"You keep thinking on it - I can't make updates if you don't provide them." + ) return - team = await db_patch('teams', object_id=owner_team['id'], params=params) + team = await db_patch("teams", object_id=owner_team["id"], params=params) embed = await team_summary_embed(team, ctx) await ctx.send(content=None, embed=embed) - @commands.hybrid_command(name='fuck', help='You know') + @commands.hybrid_command(name="fuck", help="You know") @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def fuck_command(self, ctx, gm: Member): - t_query = await db_get('teams', params=[('gm_id', gm.id)]) - if t_query['count'] == 0: - await ctx.send(f'Who?') + t_query = await db_get("teams", params=[("gm_id", gm.id)]) + if t_query["count"] == 0: + await ctx.send(f"Who?") return await ctx.send(f'{t_query["teams"][0]["sname"]} are a bunch of cuties!') - @commands.hybrid_command(name='random', help='Check out a random card') + @commands.hybrid_command(name="random", help="Check out a random card") @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def random_card_command(self, ctx: commands.Context): - p_query = await db_get('players/random', params=[('limit', 1)]) - this_player = p_query['players'][0] + p_query = await db_get("players/random", params=[("limit", 1)]) + this_player = p_query["players"][0] this_embed = await get_card_embeds( - {'player': this_player, 'team': {'lname': 'Paper Dynasty', 'logo': IMAGES['logo'], 'season': PD_SEASON}} + { + "player": this_player, + "team": { + "lname": "Paper Dynasty", + "logo": IMAGES["logo"], + "season": PD_SEASON, + }, + } ) await ctx.send(content=None, embeds=this_embed) - group_paperdex = app_commands.Group(name='paperdex', description='Check your collection counts') + group_paperdex = app_commands.Group( + name="paperdex", description="Check your collection counts" + ) - @group_paperdex.command(name='cardset', description='Check your collection of a specific cardset') + @group_paperdex.command( + name="cardset", description="Check your collection of a specific cardset" + ) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def paperdex_cardset_slash(self, interaction: discord.Interaction): team = await get_team_by_owner(interaction.user.id) if not team: - await interaction.response.send_message(f'Do you even have a team? I don\'t know you.', ephemeral=True) + await interaction.response.send_message( + f"Do you even have a team? I don't know you.", ephemeral=True + ) return view = SelectView([SelectPaperdexCardset()], timeout=15) await interaction.response.send_message( - content='You have 15 seconds to select a cardset.', + content="You have 15 seconds to select a cardset.", view=view, - ephemeral=True + ephemeral=True, ) await view.wait() await interaction.delete_original_response() - @group_paperdex.command(name='team', description='Check your collection of a specific MLB franchise') + @group_paperdex.command( + name="team", description="Check your collection of a specific MLB franchise" + ) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def paperdex_cardset_slash(self, interaction: discord.Interaction): team = await get_team_by_owner(interaction.user.id) if not team: - await interaction.response.send_message(f'Do you even have a team? I don\'t know you.', ephemeral=True) + await interaction.response.send_message( + f"Do you even have a team? I don't know you.", ephemeral=True + ) return - view = SelectView([SelectPaperdexTeam('AL'), SelectPaperdexTeam('NL')], timeout=30) + view = SelectView( + [SelectPaperdexTeam("AL"), SelectPaperdexTeam("NL")], timeout=30 + ) await interaction.response.send_message( - content='You have 30 seconds to select a team.', - view=view, - ephemeral=True + content="You have 30 seconds to select a team.", view=view, ephemeral=True ) await view.wait() await interaction.delete_original_response() - @commands.hybrid_command(name='ai-teams', help='Get list of AI teams and abbreviations') + @commands.hybrid_command( + name="ai-teams", help="Get list of AI teams and abbreviations" + ) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) async def ai_teams_command(self, ctx: commands.Context): - embed = get_team_embed(f'Paper Dynasty AI Teams') - embed.description = 'Teams Available for Solo Play' + embed = get_team_embed(f"Paper Dynasty AI Teams") + embed.description = "Teams Available for Solo Play" embed.add_field( - name='AL East', - value=f'BAL - Baltimore Orioles\nBOS - Boston Red Sox\nNYY - New York Yankees\nTBR - Tampa Bay Rays\nTOR - ' - f'Toronto Blue Jays' + name="AL East", + value=f"BAL - Baltimore Orioles\nBOS - Boston Red Sox\nNYY - New York Yankees\nTBR - Tampa Bay Rays\nTOR - " + f"Toronto Blue Jays", ) embed.add_field( - name='AL Central', - value=f'CLE - Cleveland Guardians\nCHW - Chicago White Sox\nDET - Detroit Tigers\nKCR - Kansas City ' - f'Royals\nMIN - Minnesota Twins' + name="AL Central", + value=f"CLE - Cleveland Guardians\nCHW - Chicago White Sox\nDET - Detroit Tigers\nKCR - Kansas City " + f"Royals\nMIN - Minnesota Twins", ) embed.add_field( - name='NL West', - value=f'HOU - Houston Astros\nLAA - Los Angeles Angels\nOAK - Oakland Athletics\nSEA - Seattle Mariners' - f'\nTEX - Texas Rangers' + name="NL West", + value=f"HOU - Houston Astros\nLAA - Los Angeles Angels\nOAK - Oakland Athletics\nSEA - Seattle Mariners" + f"\nTEX - Texas Rangers", ) embed.add_field( - name='NL East', - value=f'ATL - Atlanta Braves\nMIA - Miami Marlins\nNYM - New York Mets\nPHI - Philadelphia Phillies\n' - f'WSN - Washington Nationals' + name="NL East", + value=f"ATL - Atlanta Braves\nMIA - Miami Marlins\nNYM - New York Mets\nPHI - Philadelphia Phillies\n" + f"WSN - Washington Nationals", ) embed.add_field( - name='NL Central', - value=f'CHC - Chicago Cubs\nCIN - Cincinnati Reds\nMIL - Milwaukee Brewers\nPIT - Pittsburgh Pirates\n' - f'STL - St Louis Cardinals' + name="NL Central", + value=f"CHC - Chicago Cubs\nCIN - Cincinnati Reds\nMIL - Milwaukee Brewers\nPIT - Pittsburgh Pirates\n" + f"STL - St Louis Cardinals", ) embed.add_field( - name='NL West', - value=f'ARI - Arizona Diamondbacks\nCOL - Colorado Rockies\nLAD - Los Angeles Dodgers\nSDP - San Diego ' - f'Padres\nSFG - San Francisco Giants' + name="NL West", + value=f"ARI - Arizona Diamondbacks\nCOL - Colorado Rockies\nLAD - Los Angeles Dodgers\nSDP - San Diego " + f"Padres\nSFG - San Francisco Giants", ) await ctx.send(content=None, embed=embed) - @commands.hybrid_command(name='standings', help='Check weekly or season-long standings') + @commands.hybrid_command( + name="standings", help="Check weekly or season-long standings" + ) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) - async def standings_command(self, ctx: commands.Context, which: Literal['week', 'season']): - current = await db_get('current') - params = [('season', current['season']), ('ranked', True)] + async def standings_command( + self, ctx: commands.Context, which: Literal["week", "season"] + ): + current = await db_get("current") + params = [("season", current["season"]), ("ranked", True)] - if which == 'week': - params.append(('week', current['week'])) + if which == "week": + params.append(("week", current["week"])) - r_query = await db_get('results', params=params) - if not r_query['count']: - await ctx.send(f'There are no Ranked games on record this {"week" if which == "week" else "season"}.') + r_query = await db_get("results", params=params) + if not r_query["count"]: + await ctx.send( + f'There are no Ranked games on record this {"week" if which == "week" else "season"}.' + ) return all_records = {} - for line in r_query['results']: - home_win = True if line['home_score'] > line['away_score'] else False + for line in r_query["results"]: + home_win = True if line["home_score"] > line["away_score"] else False - if line['away_team']['id'] not in all_records: - all_records[line['away_team']['id']] = { - 'wins': 1 if not home_win else 0, - 'losses': 1 if home_win else 0, - 'points': 2 if not home_win else 1 + if line["away_team"]["id"] not in all_records: + all_records[line["away_team"]["id"]] = { + "wins": 1 if not home_win else 0, + "losses": 1 if home_win else 0, + "points": 2 if not home_win else 1, } else: - all_records[line['away_team']['id']]['wins'] += 1 if not home_win else 0 - all_records[line['away_team']['id']]['losses'] += 1 if home_win else 0 - all_records[line['away_team']['id']]['points'] += 2 if not home_win else 1 + all_records[line["away_team"]["id"]]["wins"] += 1 if not home_win else 0 + all_records[line["away_team"]["id"]]["losses"] += 1 if home_win else 0 + all_records[line["away_team"]["id"]]["points"] += ( + 2 if not home_win else 1 + ) - if line['home_team']['id'] not in all_records: - all_records[line['home_team']['id']] = { - 'wins': 1 if home_win else 0, - 'losses': 1 if not home_win else 0, - 'points': 2 if home_win else 1 + if line["home_team"]["id"] not in all_records: + all_records[line["home_team"]["id"]] = { + "wins": 1 if home_win else 0, + "losses": 1 if not home_win else 0, + "points": 2 if home_win else 1, } else: - all_records[line['home_team']['id']]['wins'] += 1 if home_win else 0 - all_records[line['home_team']['id']]['losses'] += 1 if not home_win else 0 - all_records[line['home_team']['id']]['points'] += 2 if home_win else 0 + all_records[line["home_team"]["id"]]["wins"] += 1 if home_win else 0 + all_records[line["home_team"]["id"]]["losses"] += ( + 1 if not home_win else 0 + ) + all_records[line["home_team"]["id"]]["points"] += 2 if home_win else 0 # logger.info(f'all_records:\n\n{all_records}') - sorted_records = sorted(all_records.items(), key=lambda k_v: k_v[1]['points'], reverse=True) + sorted_records = sorted( + all_records.items(), key=lambda k_v: k_v[1]["points"], reverse=True + ) # logger.info(f'sorted_records: {sorted_records}') # await ctx.send(f'sorted: {sorted_records}') embed = get_team_embed( title=f'{"Season" if which == "season" else "Week"} ' - f'{current["season"] if which == "season" else current["week"]} Standings' + f'{current["season"] if which == "season" else current["week"]} Standings' ) - chunk_string = '' + chunk_string = "" for index, record in enumerate(sorted_records): # logger.info(f'index: {index} / record: {record}') - team = await db_get('teams', object_id=record[0]) + team = await db_get("teams", object_id=record[0]) if team: - chunk_string += f'{record[1]["points"]} pt{"s" if record[1]["points"] != 1 else ""} ' \ - f'({record[1]["wins"]}-{record[1]["losses"]}) - {team["sname"]} [{team["ranking"]}]\n' + chunk_string += ( + f'{record[1]["points"]} pt{"s" if record[1]["points"] != 1 else ""} ' + f'({record[1]["wins"]}-{record[1]["losses"]}) - {team["sname"]} [{team["ranking"]}]\n' + ) else: - logger.error(f'Could not find team {record[0]} when running standings.') + logger.error(f"Could not find team {record[0]} when running standings.") if (index + 1) == len(sorted_records): embed.add_field( - name=f'Group {math.ceil((index + 1) / 20)} / ' - f'{math.ceil(len(sorted_records) / 20)}', - value=chunk_string + name=f"Group {math.ceil((index + 1) / 20)} / " + f"{math.ceil(len(sorted_records) / 20)}", + value=chunk_string, ) elif (index + 1) % 20 == 0: embed.add_field( - name=f'Group {math.ceil((index + 1) / 20)} / ' - f'{math.floor(len(sorted_records) / 20)}', - value=chunk_string + name=f"Group {math.ceil((index + 1) / 20)} / " + f"{math.floor(len(sorted_records) / 20)}", + value=chunk_string, ) await ctx.send(content=None, embed=embed) - @commands.hybrid_command(name='pullroster', help='Pull saved rosters from your team Sheet', - aliases=['roster', 'rosters', 'pullrosters']) + @commands.hybrid_command( + name="pullroster", + help="Pull saved rosters from your team Sheet", + aliases=["roster", "rosters", "pullrosters"], + ) @app_commands.describe( - specific_roster_num='Enter 1, 2, or 3 to only pull one roster; leave blank to pull all 3', + specific_roster_num="Enter 1, 2, or 3 to only pull one roster; leave blank to pull all 3", ) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.check(legal_channel) - async def pull_roster_command(self, ctx: commands.Context, specific_roster_num: Optional[int] = None): + async def pull_roster_command( + self, ctx: commands.Context, specific_roster_num: Optional[int] = None + ): team = await get_team_by_owner(get_context_user(ctx).id) if not team: - await ctx.send(f'Do you even have a team? I don\'t know you.') + await ctx.send(f"Do you even have a team? I don't know you.") return # Pull data from Sheets async with ctx.typing(): roster_data = get_rosters(team, self.bot) - logger.debug(f'roster_data: {roster_data}') + logger.debug(f"roster_data: {roster_data}") # Post roster team/card ids and throw error if db says no for index, roster in enumerate(roster_data): - logger.debug(f'index: {index} / roster: {roster}') + logger.debug(f"index: {index} / roster: {roster}") if (not specific_roster_num or specific_roster_num == index + 1) and roster: this_roster = await db_post( - 'rosters', + "rosters", payload={ - 'team_id': team['id'], 'name': roster['name'], - 'roster_num': roster['roster_num'], 'card_ids': roster['cards'] - } + "team_id": team["id"], + "name": roster["name"], + "roster_num": roster["roster_num"], + "card_ids": roster["cards"], + }, ) await ctx.send(random_conf_gif()) - group_gauntlet = app_commands.Group(name='gauntlets', description='Check your progress or start a new Gauntlet') + group_gauntlet = app_commands.Group( + name="gauntlets", description="Check your progress or start a new Gauntlet" + ) - @group_gauntlet.command(name='status', description='View status of current Gauntlet run') + @group_gauntlet.command( + name="status", description="View status of current Gauntlet run" + ) @app_commands.describe( - team_abbrev='To check the status of a team\'s active run, enter their abbreviation' + team_abbrev="To check the status of a team's active run, enter their abbreviation" ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def gauntlet_run_command( - self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL, # type: ignore - team_abbrev: str = None): + self, + interaction: discord.Interaction, + event_name: ACTIVE_EVENT_LITERAL, # type: ignore + team_abbrev: str = None, + ): await interaction.response.defer() - e_query = await db_get('events', params=[("name", event_name), ("active", True)]) - if e_query['count'] == 0: - await interaction.edit_original_response(content=f'Hmm...looks like that event is inactive.') + e_query = await db_get( + "events", params=[("name", event_name), ("active", True)] + ) + if e_query["count"] == 0: + await interaction.edit_original_response( + content=f"Hmm...looks like that event is inactive." + ) return else: - this_event = e_query['events'][0] + this_event = e_query["events"][0] this_run, this_team = None, None if team_abbrev: - if 'Gauntlet-' not in team_abbrev: - team_abbrev = f'Gauntlet-{team_abbrev}' - t_query = await db_get('teams', params=[('abbrev', team_abbrev)]) - if t_query['count'] != 0: - this_team = t_query['teams'][0] - r_query = await db_get('gauntletruns', params=[ - ('team_id', this_team['id']), ('is_active', True), ('gauntlet_id', this_event['id']) - ]) + if "Gauntlet-" not in team_abbrev: + team_abbrev = f"Gauntlet-{team_abbrev}" + t_query = await db_get("teams", params=[("abbrev", team_abbrev)]) + if t_query["count"] != 0: + this_team = t_query["teams"][0] + r_query = await db_get( + "gauntletruns", + params=[ + ("team_id", this_team["id"]), + ("is_active", True), + ("gauntlet_id", this_event["id"]), + ], + ) - if r_query['count'] != 0: - this_run = r_query['runs'][0] + if r_query["count"] != 0: + this_run = r_query["runs"][0] else: await interaction.channel.send( content=f'I do not see an active run for the {this_team["lname"]}.' ) else: await interaction.channel.send( - content=f'I do not see an active run for {team_abbrev.upper()}.' + content=f"I do not see an active run for {team_abbrev.upper()}." ) await interaction.edit_original_response( content=None, - embed=await gauntlets.get_embed(this_run, this_event, this_team) + embed=await gauntlets.get_embed(this_run, this_event, this_team), ) - @group_gauntlet.command(name='start', description='Start a new Gauntlet run') + @group_gauntlet.command(name="start", description="Start a new Gauntlet run") @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def gauntlet_start_command( - self, interaction: discord.Interaction): - if 'hello' not in interaction.channel.name: + async def gauntlet_start_command(self, interaction: discord.Interaction): + if "hello" not in interaction.channel.name: await interaction.response.send_message( - content='The draft will probably take you about 15 minutes. Why don\'t you head to your private ' - 'channel to run the draft?', - ephemeral=True + content="The draft will probably take you about 15 minutes. Why don't you head to your private " + "channel to run the draft?", + ephemeral=True, ) return - logger.info(f'Starting a gauntlet run for user {interaction.user.name}') + logger.info(f"Starting a gauntlet run for user {interaction.user.name}") await interaction.response.defer() with Session(engine) as session: - main_team = await get_team_or_none(session, gm_id=interaction.user.id, main_team=True) - draft_team = await get_team_or_none(session, gm_id=interaction.user.id, gauntlet_team=True) + main_team = await get_team_or_none( + session, gm_id=interaction.user.id, main_team=True + ) + draft_team = await get_team_or_none( + session, gm_id=interaction.user.id, gauntlet_team=True + ) - e_query = await db_get('events', params=[("active", True)]) - if e_query['count'] == 0: - await interaction.edit_original_response(content='Hmm...I don\'t see any active events.') + e_query = await db_get("events", params=[("active", True)]) + if e_query["count"] == 0: + await interaction.edit_original_response( + content="Hmm...I don't see any active events." + ) return - elif e_query['count'] == 1: - this_event = e_query['events'][0] + elif e_query["count"] == 1: + this_event = e_query["events"][0] else: event_choice = await ask_with_buttons( interaction, - button_options=[x['name'] for x in e_query['events']], - question='Which event would you like to take on?', + button_options=[x["name"] for x in e_query["events"]], + question="Which event would you like to take on?", # edit_original_interaction=True, timeout=3, - delete_question=False + delete_question=False, ) - this_event = [event for event in e_query['events'] if event['name'] == event_choice][0] + this_event = [ + event + for event in e_query["events"] + if event["name"] == event_choice + ][0] # await interaction.channel.send( # content=f'You chose the {event_choice} event!' # ) - logger.info(f'this_event: {this_event}') + logger.info(f"this_event: {this_event}") first_flag = draft_team is None if draft_team is not None: r_query = await db_get( - 'gauntletruns', - params=[('team_id', draft_team.id), ('gauntlet_id', this_event['id']), ('is_active', True)] + "gauntletruns", + params=[ + ("team_id", draft_team.id), + ("gauntlet_id", this_event["id"]), + ("is_active", True), + ], ) - if r_query['count'] != 0: + if r_query["count"] != 0: await interaction.edit_original_response( content=f'Looks like you already have a {r_query["runs"][0]["gauntlet"]["name"]} run active! ' - f'You can check it out with the `/gauntlets status` command.' + f"You can check it out with the `/gauntlets status` command." ) return try: - draft_embed = await gauntlets.run_draft(interaction, main_team, this_event, draft_team) - except ZeroDivisionError as e: + draft_embed = await gauntlets.run_draft( + interaction, main_team, this_event, draft_team + ) + except ZeroDivisionError: + logger.info(f'User declined {this_event["name"]} draft - cancelling') return except Exception as e: - logger.error(f'Failed to run {this_event["name"]} draft for the {main_team.sname}: {e}') + logger.error( + f'Failed to run {this_event["name"]} draft for the {main_team.sname}: {e}' + ) await gauntlets.wipe_team(draft_team, interaction) await interaction.channel.send( - content=f'Shoot - it looks like we ran into an issue running the draft. I had to clear it all out ' - f'for now. I let {get_cal_user(interaction).mention} know what happened so he better ' - f'fix it quick.' + content=f"Shoot - it looks like we ran into an issue running the draft. I had to clear it all out " + f"for now. I let {get_cal_user(interaction).mention} know what happened so he better " + f"fix it quick." ) return if first_flag: await interaction.channel.send( - f'Good luck, champ in the making! To start playing, follow these steps:\n\n' - f'1) Make a copy of the Team Sheet Template found in `/help-pd links`\n' - f'2) Run `/newsheet` to link it to your Gauntlet team\n' + f"Good luck, champ in the making! To start playing, follow these steps:\n\n" + f"1) Make a copy of the Team Sheet Template found in `/help-pd links`\n" + f"2) Run `/newsheet` to link it to your Gauntlet team\n" f'3) Go play your first game with `/new-game gauntlet {this_event["name"]}`' ) else: await interaction.channel.send( - f'Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> ' - f'**Data Imports** -> **My Cards** then you can set your lineup here and you\'ll be ready to go!\n\n' - f'{get_roster_sheet(draft_team)}' + f"Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> " + f"**Data Imports** -> **My Cards** then you can set your lineup here and you'll be ready to go!\n\n" + f"{get_roster_sheet(draft_team)}" ) await helpers.send_to_channel( bot=self.bot, - channel_name='pd-news-ticker', + channel_name="pd-news-ticker", content=f'The {main_team.lname} have entered the {this_event["name"]} Gauntlet!', - embed=draft_embed + embed=draft_embed, ) - @group_gauntlet.command(name='reset', description='Wipe your current team so you can re-draft') + @group_gauntlet.command( + name="reset", description="Wipe your current team so you can re-draft" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def gauntlet_reset_command( - self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL): # type: ignore + self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL + ): # type: ignore await interaction.response.defer() main_team = await get_team_by_owner(interaction.user.id) draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}') if draft_team is None: await interaction.edit_original_response( - content='Hmm, I can\'t find a gauntlet team for you. Have you signed up already?') + content="Hmm, I can't find a gauntlet team for you. Have you signed up already?" + ) return - e_query = await db_get('events', params=[("name", event_name), ("active", True)]) - if e_query['count'] == 0: - await interaction.edit_original_response(content='Hmm...looks like that event is inactive.') + e_query = await db_get( + "events", params=[("name", event_name), ("active", True)] + ) + if e_query["count"] == 0: + await interaction.edit_original_response( + content="Hmm...looks like that event is inactive." + ) return else: - this_event = e_query['events'][0] + this_event = e_query["events"][0] - r_query = await db_get('gauntletruns', params=[ - ('team_id', draft_team['id']), ('is_active', True), ('gauntlet_id', this_event['id']) - ]) + r_query = await db_get( + "gauntletruns", + params=[ + ("team_id", draft_team["id"]), + ("is_active", True), + ("gauntlet_id", this_event["id"]), + ], + ) - if r_query['count'] != 0: - this_run = r_query['runs'][0] + if r_query["count"] != 0: + this_run = r_query["runs"][0] else: await interaction.edit_original_response( content=f'I do not see an active run for the {draft_team["lname"]}.' @@ -1060,27 +1401,23 @@ class Players(commands.Cog): return view = helpers.Confirm(responders=[interaction.user], timeout=60) - conf_string = f'Are you sure you want to wipe your active run?' - await interaction.edit_original_response( - content=conf_string, - view=view - ) + conf_string = f"Are you sure you want to wipe your active run?" + await interaction.edit_original_response(content=conf_string, view=view) await view.wait() if view.value: await gauntlets.end_run(this_run, this_event, draft_team, force_end=True) await interaction.edit_original_response( - content=f'Your {event_name} run has been reset. Run `/gauntlets start {event_name}` to redraft!', - view=None + content=f"Your {event_name} run has been reset. Run `/gauntlets start {event_name}` to redraft!", + view=None, ) else: await interaction.edit_original_response( - content=f'~~{conf_string}~~\n\nNo worries, I will leave it active.', - view=None + content=f"~~{conf_string}~~\n\nNo worries, I will leave it active.", + view=None, ) - # @commands.command(name='standings', aliases=['leaders', 'points', 'weekly'], help='Weekly standings') # async def standings_command(self, ctx, *week_or_season): # if not await legal_channel(ctx): @@ -1104,17 +1441,25 @@ class Players(commands.Cog): # for embed in all_embeds: # await ctx.send(content=None, embed=embed) - @commands.command(name='in', help='Get Paper Dynasty Players role') + @commands.command(name="in", help="Get Paper Dynasty Players role") async def give_role(self, ctx, *args): - await ctx.author.add_roles(discord.utils.get(ctx.guild.roles, name='Paper Dynasty Players')) - await ctx.send('I got u, boo. ;)\n\nNow that you\'ve got the PD role, you can run all of the Paper Dynasty ' - 'bot commands. For help, check out `/help-pd`') + await ctx.author.add_roles( + discord.utils.get(ctx.guild.roles, name="Paper Dynasty Players") + ) + await ctx.send( + "I got u, boo. ;)\n\nNow that you've got the PD role, you can run all of the Paper Dynasty " + "bot commands. For help, check out `/help-pd`" + ) - @commands.command(name='out', help='Remove Paper Dynasty Players role') - @commands.has_any_role('Paper Dynasty Players') + @commands.command(name="out", help="Remove Paper Dynasty Players role") + @commands.has_any_role("Paper Dynasty Players") async def take_role(self, ctx, *args): - await ctx.author.remove_roles(discord.utils.get(ctx.guild.roles, name='Paper Dynasty Players')) - await ctx.send('Oh no! I\'m so sad to see you go! What are we going to do without you?') + await ctx.author.remove_roles( + discord.utils.get(ctx.guild.roles, name="Paper Dynasty Players") + ) + await ctx.send( + "Oh no! I'm so sad to see you go! What are we going to do without you?" + ) # @commands.command(name='teams', help='List all teams') # @commands.has_any_role('Paper Dynasty Players') @@ -1617,7 +1962,7 @@ class Players(commands.Cog): # all_embeds[x].add_field(name='Abbrev - Name - GM', value=team_strings[x], inline=False) # await ctx.send(content=None, embed=all_embeds[x]) - @commands.command(name='c', aliases=['chaos', 'choas'], help='c, chaos, or choas') + @commands.command(name="c", aliases=["chaos", "choas"], help="c, chaos, or choas") async def chaos_roll(self, ctx): """ Have the pitcher check for chaos with a runner on base. @@ -1627,35 +1972,41 @@ class Players(commands.Cog): flag = None if d_twenty == 1: - flag = 'wild pitch' + flag = "wild pitch" elif d_twenty == 2: if random.randint(1, 2) == 1: - flag = 'balk' + flag = "balk" else: - flag = 'passed ball' + flag = "passed ball" if not flag: - roll_message = f'Chaos roll for {ctx.author.name}\n```md\nNo Chaos```' + roll_message = f"Chaos roll for {ctx.author.name}\n```md\nNo Chaos```" else: - roll_message = f'Chaos roll for {ctx.author.name}\n```md\nCheck {flag}```\n'\ - f'{flag.title()} roll```md\n# {d_twenty_two}\nDetails: [1d20 ({d_twenty_two})]```' + roll_message = ( + f"Chaos roll for {ctx.author.name}\n```md\nCheck {flag}```\n" + f"{flag.title()} roll```md\n# {d_twenty_two}\nDetails: [1d20 ({d_twenty_two})]```" + ) await ctx.send(roll_message) - @commands.command(name='sba', hidden=True) + @commands.command(name="sba", hidden=True) async def sba_command(self, ctx, *, player_name): async def get_one_player(id_or_name): - req_url = f'http://database/api/v1/players/{id_or_name}' + req_url = f"http://database/api/v1/players/{id_or_name}" - resp = requests.get(req_url, timeout=3) - if resp.status_code == 200: - return resp.json() - else: - logger.warning(resp.text) - raise ValueError(f'DB: {resp.text}') + async with aiohttp.ClientSession() as session: + async with session.get( + req_url, timeout=aiohttp.ClientTimeout(total=3) + ) as resp: + if resp.status == 200: + return await resp.json() + else: + text = await resp.text() + logger.warning(text) + raise ValueError(f"DB: {text}") this_player = await get_one_player(player_name) - logger.debug(f'this_player: {this_player}') + logger.debug(f"this_player: {this_player}") # @app_commands.command(name='matchup', description='Simulate a matchup between a pitcher and batter') # @app_commands.describe( diff --git a/cogs/players_new/gauntlet.py b/cogs/players_new/gauntlet.py index 1889c83..4f42274 100644 --- a/cogs/players_new/gauntlet.py +++ b/cogs/players_new/gauntlet.py @@ -12,65 +12,89 @@ import datetime from sqlmodel import Session from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev from helpers import ( - ACTIVE_EVENT_LITERAL, PD_PLAYERS_ROLE_NAME, get_team_embed, get_team_by_owner, - legal_channel, Confirm, send_to_channel + ACTIVE_EVENT_LITERAL, + PD_PLAYERS_ROLE_NAME, + get_team_embed, + get_team_by_owner, + legal_channel, + Confirm, + send_to_channel, ) from helpers.utils import get_roster_sheet, get_cal_user from utilities.buttons import ask_with_buttons from in_game.gameplay_models import engine from in_game.gameplay_queries import get_team_or_none -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") # Try to import gauntlets module, provide fallback if not available try: import gauntlets + GAUNTLETS_AVAILABLE = True except ImportError: - logger.warning("Gauntlets module not available - gauntlet commands will have limited functionality") + logger.warning( + "Gauntlets module not available - gauntlet commands will have limited functionality" + ) GAUNTLETS_AVAILABLE = False gauntlets = None class Gauntlet(commands.Cog): """Gauntlet game mode functionality for Paper Dynasty.""" - + def __init__(self, bot): self.bot = bot - group_gauntlet = app_commands.Group(name='gauntlets', description='Check your progress or start a new Gauntlet') + group_gauntlet = app_commands.Group( + name="gauntlets", description="Check your progress or start a new Gauntlet" + ) - @group_gauntlet.command(name='status', description='View status of current Gauntlet run') + @group_gauntlet.command( + name="status", description="View status of current Gauntlet run" + ) @app_commands.describe( - team_abbrev='To check the status of a team\'s active run, enter their abbreviation' + team_abbrev="To check the status of a team's active run, enter their abbreviation" ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def gauntlet_run_command( - self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL, # type: ignore - team_abbrev: Optional[str] = None): + self, + interaction: discord.Interaction, + event_name: ACTIVE_EVENT_LITERAL, # type: ignore + team_abbrev: Optional[str] = None, + ): """View status of current gauntlet run - corrected to match original business logic.""" await interaction.response.defer() - e_query = await db_get('events', params=[("name", event_name), ("active", True)]) - if not e_query or e_query.get('count', 0) == 0: - await interaction.edit_original_response(content=f'Hmm...looks like that event is inactive.') + e_query = await db_get( + "events", params=[("name", event_name), ("active", True)] + ) + if not e_query or e_query.get("count", 0) == 0: + await interaction.edit_original_response( + content=f"Hmm...looks like that event is inactive." + ) return else: - this_event = e_query['events'][0] + this_event = e_query["events"][0] this_run, this_team = None, None if team_abbrev: - if 'Gauntlet-' not in team_abbrev: - team_abbrev = f'Gauntlet-{team_abbrev}' - t_query = await db_get('teams', params=[('abbrev', team_abbrev)]) - if t_query and t_query.get('count', 0) != 0: - this_team = t_query['teams'][0] - r_query = await db_get('gauntletruns', params=[ - ('team_id', this_team['id']), ('is_active', True), ('gauntlet_id', this_event['id']) - ]) + if "Gauntlet-" not in team_abbrev: + team_abbrev = f"Gauntlet-{team_abbrev}" + t_query = await db_get("teams", params=[("abbrev", team_abbrev)]) + if t_query and t_query.get("count", 0) != 0: + this_team = t_query["teams"][0] + r_query = await db_get( + "gauntletruns", + params=[ + ("team_id", this_team["id"]), + ("is_active", True), + ("gauntlet_id", this_event["id"]), + ], + ) - if r_query and r_query.get('count', 0) != 0: - this_run = r_query['runs'][0] + if r_query and r_query.get("count", 0) != 0: + this_run = r_query["runs"][0] else: await interaction.edit_original_response( content=f'I do not see an active run for the {this_team["lname"]}.' @@ -78,7 +102,7 @@ class Gauntlet(commands.Cog): return else: await interaction.edit_original_response( - content=f'I do not see an active run for {team_abbrev.upper()}.' + content=f"I do not see an active run for {team_abbrev.upper()}." ) return @@ -86,127 +110,160 @@ class Gauntlet(commands.Cog): if GAUNTLETS_AVAILABLE and gauntlets: await interaction.edit_original_response( content=None, - embed=await gauntlets.get_embed(this_run, this_event, this_team) # type: ignore + embed=await gauntlets.get_embed(this_run, this_event, this_team), # type: ignore ) else: await interaction.edit_original_response( - content='Gauntlet status unavailable - gauntlets module not loaded.' + content="Gauntlet status unavailable - gauntlets module not loaded." ) - @group_gauntlet.command(name='start', description='Start a new Gauntlet run') + @group_gauntlet.command(name="start", description="Start a new Gauntlet run") @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def gauntlet_start_command(self, interaction: discord.Interaction): """Start a new gauntlet run.""" - + # Channel restriction - must be in a 'hello' channel (private channel) - if interaction.channel and hasattr(interaction.channel, 'name') and 'hello' not in str(interaction.channel.name): + if ( + interaction.channel + and hasattr(interaction.channel, "name") + and "hello" not in str(interaction.channel.name) + ): await interaction.response.send_message( - content='The draft will probably take you about 15 minutes. Why don\'t you head to your private ' - 'channel to run the draft?', - ephemeral=True + content="The draft will probably take you about 15 minutes. Why don't you head to your private " + "channel to run the draft?", + ephemeral=True, ) return - logger.info(f'Starting a gauntlet run for user {interaction.user.name}') + logger.info(f"Starting a gauntlet run for user {interaction.user.name}") await interaction.response.defer() with Session(engine) as session: - main_team = await get_team_or_none(session, gm_id=interaction.user.id, main_team=True) - draft_team = await get_team_or_none(session, gm_id=interaction.user.id, gauntlet_team=True) + main_team = await get_team_or_none( + session, gm_id=interaction.user.id, main_team=True + ) + draft_team = await get_team_or_none( + session, gm_id=interaction.user.id, gauntlet_team=True + ) # Get active events - e_query = await db_get('events', params=[("active", True)]) - if not e_query or e_query.get('count', 0) == 0: - await interaction.edit_original_response(content='Hmm...I don\'t see any active events.') + e_query = await db_get("events", params=[("active", True)]) + if not e_query or e_query.get("count", 0) == 0: + await interaction.edit_original_response( + content="Hmm...I don't see any active events." + ) return - elif e_query.get('count', 0) == 1: - this_event = e_query['events'][0] + elif e_query.get("count", 0) == 1: + this_event = e_query["events"][0] else: event_choice = await ask_with_buttons( interaction, - button_options=[x['name'] for x in e_query['events']], - question='Which event would you like to take on?', + button_options=[x["name"] for x in e_query["events"]], + question="Which event would you like to take on?", timeout=3, - delete_question=False + delete_question=False, ) - this_event = [event for event in e_query['events'] if event['name'] == event_choice][0] - - logger.info(f'this_event: {this_event}') + this_event = [ + event + for event in e_query["events"] + if event["name"] == event_choice + ][0] + + logger.info(f"this_event: {this_event}") first_flag = draft_team is None if draft_team is not None: r_query = await db_get( - 'gauntletruns', - params=[('team_id', draft_team.id), ('gauntlet_id', this_event['id']), ('is_active', True)] + "gauntletruns", + params=[ + ("team_id", draft_team.id), + ("gauntlet_id", this_event["id"]), + ("is_active", True), + ], ) - if r_query and r_query.get('count', 0) != 0: + if r_query and r_query.get("count", 0) != 0: await interaction.edit_original_response( content=f'Looks like you already have a {r_query["runs"][0]["gauntlet"]["name"]} run active! ' - f'You can check it out with the `/gauntlets status` command.' + f"You can check it out with the `/gauntlets status` command." ) return try: - draft_embed = await gauntlets.run_draft(interaction, main_team, this_event, draft_team) # type: ignore - except ZeroDivisionError as e: + draft_embed = await gauntlets.run_draft(interaction, main_team, this_event, draft_team) # type: ignore + except ZeroDivisionError: + logger.info(f'User declined {this_event["name"]} draft - cancelling') return except Exception as e: - logger.error(f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}') - await gauntlets.wipe_team(draft_team, interaction) # type: ignore + logger.error( + f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}' + ) + await gauntlets.wipe_team(draft_team, interaction) # type: ignore await interaction.followup.send( - content=f'Shoot - it looks like we ran into an issue running the draft. I had to clear it all out ' - f'for now. I let {get_cal_user(interaction).mention} know what happened so he better ' - f'fix it quick.' + content=f"Shoot - it looks like we ran into an issue running the draft. I had to clear it all out " + f"for now. I let {get_cal_user(interaction).mention} know what happened so he better " + f"fix it quick." ) return if first_flag: await interaction.followup.send( - f'Good luck, champ in the making! To start playing, follow these steps:\n\n' - f'1) Make a copy of the Team Sheet Template found in `/help-pd links`\n' - f'2) Run `/newsheet` to link it to your Gauntlet team\n' + f"Good luck, champ in the making! To start playing, follow these steps:\n\n" + f"1) Make a copy of the Team Sheet Template found in `/help-pd links`\n" + f"2) Run `/newsheet` to link it to your Gauntlet team\n" f'3) Go play your first game with `/new-game gauntlet {this_event["name"]}`' ) else: await interaction.followup.send( - f'Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> ' - f'**Data Imports** -> **My Cards** then you can set your lineup here and you\'ll be ready to go!\n\n' - f'{get_roster_sheet(draft_team)}' + f"Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> " + f"**Data Imports** -> **My Cards** then you can set your lineup here and you'll be ready to go!\n\n" + f"{get_roster_sheet(draft_team)}" ) await send_to_channel( bot=self.bot, - channel_name='pd-news-ticker', + channel_name="pd-news-ticker", content=f'The {main_team.lname if main_team else "Unknown Team"} have entered the {this_event["name"]} Gauntlet!', - embed=draft_embed + embed=draft_embed, ) - @group_gauntlet.command(name='reset', description='Wipe your current team so you can re-draft') + @group_gauntlet.command( + name="reset", description="Wipe your current team so you can re-draft" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def gauntlet_reset_command(self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL): # type: ignore + async def gauntlet_reset_command(self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL): # type: ignore """Reset current gauntlet run.""" await interaction.response.defer() main_team = await get_team_by_owner(interaction.user.id) draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}') if draft_team is None: await interaction.edit_original_response( - content='Hmm, I can\'t find a gauntlet team for you. Have you signed up already?') + content="Hmm, I can't find a gauntlet team for you. Have you signed up already?" + ) return - e_query = await db_get('events', params=[("name", event_name), ("active", True)]) - if e_query['count'] == 0: - await interaction.edit_original_response(content='Hmm...looks like that event is inactive.') + e_query = await db_get( + "events", params=[("name", event_name), ("active", True)] + ) + if e_query["count"] == 0: + await interaction.edit_original_response( + content="Hmm...looks like that event is inactive." + ) return else: - this_event = e_query['events'][0] + this_event = e_query["events"][0] - r_query = await db_get('gauntletruns', params=[ - ('team_id', draft_team['id']), ('is_active', True), ('gauntlet_id', this_event['id']) - ]) + r_query = await db_get( + "gauntletruns", + params=[ + ("team_id", draft_team["id"]), + ("is_active", True), + ("gauntlet_id", this_event["id"]), + ], + ) - if r_query and r_query.get('count', 0) != 0: - this_run = r_query['runs'][0] + if r_query and r_query.get("count", 0) != 0: + this_run = r_query["runs"][0] else: await interaction.edit_original_response( content=f'I do not see an active run for the {draft_team["lname"]}.' @@ -214,27 +271,24 @@ class Gauntlet(commands.Cog): return view = Confirm(responders=[interaction.user], timeout=60) - conf_string = f'Are you sure you want to wipe your active run?' - await interaction.edit_original_response( - content=conf_string, - view=view - ) + conf_string = f"Are you sure you want to wipe your active run?" + await interaction.edit_original_response(content=conf_string, view=view) await view.wait() if view.value: - await gauntlets.end_run(this_run, this_event, draft_team, force_end=True) # type: ignore + await gauntlets.end_run(this_run, this_event, draft_team, force_end=True) # type: ignore await interaction.edit_original_response( - content=f'Your {event_name} run has been reset. Run `/gauntlets start` to redraft!', - view=None + content=f"Your {event_name} run has been reset. Run `/gauntlets start` to redraft!", + view=None, ) else: await interaction.edit_original_response( - content=f'~~{conf_string}~~\n\nNo worries, I will leave it active.', - view=None + content=f"~~{conf_string}~~\n\nNo worries, I will leave it active.", + view=None, ) async def setup(bot): """Setup function for the Gauntlet cog.""" - await bot.add_cog(Gauntlet(bot)) \ No newline at end of file + await bot.add_cog(Gauntlet(bot)) -- 2.25.1