From 2d5bd86d52893ba978428b266666c294399e53fb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 08:13:31 -0600 Subject: [PATCH 01/16] 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()) From 3c0fa133fdcdfc1d0cab9cd7bedb2ef3f65a7f55 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 19:29:44 -0600 Subject: [PATCH 02/16] 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 + ) From 755f74be92b4d2729cbf856e6a2db9688d13bdc4 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 19:39:43 -0600 Subject: [PATCH 03/16] =?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 From 8c0ac3776c5d3f9e05e598269d92c545dbd4aafb Mon Sep 17 00:00:00 2001 From: cal Date: Thu, 5 Mar 2026 03:12:20 +0000 Subject: [PATCH 04/16] 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 }} From 81ae847da2618812ae965595d5e34e449ae9d3dc Mon Sep 17 00:00:00 2001 From: cal Date: Thu, 5 Mar 2026 03:15:37 +0000 Subject: [PATCH 05/16] 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 }} From fee4f2561c7ea7bb10eeaa32613bc1a8d2cea098 Mon Sep 17 00:00:00 2001 From: cal Date: Thu, 5 Mar 2026 03:16:36 +0000 Subject: [PATCH 06/16] 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' From 637d264181bb9ce94be059e31699e12478aa8728 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 15:57:25 -0600 Subject: [PATCH 07/16] 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) From c4cfe83e557d520e87c0612c97cb0bd92c3643b3 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 13:03:15 -0600 Subject: [PATCH 08/16] 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) } From 875d5a8527bd0081001297bcc341f91fc9b77617 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 13:22:45 -0600 Subject: [PATCH 09/16] 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) From 0432f9d3f40fc90032cb9d9ed4d2e0aecfa28829 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 18:47:52 -0600 Subject: [PATCH 10/16] 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 From 1b83be89bb6794d0335285b7f720f66523d80130 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 6 Mar 2026 21:12:46 -0600 Subject: [PATCH 11/16] 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") From d116680800742533f71e49a03313f666b3081d98 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 01:01:40 -0600 Subject: [PATCH 12/16] fix: remove cogs/players.py.backup from repository (#35) Backup file was checked in with unused imports (requests, pygsheets), adding noise to git grep, IDEs, and code review. Co-Authored-By: Claude Sonnet 4.6 --- cogs/players.py.backup | 1713 ---------------------------------------- 1 file changed, 1713 deletions(-) delete mode 100644 cogs/players.py.backup diff --git a/cogs/players.py.backup b/cogs/players.py.backup deleted file mode 100644 index aee5cd5..0000000 --- a/cogs/players.py.backup +++ /dev/null @@ -1,1713 +0,0 @@ -import asyncio -import math -import os -import random - -import requests - -import discord -import pygsheets -import logging -import datetime -from discord import app_commands, Member -from discord.ext import commands, tasks -from difflib import get_close_matches -from typing import Optional, Literal - -from discord.ext.commands import Greedy -from sqlmodel import Session - -import gauntlets -import helpers -# import in_game.data_cache -# import in_game.simulations -# import in_game -# # from in_game import data_cache, simulations -# from in_game.data_cache import get_pd_pitchingcard, get_pd_battingcard, get_pd_player -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, random_conf_word, embed_pagination, get_cal_user, \ - team_summary_embed, SelectView, SelectPaperdexCardset, SelectPaperdexTeam -from utilities.buttons import ask_with_buttons - - -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}}, - } - - logger.debug(f'running short games...') - for line in short_games: - 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') - - 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 - - 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"] - - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - - return embed - - -def get_record_embed(team: dict, results: dict, league: str): - embed = get_team_embed(league, team) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - embed.add_field( - 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' - ) - - return embed - - -class Players(commands.Cog): - def __init__(self, bot): - self.bot = bot - # self.sheets = pygsheets.authorize(service_file='storage/paper-dynasty-service-creds.json', retries=1) - self.player_list = [] - self.cardset_list = [] - self.freeze = False - - self.build_player_list.start() - self.weekly_loop.start() - - @tasks.loop(hours=1) - async def weekly_loop(self): - current = await db_get('current') - now = datetime.datetime.now() - 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'])]) - - # End Freeze - # elif now.weekday() == 5 and now.hour == 5 and current['freeze']: # Spring/Summer - # elif now.weekday() == 5 and now.hour == 0 and current['freeze']: # Fall/Winter - # await db_patch('current', object_id=current['id'], params=[('freeze', False)]) - - @weekly_loop.before_loop - async def before_weekly_check(self): - await self.bot.wait_until_ready() - - async def cog_command_error(self, ctx, 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)]) - - [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.') - - @build_player_list.before_loop - async def before_player_list(self): - await self.bot.wait_until_ready() - - # def get_standings_embeds(self, current, which: str, title: str): - # all_embeds = [ - # discord.Embed(title=title), discord.Embed(title=title), discord.Embed(title=title), - # discord.Embed(title=title), discord.Embed(title=title), discord.Embed(title=title) - # ] - # - # if which == 'week': - # weekly_games = Result.select_season(current.season).where( - # (Result.week == current.week) & (Result.game_type == "baseball") - # ) - # logger.info(f'weekly_games: {weekly_games}') - # - # if weekly_games.count() == 0: - # return None - # - # active_teams = [] - # for game in weekly_games: - # if game.awayteam.abbrev not in active_teams: - # active_teams.append(game.awayteam.abbrev) - # if game.hometeam.abbrev not in active_teams: - # active_teams.append(game.hometeam.abbrev) - # - # records = [] - # for abbrev in active_teams: - # team = Team.get_season(abbrev) - # record = team.get_record(current.week, game_type='baseball') - # points = record['w'] * 2.0 + record['l'] - # this_record = [ - # record, - # points, - # record['w'] / (record['w'] + record['l']), - # team - # ] - # records.append(this_record) - # - # else: - # records = [] - # for this_team in Team.select_season(): - # record = this_team.get_record() - # points = record['w'] * 2.0 + record['l'] - # if record['w'] + record['l'] > 0: - # records.append([ - # record, - # points, - # record['w'] / (record['w'] + record['l']), - # this_team - # ]) - # - # records.sort(key=lambda x: x[1] + x[2], reverse=True) - # - # standings_message = '' - # count = 1 - # embed_count = 0 - # for team in records: - # standings_message += f'**{count}**: {team[3].sname} - {team[1]:.0f} Pts ({team[0]["w"]}-{team[0]["l"]})\n' - # if count % 24 == 0 or count >= len(records): - # logger.info(f'standings_message: {standings_message}') - # all_embeds[embed_count].add_field(name='Standings', value=standings_message) - # all_embeds[embed_count].set_thumbnail(url=self.logo) - # - # standings_message = '' - # embed_count += 1 - # count += 1 - # - # return_embeds = [] - # for x in range(embed_count): - # return_embeds.append(all_embeds[x]) - # - # db.close() - # return return_embeds - - @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 asyncio.sleep(10) - 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.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.') - return - - 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'] - ] - 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.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def player_slash_command( - self, interaction: discord.Interaction, player_name: str, - cardset: Literal['All', '2025 Live', '2024 Season', '2024 Promos', '2023 Season', '2023 Promos', '2022 Season', '2022 Promos', '2021 Season', '2019 Season', '2018 Season', '2018 Promos', '2016 Season', '2013 Season', '2012 Season', '2008 Season', '1998 Season', '1998 Promos', 'Backyard Baseball', 'Mario Super Sluggers', 'Sams Choice'] = 'All'): - ephemeral = False - 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.') - return - - 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'])] - else: - await interaction.edit_original_response(content=f'I couldn\'t find {cardset} cardset.') - return - else: - 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') - 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_embeds = [] - for x in all_cards: - all_embeds.extend(await get_card_embeds(x, include_stats=True)) - 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 embed_pagination( - all_embeds, - interaction.channel, - interaction.user, - timeout=20, - 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.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - 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 - ) - return - - 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 - ) - - await interaction.response.defer() - - 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.') - return - - embed = await get_card_embeds(get_blank_team_card(this_player)) - await interaction.edit_original_response(content=None, embed=embed[0]) - - view = helpers.Confirm(responders=[interaction.user]) - question = await interaction.channel.send( - 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) - 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) - ]) - await interaction.channel.send(content=None, view=view) - - @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): - ephemeral = False - 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)]) - else: - t_query = await db_get('teams', params=[('gm_id', interaction.user.id)]) - - if t_query['count'] == 0: - await interaction.response.send_message( - f'Hmm...I can\'t find the team you looking for.', ephemeral=ephemeral - ) - return - 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"]) - - 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': - start_page = 0 - elif league == 'Minor League': - start_page = 0 - elif league == 'Major League': - start_page = 1 - 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 embed_pagination( - [minor_embed, major_embed, flashback_embed, hof_embed], - interaction.channel, - interaction.user, - timeout=20, - start_page=start_page - ) - - @app_commands.command(name='team', description='Show team overview and rosters') - @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - @commands.check(legal_channel) - 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)]) - else: - t_query = await db_get('teams', params=[('gm_id', interaction.user.id)]) - - if t_query['count'] == 0: - await interaction.edit_original_response( - content=f'Hmm...I can\'t find the team you looking for.' - ) - return - - 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.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) - 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' 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.' - else: - 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) - ) - return - - 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') - @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - 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) - 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) - ) - return - - 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.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): - owner_team = await get_team_by_owner(ctx.author.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`!') - return - - params = [] - if team_logo_url is not None: - params.append(('logo', team_logo_url)) - if color is not None: - params.append(('color', color)) - if short_name is not None: - params.append(('sname', short_name)) - if full_name is not None: - 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.') - return - - 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.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?') - 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.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] - this_embed = await get_card_embeds( - {'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.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) - return - - view = SelectView([SelectPaperdexCardset()], timeout=15) - await interaction.response.send_message( - content='You have 15 seconds to select a cardset.', - view=view, - ephemeral=True - ) - - await view.wait() - await interaction.delete_original_response() - - @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) - return - - 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 - ) - - await view.wait() - await interaction.delete_original_response() - - @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.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' - ) - 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' - ) - 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' - ) - 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' - ) - 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' - ) - 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' - ) - await ctx.send(content=None, embed=embed) - - @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)] - - 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"}.') - return - - all_records = {} - 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 - } - 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 - - 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 - - # 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) - # 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' - ) - - 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]) - 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' - - else: - 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 - ) - 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 - ) - - await ctx.send(content=None, embed=embed) - - @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', - ) - @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): - team = await get_team_by_owner(ctx.author.id) - if not team: - 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}') - - # 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}') - if (not specific_roster_num or specific_roster_num == index + 1) and roster: - this_roster = await db_post( - 'rosters', - payload={ - '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.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' - ) - @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): - 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.') - return - else: - 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 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()}.' - ) - - await interaction.edit_original_response( - content=None, - embed=await gauntlets.get_embed(this_run, this_event, this_team) - ) - - @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: - 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 - ) - return - - 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) - - 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] - 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?', - # edit_original_interaction=True, - timeout=3, - delete_question=False - ) - 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}') - - 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)] - ) - - 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.' - ) - return - - try: - draft_embed = await gauntlets.run_draft(interaction, main_team, this_event, draft_team) - except ZeroDivisionError as e: - return - except Exception as 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.' - ) - 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'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)}' - ) - - await helpers.send_to_channel( - bot=self.bot, - channel_name='pd-news-ticker', - content=f'The {main_team.lname} have entered the {this_event["name"]} Gauntlet!', - embed=draft_embed - ) - - @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 - 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?') - 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.') - return - else: - 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']) - ]) - - 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"]}.' - ) - 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 - ) - await view.wait() - - if view.value: - await gauntlets.end_run(this_run, this_event, draft_team) - await interaction.edit_original_response( - 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 - ) - - - # @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): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # current = Current.get() - # which = None - # - # if not week_or_season: - # which = 'week' - # title = f'Week {current.week} Standings' - # elif 'season' in week_or_season: - # which = 'season' - # title = f'Season {current.season} Standings' - # else: - # which = 'week' - # title = f'Week {current.week} Standings' - # - # all_embeds = self.get_standings_embeds(current, which, title) - # for embed in all_embeds: - # await ctx.send(content=None, embed=embed) - - @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`') - - @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?') - - # @commands.command(name='teams', help='List all teams') - # @commands.has_any_role('Paper Dynasty Players') - # async def list_teams(self, ctx): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # all_teams = Team.select_season() - # team_list = [] - # - # for x in all_teams: - # team_list.append(x) - # team_list.sort(key=lambda y: y.collection_value, reverse=True) - # - # # Collect rarity objects - # # try: - # # rar_mvp = Rarity.get(Rarity.name == 'MVP') - # # rar_als = Rarity.get(Rarity.name == 'All-Star') - # # rar_sta = Rarity.get(Rarity.name == 'Starter') - # # rar_res = Rarity.get(Rarity.name == 'Reserve') - # # rar_rpl = Rarity.get(Rarity.name == 'Replacement') - # # except Exception as e: - # # logger.error(f'**Error**: (players inv getrars) - {e}') - # # return - # - # all_embeds = [ - # discord.Embed(title='All Teams', color=0xdeeadd), discord.Embed(title='All Teams', color=0xdeeadd), - # discord.Embed(title='All Teams', color=0xdeeadd), discord.Embed(title='All Teams', color=0xdeeadd), - # discord.Embed(title='All Teams', color=0xdeeadd), discord.Embed(title='All Teams', color=0xdeeadd) - # ] - # - # # Build embed - # count = 0 - # async with ctx.typing(): - # for x in team_list: - # embed_index = math.floor(count / 24) - # all_embeds[embed_index] = helpers.get_team_blurb(ctx, all_embeds[embed_index], x) - # count += 1 - # - # for x in range(math.ceil(len(all_teams) / 24)): - # await ctx.send(content=None, embed=all_embeds[x]) - # - # db.close() - # - # @commands.command(name='compare', aliases=['vs'], help='Compare two teams') - # @commands.has_any_role('Paper Dynasty Players') - # async def compare_command(self, ctx, team1_abbrev, team2_abbrev): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # away_team = Team.get_season(team1_abbrev) - # if not away_team: - # await ctx.send(f'I couldn\'t find **{team1_abbrev}**. Is that the team\'s abbreviation?') - # return - # home_team = Team.get_season(team2_abbrev) - # if not home_team: - # await ctx.send(f'I couldn\'t find **{team2_abbrev}**. Is that the team\'s abbreviation?') - # return - # - # embed = discord.Embed(title=f'{away_team.abbrev} vs {home_team.abbrev}', color=0xdeeadd) - # embed = helpers.get_team_blurb(ctx, embed, away_team) - # embed = helpers.get_team_blurb(ctx, embed, home_team) - # - # away_tv = away_team.team_value - # home_tv = home_team.team_value - # diff = abs(away_tv - home_tv) - # - # if diff > 12: - # embed.add_field(name='Both Teams Eligible for Packs?', value=f'No, diff is {diff}', inline=False) - # else: - # embed.add_field(name='Both Teams Eligible for Packs?', value='Yes!', inline=False) - # - # await ctx.send(content=None, embed=embed) - # - # db.close() - # - # @commands.command(name='result', help='Log your game results') - # @commands.has_any_role('Paper Dynasty Players') - # async def result_command(self, ctx, awayabbrev: str, awayscore: int, homeabbrev: str, - # homescore: int, scorecard_url, *game_type: str): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # # Check access on the scorecard - # try: - # await ctx.send('Alright, let me go open that Sheet...') - # scorecard = self.sheets.open_by_url(scorecard_url).worksheet_by_title('Results') - # except Exception as e: - # logger.error(f'Unable to access sheet ({scorecard_url}) submitted by {ctx.author.name}') - # await ctx.message.add_reaction('❌') - # await ctx.send(f'{ctx.message.author.mention}, I can\'t access that sheet.') - # return - # - # # Validate teams listed - # try: - # awayteam = Team.get_season(awayabbrev) - # hometeam = Team.get_season(homeabbrev) - # logger.info(f'Final: {awayabbrev} {awayscore} - {homescore} {homeabbrev}') - # if awayteam == hometeam: - # await ctx.message.add_reaction('❌') - # await helpers.send_to_news( - # ctx, - # f'{self.bot.get_user(ctx.author.id).mention} just tried to log ' - # f'a game result played against themselves...', - # embed=None) - # return - # except Exception as e: - # error = f'**ERROR:** {type(e).__name__} - {e}' - # logger.error(error) - # await ctx.message.add_reaction('❌') - # await ctx.send(f'Hey, {ctx.author.mention}, I couldn\'t find the teams you mentioned. You put ' - # f'**{awayabbrev}** as the away team and **{homeabbrev}** as the home team.') - # return - # - # # Check for duplicate scorecard - # dupes = Result.select().where(Result.scorecard == scorecard_url) - # if dupes.count() > 0: - # await ctx.message.add_reaction('❌') - # await ctx.send(f'Bruh. This scorecard was already submitted for credit.') - # return - # - # if not game_type: - # this_q = helpers.Question(self.bot, ctx.channel, 'Was this a wiffleball game?', 'yesno', 15) - # resp = await this_q.ask([ctx.author]) - # - # if resp is None: - # await helpers.react_and_reply(ctx, '❌', 'You think on it and get back to me.') - # return - # elif not resp: - # game_type = 'baseball' - # else: - # game_type = 'wiffleball' - # elif game_type[0] in ['b', 'base', 'baseball', 'standard', 'regular']: - # game_type = 'baseball' - # elif game_type[0] in ['w', 'wif', 'wiff', 'wiffleball']: - # game_type = 'wiffleball' - # else: - # this_q = helpers.Question(self.bot, ctx.channel, 'Was this a wiffleball game?', 'yesno', 15) - # resp = await this_q.ask([ctx.author]) - # - # if resp is None: - # await helpers.react_and_reply(ctx, '❌', 'You think on it and get back to me.') - # return - # elif not resp: - # game_type = 'baseball' - # else: - # game_type = 'wiffleball' - # - # earnings = { - # 'away': 'None', - # 'home': 'None', - # } - # - # if game_type == 'wiffleball': - # away_team_value = 10 - # home_team_value = 10 - # else: - # away_team_value = awayteam.team_value - # home_team_value = hometeam.team_value - # - # # Check author then log result - # if ctx.author.id in [awayteam.gmid, awayteam.gmid2, hometeam.gmid, hometeam.gmid2] \ - # or ctx.author.id == self.bot.owner_id: - # this_result = Result(week=Current.get_by_id(1).week, - # awayteam=awayteam, hometeam=hometeam, - # awayscore=awayscore, homescore=homescore, - # home_team_value=home_team_value, away_team_value=away_team_value, - # scorecard=scorecard_url, season=Current.get_by_id(1).season, game_type=game_type) - # this_result.save() - # await helpers.pause_then_type( - # ctx, - # f'Just logged {awayteam.abbrev.upper()} {awayscore} - ' - # f'{homescore} {hometeam.abbrev.upper()}' - # ) - # await ctx.message.add_reaction('✅') - # - # logger.info('Checking for credit') - # # Credit pack for win - # economy = self.bot.get_cog('Economy') - # if awayscore > homescore: - # # Set embed logo - # if awayteam.logo: - # winner_avatar = awayteam.logo - # else: - # winner_avatar = self.bot.get_user(awayteam.gmid).avatar_url - # - # # Check values and distribute earnings - # if awayteam.team_value - hometeam.team_value <= 12: - # earnings['away'] = '1 Premium Pack' - # logger.info(f'{awayteam.sname} earns 1 Premium pack for the win') - # economy.give_pack(awayteam, 1, 'Premium') - # else: - # logger.info(f'{awayteam.sname} earns nothing for the win - team value {awayteam.team_value} vs ' - # f'{hometeam.team_value}') - # earnings['away'] = f'None - Team was {awayteam.team_value - hometeam.team_value} points higher' - # - # if hometeam.team_value - awayteam.team_value <= 12: - # earnings['home'] = '1 Standard Pack' - # logger.info(f'{hometeam.sname} earns 1 Standard pack for the loss') - # economy.give_pack(hometeam, 1) - # else: - # logger.info(f'{hometeam.sname} earns nothing for the loss - team value {hometeam.team_value} vs ' - # f'{awayteam.team_value}') - # earnings['home'] = f'None - Team was {hometeam.team_value - awayteam.team_value} points higher' - # else: - # if hometeam.logo: - # winner_avatar = hometeam.logo - # else: - # winner_avatar = self.bot.get_user(hometeam.gmid).avatar_url - # - # # Check values and distribute earnings - # if hometeam.team_value - awayteam.team_value <= 12: - # earnings['home'] = '1 Premium Pack' - # logger.info(f'{hometeam.sname} earns 1 Premium pack for the win') - # economy.give_pack(hometeam, 1, 'Premium') - # else: - # logger.info(f'{hometeam.sname} earns nothing for the win - team value {hometeam.team_value} vs ' - # f'{awayteam.team_value}') - # earnings['home'] = f'None - Team was {hometeam.team_value - awayteam.team_value} points higher' - # - # if awayteam.team_value - hometeam.team_value <= 12: - # earnings['away'] = '1 Standard Pack' - # logger.info(f'{awayteam.sname} earns 1 Standard pack for the loss') - # economy.give_pack(awayteam, 1) - # else: - # logger.info(f'{awayteam.sname} earns nothing for the loss - team value {awayteam.team_value} vs ' - # f'{hometeam.team_value}') - # earnings['away'] = f'None - Team was {awayteam.team_value - hometeam.team_value} points higher' - # - # # Get team records - # away_record = awayteam.get_record() - # home_record = hometeam.get_record() - # - # # away_team_value = helpers.get_collection_value(awayteam) - # # home_team_value = helpers.get_collection_value(hometeam) - # # delta = away_team_value - home_team_value - # # if delta < 0: - # # increments = divmod(-delta, helpers.TEAM_DELTA_CONSTANT) - # # # logger.info(f'increments: {increments}') - # # packs = min(increments[0], 5) - # # if packs > 0: - # # earnings['away'] += packs - # # earnings_away.append(f'- {packs} pack{"s" if packs > 1 else ""} for underdog\n') - # # else: - # # increments = divmod(delta, helpers.TEAM_DELTA_CONSTANT) - # # # logger.info(f'increments: {increments}') - # # packs = min(increments[0], 5) - # # if packs > 0: - # # earnings['home'] += packs - # # earnings_home.append(f'- {packs} pack{"s" if packs > 1 else ""} for underdog\n') - # - # # logger.info(f'earn away: {earnings["away"]} / earn home: {earnings["home"]}') - # # away_packs_remaining = Current.get_by_id(1).packlimit - awayteam.weeklypacks - # # home_packs_remaining = Current.get_by_id(1).packlimit - hometeam.weeklypacks - # # away_final_earnings = earnings["away"] if away_packs_remaining >= earnings["away"] else max(away_packs_remaining, 0) - # # home_final_earnings = earnings["home"] if home_packs_remaining >= earnings["home"] else max(home_packs_remaining, 0) - # # ogging.info(f'away_final_earnings: {away_final_earnings}') - # # ogging.info(f'home_final_earnings: {home_final_earnings}') - # - # # economy = self.bot.get_cog('Economy') - # # if away_final_earnings > 0: - # # logger.info(f'away_final_earnings: {away_final_earnings}') - # # economy.give_pack(awayteam, away_final_earnings, True) - # # else: - # # away_final_earnings = 0 - # # if home_final_earnings > 0: - # # logger.info(f'home_final_earnings: {home_final_earnings}') - # # economy.give_pack(hometeam, home_final_earnings, True) - # # else: - # # home_final_earnings = 0 - # - # embed = discord.Embed(title=f'{awayteam.sname} {awayscore} - {homescore} {hometeam.sname}', - # description=f'Score Report - {game_type.title()}') - # embed.add_field(name=awayteam.lname, - # value=f'Team Value: {awayteam.team_value}\n\n' - # f'Earn: {earnings["away"]}\n' - # f'Record: {away_record["w"]}-{away_record["l"]}', - # inline=False) - # embed.add_field(name=hometeam.lname, - # value=f'Team Value: {hometeam.team_value}\n\n' - # f'Earn: {earnings["home"]}\n' - # f'Record: {home_record["w"]}-{home_record["l"]}', - # inline=False) - # embed.add_field(name='Scorecard', - # value=scorecard_url, - # inline=False) - # embed.set_thumbnail(url=winner_avatar) - # await helpers.send_to_news(ctx, None, embed) - # - # db.close() - # - # @result_command.error - # async def result_command_error(self, ctx, error): - # if isinstance(error, commands.MissingRequiredArgument): - # await ctx.send('The syntax is .result ' - # '') - # else: - # await ctx.send(f'Error: {error}') - # - # db.close() - # - # @commands.command(name='sheet', aliases=['google'], help='Link to your roster sheet') - # @commands.has_any_role('Paper Dynasty Players') - # async def get_roster_command(self, ctx): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # team = Team.get_by_owner(ctx.author.id) - # if not team: - # await ctx.send(f'Do you have a team? I don\'t see your name here...') - # return - # - # await ctx.send(f'{ctx.author.mention}\n{team.lname} Roster Sheet: <{helpers.get_roster_sheet_legacy(team)}>') - # - # db.close() - # - # @commands.command(name='setthumbnail', help='Set your team\'s thumbnail image') - # @commands.has_any_role('Paper Dynasty Players') - # async def set_thumbnail_command(self, ctx, url): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # team = Team.get_by_owner(ctx.author.id) - # if not team: - # await ctx.send(f'I cannot find a team that you manage. Are you registered for Paper Dynasty?') - # return - # - # try: - # team.logo = url - # team.save() - # embed = discord.Embed(title=f'{team.lname} Test') - # embed.set_thumbnail(url=team.logo if team.logo else self.logo) - # await ctx.send(content='Got it! What do you think?', embed=embed) - # except Exception as e: - # await ctx.send(f'Huh. Do you know what this means?\n\n{e}') - # - # db.close() - # - # @commands.command(name='rates', help='Check current pull rates') - # @commands.has_any_role('Paper Dynasty Players') - # async def all_card_pulls(self, ctx): - # await self.bot.change_presence(activity=discord.Game(name='strat | .help')) - # total_count = Card.select().count() - # mvp_count = (Card - # .select() - # .join(Player) - # .join(Rarity) - # .where(Card.player.rarity.value == 10)).count() - # als_count = (Card - # .select() - # .join(Player) - # .join(Rarity) - # .where(Card.player.rarity.value == 7)).count() - # sta_count = (Card - # .select() - # .join(Player) - # .join(Rarity) - # .where(Card.player.rarity.value == 5)).count() - # res_count = (Card - # .select() - # .join(Player) - # .join(Rarity) - # .where(Card.player.rarity.value == 3)).count() - # rep_count = (Card - # .select() - # .join(Player) - # .join(Rarity) - # .where(Card.player.rarity.value == 0)).count() - # - # embed = discord.Embed(title='Current Pull Rates', color=0x800080) - # embed.add_field(name='Total Pulls', value=f'{total_count}') - # embed.add_field(name='MVPs', value=f'{mvp_count} ({(mvp_count / total_count)*100:.2f}%)\n' - # f'Target: 0.33%', inline=False) - # embed.add_field(name='All-Stars', value=f'{als_count} ({(als_count / total_count)*100:.2f}%)\n' - # f'Target: 2.50%', inline=False) - # embed.add_field(name='Starters', value=f'{sta_count} ({(sta_count / total_count)*100:.2f}%)\n' - # f'Target: 18.83%', inline=False) - # embed.add_field(name='Reserves', value=f'{res_count} ({(res_count / total_count)*100:.2f}%)\n' - # f'Target: 45.00%', inline=False) - # embed.add_field(name='Replacements', value=f'{rep_count} ({(rep_count / total_count)*100:.2f}%)\n' - # f'Target: 33.33%', inline=False) - # await ctx.send(content=None, embed=embed) - # - # db.close() - # - # @commands.command(name='paperdex', aliases=['collection', 'pokedex'], help='See collection counts') - # @commands.has_any_role('Paper Dynasty Players') - # async def collection_command(self, ctx, *team_or_league): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # league = False - # team = None - # - # if team_or_league: - # if team_or_league[0].lower() in ['l', 'lg', 'league']: - # league = True - # else: - # team = Team.get_season(team_or_league[0]) - # - # if not team: - # team = Team.get_by_owner(ctx.author.id) - # if not team: - # await ctx.send(f'I cannot find a team that you manage. Are you registered for Paper Dynasty?') - # return - # - # if league: - # thumb = 'https://sombaseball.ddns.net/static/images/sba-logo.png' - # title = 'League Paperdex' - # elif team.logo: - # thumb = team.logo - # title = f'{team.lname} Paperdex' - # else: - # thumb = self.bot.get_user(team.gmid).avatar_url - # title = f'{team.lname} Paperdex' - # - # embed = helpers.get_random_embed(title, thumb) - # embed.description = '(Seen / Owned / Total)' - # - # cardsets = Player.select(Player.cardset).distinct().order_by(-Player.cardset) - # overall_total = 0 - # overall_owned = 0 - # overall_seen = 0 - # - # for x in cardsets: - # total_players = Player.select().where((Player.cardset == x.cardset) & (Player.pos1 != 'Park')).count() - # total_parks = Player.select().where((Player.cardset == x.cardset) & (Player.pos1 == 'Park')).count() - # - # if league: - # owned_cards = Card.select().join(Player).distinct() - # seen_cards = len(get_pokedex(cardset=x.cardset, is_park=False)) - # seen_parks = len(get_pokedex(cardset=x.cardset, is_park=True)) - # else: - # owned_cards = Card.select().join(Player).where(Card.team == team) - # seen_cards = len(get_pokedex(team, cardset=x.cardset, is_park=False)) - # seen_parks = len(get_pokedex(team, cardset=x.cardset, is_park=True)) - # - # owned_players = owned_cards.select(Card.player).where( - # (Card.player.cardset == x.cardset) & (Card.player.pos1 != 'Park') - # ).distinct().count() - # - # owned_parks = owned_cards.select(Card.player).where( - # (Card.player.cardset == x.cardset) & (Card.player.pos1 == 'Park') - # ).distinct().count() - # - # set_string = f'Players: {seen_cards} / {owned_players} / {total_players}\n' \ - # f'Parks: {seen_parks} / {owned_parks} / {total_parks}\n' - # ratio = f'{((seen_cards + seen_parks) / (total_players + total_parks)) * 100:.0f}' - # field_name = f'{x.cardset} Set ({ratio}%)' - # - # embed.add_field(name=field_name, value=set_string, inline=False) - # overall_total += total_players + total_parks - # overall_owned += owned_players + owned_parks - # overall_seen += seen_cards + seen_parks - # - # overall_ratio = (overall_seen / overall_total) * 100 - # embed.add_field(name=f'Paper Dynasty Universe ({overall_ratio:.0f}%)', - # value=f'{overall_seen} / {overall_owned} / {overall_total}\n', - # inline=False) - # - # await ctx.send(content=None, embed=embed) - # - # @commands.command(name='gms', aliases=['allgms', 'list'], help='List team/gm info') - # @commands.has_any_role('Paper Dynasty Players') - # async def gms_command(self, ctx): - # if not await legal_channel(ctx): - # await ctx.send('Slide on down to my #pd-bot-hole ;)') - # return - # - # all_teams = Team.select_season() - # team_list = [] - # - # for x in all_teams: - # team_list.append(x) - # team_list.sort(key=lambda y: y.abbrev) - # - # this_color = discord.Color.random() - # all_embeds = [ - # discord.Embed(title='All Teams', color=this_color), discord.Embed(title='All Teams', color=this_color), - # discord.Embed(title='All Teams', color=this_color), discord.Embed(title='All Teams', color=this_color), - # discord.Embed(title='All Teams', color=this_color), discord.Embed(title='All Teams', color=this_color) - # ] - # team_strings = [ - # '', '', '', '', '', '' - # ] - # - # count = 0 - # for x in team_list: - # index = math.floor(count / 18) - # team_strings[index] += f'**{x.abbrev}** - **{x.lname}** - {x.gmname}\n' - # count += 1 - # - # for x in range(math.ceil(len(team_list) / 18)): - # all_embeds[x].set_thumbnail(url=self.logo) - # 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') - async def chaos_roll(self, ctx): - """ - Have the pitcher check for chaos with a runner on base. - """ - d_twenty = random.randint(1, 20) - d_twenty_two = random.randint(1, 20) - flag = None - - if ctx: - if ctx.author.id == 258104532423147520: - d_twenty_three = random.randint(5, 16) - if d_twenty_two < d_twenty_three: - d_twenty_two = d_twenty_three - - if d_twenty == 1: - flag = 'wild pitch' - elif d_twenty == 2: - if random.randint(1, 2) == 1: - flag = 'balk' - else: - flag = 'passed ball' - - if not flag: - 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})]```' - - await ctx.send(roll_message) - - @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}' - - 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}') - - this_player = await get_one_player(player_name) - logger.debug(f'this_player: {this_player}') - - # @app_commands.command(name='matchup', description='Simulate a matchup between a pitcher and batter') - # @app_commands.describe( - # pitcher_id='The pitcher\'s player_id', - # batter_id='The batter\'s player_id' - # ) - # async def matchup_command(self, interaction: discord.Interaction, pitcher_id: int, batter_id: int): - # await interaction.response.defer() - # try: - # pit_card = await get_pd_pitchingcard(pitcher_id) - # except KeyError as e: - # await interaction.edit_original_response( - # content=f'I could not find a pitcher card for player_id {pitcher_id}' - # ) - # return - # try: - # bat_card = await get_pd_battingcard(batter_id) - # except KeyError as e: - # await interaction.edit_original_response( - # content=f'I could not find a batter card for player_id {batter_id}' - # ) - # return - - # this_pitcher = await get_pd_player(pitcher_id) - # this_batter = await get_pd_player(batter_id) - - # # view = helpers.ButtonOptions( - # # responders=[interaction.user], timeout=60, - # # labels=['Reroll', None, None, None, None] - # # ) - - # await interaction.edit_original_response( - # content=None, - # embeds=get_pos_embeds(this_pitcher, this_batter, pit_card, bat_card), - # # view=view - # ) - # # await view.wait() - # # - # # if view.value: - # # await question.delete() - # # if view.value == 'Tagged Up': - # # advance_one_runner(this_play.id, from_base=2, num_bases=1) - # # elif view.value == 'Out at 3rd': - # # num_outs += 1 - # # patch_play(this_play.id, on_second_final=False, outs=num_outs) - # # else: - # # await question.delete() - - -async def setup(bot): - await bot.add_cog(Players(bot)) From 8b0c82f687c660c14d92b183aeab2247ccb8a315 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 02:34:54 -0600 Subject: [PATCH 13/16] fix: invoke actual cog callback in test_error_handling_and_logging (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test patched api_calls.db_get and pygsheets.authorize then called those mocks directly—never invoking any cog method. The test passed even when all cog code was deleted. Replace with a test that retrieves the real pull_roster_command.callback from the cog instance, patches dependencies at the correct module-level names, calls the callback, and asserts ctx.send was called with the expected error message. If the cog cannot be imported, the test skips gracefully via pytest.skip. Co-Authored-By: Claude Sonnet 4.6 --- .../players_refactor/test_team_management.py | 690 ++++++++++-------- 1 file changed, 394 insertions(+), 296 deletions(-) diff --git a/tests/players_refactor/test_team_management.py b/tests/players_refactor/test_team_management.py index 3d131a1..a16e480 100644 --- a/tests/players_refactor/test_team_management.py +++ b/tests/players_refactor/test_team_management.py @@ -27,498 +27,596 @@ except ImportError: @pytest.mark.asyncio class TestTeamManagement: """Test suite for TeamManagement cog functionality.""" - + @pytest.fixture def team_management_cog(self, mock_bot): """Create TeamManagement cog instance for testing.""" return TeamManagement(mock_bot) - + async def test_init(self, team_management_cog, mock_bot): """Test cog initialization.""" assert team_management_cog.bot == mock_bot - - @patch('api_calls.get_team_by_abbrev') - @patch('helpers.get_team_by_owner') - @patch('helpers.team_summary_embed') - async def test_team_command_with_abbreviation_success(self, mock_team_summary, - mock_get_by_owner, mock_get_by_abbrev, - team_management_cog, mock_interaction, - sample_team_data, mock_embed): + + @patch("api_calls.get_team_by_abbrev") + @patch("helpers.get_team_by_owner") + @patch("helpers.team_summary_embed") + async def test_team_command_with_abbreviation_success( + self, + mock_team_summary, + mock_get_by_owner, + mock_get_by_abbrev, + team_management_cog, + mock_interaction, + sample_team_data, + mock_embed, + ): """Test team command with team abbreviation provided.""" mock_get_by_abbrev.return_value = sample_team_data mock_team_summary.return_value = mock_embed - + async def mock_team_command(interaction, team_abbrev=None): await interaction.response.defer() - + if team_abbrev: team = await mock_get_by_abbrev(team_abbrev) if not team: - await interaction.followup.send(f'Could not find team with abbreviation: {team_abbrev}') + await interaction.followup.send( + f"Could not find team with abbreviation: {team_abbrev}" + ) return else: team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet! Use `/newteam` to create one.') + await interaction.followup.send( + "You don't have a team yet! Use `/newteam` to create one." + ) return - + embed = await mock_team_summary(team, interaction, include_roster=True) await interaction.followup.send(embed=embed) - - await mock_team_command(mock_interaction, 'TST') - + + await mock_team_command(mock_interaction, "TST") + mock_interaction.response.defer.assert_called_once() - mock_get_by_abbrev.assert_called_once_with('TST') + mock_get_by_abbrev.assert_called_once_with("TST") mock_team_summary.assert_called_once() mock_interaction.followup.send.assert_called_once() - - @patch('api_calls.get_team_by_abbrev') - async def test_team_command_abbreviation_not_found(self, mock_get_by_abbrev, - team_management_cog, mock_interaction): + + @patch("api_calls.get_team_by_abbrev") + async def test_team_command_abbreviation_not_found( + self, mock_get_by_abbrev, team_management_cog, mock_interaction + ): """Test team command when abbreviation is not found.""" mock_get_by_abbrev.return_value = None - + async def mock_team_command(interaction, team_abbrev): await interaction.response.defer() - + team = await mock_get_by_abbrev(team_abbrev) if not team: - await interaction.followup.send(f'Could not find team with abbreviation: {team_abbrev}') + await interaction.followup.send( + f"Could not find team with abbreviation: {team_abbrev}" + ) return - - await mock_team_command(mock_interaction, 'XYZ') - - mock_interaction.followup.send.assert_called_once_with('Could not find team with abbreviation: XYZ') - - @patch('helpers.get_team_by_owner') - @patch('helpers.team_summary_embed') - async def test_team_command_without_abbreviation_success(self, mock_team_summary, - mock_get_by_owner, - team_management_cog, mock_interaction, - sample_team_data, mock_embed): + + await mock_team_command(mock_interaction, "XYZ") + + mock_interaction.followup.send.assert_called_once_with( + "Could not find team with abbreviation: XYZ" + ) + + @patch("helpers.get_team_by_owner") + @patch("helpers.team_summary_embed") + async def test_team_command_without_abbreviation_success( + self, + mock_team_summary, + mock_get_by_owner, + team_management_cog, + mock_interaction, + sample_team_data, + mock_embed, + ): """Test team command without abbreviation (user's own team).""" mock_get_by_owner.return_value = sample_team_data mock_team_summary.return_value = mock_embed - + async def mock_team_command(interaction, team_abbrev=None): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet! Use `/newteam` to create one.') + await interaction.followup.send( + "You don't have a team yet! Use `/newteam` to create one." + ) return - + embed = await mock_team_summary(team, interaction, include_roster=True) await interaction.followup.send(embed=embed) - + await mock_team_command(mock_interaction) - + mock_get_by_owner.assert_called_once_with(mock_interaction.user.id) mock_team_summary.assert_called_once() mock_interaction.followup.send.assert_called_once() - - @patch('helpers.get_team_by_owner') - async def test_team_command_user_no_team(self, mock_get_by_owner, - team_management_cog, mock_interaction): + + @patch("helpers.get_team_by_owner") + async def test_team_command_user_no_team( + self, mock_get_by_owner, team_management_cog, mock_interaction + ): """Test team command when user has no team.""" mock_get_by_owner.return_value = None - + async def mock_team_command(interaction, team_abbrev=None): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet! Use `/newteam` to create one.') + await interaction.followup.send( + "You don't have a team yet! Use `/newteam` to create one." + ) return - + await mock_team_command(mock_interaction) - - mock_interaction.followup.send.assert_called_once_with('You don\'t have a team yet! Use `/newteam` to create one.') - - @patch('helpers.get_team_by_owner') - @patch('api_calls.db_patch') - async def test_branding_command_success(self, mock_db_patch, mock_get_by_owner, - team_management_cog, mock_interaction, - sample_team_data): + + mock_interaction.followup.send.assert_called_once_with( + "You don't have a team yet! Use `/newteam` to create one." + ) + + @patch("helpers.get_team_by_owner") + @patch("api_calls.db_patch") + async def test_branding_command_success( + self, + mock_db_patch, + mock_get_by_owner, + team_management_cog, + mock_interaction, + sample_team_data, + ): """Test successful team branding update.""" mock_get_by_owner.return_value = sample_team_data - mock_db_patch.return_value = {'success': True} - + mock_db_patch.return_value = {"success": True} + async def mock_branding_command(interaction, new_color, new_logo_url=None): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - - update_data = {'color': new_color} + + update_data = {"color": new_color} if new_logo_url: - update_data['logo'] = new_logo_url - + update_data["logo"] = new_logo_url + response = await mock_db_patch(f'teams/{team["id"]}', data=update_data) - - if response.get('success'): - await interaction.followup.send(f'Successfully updated team branding!') + + if response.get("success"): + await interaction.followup.send(f"Successfully updated team branding!") else: - await interaction.followup.send('Failed to update team branding.') - - await mock_branding_command(mock_interaction, '#FF0000', 'https://example.com/logo.png') - + await interaction.followup.send("Failed to update team branding.") + + await mock_branding_command( + mock_interaction, "#FF0000", "https://example.com/logo.png" + ) + mock_get_by_owner.assert_called_once() mock_db_patch.assert_called_once() - mock_interaction.followup.send.assert_called_once_with('Successfully updated team branding!') - - @patch('helpers.get_team_by_owner') - async def test_branding_command_no_team(self, mock_get_by_owner, - team_management_cog, mock_interaction): + mock_interaction.followup.send.assert_called_once_with( + "Successfully updated team branding!" + ) + + @patch("helpers.get_team_by_owner") + async def test_branding_command_no_team( + self, mock_get_by_owner, team_management_cog, mock_interaction + ): """Test team branding command when user has no team.""" mock_get_by_owner.return_value = None - + async def mock_branding_command(interaction, new_color): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - - await mock_branding_command(mock_interaction, '#FF0000') - - mock_interaction.followup.send.assert_called_once_with('You don\'t have a team yet!') - - @patch('helpers.get_team_by_owner') - @patch('api_calls.db_patch') - async def test_branding_command_failure(self, mock_db_patch, mock_get_by_owner, - team_management_cog, mock_interaction, - sample_team_data): + + await mock_branding_command(mock_interaction, "#FF0000") + + mock_interaction.followup.send.assert_called_once_with( + "You don't have a team yet!" + ) + + @patch("helpers.get_team_by_owner") + @patch("api_calls.db_patch") + async def test_branding_command_failure( + self, + mock_db_patch, + mock_get_by_owner, + team_management_cog, + mock_interaction, + sample_team_data, + ): """Test team branding update failure.""" mock_get_by_owner.return_value = sample_team_data - mock_db_patch.return_value = {'success': False} - + mock_db_patch.return_value = {"success": False} + async def mock_branding_command(interaction, new_color): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - - update_data = {'color': new_color} + + update_data = {"color": new_color} response = await mock_db_patch(f'teams/{team["id"]}', data=update_data) - - if response.get('success'): - await interaction.followup.send('Successfully updated team branding!') + + if response.get("success"): + await interaction.followup.send("Successfully updated team branding!") else: - await interaction.followup.send('Failed to update team branding.') - - await mock_branding_command(mock_interaction, '#FF0000') - - mock_interaction.followup.send.assert_called_once_with('Failed to update team branding.') - - @patch('helpers.get_team_by_owner') - @patch('pygsheets.authorize') - @patch('helpers.get_roster_sheet') - async def test_pullroster_command_success(self, mock_get_roster_sheet, mock_authorize, - mock_get_by_owner, team_management_cog, - mock_interaction, sample_team_data): + await interaction.followup.send("Failed to update team branding.") + + await mock_branding_command(mock_interaction, "#FF0000") + + mock_interaction.followup.send.assert_called_once_with( + "Failed to update team branding." + ) + + @patch("helpers.get_team_by_owner") + @patch("pygsheets.authorize") + @patch("helpers.get_roster_sheet") + async def test_pullroster_command_success( + self, + mock_get_roster_sheet, + mock_authorize, + mock_get_by_owner, + team_management_cog, + mock_interaction, + sample_team_data, + ): """Test successful roster pull from Google Sheets.""" mock_get_by_owner.return_value = sample_team_data mock_gc = Mock() mock_authorize.return_value = mock_gc mock_get_roster_sheet.return_value = Mock() - + async def mock_pullroster_command(interaction): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - - if not team.get('gsheet'): - await interaction.followup.send('No Google Sheet configured for your team.') + + if not team.get("gsheet"): + await interaction.followup.send( + "No Google Sheet configured for your team." + ) return - + try: gc = mock_authorize() - roster_data = await mock_get_roster_sheet(gc, team['gsheet']) - await interaction.followup.send('Successfully pulled roster from Google Sheets!') + roster_data = await mock_get_roster_sheet(gc, team["gsheet"]) + await interaction.followup.send( + "Successfully pulled roster from Google Sheets!" + ) except Exception as e: - await interaction.followup.send(f'Error pulling roster: {str(e)}') - + await interaction.followup.send(f"Error pulling roster: {str(e)}") + await mock_pullroster_command(mock_interaction) - + mock_get_by_owner.assert_called_once() mock_authorize.assert_called_once() mock_get_roster_sheet.assert_called_once() - mock_interaction.followup.send.assert_called_once_with('Successfully pulled roster from Google Sheets!') - - @patch('helpers.get_team_by_owner') - async def test_pullroster_command_no_team(self, mock_get_by_owner, - team_management_cog, mock_interaction): + mock_interaction.followup.send.assert_called_once_with( + "Successfully pulled roster from Google Sheets!" + ) + + @patch("helpers.get_team_by_owner") + async def test_pullroster_command_no_team( + self, mock_get_by_owner, team_management_cog, mock_interaction + ): """Test roster pull when user has no team.""" mock_get_by_owner.return_value = None - + async def mock_pullroster_command(interaction): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - + await mock_pullroster_command(mock_interaction) - - mock_interaction.followup.send.assert_called_once_with('You don\'t have a team yet!') - - @patch('helpers.get_team_by_owner') - async def test_pullroster_command_no_sheet(self, mock_get_by_owner, - team_management_cog, mock_interaction): + + mock_interaction.followup.send.assert_called_once_with( + "You don't have a team yet!" + ) + + @patch("helpers.get_team_by_owner") + async def test_pullroster_command_no_sheet( + self, mock_get_by_owner, team_management_cog, mock_interaction + ): """Test roster pull when team has no Google Sheet configured.""" - team_data_no_sheet = {**sample_team_data, 'gsheet': None} + team_data_no_sheet = {**sample_team_data, "gsheet": None} mock_get_by_owner.return_value = team_data_no_sheet - + async def mock_pullroster_command(interaction): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - - if not team.get('gsheet'): - await interaction.followup.send('No Google Sheet configured for your team.') + + if not team.get("gsheet"): + await interaction.followup.send( + "No Google Sheet configured for your team." + ) return - + await mock_pullroster_command(mock_interaction) - - mock_interaction.followup.send.assert_called_once_with('No Google Sheet configured for your team.') - - @patch('helpers.get_team_by_owner') - @patch('pygsheets.authorize') - async def test_pullroster_command_error(self, mock_authorize, mock_get_by_owner, - team_management_cog, mock_interaction, - sample_team_data): + + mock_interaction.followup.send.assert_called_once_with( + "No Google Sheet configured for your team." + ) + + @patch("helpers.get_team_by_owner") + @patch("pygsheets.authorize") + async def test_pullroster_command_error( + self, + mock_authorize, + mock_get_by_owner, + team_management_cog, + mock_interaction, + sample_team_data, + ): """Test roster pull error handling.""" mock_get_by_owner.return_value = sample_team_data mock_authorize.side_effect = Exception("Google Sheets API Error") - + async def mock_pullroster_command(interaction): await interaction.response.defer() - + team = await mock_get_by_owner(interaction.user.id) if not team: - await interaction.followup.send('You don\'t have a team yet!') + await interaction.followup.send("You don't have a team yet!") return - - if not team.get('gsheet'): - await interaction.followup.send('No Google Sheet configured for your team.') + + if not team.get("gsheet"): + await interaction.followup.send( + "No Google Sheet configured for your team." + ) return - + try: gc = mock_authorize() except Exception as e: - await interaction.followup.send(f'Error pulling roster: {str(e)}') - + await interaction.followup.send(f"Error pulling roster: {str(e)}") + await mock_pullroster_command(mock_interaction) - - mock_interaction.followup.send.assert_called_once_with('Error pulling roster: Google Sheets API Error') - - @patch('api_calls.db_get') - async def test_ai_teams_command_success(self, mock_db_get, team_management_cog, - mock_interaction, mock_embed): + + mock_interaction.followup.send.assert_called_once_with( + "Error pulling roster: Google Sheets API Error" + ) + + @patch("api_calls.db_get") + async def test_ai_teams_command_success( + self, mock_db_get, team_management_cog, mock_interaction, mock_embed + ): """Test successful AI teams listing.""" ai_teams_data = { - 'count': 2, - 'teams': [ - {'id': 1, 'abbrev': 'AI1', 'sname': 'AI Team 1', 'is_ai': True}, - {'id': 2, 'abbrev': 'AI2', 'sname': 'AI Team 2', 'is_ai': True} - ] + "count": 2, + "teams": [ + {"id": 1, "abbrev": "AI1", "sname": "AI Team 1", "is_ai": True}, + {"id": 2, "abbrev": "AI2", "sname": "AI Team 2", "is_ai": True}, + ], } mock_db_get.return_value = ai_teams_data - + async def mock_ai_teams_command(interaction): await interaction.response.defer() - - teams_response = await mock_db_get('teams', params=[('is_ai', 'true')]) - - if not teams_response or teams_response['count'] == 0: - await interaction.followup.send('No AI teams found.') + + teams_response = await mock_db_get("teams", params=[("is_ai", "true")]) + + if not teams_response or teams_response["count"] == 0: + await interaction.followup.send("No AI teams found.") return - - ai_teams = teams_response['teams'] - team_list = '\n'.join([f"{team['abbrev']} - {team['sname']}" for team in ai_teams]) - + + ai_teams = teams_response["teams"] + team_list = "\n".join( + [f"{team['abbrev']} - {team['sname']}" for team in ai_teams] + ) + embed = mock_embed - embed.title = f'AI Teams ({len(ai_teams)})' + embed.title = f"AI Teams ({len(ai_teams)})" embed.description = team_list - + await interaction.followup.send(embed=embed) - + await mock_ai_teams_command(mock_interaction) - - mock_db_get.assert_called_once_with('teams', params=[('is_ai', 'true')]) + + mock_db_get.assert_called_once_with("teams", params=[("is_ai", "true")]) mock_interaction.followup.send.assert_called_once() - - @patch('api_calls.db_get') - async def test_ai_teams_command_no_teams(self, mock_db_get, team_management_cog, - mock_interaction): + + @patch("api_calls.db_get") + async def test_ai_teams_command_no_teams( + self, mock_db_get, team_management_cog, mock_interaction + ): """Test AI teams command when no AI teams exist.""" - mock_db_get.return_value = {'count': 0, 'teams': []} - + mock_db_get.return_value = {"count": 0, "teams": []} + async def mock_ai_teams_command(interaction): await interaction.response.defer() - - teams_response = await mock_db_get('teams', params=[('is_ai', 'true')]) - - if not teams_response or teams_response['count'] == 0: - await interaction.followup.send('No AI teams found.') + + teams_response = await mock_db_get("teams", params=[("is_ai", "true")]) + + if not teams_response or teams_response["count"] == 0: + await interaction.followup.send("No AI teams found.") return - + await mock_ai_teams_command(mock_interaction) - - mock_interaction.followup.send.assert_called_once_with('No AI teams found.') - - @patch('api_calls.db_get') - async def test_ai_teams_command_api_error(self, mock_db_get, team_management_cog, - mock_interaction): + + mock_interaction.followup.send.assert_called_once_with("No AI teams found.") + + @patch("api_calls.db_get") + async def test_ai_teams_command_api_error( + self, mock_db_get, team_management_cog, mock_interaction + ): """Test AI teams command API error handling.""" mock_db_get.return_value = None - + async def mock_ai_teams_command(interaction): await interaction.response.defer() - - teams_response = await mock_db_get('teams', params=[('is_ai', 'true')]) - + + teams_response = await mock_db_get("teams", params=[("is_ai", "true")]) + if not teams_response: - await interaction.followup.send('Error retrieving AI teams.') + await interaction.followup.send("Error retrieving AI teams.") return - + await mock_ai_teams_command(mock_interaction) - - mock_interaction.followup.send.assert_called_once_with('Error retrieving AI teams.') - + + mock_interaction.followup.send.assert_called_once_with( + "Error retrieving AI teams." + ) + def test_color_validation(self, team_management_cog): """Test color format validation for branding command.""" - valid_colors = ['#FF0000', '#00FF00', '#0000FF', 'FF0000', '123ABC'] - invalid_colors = ['invalid', '#GGGGGG', '12345', '#1234567'] - + valid_colors = ["#FF0000", "#00FF00", "#0000FF", "FF0000", "123ABC"] + invalid_colors = ["invalid", "#GGGGGG", "12345", "#1234567"] + def is_valid_color(color): # Basic hex color validation - if color.startswith('#'): + if color.startswith("#"): color = color[1:] - return len(color) == 6 and all(c in '0123456789ABCDEFabcdef' for c in color) - + return len(color) == 6 and all(c in "0123456789ABCDEFabcdef" for c in color) + for color in valid_colors: assert is_valid_color(color), f"Color {color} should be valid" - + for color in invalid_colors: assert not is_valid_color(color), f"Color {color} should be invalid" - + def test_url_validation(self, team_management_cog): """Test URL validation for logo updates.""" valid_urls = [ - 'https://example.com/image.png', - 'https://cdn.example.com/logo.jpg', - 'http://test.com/image.gif' + "https://example.com/image.png", + "https://cdn.example.com/logo.jpg", + "http://test.com/image.gif", ] invalid_urls = [ - 'not_a_url', - 'ftp://example.com/file.txt', - 'javascript:alert(1)' + "not_a_url", + "ftp://example.com/file.txt", + "javascript:alert(1)", ] - + def is_valid_url(url): - return url.startswith(('http://', 'https://')) - + return url.startswith(("http://", "https://")) + for url in valid_urls: assert is_valid_url(url), f"URL {url} should be valid" - + for url in invalid_urls: assert not is_valid_url(url), f"URL {url} should be invalid" - - @patch('helpers.get_rosters') - async def test_roster_integration(self, mock_get_rosters, team_management_cog, - sample_team_data): + + @patch("helpers.get_rosters") + async def test_roster_integration( + self, mock_get_rosters, team_management_cog, sample_team_data + ): """Test roster data integration with team display.""" roster_data = { - 'active_roster': [ - {'card_id': 1, 'player_name': 'Player 1', 'position': 'C'}, - {'card_id': 2, 'player_name': 'Player 2', 'position': '1B'} + "active_roster": [ + {"card_id": 1, "player_name": "Player 1", "position": "C"}, + {"card_id": 2, "player_name": "Player 2", "position": "1B"}, ], - 'bench': [ - {'card_id': 3, 'player_name': 'Player 3', 'position': 'OF'} - ] + "bench": [{"card_id": 3, "player_name": "Player 3", "position": "OF"}], } mock_get_rosters.return_value = roster_data - - rosters = await mock_get_rosters(sample_team_data['id']) - + + rosters = await mock_get_rosters(sample_team_data["id"]) + assert rosters is not None - assert 'active_roster' in rosters - assert 'bench' in rosters - assert len(rosters['active_roster']) == 2 - assert len(rosters['bench']) == 1 - - def test_team_embed_formatting(self, team_management_cog, sample_team_data, mock_embed): + assert "active_roster" in rosters + assert "bench" in rosters + assert len(rosters["active_roster"]) == 2 + assert len(rosters["bench"]) == 1 + + def test_team_embed_formatting( + self, team_management_cog, sample_team_data, mock_embed + ): """Test proper formatting of team summary embeds.""" + # Mock the team summary embed creation def create_team_summary_embed(team, include_roster=False): embed = mock_embed embed.title = f"{team['abbrev']} - {team['sname']}" - embed.add_field(name="GM", value=team['gmname'], inline=True) + embed.add_field(name="GM", value=team["gmname"], inline=True) embed.add_field(name="Wallet", value=f"${team['wallet']}", inline=True) - embed.add_field(name="Team Value", value=f"${team['team_value']}", inline=True) - - if team['color']: - embed.color = int(team['color'], 16) - + embed.add_field( + name="Team Value", value=f"${team['team_value']}", inline=True + ) + + if team["color"]: + embed.color = int(team["color"], 16) + if include_roster: - embed.add_field(name="Roster", value="Active roster info...", inline=False) - + embed.add_field( + name="Roster", value="Active roster info...", inline=False + ) + return embed - + embed = create_team_summary_embed(sample_team_data, include_roster=True) - - assert embed.title == f"{sample_team_data['abbrev']} - {sample_team_data['sname']}" + + assert ( + embed.title == f"{sample_team_data['abbrev']} - {sample_team_data['sname']}" + ) embed.add_field.assert_called() - + def test_permission_checks(self, team_management_cog, mock_interaction): """Test role and channel permission checking.""" # Test role check mock_member_with_role = Mock() - mock_member_with_role.roles = [Mock(name='Paper Dynasty')] + mock_member_with_role.roles = [Mock(name="Paper Dynasty")] mock_interaction.user = mock_member_with_role - + # Test channel check - with patch('helpers.legal_channel') as mock_legal_check: + with patch("helpers.legal_channel") as mock_legal_check: mock_legal_check.return_value = True result = mock_legal_check(mock_interaction.channel) assert result is True - - @patch('logging.getLogger') - async def test_error_handling_and_logging(self, mock_logger, team_management_cog): - """Test error handling and logging across team management operations.""" - mock_logger_instance = Mock() - mock_logger.return_value = mock_logger_instance - - # Test API timeout error - with patch('api_calls.db_get') as mock_db_get: - mock_db_get.side_effect = asyncio.TimeoutError("Request timeout") - - try: - await mock_db_get('teams') - except asyncio.TimeoutError: - # In actual implementation, this would be caught and logged - pass - - # Test Google Sheets authentication error - with patch('pygsheets.authorize') as mock_authorize: - mock_authorize.side_effect = Exception("Auth failed") - - try: - mock_authorize() - except Exception: - # In actual implementation, this would be caught and logged - pass \ No newline at end of file + + async def test_error_handling_and_logging(self, team_management_cog, mock_context): + """Test that pull_roster_command sends an error message when get_rosters raises. + + Invokes the actual cog method callback so the test fails if the method body is + removed or the exception-handling branch is broken. + """ + cmd = getattr(team_management_cog, "pull_roster_command", None) + if cmd is None or not hasattr(cmd, "callback"): + pytest.skip( + "TeamManagement cog not importable; cannot test callback directly" + ) + + team_with_sheet = { + "id": 1, + "abbrev": "TST", + "sname": "Test", + "gsheet": "valid-sheet-id", + } + with patch( + "cogs.players_new.team_management.get_context_user" + ) as mock_get_ctx_user, patch( + "cogs.players_new.team_management.get_team_by_owner", + new=AsyncMock(return_value=team_with_sheet), + ), patch( + "cogs.players_new.team_management.get_rosters", + side_effect=Exception("Connection error"), + ): + mock_get_ctx_user.return_value = mock_context.author + await cmd.callback(team_management_cog, mock_context) + + mock_context.send.assert_called_once_with( + "Could not retrieve rosters from your sheet." + ) From 4fcc8ed269f7a18d3a6bb09c1edbf03353dec8b7 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 03:31:39 -0600 Subject: [PATCH 14/16] fix: remove duplicate sheets.open_by_key() call in get_full_roster_from_sheets (#30) Co-Authored-By: Claude Sonnet 4.6 --- command_logic/logic_gameplay.py | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 0d947be..55f2532 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -75,7 +75,6 @@ from utilities.dropdown import ( from utilities.embeds import image_embed from utilities.pages import Pagination - logger = logging.getLogger("discord_app") WPA_DF = pd.read_csv(f"storage/wpa_data.csv").set_index("index") TO_BASE = {2: "to second", 3: "to third", 4: "home"} @@ -612,9 +611,11 @@ async def read_lineup( this_game, this_team=lineup_team, lineup_num=lineup_num, - roster_num=this_game.away_roster_id - if this_game.home_team.is_ai - else this_game.home_roster_id, + roster_num=( + this_game.away_roster_id + if this_game.home_team.is_ai + else this_game.home_roster_id + ), ) await interaction.edit_original_response( @@ -759,7 +760,11 @@ def complete_play(session: Session, this_play: Play): opponent_play = get_last_team_play( session, this_play.game, this_play.pitcher.team ) - nbo = opponent_play.batting_order + 1 if opponent_play.pa == 1 else opponent_play.batting_order + nbo = ( + opponent_play.batting_order + 1 + if opponent_play.pa == 1 + else opponent_play.batting_order + ) except PlayNotFoundException as e: logger.info( f"logic_gameplay - complete_play - No last play found for {this_play.pitcher.team.sname}, setting upcoming batting order to 1" @@ -1106,7 +1111,8 @@ async def get_lineups_from_sheets( position = row[0].upper() if position != "DH": player_positions = [ - getattr(this_card.player, f"pos_{i}") for i in range(1, 9) + getattr(this_card.player, f"pos_{i}") + for i in range(1, 9) if getattr(this_card.player, f"pos_{i}") is not None ] if position not in player_positions: @@ -1156,8 +1162,6 @@ async def get_full_roster_from_sheets( """ logger.debug(f"get_full_roster_from_sheets - sheets: {sheets}") - this_sheet = sheets.open_by_key(this_team.gsheet) - this_sheet = sheets.open_by_key(this_team.gsheet) logger.debug(f"this_sheet: {this_sheet}") @@ -1216,7 +1220,10 @@ async def get_full_roster_from_sheets( async def checks_log_interaction( - session: Session, interaction: discord.Interaction, command_name: str, lock_play: bool = True + session: Session, + interaction: discord.Interaction, + command_name: str, + lock_play: bool = True, ) -> tuple[Game, Team, Play]: """ Validates interaction permissions and optionally locks the current play for processing. @@ -3862,7 +3869,14 @@ async def xchecks( this_play.run, this_play.triple, this_play.batter_final, - ) = 1, 1, 1, 1, 1, 4 + ) = ( + 1, + 1, + 1, + 1, + 1, + 4, + ) this_play = advance_runners(session, this_play, num_bases=4, earned_bases=3) session.add(this_play) From d7af52976311cce43ae644577c227a1ebec12b7d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 04:03:11 -0600 Subject: [PATCH 15/16] fix: catch aiohttp.ClientError in all API call functions (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNS failures and refused connections raised raw aiohttp errors to Discord users. Added except aiohttp.ClientError handlers to db_get, db_patch, db_post, db_put, and db_delete — each logs the error and raises DatabaseError for consistent handling upstream. Co-Authored-By: Claude Sonnet 4.6 --- api_calls.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api_calls.py b/api_calls.py index 222847a..bb969b4 100644 --- a/api_calls.py +++ b/api_calls.py @@ -126,6 +126,9 @@ async def db_get( f"Connection timeout to host {req_url} after {retries} attempts" ) raise APITimeoutError(f"Connection timeout to host {req_url}") + except aiohttp.ClientError as e: + logger.error(f"Connection error on GET {req_url}: {e}") + raise DatabaseError(f"Connection error: {e}") async def db_patch( @@ -166,6 +169,9 @@ async def db_patch( except asyncio.TimeoutError: logger.error(f"Connection timeout to host {req_url}") raise APITimeoutError(f"Connection timeout to host {req_url}") + except aiohttp.ClientError as e: + logger.error(f"Connection error on PATCH {req_url}: {e}") + raise DatabaseError(f"Connection error: {e}") async def db_post( @@ -205,6 +211,9 @@ async def db_post( except asyncio.TimeoutError: logger.error(f"Connection timeout to host {req_url}") raise APITimeoutError(f"Connection timeout to host {req_url}") + except aiohttp.ClientError as e: + logger.error(f"Connection error on POST {req_url}: {e}") + raise DatabaseError(f"Connection error: {e}") async def db_put( @@ -244,6 +253,9 @@ async def db_put( except asyncio.TimeoutError: logger.error(f"Connection timeout to host {req_url}") raise APITimeoutError(f"Connection timeout to host {req_url}") + except aiohttp.ClientError as e: + logger.error(f"Connection error on PUT {req_url}: {e}") + raise DatabaseError(f"Connection error: {e}") async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout: int = 5): @@ -281,6 +293,9 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout: in except asyncio.TimeoutError: logger.error(f"Connection timeout to host {req_url}") raise APITimeoutError(f"Connection timeout to host {req_url}") + except aiohttp.ClientError as e: + logger.error(f"Connection error on DELETE {req_url}: {e}") + raise DatabaseError(f"Connection error: {e}") async def get_team_by_abbrev(abbrev: str): From 096176fe63f8728ebecf5b274f8b24fe4b4c5895 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 5 Mar 2026 04:32:11 -0600 Subject: [PATCH 16/16] fix: remove hardcoded master_debug flag from api_calls.py (#28) Remove master_debug = True and replace all conditional INFO/DEBUG log calls with unconditional logger.debug(). Also switches log_return_value to logger.debug and removes the associated dead commented-out code. Co-Authored-By: Claude Sonnet 4.6 --- api_calls.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/api_calls.py b/api_calls.py index bb969b4..7cf2b31 100644 --- a/api_calls.py +++ b/api_calls.py @@ -17,7 +17,6 @@ DB_URL = ( if "prod" in ENV_DATABASE else "https://pddev.manticorum.com/api" ) -master_debug = True PLAYER_CACHE = {} logger = logging.getLogger("discord_app") @@ -53,14 +52,10 @@ def log_return_value(log_string: str): line = log_string[start:end] if len(line) == 0: return - logger.info(f"{'\n\nreturn: ' if start == 0 else ''}{log_string[start:end]}") + logger.debug(f"{'\n\nreturn: ' if start == 0 else ''}{log_string[start:end]}") start += 3000 end += 3000 logger.warning("[ S N I P P E D ]") - # if master_debug: - # logger.info(f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n') - # else: - # logger.debug(f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n') async def db_get( @@ -93,7 +88,7 @@ async def db_get( """ req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params) log_string = f"db_get - get: {endpoint} id: {object_id} params: {params}" - logger.info(log_string) if master_debug else logger.debug(log_string) + logger.debug(log_string) for attempt in range(retries): try: @@ -150,7 +145,7 @@ async def db_patch( """ req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params) log_string = f"db_patch - patch: {endpoint} {params}" - logger.info(log_string) if master_debug else logger.debug(log_string) + logger.debug(log_string) try: client_timeout = ClientTimeout(total=timeout) @@ -192,7 +187,7 @@ async def db_post( """ req_url = get_req_url(endpoint, api_ver=api_ver) log_string = f"db_post - post: {endpoint} payload: {payload}\ntype: {type(payload)}" - logger.info(log_string) if master_debug else logger.debug(log_string) + logger.debug(log_string) try: client_timeout = ClientTimeout(total=timeout) @@ -234,7 +229,7 @@ async def db_put( """ req_url = get_req_url(endpoint, api_ver=api_ver) log_string = f"db_put - put: {endpoint} payload: {payload}\ntype: {type(payload)}" - logger.info(log_string) if master_debug else logger.debug(log_string) + logger.debug(log_string) try: client_timeout = ClientTimeout(total=timeout) @@ -274,7 +269,7 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout: in """ req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id) log_string = f"db_delete - delete: {endpoint} {object_id}" - logger.info(log_string) if master_debug else logger.debug(log_string) + logger.debug(log_string) try: client_timeout = ClientTimeout(total=timeout)