diff --git a/discord_ui/selectors.py b/discord_ui/selectors.py index 0278945..0bd3243 100644 --- a/discord_ui/selectors.py +++ b/discord_ui/selectors.py @@ -3,126 +3,148 @@ Discord Select UI components. Contains all Select classes for various team, cardset, and pack selections. """ + import logging import discord from typing import Literal, Optional from helpers.constants import ALL_MLB_TEAMS, IMAGES, normalize_franchise -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") # Team name to ID mappings AL_TEAM_IDS = { - 'Baltimore Orioles': 3, - 'Boston Red Sox': 4, - 'Chicago White Sox': 6, - 'Cleveland Guardians': 8, - 'Detroit Tigers': 10, - 'Houston Astros': 11, - 'Kansas City Royals': 12, - 'Los Angeles Angels': 13, - 'Minnesota Twins': 17, - 'New York Yankees': 19, - 'Oakland Athletics': 20, - 'Athletics': 20, # Alias for post-Oakland move - 'Seattle Mariners': 24, - 'Tampa Bay Rays': 27, - 'Texas Rangers': 28, - 'Toronto Blue Jays': 29 + "Baltimore Orioles": 3, + "Boston Red Sox": 4, + "Chicago White Sox": 6, + "Cleveland Guardians": 8, + "Detroit Tigers": 10, + "Houston Astros": 11, + "Kansas City Royals": 12, + "Los Angeles Angels": 13, + "Minnesota Twins": 17, + "New York Yankees": 19, + "Oakland Athletics": 20, + "Athletics": 20, # Alias for post-Oakland move + "Seattle Mariners": 24, + "Tampa Bay Rays": 27, + "Texas Rangers": 28, + "Toronto Blue Jays": 29, } NL_TEAM_IDS = { - 'Arizona Diamondbacks': 1, - 'Atlanta Braves': 2, - 'Chicago Cubs': 5, - 'Cincinnati Reds': 7, - 'Colorado Rockies': 9, - 'Los Angeles Dodgers': 14, - 'Miami Marlins': 15, - 'Milwaukee Brewers': 16, - 'New York Mets': 18, - 'Philadelphia Phillies': 21, - 'Pittsburgh Pirates': 22, - 'San Diego Padres': 23, - 'San Francisco Giants': 25, - 'St Louis Cardinals': 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals' - 'Washington Nationals': 30 + "Arizona Diamondbacks": 1, + "Atlanta Braves": 2, + "Chicago Cubs": 5, + "Cincinnati Reds": 7, + "Colorado Rockies": 9, + "Los Angeles Dodgers": 14, + "Miami Marlins": 15, + "Milwaukee Brewers": 16, + "New York Mets": 18, + "Philadelphia Phillies": 21, + "Pittsburgh Pirates": 22, + "San Diego Padres": 23, + "San Francisco Giants": 25, + "St Louis Cardinals": 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals' + "Washington Nationals": 30, } # Get AL teams from constants AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS] -NL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in NL_TEAM_IDS or team == 'St Louis Cardinals'] +NL_TEAMS = [ + team + for team in ALL_MLB_TEAMS.keys() + if team in NL_TEAM_IDS or team == "St Louis Cardinals" +] # Cardset mappings CARDSET_LABELS_TO_IDS = { - '2022 Season': 3, - '2022 Promos': 4, - '2021 Season': 1, - '2019 Season': 5, - '2013 Season': 6, - '2012 Season': 7, - 'Mario Super Sluggers': 8, - '2023 Season': 9, - '2016 Season': 11, - '2008 Season': 12, - '2018 Season': 13, - '2024 Season': 17, - '2024 Promos': 18, - '1998 Season': 20, - '2025 Season': 24, - '2005 Live': 27, - 'Pokemon - Brilliant Stars': 23 + "2022 Season": 3, + "2022 Promos": 4, + "2021 Season": 1, + "2019 Season": 5, + "2013 Season": 6, + "2012 Season": 7, + "Mario Super Sluggers": 8, + "2023 Season": 9, + "2016 Season": 11, + "2008 Season": 12, + "2018 Season": 13, + "2024 Season": 17, + "2024 Promos": 18, + "1998 Season": 20, + "2025 Season": 24, + "2005 Live": 27, + "Pokemon - Brilliant Stars": 23, } -def _get_team_id(team_name: str, league: Literal['AL', 'NL']) -> int: +def _get_team_id(team_name: str, league: Literal["AL", "NL"]) -> int: """Get team ID from team name and league.""" - if league == 'AL': + if league == "AL": return AL_TEAM_IDS.get(team_name) else: # Handle the St. Louis Cardinals special case - if team_name == 'St. Louis Cardinals': - return NL_TEAM_IDS.get('St Louis Cardinals') + if team_name == "St. Louis Cardinals": + return NL_TEAM_IDS.get("St Louis Cardinals") return NL_TEAM_IDS.get(team_name) class SelectChoicePackTeam(discord.ui.Select): - def __init__(self, which: Literal['AL', 'NL'], team, cardset_id: Optional[int] = None): + def __init__( + self, which: Literal["AL", "NL"], team, cardset_id: Optional[int] = None + ): self.which = which self.owner_team = team self.cardset_id = cardset_id - - if which == 'AL': + + if which == "AL": options = [discord.SelectOption(label=team) for team in AL_TEAMS] else: # Handle St. Louis Cardinals display name - options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) - for team in NL_TEAMS] - - super().__init__(placeholder=f'Select an {which} team', options=options) + options = [ + discord.SelectOption( + label=( + "St. Louis Cardinals" if team == "St Louis Cardinals" else team + ) + ) + for team in NL_TEAMS + ] + + super().__init__(placeholder=f"Select an {which} team", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_get, db_patch from helpers import open_choice_pack - + team_id = _get_team_id(self.values[0], self.which) if team_id is None: - raise ValueError(f'Unknown team: {self.values[0]}') + raise ValueError(f"Unknown team: {self.values[0]}") - await interaction.response.edit_message(content=f'You selected the **{self.values[0]}**', view=None) + await interaction.response.edit_message( + content=f"You selected the **{self.values[0]}**", view=None + ) # Get the selected packs params = [ - ('pack_type_id', 8), ('team_id', self.owner_team['id']), ('opened', False), ('limit', 1), - ('exact_match', True) + ("pack_type_id", 8), + ("team_id", self.owner_team["id"]), + ("opened", False), + ("limit", 1), + ("exact_match", True), ] if self.cardset_id is not None: - params.append(('pack_cardset_id', self.cardset_id)) - p_query = await db_get('packs', params=params) - if p_query['count'] == 0: - logger.error(f'open-packs - no packs found with params: {params}') - raise ValueError(f'Unable to open packs') + params.append(("pack_cardset_id", self.cardset_id)) + p_query = await db_get("packs", params=params) + if p_query["count"] == 0: + logger.error(f"open-packs - no packs found with params: {params}") + raise ValueError("Unable to open packs") - this_pack = await db_patch('packs', object_id=p_query['packs'][0]['id'], params=[('pack_team_id', team_id)]) + this_pack = await db_patch( + "packs", + object_id=p_query["packs"][0]["id"], + params=[("pack_team_id", team_id)], + ) await open_choice_pack(this_pack, self.owner_team, interaction, self.cardset_id) @@ -130,104 +152,116 @@ class SelectChoicePackTeam(discord.ui.Select): class SelectOpenPack(discord.ui.Select): def __init__(self, options: list, team: dict): self.owner_team = team - super().__init__(placeholder='Select a Pack Type', options=options) + super().__init__(placeholder="Select a Pack Type", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_get from helpers import open_st_pr_packs, open_choice_pack - - logger.info(f'SelectPackChoice - selection: {self.values[0]}') - pack_vals = self.values[0].split('-') - logger.info(f'pack_vals: {pack_vals}') + + logger.info(f"SelectPackChoice - selection: {self.values[0]}") + pack_vals = self.values[0].split("-") + logger.info(f"pack_vals: {pack_vals}") # Get the selected packs - params = [('team_id', self.owner_team['id']), ('opened', False), ('limit', 5), ('exact_match', True)] + params = [ + ("team_id", self.owner_team["id"]), + ("opened", False), + ("limit", 20), + ("exact_match", True), + ] - open_type = 'standard' - if 'Standard' in pack_vals: - open_type = 'standard' - params.append(('pack_type_id', 1)) - elif 'Premium' in pack_vals: - open_type = 'standard' - params.append(('pack_type_id', 3)) - elif 'Daily' in pack_vals: - params.append(('pack_type_id', 4)) - elif 'Promo Choice' in pack_vals: - open_type = 'choice' - params.append(('pack_type_id', 9)) - elif 'MVP' in pack_vals: - open_type = 'choice' - params.append(('pack_type_id', 5)) - elif 'All Star' in pack_vals: - open_type = 'choice' - params.append(('pack_type_id', 6)) - elif 'Mario' in pack_vals: - open_type = 'choice' - params.append(('pack_type_id', 7)) - elif 'Team Choice' in pack_vals: - open_type = 'choice' - params.append(('pack_type_id', 8)) + open_type = "standard" + if "Standard" in pack_vals: + open_type = "standard" + params.append(("pack_type_id", 1)) + elif "Premium" in pack_vals: + open_type = "standard" + params.append(("pack_type_id", 3)) + elif "Daily" in pack_vals: + params.append(("pack_type_id", 4)) + elif "Promo Choice" in pack_vals: + open_type = "choice" + params.append(("pack_type_id", 9)) + elif "MVP" in pack_vals: + open_type = "choice" + params.append(("pack_type_id", 5)) + elif "All Star" in pack_vals: + open_type = "choice" + params.append(("pack_type_id", 6)) + elif "Mario" in pack_vals: + open_type = "choice" + params.append(("pack_type_id", 7)) + elif "Team Choice" in pack_vals: + open_type = "choice" + params.append(("pack_type_id", 8)) else: - raise KeyError(f'Cannot identify pack details: {pack_vals}') + raise KeyError(f"Cannot identify pack details: {pack_vals}") # If team isn't already set on team choice pack, make team pack selection now await interaction.response.edit_message(view=None) cardset_id = None # Handle Team Choice packs with no team/cardset assigned - if 'Team Choice' in pack_vals and 'Team' not in pack_vals and 'Cardset' not in pack_vals: + if ( + "Team Choice" in pack_vals + and "Team" not in pack_vals + and "Cardset" not in pack_vals + ): await interaction.followup.send( - content='This Team Choice pack needs to be assigned a team and cardset. ' - 'Please contact an admin to configure this pack.', - ephemeral=True + content="This Team Choice pack needs to be assigned a team and cardset. " + "Please contact an admin to configure this pack.", + ephemeral=True, ) return - elif 'Team Choice' in pack_vals and 'Cardset' in pack_vals: + elif "Team Choice" in pack_vals and "Cardset" in pack_vals: # cardset_id = pack_vals[2] - cardset_index = pack_vals.index('Cardset') + cardset_index = pack_vals.index("Cardset") cardset_id = pack_vals[cardset_index + 1] - params.append(('pack_cardset_id', cardset_id)) - if 'Team' not in pack_vals: + params.append(("pack_cardset_id", cardset_id)) + if "Team" not in pack_vals: view = SelectView( - [SelectChoicePackTeam('AL', self.owner_team, cardset_id), - SelectChoicePackTeam('NL', self.owner_team, cardset_id)], - timeout=30 + [ + SelectChoicePackTeam("AL", self.owner_team, cardset_id), + SelectChoicePackTeam("NL", self.owner_team, cardset_id), + ], + timeout=30, ) await interaction.followup.send( - content='Please select a team for your Team Choice pack:', - view=view + content="Please select a team for your Team Choice pack:", view=view ) return - - params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1])) - else: - if 'Team' in pack_vals: - params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1])) - if 'Cardset' in pack_vals: - cardset_id = pack_vals[pack_vals.index('Cardset') + 1] - params.append(('pack_cardset_id', cardset_id)) - p_query = await db_get('packs', params=params) - if p_query['count'] == 0: - logger.error(f'open-packs - no packs found with params: {params}') + params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1])) + else: + if "Team" in pack_vals: + params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1])) + if "Cardset" in pack_vals: + cardset_id = pack_vals[pack_vals.index("Cardset") + 1] + params.append(("pack_cardset_id", cardset_id)) + + p_query = await db_get("packs", params=params) + if p_query["count"] == 0: + logger.error(f"open-packs - no packs found with params: {params}") await interaction.followup.send( - content='Unable to find the selected pack. Please contact an admin.', - ephemeral=True + content="Unable to find the selected pack. Please contact an admin.", + ephemeral=True, ) return # Open the packs try: - if open_type == 'standard': - await open_st_pr_packs(p_query['packs'], self.owner_team, interaction) - elif open_type == 'choice': - await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id) + if open_type == "standard": + await open_st_pr_packs(p_query["packs"], self.owner_team, interaction) + elif open_type == "choice": + await open_choice_pack( + p_query["packs"][0], self.owner_team, interaction, cardset_id + ) except Exception as e: - logger.error(f'Failed to open pack: {e}') + logger.error(f"Failed to open pack: {e}") await interaction.followup.send( - content=f'Failed to open pack. Please contact an admin. Error: {str(e)}', - ephemeral=True + content=f"Failed to open pack. Please contact an admin. Error: {str(e)}", + ephemeral=True, ) return @@ -235,275 +269,317 @@ class SelectOpenPack(discord.ui.Select): class SelectPaperdexCardset(discord.ui.Select): def __init__(self): options = [ - discord.SelectOption(label='2005 Live'), - discord.SelectOption(label='2025 Season'), - discord.SelectOption(label='1998 Season'), - discord.SelectOption(label='2024 Season'), - discord.SelectOption(label='2023 Season'), - discord.SelectOption(label='2022 Season'), - discord.SelectOption(label='2022 Promos'), - discord.SelectOption(label='2021 Season'), - discord.SelectOption(label='2019 Season'), - discord.SelectOption(label='2018 Season'), - discord.SelectOption(label='2016 Season'), - discord.SelectOption(label='2013 Season'), - discord.SelectOption(label='2012 Season'), - discord.SelectOption(label='2008 Season'), - discord.SelectOption(label='Mario Super Sluggers') + discord.SelectOption(label="2005 Live"), + discord.SelectOption(label="2025 Season"), + discord.SelectOption(label="1998 Season"), + discord.SelectOption(label="2024 Season"), + discord.SelectOption(label="2023 Season"), + discord.SelectOption(label="2022 Season"), + discord.SelectOption(label="2022 Promos"), + discord.SelectOption(label="2021 Season"), + discord.SelectOption(label="2019 Season"), + discord.SelectOption(label="2018 Season"), + discord.SelectOption(label="2016 Season"), + discord.SelectOption(label="2013 Season"), + discord.SelectOption(label="2012 Season"), + discord.SelectOption(label="2008 Season"), + discord.SelectOption(label="Mario Super Sluggers"), ] - super().__init__(placeholder='Select a Cardset', options=options) + super().__init__(placeholder="Select a Cardset", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_get from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination - - logger.info(f'SelectPaperdexCardset - selection: {self.values[0]}') + + logger.info(f"SelectPaperdexCardset - selection: {self.values[0]}") cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0]) if cardset_id is None: - raise ValueError(f'Unknown cardset: {self.values[0]}') + raise ValueError(f"Unknown cardset: {self.values[0]}") - c_query = await db_get('cardsets', object_id=cardset_id, none_okay=False) - await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None) + c_query = await db_get("cardsets", object_id=cardset_id, none_okay=False) + await interaction.response.edit_message( + content="Okay, sifting through your cards...", view=None + ) cardset_embeds = await paperdex_cardset_embed( - team=await get_team_by_owner(interaction.user.id), - this_cardset=c_query + team=await get_team_by_owner(interaction.user.id), this_cardset=c_query ) await embed_pagination(cardset_embeds, interaction.channel, interaction.user) class SelectPaperdexTeam(discord.ui.Select): - def __init__(self, which: Literal['AL', 'NL']): + def __init__(self, which: Literal["AL", "NL"]): self.which = which - - if which == 'AL': + + if which == "AL": options = [discord.SelectOption(label=team) for team in AL_TEAMS] else: # Handle St. Louis Cardinals display name - options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) - for team in NL_TEAMS] - - super().__init__(placeholder=f'Select an {which} team', options=options) + options = [ + discord.SelectOption( + label=( + "St. Louis Cardinals" if team == "St Louis Cardinals" else team + ) + ) + for team in NL_TEAMS + ] + + super().__init__(placeholder=f"Select an {which} team", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_get from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination - + team_id = _get_team_id(self.values[0], self.which) if team_id is None: - raise ValueError(f'Unknown team: {self.values[0]}') + raise ValueError(f"Unknown team: {self.values[0]}") - t_query = await db_get('teams', object_id=team_id, none_okay=False) - await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None) + t_query = await db_get("teams", object_id=team_id, none_okay=False) + await interaction.response.edit_message( + content="Okay, sifting through your cards...", view=None + ) - team_embeds = await paperdex_team_embed(team=await get_team_by_owner(interaction.user.id), mlb_team=t_query) + team_embeds = await paperdex_team_embed( + team=await get_team_by_owner(interaction.user.id), mlb_team=t_query + ) await embed_pagination(team_embeds, interaction.channel, interaction.user) class SelectBuyPacksCardset(discord.ui.Select): - def __init__(self, team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, cost: int): + def __init__( + self, + team: dict, + quantity: int, + pack_type_id: int, + pack_embed: discord.Embed, + cost: int, + ): options = [ - discord.SelectOption(label='2005 Live'), - discord.SelectOption(label='2025 Season'), - discord.SelectOption(label='1998 Season'), - discord.SelectOption(label='Pokemon - Brilliant Stars'), - discord.SelectOption(label='2024 Season'), - discord.SelectOption(label='2023 Season'), - discord.SelectOption(label='2022 Season'), - discord.SelectOption(label='2021 Season'), - discord.SelectOption(label='2019 Season'), - discord.SelectOption(label='2018 Season'), - discord.SelectOption(label='2016 Season'), - discord.SelectOption(label='2013 Season'), - discord.SelectOption(label='2012 Season'), - discord.SelectOption(label='2008 Season') + discord.SelectOption(label="2005 Live"), + discord.SelectOption(label="2025 Season"), + discord.SelectOption(label="1998 Season"), + discord.SelectOption(label="Pokemon - Brilliant Stars"), + discord.SelectOption(label="2024 Season"), + discord.SelectOption(label="2023 Season"), + discord.SelectOption(label="2022 Season"), + discord.SelectOption(label="2021 Season"), + discord.SelectOption(label="2019 Season"), + discord.SelectOption(label="2018 Season"), + discord.SelectOption(label="2016 Season"), + discord.SelectOption(label="2013 Season"), + discord.SelectOption(label="2012 Season"), + discord.SelectOption(label="2008 Season"), ] self.team = team self.quantity = quantity self.pack_type_id = pack_type_id self.pack_embed = pack_embed self.cost = cost - super().__init__(placeholder='Select a Cardset', options=options) + super().__init__(placeholder="Select a Cardset", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_post from discord_ui.confirmations import Confirm - - logger.info(f'SelectBuyPacksCardset - selection: {self.values[0]}') + + logger.info(f"SelectBuyPacksCardset - selection: {self.values[0]}") cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0]) if cardset_id is None: - raise ValueError(f'Unknown cardset: {self.values[0]}') - - if self.values[0] == 'Pokemon - Brilliant Stars': - self.pack_embed.set_image(url=IMAGES['pack-pkmnbs']) + raise ValueError(f"Unknown cardset: {self.values[0]}") - self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}' + if self.values[0] == "Pokemon - Brilliant Stars": + self.pack_embed.set_image(url=IMAGES["pack-pkmnbs"]) + + self.pack_embed.description = ( + f"{self.pack_embed.description} - {self.values[0]}" + ) view = Confirm(responders=[interaction.user], timeout=30) await interaction.response.edit_message( - content=None, - embed=self.pack_embed, - view=None + content=None, embed=self.pack_embed, view=None ) question = await interaction.channel.send( - content=f'Your Wallet: {self.team["wallet"]}₼\n' - f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}₼\n' - f'After Purchase: {self.team["wallet"] - self.cost}₼\n\n' - f'Would you like to make this purchase?', - view=view + content=f"Your Wallet: {self.team['wallet']}₼\n" + f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}₼\n" + f"After Purchase: {self.team['wallet'] - self.cost}₼\n\n" + f"Would you like to make this purchase?", + view=view, ) await view.wait() if not view.value: - await question.edit( - content='Saving that money. Smart.', - view=None - ) + await question.edit(content="Saving that money. Smart.", view=None) return p_model = { - 'team_id': self.team['id'], - 'pack_type_id': self.pack_type_id, - 'pack_cardset_id': cardset_id + "team_id": self.team["id"], + "pack_type_id": self.pack_type_id, + "pack_cardset_id": cardset_id, } - await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]}) - await db_post(f'teams/{self.team["id"]}/money/-{self.cost}') + await db_post( + "packs", payload={"packs": [p_model for x in range(self.quantity)]} + ) + await db_post(f"teams/{self.team['id']}/money/-{self.cost}") await question.edit( - content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', - view=None + content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`", + view=None, ) class SelectBuyPacksTeam(discord.ui.Select): def __init__( - self, which: Literal['AL', 'NL'], team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, - cost: int): + self, + which: Literal["AL", "NL"], + team: dict, + quantity: int, + pack_type_id: int, + pack_embed: discord.Embed, + cost: int, + ): self.which = which self.team = team self.quantity = quantity self.pack_type_id = pack_type_id self.pack_embed = pack_embed self.cost = cost - - if which == 'AL': + + if which == "AL": options = [discord.SelectOption(label=team) for team in AL_TEAMS] else: # Handle St. Louis Cardinals display name - options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) - for team in NL_TEAMS] - - super().__init__(placeholder=f'Select an {which} team', options=options) + options = [ + discord.SelectOption( + label=( + "St. Louis Cardinals" if team == "St Louis Cardinals" else team + ) + ) + for team in NL_TEAMS + ] + + super().__init__(placeholder=f"Select an {which} team", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_post from discord_ui.confirmations import Confirm - + team_id = _get_team_id(self.values[0], self.which) if team_id is None: - raise ValueError(f'Unknown team: {self.values[0]}') + raise ValueError(f"Unknown team: {self.values[0]}") - self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}' + self.pack_embed.description = ( + f"{self.pack_embed.description} - {self.values[0]}" + ) view = Confirm(responders=[interaction.user], timeout=30) await interaction.response.edit_message( - content=None, - embed=self.pack_embed, - view=None + content=None, embed=self.pack_embed, view=None ) question = await interaction.channel.send( - content=f'Your Wallet: {self.team["wallet"]}₼\n' - f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}₼\n' - f'After Purchase: {self.team["wallet"] - self.cost}₼\n\n' - f'Would you like to make this purchase?', - view=view + content=f"Your Wallet: {self.team['wallet']}₼\n" + f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}₼\n" + f"After Purchase: {self.team['wallet'] - self.cost}₼\n\n" + f"Would you like to make this purchase?", + view=view, ) await view.wait() if not view.value: - await question.edit( - content='Saving that money. Smart.', - view=None - ) + await question.edit(content="Saving that money. Smart.", view=None) return p_model = { - 'team_id': self.team['id'], - 'pack_type_id': self.pack_type_id, - 'pack_team_id': team_id + "team_id": self.team["id"], + "pack_type_id": self.pack_type_id, + "pack_team_id": team_id, } - await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]}) - await db_post(f'teams/{self.team["id"]}/money/-{self.cost}') + await db_post( + "packs", payload={"packs": [p_model for x in range(self.quantity)]} + ) + await db_post(f"teams/{self.team['id']}/money/-{self.cost}") await question.edit( - content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', - view=None + content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`", + view=None, ) class SelectUpdatePlayerTeam(discord.ui.Select): - def __init__(self, which: Literal['AL', 'NL'], player: dict, reporting_team: dict, bot): + def __init__( + self, which: Literal["AL", "NL"], player: dict, reporting_team: dict, bot + ): self.bot = bot self.which = which self.player = player self.reporting_team = reporting_team - - if which == 'AL': + + if which == "AL": options = [discord.SelectOption(label=team) for team in AL_TEAMS] else: # Handle St. Louis Cardinals display name - options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) - for team in NL_TEAMS] - - super().__init__(placeholder=f'Select an {which} team', options=options) + options = [ + discord.SelectOption( + label=( + "St. Louis Cardinals" if team == "St Louis Cardinals" else team + ) + ) + for team in NL_TEAMS + ] + + super().__init__(placeholder=f"Select an {which} team", options=options) async def callback(self, interaction: discord.Interaction): # Import here to avoid circular imports from api_calls import db_patch, db_post from discord_ui.confirmations import Confirm from helpers import player_desc, send_to_channel - + # Check if already assigned - compare against both normalized franchise and full mlbclub normalized_selection = normalize_franchise(self.values[0]) - if normalized_selection == self.player['franchise'] or self.values[0] == self.player['mlbclub']: + if ( + normalized_selection == self.player["franchise"] + or self.values[0] == self.player["mlbclub"] + ): await interaction.response.send_message( - content=f'Thank you for the help, but it looks like somebody beat you to it! ' - f'**{player_desc(self.player)}** is already assigned to the **{self.player["mlbclub"]}**.' + content=f"Thank you for the help, but it looks like somebody beat you to it! " + f"**{player_desc(self.player)}** is already assigned to the **{self.player['mlbclub']}**." ) return view = Confirm(responders=[interaction.user], timeout=15) await interaction.response.edit_message( - content=f'Should I update **{player_desc(self.player)}**\'s team to the **{self.values[0]}**?', - view=None - ) - question = await interaction.channel.send( - content=None, - view=view + content=f"Should I update **{player_desc(self.player)}**'s team to the **{self.values[0]}**?", + view=None, ) + question = await interaction.channel.send(content=None, view=view) await view.wait() if not view.value: await question.edit( - content='That didnt\'t sound right to me, either. Let\'s not touch that.', - view=None + content="That didnt't sound right to me, either. Let's not touch that.", + view=None, ) return else: await question.delete() - await db_patch('players', object_id=self.player['player_id'], params=[ - ('mlbclub', self.values[0]), ('franchise', normalize_franchise(self.values[0])) - ]) - await db_post(f'teams/{self.reporting_team["id"]}/money/25') - await send_to_channel( - self.bot, 'pd-news-ticker', - content=f'{interaction.user.name} just updated **{player_desc(self.player)}**\'s team to the ' - f'**{self.values[0]}**' + await db_patch( + "players", + object_id=self.player["player_id"], + params=[ + ("mlbclub", self.values[0]), + ("franchise", normalize_franchise(self.values[0])), + ], ) - await interaction.channel.send(f'All done!') + await db_post(f"teams/{self.reporting_team['id']}/money/25") + await send_to_channel( + self.bot, + "pd-news-ticker", + content=f"{interaction.user.name} just updated **{player_desc(self.player)}**'s team to the " + f"**{self.values[0]}**", + ) + await interaction.channel.send("All done!") class SelectView(discord.ui.View): @@ -511,4 +587,4 @@ class SelectView(discord.ui.View): super().__init__(timeout=timeout) for x in select_objects: - self.add_item(x) \ No newline at end of file + self.add_item(x) diff --git a/helpers/main.py b/helpers/main.py index b879ea1..41347f8 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -2,35 +2,23 @@ import asyncio import datetime import logging import math -import os import random -import traceback import discord -import pygsheets import aiohttp from discord.ext import commands from api_calls import * from bs4 import BeautifulSoup -from difflib import get_close_matches -from dataclasses import dataclass -from typing import Optional, Literal, Union, List +from typing import Optional, Union, List -from exceptions import log_exception from in_game.gameplay_models import Team from constants import * from discord_ui import * from random_content import * from utils import ( - position_name_to_abbrev, - user_has_role, - get_roster_sheet_legacy, get_roster_sheet, - get_player_url, - owner_only, get_cal_user, - get_context_user, ) from search_utils import * from discord_utils import * @@ -182,7 +170,7 @@ async def get_card_embeds(card, include_stats=False) -> list: ] if any(bool_list): if count == 1: - coll_string = f"Only you" + coll_string = "Only you" else: coll_string = ( f"You and {count - 1} other{'s' if count - 1 != 1 else ''}" @@ -190,7 +178,7 @@ async def get_card_embeds(card, include_stats=False) -> list: elif count: coll_string = f"{count} other team{'s' if count != 1 else ''}" else: - coll_string = f"0 teams" + coll_string = "0 teams" embed.add_field(name="Collected By", value=coll_string) else: embed.add_field( @@ -229,7 +217,7 @@ async def get_card_embeds(card, include_stats=False) -> list: ) if evo_mon is not None: embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}") - except Exception as e: + except Exception: logging.error( "could not pull evolution: {e}", exc_info=True, stack_info=True ) @@ -240,7 +228,7 @@ async def get_card_embeds(card, include_stats=False) -> list: ) if evo_mon is not None: embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}") - except Exception as e: + except Exception: logging.error( "could not pull evolution: {e}", exc_info=True, stack_info=True ) @@ -342,7 +330,7 @@ async def display_cards( ) try: cards.sort(key=lambda x: x["player"]["rarity"]["value"]) - logger.debug(f"Cards sorted successfully") + logger.debug("Cards sorted successfully") card_embeds = [await get_card_embeds(x) for x in cards] logger.debug(f"Created {len(card_embeds)} card embeds") @@ -363,15 +351,15 @@ async def display_cards( r_emoji = "→" view.left_button.disabled = True view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}" - view.cancel_button.label = f"Close Pack" + view.cancel_button.label = "Close Pack" view.right_button.label = f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}" if len(cards) == 1: view.right_button.disabled = True - logger.debug(f"Pagination view created successfully") + logger.debug("Pagination view created successfully") if pack_cover: - logger.debug(f"Sending pack cover message") + logger.debug("Sending pack cover message") msg = await channel.send( content=None, embed=image_embed(pack_cover, title=f"{team['lname']}", desc=pack_name), @@ -383,7 +371,7 @@ async def display_cards( content=None, embeds=card_embeds[page_num], view=view ) - logger.debug(f"Initial message sent successfully") + logger.debug("Initial message sent successfully") except Exception as e: logger.error( f"Error creating view or sending initial message: {e}", exc_info=True @@ -400,12 +388,12 @@ async def display_cards( f"{user.mention} you've got {len(cards)} cards here" ) - logger.debug(f"Follow-up message sent successfully") + logger.debug("Follow-up message sent successfully") except Exception as e: logger.error(f"Error sending follow-up message: {e}", exc_info=True) return False - logger.debug(f"Starting main interaction loop") + logger.debug("Starting main interaction loop") while True: try: logger.debug(f"Waiting for user interaction on page {page_num}") @@ -471,7 +459,7 @@ async def display_cards( ), view=view, ) - logger.debug(f"MVP display updated successfully") + logger.debug("MVP display updated successfully") except Exception as e: logger.error( f"Error processing shiny card on page {page_num}: {e}", exc_info=True @@ -479,19 +467,19 @@ async def display_cards( # Continue with regular flow instead of crashing try: tmp_msg = await channel.send( - content=f"<@&1163537676885033010> we've got an MVP!" + content="<@&1163537676885033010> we've got an MVP!" ) await follow_up.edit( - content=f"<@&1163537676885033010> we've got an MVP!" + content="<@&1163537676885033010> we've got an MVP!" ) await tmp_msg.delete() except discord.errors.NotFound: # Role might not exist or message was already deleted - await follow_up.edit(content=f"We've got an MVP!") + await follow_up.edit(content="We've got an MVP!") except Exception as e: # Log error but don't crash the function logger.error(f"Error handling MVP notification: {e}") - await follow_up.edit(content=f"We've got an MVP!") + await follow_up.edit(content="We've got an MVP!") await view.wait() view = Pagination([user], timeout=10) @@ -499,7 +487,7 @@ async def display_cards( view.right_button.label = ( f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}" ) - view.cancel_button.label = f"Close Pack" + view.cancel_button.label = "Close Pack" view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(card_embeds)}" if page_num == 0: view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}" @@ -547,7 +535,7 @@ async def embed_pagination( l_emoji = "" r_emoji = "" view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}" - view.cancel_button.label = f"Cancel" + view.cancel_button.label = "Cancel" view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}" if page_num == 0: view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}" @@ -582,7 +570,7 @@ async def embed_pagination( view = Pagination([user], timeout=timeout) view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}" - view.cancel_button.label = f"Cancel" + view.cancel_button.label = "Cancel" view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}" if page_num == 0: view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}" @@ -661,21 +649,15 @@ async def get_test_pack(ctx, team): async def roll_for_cards(all_packs: list, extra_val=None) -> list: + """Open packs by rolling dice, fetching random players, and creating cards. + + Parallelizes DB calls: one fetch per rarity tier across all packs, + then gathers all card creates and pack patches concurrently. """ - Pack odds are calculated based on the pack type - - Parameters - ---------- - extra_val - all_packs - - Returns - ------- - - """ - all_players = [] team = all_packs[0]["team"] - pack_ids = [] + + # --- Phase A: Roll dice for every pack (CPU-only, no I/O) --- + pack_counts = [] for pack in all_packs: counts = { "Rep": {"count": 0, "rarity": 0}, @@ -685,10 +667,9 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list: "MVP": {"count": 0, "rarity": 5}, "HoF": {"count": 0, "rarity": 8}, } - this_pack_players = [] if pack["pack_type"]["name"] == "Standard": # Cards 1 - 2 - for x in range(2): + for _ in range(2): d_1000 = random.randint(1, 1000) if d_1000 <= 450: counts["Rep"]["count"] += 1 @@ -808,7 +789,6 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list: logger.info( f"Building Check-In Pack // extra_val (type): {extra_val} {type(extra_val)}" ) - # Single Card mod = 0 if isinstance(extra_val, int): mod = extra_val @@ -826,106 +806,195 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list: else: raise TypeError(f"Pack type not recognized: {pack['pack_type']['name']}") - pull_notifs = [] - for key in counts: - mvp_flag = None + pack_counts.append(counts) - if counts[key]["count"] > 0: - params = [ - ("min_rarity", counts[key]["rarity"]), - ("max_rarity", counts[key]["rarity"]), - ("limit", counts[key]["count"]), - ] - if all_packs[0]["pack_team"] is not None: - params.extend( - [ - ("franchise", all_packs[0]["pack_team"]["sname"]), - ("in_packs", True), - ] - ) - elif all_packs[0]["pack_cardset"] is not None: - params.append(("cardset_id", all_packs[0]["pack_cardset"]["id"])) - else: - params.append(("in_packs", True)) + # --- Phase B: Fetch players — one call per rarity tier, all gathered --- + # Sum counts across all packs per rarity tier + rarity_keys = ["Rep", "Res", "Sta", "All", "MVP", "HoF"] + summed = {key: 0 for key in rarity_keys} + for counts in pack_counts: + for key in rarity_keys: + summed[key] += counts[key]["count"] - pl = await db_get("players/random", params=params) - - if pl["count"] != counts[key]["count"]: - mvp_flag = counts[key]["count"] - pl["count"] - logging.info( - f"Set mvp flag to {mvp_flag} / cardset_id: {all_packs[0]['pack_cardset']['id']}" - ) - - for x in pl["players"]: - this_pack_players.append(x) - all_players.append(x) - - if x["rarity"]["value"] >= 3: - pull_notifs.append(x) - - if mvp_flag and all_packs[0]["pack_cardset"]["id"] not in [23]: - logging.info(f"Adding {mvp_flag} MVPs for missing cards") - pl = await db_get( - "players/random", params=[("min_rarity", 5), ("limit", mvp_flag)] - ) - - for x in pl["players"]: - this_pack_players.append(x) - all_players.append(x) - - # Add dupes of Replacement/Reserve cards - elif mvp_flag: - logging.info(f"Adding {mvp_flag} duplicate pokemon cards") - for count in range(mvp_flag): - logging.info(f"Adding {pl['players'][0]['p_name']} to the pack") - this_pack_players.append(x) - all_players.append(pl["players"][0]) - - success = await db_post( - "cards", - payload={ - "cards": [ - { - "player_id": x["player_id"], - "team_id": pack["team"]["id"], - "pack_id": pack["id"], - } - for x in this_pack_players - ] - }, - timeout=10, + # Build shared filter params + base_params = [] + if all_packs[0]["pack_team"] is not None: + base_params.extend( + [ + ("franchise", all_packs[0]["pack_team"]["sname"]), + ("in_packs", True), + ] ) - if not success: - raise ConnectionError(f"Failed to create this pack of cards.") + elif all_packs[0]["pack_cardset"] is not None: + base_params.append(("cardset_id", all_packs[0]["pack_cardset"]["id"])) + else: + base_params.append(("in_packs", True)) - await db_patch( - "packs", - object_id=pack["id"], - params=[ - ( - "open_time", - int(datetime.datetime.timestamp(datetime.datetime.now()) * 1000), - ) - ], - ) - pack_ids.append(pack["id"]) + # Fire one request per non-zero rarity tier concurrently + rarity_values = { + "Rep": 0, + "Res": 1, + "Sta": 2, + "All": 3, + "MVP": 5, + "HoF": 8, + } + fetch_keys = [key for key in rarity_keys if summed[key] > 0] + fetch_coros = [] + for key in fetch_keys: + params = [ + ("min_rarity", rarity_values[key]), + ("max_rarity", rarity_values[key]), + ("limit", summed[key]), + ] + base_params + fetch_coros.append(db_get("players/random", params=params)) - for pull in pull_notifs: - logger.info(f"good pull: {pull}") - await db_post( - "notifs", - payload={ - "created": int( - datetime.datetime.timestamp(datetime.datetime.now()) * 1000 - ), - "title": "Rare Pull", - "field_name": f"{player_desc(pull)} ({pull['rarity']['name']})", - "message": f"Pulled by {team['abbrev']}", - "about": f"Player-{pull['player_id']}", - }, + fetch_results = await asyncio.gather(*fetch_coros) + + # Map results back: rarity key -> list of players + fetched_players = {} + for key, result in zip(fetch_keys, fetch_results): + fetched_players[key] = result.get("players", []) + + # Handle shortfalls — collect total MVP backfill needed + total_mvp_shortfall = 0 + # Track per-tier shortfall for dupe-branch (cardset 23 exclusion) + tier_shortfalls = {} + for key in fetch_keys: + returned = len(fetched_players[key]) + requested = summed[key] + if returned < requested: + shortfall = requested - returned + tier_shortfalls[key] = shortfall + total_mvp_shortfall += shortfall + logging.info( + f"Shortfall in {key}: requested {requested}, got {returned} " + f"(cardset_id: {all_packs[0]['pack_cardset']['id'] if all_packs[0]['pack_cardset'] else 'N/A'})" ) - return pack_ids + # Fetch MVP backfill or duplicate existing players + backfill_players = [] + is_dupe_cardset = all_packs[0]["pack_cardset"] is not None and all_packs[0][ + "pack_cardset" + ]["id"] in [23] + if total_mvp_shortfall > 0 and not is_dupe_cardset: + logging.info(f"Adding {total_mvp_shortfall} MVPs for missing cards") + mvp_result = await db_get( + "players/random", + params=[("min_rarity", 5), ("limit", total_mvp_shortfall)], + ) + backfill_players = mvp_result.get("players", []) + elif total_mvp_shortfall > 0 and is_dupe_cardset: + logging.info( + f"Adding {total_mvp_shortfall} duplicate cards for excluded cardset" + ) + # Duplicate from first available player in the fetched results + for key in fetch_keys: + if fetched_players[key]: + for _ in range(total_mvp_shortfall): + backfill_players.append(fetched_players[key][0]) + break + + # Slice fetched players back into per-pack groups + # Track consumption offset per rarity tier + tier_offsets = {key: 0 for key in rarity_keys} + backfill_offset = 0 + per_pack_players = [] + all_pull_notifs = [] + + for pack_idx, counts in enumerate(pack_counts): + this_pack_players = [] + pack_shortfall = 0 + + for key in rarity_keys: + needed = counts[key]["count"] + if needed == 0: + continue + + available = fetched_players.get(key, []) + start = tier_offsets[key] + end = start + needed + got = available[start:end] + this_pack_players.extend(got) + tier_offsets[key] = end + + # Track shortfall for this pack + if len(got) < needed: + pack_shortfall += needed - len(got) + + # Distribute backfill players to this pack + if pack_shortfall > 0 and backfill_offset < len(backfill_players): + bf_slice = backfill_players[ + backfill_offset : backfill_offset + pack_shortfall + ] + this_pack_players.extend(bf_slice) + backfill_offset += len(bf_slice) + + # Collect rare pull notifications + for player in this_pack_players: + if player["rarity"]["value"] >= 3: + all_pull_notifs.append(player) + + per_pack_players.append(this_pack_players) + + # --- Phase C: Write cards + mark packs opened, all gathered --- + open_time = int(datetime.datetime.timestamp(datetime.datetime.now()) * 1000) + + write_coros = [] + for pack, this_pack_players in zip(all_packs, per_pack_players): + write_coros.append( + db_post( + "cards", + payload={ + "cards": [ + { + "player_id": p["player_id"], + "team_id": pack["team"]["id"], + "pack_id": pack["id"], + } + for p in this_pack_players + ] + }, + timeout=10, + ) + ) + write_coros.append( + db_patch( + "packs", + object_id=pack["id"], + params=[("open_time", open_time)], + ) + ) + + write_results = await asyncio.gather(*write_coros) + + # Check card creation results (every other result starting at index 0) + for i in range(0, len(write_results), 2): + if not write_results[i]: + raise ConnectionError("Failed to create this pack of cards.") + + # --- Gather notification posts --- + if all_pull_notifs: + notif_coros = [] + for pull in all_pull_notifs: + logger.info(f"good pull: {pull}") + notif_coros.append( + db_post( + "notifs", + payload={ + "created": int( + datetime.datetime.timestamp(datetime.datetime.now()) * 1000 + ), + "title": "Rare Pull", + "field_name": f"{player_desc(pull)} ({pull['rarity']['name']})", + "message": f"Pulled by {team['abbrev']}", + "about": f"Player-{pull['player_id']}", + }, + ) + ) + await asyncio.gather(*notif_coros) + + return [pack["id"] for pack in all_packs] async def give_packs(team: dict, num_packs: int, pack_type: dict = None) -> dict: @@ -962,7 +1031,7 @@ def get_sheets(bot): except Exception as e: logger.error(f"Could not grab sheets auth: {e}") raise ConnectionError( - f"Bot has not authenticated with discord; please try again in 1 minute." + "Bot has not authenticated with discord; please try again in 1 minute." ) @@ -1072,7 +1141,7 @@ def get_blank_team_card(player): def get_rosters(team, bot, roster_num: Optional[int] = None) -> list: sheets = get_sheets(bot) this_sheet = sheets.open_by_key(team["gsheet"]) - r_sheet = this_sheet.worksheet_by_title(f"My Rosters") + r_sheet = this_sheet.worksheet_by_title("My Rosters") logger.debug(f"this_sheet: {this_sheet} / r_sheet = {r_sheet}") all_rosters = [None, None, None] @@ -1153,11 +1222,11 @@ def get_roster_lineups(team, bot, roster_num, lineup_num) -> list: try: lineup_cells = [(row[0].value, int(row[1].value)) for row in raw_cells] - except ValueError as e: + except ValueError: logger.error(f"Could not pull roster for {team['abbrev']} due to a ValueError") raise ValueError( - f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to " - f"get the card IDs" + "Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to " + "get the card IDs" ) logger.debug(f"lineup_cells: {lineup_cells}") @@ -1552,7 +1621,7 @@ def get_ratings_guide(sheets): } for x in p_data ] - except Exception as e: + except Exception: return {"valid": False} return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers} @@ -1764,7 +1833,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): pack_ids = await roll_for_cards(all_packs) if not pack_ids: logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}") - raise ValueError(f"I was not able to unpack these cards") + raise ValueError("I was not able to unpack these cards") all_cards = [] for p_id in pack_ids: @@ -1775,7 +1844,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): if not all_cards: logger.error(f"open_packs - unable to get cards for packs: {pack_ids}") - raise ValueError(f"I was not able to display these cards") + raise ValueError("I was not able to display these cards") # Present cards to opening channel if type(context) == commands.Context: @@ -1834,7 +1903,7 @@ async def get_choice_from_cards( view = Pagination([interaction.user], timeout=30) view.left_button.disabled = True view.left_button.label = f"Prev: -/{len(card_embeds)}" - view.cancel_button.label = f"Take This Card" + view.cancel_button.label = "Take This Card" view.cancel_button.style = discord.ButtonStyle.success view.cancel_button.disabled = True view.right_button.label = f"Next: 1/{len(card_embeds)}" @@ -1852,7 +1921,7 @@ async def get_choice_from_cards( view = Pagination([interaction.user], timeout=30) view.left_button.label = f"Prev: -/{len(card_embeds)}" view.left_button.disabled = True - view.cancel_button.label = f"Take This Card" + view.cancel_button.label = "Take This Card" view.cancel_button.style = discord.ButtonStyle.success view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}" @@ -1895,7 +1964,7 @@ async def get_choice_from_cards( view = Pagination([interaction.user], timeout=30) view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}" - view.cancel_button.label = f"Take This Card" + view.cancel_button.label = "Take This Card" view.cancel_button.style = discord.ButtonStyle.success view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}" if page_num == 1: @@ -1941,7 +2010,7 @@ async def open_choice_pack( players = pl["players"] elif pack_type == "Team Choice": if this_pack["pack_team"] is None: - raise KeyError(f"Team not listed for Team Choice pack") + raise KeyError("Team not listed for Team Choice pack") d1000 = random.randint(1, 1000) pack_cover = this_pack["pack_team"]["logo"] @@ -1980,7 +2049,7 @@ async def open_choice_pack( rarity_id += 1 elif pack_type == "Promo Choice": if this_pack["pack_cardset"] is None: - raise KeyError(f"Cardset not listed for Promo Choice pack") + raise KeyError("Cardset not listed for Promo Choice pack") d1000 = random.randint(1, 1000) pack_cover = IMAGES["mvp-hype"] @@ -2037,8 +2106,8 @@ async def open_choice_pack( rarity_id += 3 if len(players) == 0: - logger.error(f"Could not create choice pack") - raise ConnectionError(f"Could not create choice pack") + logger.error("Could not create choice pack") + raise ConnectionError("Could not create choice pack") if type(context) == commands.Context: author = context.author @@ -2061,7 +2130,7 @@ async def open_choice_pack( view = Pagination([author], timeout=30) view.left_button.disabled = True view.left_button.label = f"Prev: -/{len(card_embeds)}" - view.cancel_button.label = f"Take This Card" + view.cancel_button.label = "Take This Card" view.cancel_button.style = discord.ButtonStyle.success view.cancel_button.disabled = True view.right_button.label = f"Next: 1/{len(card_embeds)}" @@ -2079,10 +2148,10 @@ async def open_choice_pack( ) if rarity_id >= 5: tmp_msg = await pack_channel.send( - content=f"<@&1163537676885033010> we've got an MVP!" + content="<@&1163537676885033010> we've got an MVP!" ) else: - tmp_msg = await pack_channel.send(content=f"We've got a choice pack here!") + tmp_msg = await pack_channel.send(content="We've got a choice pack here!") while True: await view.wait() @@ -2097,7 +2166,7 @@ async def open_choice_pack( ) except Exception as e: logger.error(f"failed to create cards: {e}") - raise ConnectionError(f"Failed to distribute these cards.") + raise ConnectionError("Failed to distribute these cards.") await db_patch( "packs", @@ -2131,7 +2200,7 @@ async def open_choice_pack( view = Pagination([author], timeout=30) view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}" - view.cancel_button.label = f"Take This Card" + view.cancel_button.label = "Take This Card" view.cancel_button.style = discord.ButtonStyle.success view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}" if page_num == 1: diff --git a/tests/test_roll_for_cards.py b/tests/test_roll_for_cards.py new file mode 100644 index 0000000..38aff2c --- /dev/null +++ b/tests/test_roll_for_cards.py @@ -0,0 +1,530 @@ +"""Tests for roll_for_cards parallelized implementation. + +Validates dice rolling, batched player fetches, card creation, +pack marking, MVP backfill, cardset-23 dupe fallback, and notifications. +""" + +import os +from unittest.mock import AsyncMock, patch + +import pytest + + +def _make_player( + player_id, rarity_value=0, rarity_name="Replacement", p_name="Test Player" +): + """Factory for player dicts matching API shape.""" + return { + "player_id": player_id, + "rarity": {"value": rarity_value, "name": rarity_name}, + "p_name": p_name, + "description": f"2024 {p_name}", + } + + +_UNSET = object() + + +def _make_pack( + pack_id, team_id=1, pack_type="Standard", pack_team=None, pack_cardset=_UNSET +): + """Factory for pack dicts matching API shape.""" + return { + "id": pack_id, + "team": {"id": team_id, "abbrev": "TST"}, + "pack_type": {"name": pack_type}, + "pack_team": pack_team, + "pack_cardset": {"id": 10} if pack_cardset is _UNSET else pack_cardset, + } + + +def _random_response(players): + """Wrap a list of player dicts in the API response shape.""" + return {"count": len(players), "players": players} + + +@pytest.fixture +def mock_db(): + """Patch db_get, db_post, db_patch in helpers.main.""" + with ( + patch("helpers.main.db_get", new_callable=AsyncMock) as mock_get, + patch("helpers.main.db_post", new_callable=AsyncMock) as mock_post, + patch("helpers.main.db_patch", new_callable=AsyncMock) as mock_patch, + ): + mock_post.return_value = True + yield mock_get, mock_post, mock_patch + + +class TestSinglePack: + """Single pack opening — verifies basic flow.""" + + async def test_single_standard_pack_creates_cards_and_marks_opened(self, mock_db): + """A single standard pack should fetch players, create cards, and patch open_time. + + Why: Validates the core happy path — dice roll → fetch → create → mark opened. + """ + mock_get, mock_post, mock_patch = mock_db + + # Return enough players for any rarity tier requested + mock_get.return_value = _random_response([_make_player(i) for i in range(10)]) + + pack = _make_pack(100) + from helpers.main import roll_for_cards + + result = await roll_for_cards([pack]) + + assert result == [100] + # At least one db_get for player fetches + assert mock_get.call_count >= 1 + # Exactly one db_post for cards (may have notif posts too) + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 1 + # Exactly one db_patch for marking pack opened + assert mock_patch.call_count == 1 + assert mock_patch.call_args.kwargs["object_id"] == 100 + + async def test_checkin_pack_uses_extra_val(self, mock_db): + """Check-In Player packs should apply extra_val modifier to dice range. + + Why: extra_val shifts the d1000 ceiling, affecting rarity odds for check-in rewards. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(1)]) + + pack = _make_pack(200, pack_type="Check-In Player") + from helpers.main import roll_for_cards + + result = await roll_for_cards([pack], extra_val=500) + + assert result == [200] + assert mock_get.call_count >= 1 + + async def test_unknown_pack_type_raises(self, mock_db): + """Unrecognized pack types must raise TypeError. + + Why: Guards against silent failures if a new pack type is added without dice logic. + """ + mock_get, mock_post, mock_patch = mock_db + + pack = _make_pack(300, pack_type="Unknown") + from helpers.main import roll_for_cards + + with pytest.raises(TypeError, match="Pack type not recognized"): + await roll_for_cards([pack]) + + +class TestMultiplePacks: + """Multiple packs — verifies batching and distribution.""" + + async def test_multiple_packs_return_all_ids(self, mock_db): + """Opening multiple packs should return all pack IDs. + + Why: Callers use the returned IDs to know which packs were successfully opened. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(i) for i in range(50)]) + + packs = [_make_pack(i) for i in range(5)] + from helpers.main import roll_for_cards + + result = await roll_for_cards(packs) + + assert result == [0, 1, 2, 3, 4] + + async def test_multiple_packs_batch_fetches(self, mock_db): + """Multiple packs should batch fetches — one db_get per rarity tier, not per pack. + + Why: This is the core performance optimization. 5 packs should NOT make 20-30 calls. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(i) for i in range(50)]) + + packs = [_make_pack(i) for i in range(5)] + from helpers.main import roll_for_cards + + await roll_for_cards(packs) + + # Standard packs have up to 6 rarity tiers, but typically fewer are non-zero. + # The key assertion: far fewer fetches than 5 packs * ~4 tiers = 20. + player_fetches = [ + c for c in mock_get.call_args_list if c.args[0] == "players/random" + ] + # At most 6 tier fetches + possible 1 MVP backfill = 7 + assert len(player_fetches) <= 7 + + async def test_multiple_packs_create_cards_per_pack(self, mock_db): + """Each pack should get its own db_post('cards') call with correct pack_id. + + Why: Cards must be associated with the correct pack for display and tracking. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(i) for i in range(50)]) + + packs = [_make_pack(i) for i in range(3)] + from helpers.main import roll_for_cards + + await roll_for_cards(packs) + + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 3 + # Each card post should reference the correct pack_id + for i, post_call in enumerate(card_posts): + payload = post_call.kwargs["payload"] + pack_ids_in_cards = {card["pack_id"] for card in payload["cards"]} + assert pack_ids_in_cards == {i} + + +class TestMVPBackfill: + """MVP fallback when a rarity tier returns fewer players than requested.""" + + async def test_shortfall_triggers_mvp_backfill(self, mock_db): + """When a tier returns fewer players than needed, MVP backfill should fire. + + Why: Packs must always contain the expected number of cards. Shortfalls are + filled with MVP-tier players as a fallback. + """ + mock_get, mock_post, mock_patch = mock_db + + call_count = 0 + + async def side_effect(endpoint, params=None): + nonlocal call_count + call_count += 1 + if params and any( + p[0] == "min_rarity" + and p[1] == 5 + and any(q[0] == "max_rarity" for q in params) is False + for p in params + ): + # MVP backfill call (no max_rarity) + return _random_response([_make_player(900, 5, "MVP")]) + + # For tier-specific calls, check if this is the MVP backfill + if params: + param_dict = dict(params) + if "max_rarity" not in param_dict: + return _random_response([_make_player(900, 5, "MVP")]) + + # Return fewer than requested to trigger shortfall + requested = 5 + if params: + for key, val in params: + if key == "limit": + requested = val + break + return _random_response( + [_make_player(i) for i in range(max(0, requested - 1))] + ) + + mock_get.side_effect = side_effect + + pack = _make_pack(100) + from helpers.main import roll_for_cards + + result = await roll_for_cards([pack]) + + assert result == [100] + # Should have at least the tier fetch + backfill call + assert mock_get.call_count >= 2 + + +class TestCardsetExclusion: + """Cardset 23 should duplicate existing players instead of MVP backfill.""" + + async def test_cardset_23_duplicates_instead_of_mvp(self, mock_db): + """For cardset 23, shortfalls should duplicate existing players, not fetch MVPs. + + Why: Cardset 23 (special/limited cardset) shouldn't pull from the MVP pool — + it should fill gaps by duplicating from what's already available. + """ + mock_get, mock_post, mock_patch = mock_db + + async def side_effect(endpoint, params=None): + if params: + param_dict = dict(params) + # If this is a backfill call (no max_rarity), it shouldn't happen + if "max_rarity" not in param_dict: + pytest.fail("Should not make MVP backfill call for cardset 23") + # Return fewer than requested + return _random_response([_make_player(1)]) + + mock_get.side_effect = side_effect + + pack = _make_pack(100, pack_cardset={"id": 23}) + from helpers.main import roll_for_cards + + # Force specific dice rolls to ensure a shortfall + with patch("helpers.main.random.randint", return_value=1): + # d1000=1 for Standard: Rep, Rep, Rep, Rep, Rep → 5 Reps needed + result = await roll_for_cards([pack]) + + assert result == [100] + + +class TestNotifications: + """Rare pull notifications should be gathered and sent.""" + + async def test_rare_pulls_generate_notifications(self, mock_db): + """Players with rarity >= 3 should trigger notification posts. + + Why: Rare pulls are announced to the community — all notifs should be sent. + """ + mock_get, mock_post, mock_patch = mock_db + + rare_player = _make_player( + 42, rarity_value=3, rarity_name="All-Star", p_name="Mike Trout" + ) + mock_get.return_value = _random_response([rare_player]) + + pack = _make_pack(100) + # Force all dice to land on All-Star tier (d1000=951 for card 3) + from helpers.main import roll_for_cards + + with patch("helpers.main.random.randint", return_value=960): + await roll_for_cards([pack]) + + notif_posts = [c for c in mock_post.call_args_list if c.args[0] == "notifs"] + assert len(notif_posts) >= 1 + payload = notif_posts[0].kwargs["payload"] + assert payload["title"] == "Rare Pull" + assert "Mike Trout" in payload["field_name"] + + async def test_no_notifications_for_common_pulls(self, mock_db): + """Players with rarity < 3 should NOT trigger notifications. + + Why: Only rare pulls are noteworthy — common cards would spam the notif feed. + """ + mock_get, mock_post, mock_patch = mock_db + + common_player = _make_player(1, rarity_value=0, rarity_name="Replacement") + mock_get.return_value = _random_response([common_player]) + + pack = _make_pack(100) + from helpers.main import roll_for_cards + + # Force low dice rolls (all Replacement) + with patch("helpers.main.random.randint", return_value=1): + await roll_for_cards([pack]) + + notif_posts = [c for c in mock_post.call_args_list if c.args[0] == "notifs"] + assert len(notif_posts) == 0 + + +class TestErrorHandling: + """Error propagation from gathered writes.""" + + async def test_card_creation_failure_raises(self, mock_db): + """If db_post('cards') returns falsy, ConnectionError must propagate. + + Why: Card creation failure means the pack wasn't properly opened — caller + needs to know so it can report the error to the user. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(1)]) + mock_post.return_value = False # Simulate failure + + pack = _make_pack(100) + from helpers.main import roll_for_cards + + with pytest.raises(ConnectionError, match="Failed to create"): + await roll_for_cards([pack]) + + +class TestPackTeamFiltering: + """Verify correct filter params are passed to player fetch.""" + + async def test_pack_team_adds_franchise_filter(self, mock_db): + """When pack has a pack_team, franchise filter should be applied. + + Why: Team-specific packs should only contain players from that franchise. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(1)]) + + pack = _make_pack( + 100, + pack_team={"sname": "NYY"}, + pack_cardset=None, + ) + from helpers.main import roll_for_cards + + with patch("helpers.main.random.randint", return_value=1): + await roll_for_cards([pack]) + + # Check that tier-fetch calls (those with max_rarity) include franchise filter + tier_calls = [ + c + for c in mock_get.call_args_list + if any(p[0] == "max_rarity" for p in (c.kwargs.get("params") or [])) + ] + assert len(tier_calls) >= 1 + for c in tier_calls: + param_dict = dict(c.kwargs.get("params") or []) + assert param_dict.get("franchise") == "NYY" + assert param_dict.get("in_packs") is True + + async def test_no_team_no_cardset_adds_in_packs(self, mock_db): + """When pack has no team or cardset, in_packs filter should be applied. + + Why: Generic packs still need the in_packs filter to exclude non-packable players. + """ + mock_get, mock_post, mock_patch = mock_db + mock_get.return_value = _random_response([_make_player(1)]) + + pack = _make_pack(100, pack_team=None, pack_cardset=None) + from helpers.main import roll_for_cards + + with patch("helpers.main.random.randint", return_value=1): + await roll_for_cards([pack]) + + # Check that tier-fetch calls (those with max_rarity) include in_packs filter + tier_calls = [ + c + for c in mock_get.call_args_list + if any(p[0] == "max_rarity" for p in (c.kwargs.get("params") or [])) + ] + assert len(tier_calls) >= 1 + for c in tier_calls: + param_dict = dict(c.kwargs.get("params") or []) + assert param_dict.get("in_packs") is True + + +# --------------------------------------------------------------------------- +# Integration tests — hit real dev API for reads, mock all writes +# --------------------------------------------------------------------------- +requires_api = pytest.mark.skipif( + not os.environ.get("API_TOKEN"), + reason="API_TOKEN not set — skipping integration tests", +) + + +@requires_api +class TestIntegrationRealFetches: + """Integration tests that hit the real dev API for player fetches. + + Only db_get is real — db_post and db_patch are mocked to prevent writes. + Run with: API_TOKEN= python -m pytest tests/test_roll_for_cards.py -k integration -v + """ + + @pytest.fixture + def mock_writes(self): + """Mock only write operations, let reads hit the real API.""" + with ( + patch("helpers.main.db_post", new_callable=AsyncMock) as mock_post, + patch("helpers.main.db_patch", new_callable=AsyncMock) as mock_patch, + ): + mock_post.return_value = True + yield mock_post, mock_patch + + async def test_integration_single_pack_fetches_real_players(self, mock_writes): + """A single standard pack should fetch real players from the dev API. + + Why: Validates that the batched fetch params (min_rarity, max_rarity, limit, + in_packs) produce valid responses from the real API and that the returned + players have the expected structure. + """ + mock_post, mock_patch = mock_writes + + pack = _make_pack(9999) + from helpers.main import roll_for_cards + + result = await roll_for_cards([pack]) + + assert result == [9999] + # Cards were "created" (mocked) + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 1 + payload = card_posts[0].kwargs["payload"] + # Standard pack produces 5 cards + assert len(payload["cards"]) == 5 + # Each card has the expected structure + for card in payload["cards"]: + assert "player_id" in card + assert card["team_id"] == 1 + assert card["pack_id"] == 9999 + + async def test_integration_multiple_packs_batch_correctly(self, mock_writes): + """Multiple packs should batch fetches and distribute players correctly. + + Why: Validates the core optimization — summing counts across packs, making + fewer API calls, and slicing players back into per-pack groups with real data. + """ + mock_post, mock_patch = mock_writes + + packs = [_make_pack(i + 9000) for i in range(3)] + from helpers.main import roll_for_cards + + result = await roll_for_cards(packs) + + assert result == [9000, 9001, 9002] + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 3 + # Each pack should have exactly 5 cards (Standard packs) + total_cards = 0 + for post_call in card_posts: + cards = post_call.kwargs["payload"]["cards"] + assert len(cards) == 5 + total_cards += len(cards) + assert total_cards == 15 + + async def test_integration_players_have_valid_rarity(self, mock_writes): + """Fetched players should have rarity values matching their requested tier. + + Why: Confirms the API respects min_rarity/max_rarity filters and that + the player distribution logic assigns correct-tier players to each pack. + """ + mock_post, mock_patch = mock_writes + + pack = _make_pack(9999) + from helpers.main import roll_for_cards + + # Use fixed dice to get known rarity distribution + # d1000=500 for Standard: Rep, Res, Sta, Res, Sta (mix of low tiers) + with patch("helpers.main.random.randint", return_value=500): + await roll_for_cards([pack]) + + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 1 + cards = card_posts[0].kwargs["payload"]["cards"] + # All cards should have valid player_ids (positive integers from real API) + for card in cards: + assert isinstance(card["player_id"], int) + assert card["player_id"] > 0 + + async def test_integration_cardset_filter(self, mock_writes): + """Packs with a specific cardset should only fetch players from that cardset. + + Why: Validates that the cardset_id parameter is correctly passed through + the batched fetch and the API filters accordingly. + """ + mock_post, mock_patch = mock_writes + + pack = _make_pack(9999, pack_cardset={"id": 24}) + from helpers.main import roll_for_cards + + with patch("helpers.main.random.randint", return_value=500): + result = await roll_for_cards([pack]) + + assert result == [9999] + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 1 + assert len(card_posts[0].kwargs["payload"]["cards"]) == 5 + + async def test_integration_checkin_pack(self, mock_writes): + """Check-In Player pack should fetch exactly 1 player from the real API. + + Why: Check-in packs produce a single card — validates the simplest + path through the batched fetch logic with real data. + """ + mock_post, mock_patch = mock_writes + + pack = _make_pack(9999, pack_type="Check-In Player") + from helpers.main import roll_for_cards + + result = await roll_for_cards([pack]) + + assert result == [9999] + card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"] + assert len(card_posts) == 1 + # Check-in packs produce exactly 1 card + assert len(card_posts[0].kwargs["payload"]["cards"]) == 1