Merge pull request 'perf: parallelize roll_for_cards and bump pack limit to 20' (#102) from performance/97-parallelize-roll-for-cards into next-release
All checks were successful
Build Docker Image / build (push) Successful in 1m25s
Build Docker Image / build (pull_request) Successful in 54s

Reviewed-on: #102
This commit is contained in:
cal 2026-03-20 15:34:06 +00:00
commit 44d83b321f
3 changed files with 1108 additions and 433 deletions

View File

@ -3,126 +3,148 @@ Discord Select UI components.
Contains all Select classes for various team, cardset, and pack selections. Contains all Select classes for various team, cardset, and pack selections.
""" """
import logging import logging
import discord import discord
from typing import Literal, Optional from typing import Literal, Optional
from helpers.constants import ALL_MLB_TEAMS, IMAGES, normalize_franchise 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 # Team name to ID mappings
AL_TEAM_IDS = { AL_TEAM_IDS = {
'Baltimore Orioles': 3, "Baltimore Orioles": 3,
'Boston Red Sox': 4, "Boston Red Sox": 4,
'Chicago White Sox': 6, "Chicago White Sox": 6,
'Cleveland Guardians': 8, "Cleveland Guardians": 8,
'Detroit Tigers': 10, "Detroit Tigers": 10,
'Houston Astros': 11, "Houston Astros": 11,
'Kansas City Royals': 12, "Kansas City Royals": 12,
'Los Angeles Angels': 13, "Los Angeles Angels": 13,
'Minnesota Twins': 17, "Minnesota Twins": 17,
'New York Yankees': 19, "New York Yankees": 19,
'Oakland Athletics': 20, "Oakland Athletics": 20,
'Athletics': 20, # Alias for post-Oakland move "Athletics": 20, # Alias for post-Oakland move
'Seattle Mariners': 24, "Seattle Mariners": 24,
'Tampa Bay Rays': 27, "Tampa Bay Rays": 27,
'Texas Rangers': 28, "Texas Rangers": 28,
'Toronto Blue Jays': 29 "Toronto Blue Jays": 29,
} }
NL_TEAM_IDS = { NL_TEAM_IDS = {
'Arizona Diamondbacks': 1, "Arizona Diamondbacks": 1,
'Atlanta Braves': 2, "Atlanta Braves": 2,
'Chicago Cubs': 5, "Chicago Cubs": 5,
'Cincinnati Reds': 7, "Cincinnati Reds": 7,
'Colorado Rockies': 9, "Colorado Rockies": 9,
'Los Angeles Dodgers': 14, "Los Angeles Dodgers": 14,
'Miami Marlins': 15, "Miami Marlins": 15,
'Milwaukee Brewers': 16, "Milwaukee Brewers": 16,
'New York Mets': 18, "New York Mets": 18,
'Philadelphia Phillies': 21, "Philadelphia Phillies": 21,
'Pittsburgh Pirates': 22, "Pittsburgh Pirates": 22,
'San Diego Padres': 23, "San Diego Padres": 23,
'San Francisco Giants': 25, "San Francisco Giants": 25,
'St Louis Cardinals': 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals' "St Louis Cardinals": 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals'
'Washington Nationals': 30 "Washington Nationals": 30,
} }
# Get AL teams from constants # Get AL teams from constants
AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS] 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 mappings
CARDSET_LABELS_TO_IDS = { CARDSET_LABELS_TO_IDS = {
'2022 Season': 3, "2022 Season": 3,
'2022 Promos': 4, "2022 Promos": 4,
'2021 Season': 1, "2021 Season": 1,
'2019 Season': 5, "2019 Season": 5,
'2013 Season': 6, "2013 Season": 6,
'2012 Season': 7, "2012 Season": 7,
'Mario Super Sluggers': 8, "Mario Super Sluggers": 8,
'2023 Season': 9, "2023 Season": 9,
'2016 Season': 11, "2016 Season": 11,
'2008 Season': 12, "2008 Season": 12,
'2018 Season': 13, "2018 Season": 13,
'2024 Season': 17, "2024 Season": 17,
'2024 Promos': 18, "2024 Promos": 18,
'1998 Season': 20, "1998 Season": 20,
'2025 Season': 24, "2025 Season": 24,
'2005 Live': 27, "2005 Live": 27,
'Pokemon - Brilliant Stars': 23 "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.""" """Get team ID from team name and league."""
if league == 'AL': if league == "AL":
return AL_TEAM_IDS.get(team_name) return AL_TEAM_IDS.get(team_name)
else: else:
# Handle the St. Louis Cardinals special case # Handle the St. Louis Cardinals special case
if team_name == 'St. Louis Cardinals': if team_name == "St. Louis Cardinals":
return NL_TEAM_IDS.get('St Louis Cardinals') return NL_TEAM_IDS.get("St Louis Cardinals")
return NL_TEAM_IDS.get(team_name) return NL_TEAM_IDS.get(team_name)
class SelectChoicePackTeam(discord.ui.Select): 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.which = which
self.owner_team = team self.owner_team = team
self.cardset_id = cardset_id self.cardset_id = cardset_id
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label=(
super().__init__(placeholder=f'Select an {which} team', options=options) "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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get, db_patch from api_calls import db_get, db_patch
from helpers import open_choice_pack from helpers import open_choice_pack
team_id = _get_team_id(self.values[0], self.which) team_id = _get_team_id(self.values[0], self.which)
if team_id is None: 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 # Get the selected packs
params = [ params = [
('pack_type_id', 8), ('team_id', self.owner_team['id']), ('opened', False), ('limit', 1), ("pack_type_id", 8),
('exact_match', True) ("team_id", self.owner_team["id"]),
("opened", False),
("limit", 1),
("exact_match", True),
] ]
if self.cardset_id is not None: if self.cardset_id is not None:
params.append(('pack_cardset_id', self.cardset_id)) params.append(("pack_cardset_id", self.cardset_id))
p_query = await db_get('packs', params=params) p_query = await db_get("packs", params=params)
if p_query['count'] == 0: if p_query["count"] == 0:
logger.error(f'open-packs - no packs found with params: {params}') logger.error(f"open-packs - no packs found with params: {params}")
raise ValueError(f'Unable to open packs') 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) 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): class SelectOpenPack(discord.ui.Select):
def __init__(self, options: list, team: dict): def __init__(self, options: list, team: dict):
self.owner_team = team 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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get from api_calls import db_get
from helpers import open_st_pr_packs, open_choice_pack from helpers import open_st_pr_packs, open_choice_pack
logger.info(f'SelectPackChoice - selection: {self.values[0]}') logger.info(f"SelectPackChoice - selection: {self.values[0]}")
pack_vals = self.values[0].split('-') pack_vals = self.values[0].split("-")
logger.info(f'pack_vals: {pack_vals}') logger.info(f"pack_vals: {pack_vals}")
# Get the selected packs # 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' open_type = "standard"
if 'Standard' in pack_vals: if "Standard" in pack_vals:
open_type = 'standard' open_type = "standard"
params.append(('pack_type_id', 1)) params.append(("pack_type_id", 1))
elif 'Premium' in pack_vals: elif "Premium" in pack_vals:
open_type = 'standard' open_type = "standard"
params.append(('pack_type_id', 3)) params.append(("pack_type_id", 3))
elif 'Daily' in pack_vals: elif "Daily" in pack_vals:
params.append(('pack_type_id', 4)) params.append(("pack_type_id", 4))
elif 'Promo Choice' in pack_vals: elif "Promo Choice" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 9)) params.append(("pack_type_id", 9))
elif 'MVP' in pack_vals: elif "MVP" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 5)) params.append(("pack_type_id", 5))
elif 'All Star' in pack_vals: elif "All Star" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 6)) params.append(("pack_type_id", 6))
elif 'Mario' in pack_vals: elif "Mario" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 7)) params.append(("pack_type_id", 7))
elif 'Team Choice' in pack_vals: elif "Team Choice" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 8)) params.append(("pack_type_id", 8))
else: 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 # If team isn't already set on team choice pack, make team pack selection now
await interaction.response.edit_message(view=None) await interaction.response.edit_message(view=None)
cardset_id = None cardset_id = None
# Handle Team Choice packs with no team/cardset assigned # 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( await interaction.followup.send(
content='This Team Choice pack needs to be assigned a team and cardset. ' content="This Team Choice pack needs to be assigned a team and cardset. "
'Please contact an admin to configure this pack.', "Please contact an admin to configure this pack.",
ephemeral=True ephemeral=True,
) )
return 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_id = pack_vals[2]
cardset_index = pack_vals.index('Cardset') cardset_index = pack_vals.index("Cardset")
cardset_id = pack_vals[cardset_index + 1] cardset_id = pack_vals[cardset_index + 1]
params.append(('pack_cardset_id', cardset_id)) params.append(("pack_cardset_id", cardset_id))
if 'Team' not in pack_vals: if "Team" not in pack_vals:
view = SelectView( view = SelectView(
[SelectChoicePackTeam('AL', self.owner_team, cardset_id), [
SelectChoicePackTeam('NL', self.owner_team, cardset_id)], SelectChoicePackTeam("AL", self.owner_team, cardset_id),
timeout=30 SelectChoicePackTeam("NL", self.owner_team, cardset_id),
],
timeout=30,
) )
await interaction.followup.send( await interaction.followup.send(
content='Please select a team for your Team Choice pack:', content="Please select a team for your Team Choice pack:", view=view
view=view
) )
return 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) params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
if p_query['count'] == 0: else:
logger.error(f'open-packs - no packs found with params: {params}') 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( await interaction.followup.send(
content='Unable to find the selected pack. Please contact an admin.', content="Unable to find the selected pack. Please contact an admin.",
ephemeral=True ephemeral=True,
) )
return return
# Open the packs # Open the packs
try: try:
if open_type == 'standard': if open_type == "standard":
await open_st_pr_packs(p_query['packs'], self.owner_team, interaction) await open_st_pr_packs(p_query["packs"], self.owner_team, interaction)
elif open_type == 'choice': elif open_type == "choice":
await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id) await open_choice_pack(
p_query["packs"][0], self.owner_team, interaction, cardset_id
)
except Exception as e: except Exception as e:
logger.error(f'Failed to open pack: {e}') logger.error(f"Failed to open pack: {e}")
await interaction.followup.send( await interaction.followup.send(
content=f'Failed to open pack. Please contact an admin. Error: {str(e)}', content=f"Failed to open pack. Please contact an admin. Error: {str(e)}",
ephemeral=True ephemeral=True,
) )
return return
@ -235,275 +269,317 @@ class SelectOpenPack(discord.ui.Select):
class SelectPaperdexCardset(discord.ui.Select): class SelectPaperdexCardset(discord.ui.Select):
def __init__(self): def __init__(self):
options = [ options = [
discord.SelectOption(label='2005 Live'), discord.SelectOption(label="2005 Live"),
discord.SelectOption(label='2025 Season'), discord.SelectOption(label="2025 Season"),
discord.SelectOption(label='1998 Season'), discord.SelectOption(label="1998 Season"),
discord.SelectOption(label='2024 Season'), discord.SelectOption(label="2024 Season"),
discord.SelectOption(label='2023 Season'), discord.SelectOption(label="2023 Season"),
discord.SelectOption(label='2022 Season'), discord.SelectOption(label="2022 Season"),
discord.SelectOption(label='2022 Promos'), discord.SelectOption(label="2022 Promos"),
discord.SelectOption(label='2021 Season'), discord.SelectOption(label="2021 Season"),
discord.SelectOption(label='2019 Season'), discord.SelectOption(label="2019 Season"),
discord.SelectOption(label='2018 Season'), discord.SelectOption(label="2018 Season"),
discord.SelectOption(label='2016 Season'), discord.SelectOption(label="2016 Season"),
discord.SelectOption(label='2013 Season'), discord.SelectOption(label="2013 Season"),
discord.SelectOption(label='2012 Season'), discord.SelectOption(label="2012 Season"),
discord.SelectOption(label='2008 Season'), discord.SelectOption(label="2008 Season"),
discord.SelectOption(label='Mario Super Sluggers') 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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get from api_calls import db_get
from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination 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]) cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
if cardset_id is None: 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) 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) await interaction.response.edit_message(
content="Okay, sifting through your cards...", view=None
)
cardset_embeds = await paperdex_cardset_embed( cardset_embeds = await paperdex_cardset_embed(
team=await get_team_by_owner(interaction.user.id), team=await get_team_by_owner(interaction.user.id), this_cardset=c_query
this_cardset=c_query
) )
await embed_pagination(cardset_embeds, interaction.channel, interaction.user) await embed_pagination(cardset_embeds, interaction.channel, interaction.user)
class SelectPaperdexTeam(discord.ui.Select): class SelectPaperdexTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL']): def __init__(self, which: Literal["AL", "NL"]):
self.which = which self.which = which
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label=(
super().__init__(placeholder=f'Select an {which} team', options=options) "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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get from api_calls import db_get
from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination
team_id = _get_team_id(self.values[0], self.which) team_id = _get_team_id(self.values[0], self.which)
if team_id is None: 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) 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) 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) await embed_pagination(team_embeds, interaction.channel, interaction.user)
class SelectBuyPacksCardset(discord.ui.Select): 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 = [ options = [
discord.SelectOption(label='2005 Live'), discord.SelectOption(label="2005 Live"),
discord.SelectOption(label='2025 Season'), discord.SelectOption(label="2025 Season"),
discord.SelectOption(label='1998 Season'), discord.SelectOption(label="1998 Season"),
discord.SelectOption(label='Pokemon - Brilliant Stars'), discord.SelectOption(label="Pokemon - Brilliant Stars"),
discord.SelectOption(label='2024 Season'), discord.SelectOption(label="2024 Season"),
discord.SelectOption(label='2023 Season'), discord.SelectOption(label="2023 Season"),
discord.SelectOption(label='2022 Season'), discord.SelectOption(label="2022 Season"),
discord.SelectOption(label='2021 Season'), discord.SelectOption(label="2021 Season"),
discord.SelectOption(label='2019 Season'), discord.SelectOption(label="2019 Season"),
discord.SelectOption(label='2018 Season'), discord.SelectOption(label="2018 Season"),
discord.SelectOption(label='2016 Season'), discord.SelectOption(label="2016 Season"),
discord.SelectOption(label='2013 Season'), discord.SelectOption(label="2013 Season"),
discord.SelectOption(label='2012 Season'), discord.SelectOption(label="2012 Season"),
discord.SelectOption(label='2008 Season') discord.SelectOption(label="2008 Season"),
] ]
self.team = team self.team = team
self.quantity = quantity self.quantity = quantity
self.pack_type_id = pack_type_id self.pack_type_id = pack_type_id
self.pack_embed = pack_embed self.pack_embed = pack_embed
self.cost = cost 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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_post from api_calls import db_post
from discord_ui.confirmations import Confirm 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]) cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
if cardset_id is None: if cardset_id is None:
raise ValueError(f'Unknown cardset: {self.values[0]}') raise ValueError(f"Unknown cardset: {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]}' 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) view = Confirm(responders=[interaction.user], timeout=30)
await interaction.response.edit_message( await interaction.response.edit_message(
content=None, content=None, embed=self.pack_embed, view=None
embed=self.pack_embed,
view=None
) )
question = await interaction.channel.send( question = await interaction.channel.send(
content=f'Your Wallet: {self.team["wallet"]}\n' content=f"Your Wallet: {self.team['wallet']}\n"
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}\n' f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}\n"
f'After Purchase: {self.team["wallet"] - self.cost}\n\n' f"After Purchase: {self.team['wallet'] - self.cost}\n\n"
f'Would you like to make this purchase?', f"Would you like to make this purchase?",
view=view view=view,
) )
await view.wait() await view.wait()
if not view.value: if not view.value:
await question.edit( await question.edit(content="Saving that money. Smart.", view=None)
content='Saving that money. Smart.',
view=None
)
return return
p_model = { p_model = {
'team_id': self.team['id'], "team_id": self.team["id"],
'pack_type_id': self.pack_type_id, "pack_type_id": self.pack_type_id,
'pack_cardset_id': cardset_id "pack_cardset_id": cardset_id,
} }
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]}) await db_post(
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}') "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( await question.edit(
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`",
view=None view=None,
) )
class SelectBuyPacksTeam(discord.ui.Select): class SelectBuyPacksTeam(discord.ui.Select):
def __init__( def __init__(
self, which: Literal['AL', 'NL'], team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, self,
cost: int): which: Literal["AL", "NL"],
team: dict,
quantity: int,
pack_type_id: int,
pack_embed: discord.Embed,
cost: int,
):
self.which = which self.which = which
self.team = team self.team = team
self.quantity = quantity self.quantity = quantity
self.pack_type_id = pack_type_id self.pack_type_id = pack_type_id
self.pack_embed = pack_embed self.pack_embed = pack_embed
self.cost = cost self.cost = cost
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label=(
super().__init__(placeholder=f'Select an {which} team', options=options) "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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_post from api_calls import db_post
from discord_ui.confirmations import Confirm from discord_ui.confirmations import Confirm
team_id = _get_team_id(self.values[0], self.which) team_id = _get_team_id(self.values[0], self.which)
if team_id is None: 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) view = Confirm(responders=[interaction.user], timeout=30)
await interaction.response.edit_message( await interaction.response.edit_message(
content=None, content=None, embed=self.pack_embed, view=None
embed=self.pack_embed,
view=None
) )
question = await interaction.channel.send( question = await interaction.channel.send(
content=f'Your Wallet: {self.team["wallet"]}\n' content=f"Your Wallet: {self.team['wallet']}\n"
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}\n' f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}\n"
f'After Purchase: {self.team["wallet"] - self.cost}\n\n' f"After Purchase: {self.team['wallet'] - self.cost}\n\n"
f'Would you like to make this purchase?', f"Would you like to make this purchase?",
view=view view=view,
) )
await view.wait() await view.wait()
if not view.value: if not view.value:
await question.edit( await question.edit(content="Saving that money. Smart.", view=None)
content='Saving that money. Smart.',
view=None
)
return return
p_model = { p_model = {
'team_id': self.team['id'], "team_id": self.team["id"],
'pack_type_id': self.pack_type_id, "pack_type_id": self.pack_type_id,
'pack_team_id': team_id "pack_team_id": team_id,
} }
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]}) await db_post(
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}') "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( await question.edit(
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`",
view=None view=None,
) )
class SelectUpdatePlayerTeam(discord.ui.Select): 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.bot = bot
self.which = which self.which = which
self.player = player self.player = player
self.reporting_team = reporting_team self.reporting_team = reporting_team
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label=(
super().__init__(placeholder=f'Select an {which} team', options=options) "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): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_patch, db_post from api_calls import db_patch, db_post
from discord_ui.confirmations import Confirm from discord_ui.confirmations import Confirm
from helpers import player_desc, send_to_channel from helpers import player_desc, send_to_channel
# Check if already assigned - compare against both normalized franchise and full mlbclub # Check if already assigned - compare against both normalized franchise and full mlbclub
normalized_selection = normalize_franchise(self.values[0]) 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( await interaction.response.send_message(
content=f'Thank you for the help, but it looks like somebody beat you to it! ' 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"]}**.' f"**{player_desc(self.player)}** is already assigned to the **{self.player['mlbclub']}**."
) )
return return
view = Confirm(responders=[interaction.user], timeout=15) view = Confirm(responders=[interaction.user], timeout=15)
await interaction.response.edit_message( await interaction.response.edit_message(
content=f'Should I update **{player_desc(self.player)}**\'s team to the **{self.values[0]}**?', content=f"Should I update **{player_desc(self.player)}**'s team to the **{self.values[0]}**?",
view=None view=None,
)
question = await interaction.channel.send(
content=None,
view=view
) )
question = await interaction.channel.send(content=None, view=view)
await view.wait() await view.wait()
if not view.value: if not view.value:
await question.edit( await question.edit(
content='That didnt\'t sound right to me, either. Let\'s not touch that.', content="That didnt't sound right to me, either. Let's not touch that.",
view=None view=None,
) )
return return
else: else:
await question.delete() await question.delete()
await db_patch('players', object_id=self.player['player_id'], params=[ await db_patch(
('mlbclub', self.values[0]), ('franchise', normalize_franchise(self.values[0])) "players",
]) object_id=self.player["player_id"],
await db_post(f'teams/{self.reporting_team["id"]}/money/25') params=[
await send_to_channel( ("mlbclub", self.values[0]),
self.bot, 'pd-news-ticker', ("franchise", normalize_franchise(self.values[0])),
content=f'{interaction.user.name} just updated **{player_desc(self.player)}**\'s team to the ' ],
f'**{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): class SelectView(discord.ui.View):
@ -511,4 +587,4 @@ class SelectView(discord.ui.View):
super().__init__(timeout=timeout) super().__init__(timeout=timeout)
for x in select_objects: for x in select_objects:
self.add_item(x) self.add_item(x)

View File

@ -2,35 +2,23 @@ import asyncio
import datetime import datetime
import logging import logging
import math import math
import os
import random import random
import traceback
import discord import discord
import pygsheets
import aiohttp import aiohttp
from discord.ext import commands from discord.ext import commands
from api_calls import * from api_calls import *
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from difflib import get_close_matches from typing import Optional, Union, List
from dataclasses import dataclass
from typing import Optional, Literal, Union, List
from exceptions import log_exception
from in_game.gameplay_models import Team from in_game.gameplay_models import Team
from constants import * from constants import *
from discord_ui import * from discord_ui import *
from random_content import * from random_content import *
from utils import ( from utils import (
position_name_to_abbrev,
user_has_role,
get_roster_sheet_legacy,
get_roster_sheet, get_roster_sheet,
get_player_url,
owner_only,
get_cal_user, get_cal_user,
get_context_user,
) )
from search_utils import * from search_utils import *
from discord_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 any(bool_list):
if count == 1: if count == 1:
coll_string = f"Only you" coll_string = "Only you"
else: else:
coll_string = ( coll_string = (
f"You and {count - 1} other{'s' if count - 1 != 1 else ''}" 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: elif count:
coll_string = f"{count} other team{'s' if count != 1 else ''}" coll_string = f"{count} other team{'s' if count != 1 else ''}"
else: else:
coll_string = f"0 teams" coll_string = "0 teams"
embed.add_field(name="Collected By", value=coll_string) embed.add_field(name="Collected By", value=coll_string)
else: else:
embed.add_field( embed.add_field(
@ -229,7 +217,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
) )
if evo_mon is not None: if evo_mon is not None:
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}") embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
except Exception as e: except Exception:
logging.error( logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True "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: if evo_mon is not None:
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}") embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
except Exception as e: except Exception:
logging.error( logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True "could not pull evolution: {e}", exc_info=True, stack_info=True
) )
@ -342,7 +330,7 @@ async def display_cards(
) )
try: try:
cards.sort(key=lambda x: x["player"]["rarity"]["value"]) 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] card_embeds = [await get_card_embeds(x) for x in cards]
logger.debug(f"Created {len(card_embeds)} card embeds") logger.debug(f"Created {len(card_embeds)} card embeds")
@ -363,15 +351,15 @@ async def display_cards(
r_emoji = "" r_emoji = ""
view.left_button.disabled = True view.left_button.disabled = True
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}" 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}" view.right_button.label = f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
if len(cards) == 1: if len(cards) == 1:
view.right_button.disabled = True view.right_button.disabled = True
logger.debug(f"Pagination view created successfully") logger.debug("Pagination view created successfully")
if pack_cover: if pack_cover:
logger.debug(f"Sending pack cover message") logger.debug("Sending pack cover message")
msg = await channel.send( msg = await channel.send(
content=None, content=None,
embed=image_embed(pack_cover, title=f"{team['lname']}", desc=pack_name), 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 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: except Exception as e:
logger.error( logger.error(
f"Error creating view or sending initial message: {e}", exc_info=True 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" 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: except Exception as e:
logger.error(f"Error sending follow-up message: {e}", exc_info=True) logger.error(f"Error sending follow-up message: {e}", exc_info=True)
return False return False
logger.debug(f"Starting main interaction loop") logger.debug("Starting main interaction loop")
while True: while True:
try: try:
logger.debug(f"Waiting for user interaction on page {page_num}") logger.debug(f"Waiting for user interaction on page {page_num}")
@ -471,7 +459,7 @@ async def display_cards(
), ),
view=view, view=view,
) )
logger.debug(f"MVP display updated successfully") logger.debug("MVP display updated successfully")
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error processing shiny card on page {page_num}: {e}", exc_info=True 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 # Continue with regular flow instead of crashing
try: try:
tmp_msg = await channel.send( tmp_msg = await channel.send(
content=f"<@&1163537676885033010> we've got an MVP!" content="<@&1163537676885033010> we've got an MVP!"
) )
await follow_up.edit( await follow_up.edit(
content=f"<@&1163537676885033010> we've got an MVP!" content="<@&1163537676885033010> we've got an MVP!"
) )
await tmp_msg.delete() await tmp_msg.delete()
except discord.errors.NotFound: except discord.errors.NotFound:
# Role might not exist or message was already deleted # 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: except Exception as e:
# Log error but don't crash the function # Log error but don't crash the function
logger.error(f"Error handling MVP notification: {e}") 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() await view.wait()
view = Pagination([user], timeout=10) view = Pagination([user], timeout=10)
@ -499,7 +487,7 @@ async def display_cards(
view.right_button.label = ( view.right_button.label = (
f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}" 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)}" view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(card_embeds)}"
if page_num == 0: if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}" view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
@ -547,7 +535,7 @@ async def embed_pagination(
l_emoji = "" l_emoji = ""
r_emoji = "" r_emoji = ""
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{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)}" view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0: if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}" 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 = Pagination([user], timeout=timeout)
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{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)}" view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0: if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}" 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: 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"] 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: for pack in all_packs:
counts = { counts = {
"Rep": {"count": 0, "rarity": 0}, "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}, "MVP": {"count": 0, "rarity": 5},
"HoF": {"count": 0, "rarity": 8}, "HoF": {"count": 0, "rarity": 8},
} }
this_pack_players = []
if pack["pack_type"]["name"] == "Standard": if pack["pack_type"]["name"] == "Standard":
# Cards 1 - 2 # Cards 1 - 2
for x in range(2): for _ in range(2):
d_1000 = random.randint(1, 1000) d_1000 = random.randint(1, 1000)
if d_1000 <= 450: if d_1000 <= 450:
counts["Rep"]["count"] += 1 counts["Rep"]["count"] += 1
@ -808,7 +789,6 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
logger.info( logger.info(
f"Building Check-In Pack // extra_val (type): {extra_val} {type(extra_val)}" f"Building Check-In Pack // extra_val (type): {extra_val} {type(extra_val)}"
) )
# Single Card
mod = 0 mod = 0
if isinstance(extra_val, int): if isinstance(extra_val, int):
mod = extra_val mod = extra_val
@ -826,106 +806,195 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
else: else:
raise TypeError(f"Pack type not recognized: {pack['pack_type']['name']}") raise TypeError(f"Pack type not recognized: {pack['pack_type']['name']}")
pull_notifs = [] pack_counts.append(counts)
for key in counts:
mvp_flag = None
if counts[key]["count"] > 0: # --- Phase B: Fetch players — one call per rarity tier, all gathered ---
params = [ # Sum counts across all packs per rarity tier
("min_rarity", counts[key]["rarity"]), rarity_keys = ["Rep", "Res", "Sta", "All", "MVP", "HoF"]
("max_rarity", counts[key]["rarity"]), summed = {key: 0 for key in rarity_keys}
("limit", counts[key]["count"]), for counts in pack_counts:
] for key in rarity_keys:
if all_packs[0]["pack_team"] is not None: summed[key] += counts[key]["count"]
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))
pl = await db_get("players/random", params=params) # Build shared filter params
base_params = []
if pl["count"] != counts[key]["count"]: if all_packs[0]["pack_team"] is not None:
mvp_flag = counts[key]["count"] - pl["count"] base_params.extend(
logging.info( [
f"Set mvp flag to {mvp_flag} / cardset_id: {all_packs[0]['pack_cardset']['id']}" ("franchise", all_packs[0]["pack_team"]["sname"]),
) ("in_packs", True),
]
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,
) )
if not success: elif all_packs[0]["pack_cardset"] is not None:
raise ConnectionError(f"Failed to create this pack of cards.") base_params.append(("cardset_id", all_packs[0]["pack_cardset"]["id"]))
else:
base_params.append(("in_packs", True))
await db_patch( # Fire one request per non-zero rarity tier concurrently
"packs", rarity_values = {
object_id=pack["id"], "Rep": 0,
params=[ "Res": 1,
( "Sta": 2,
"open_time", "All": 3,
int(datetime.datetime.timestamp(datetime.datetime.now()) * 1000), "MVP": 5,
) "HoF": 8,
], }
) fetch_keys = [key for key in rarity_keys if summed[key] > 0]
pack_ids.append(pack["id"]) 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: fetch_results = await asyncio.gather(*fetch_coros)
logger.info(f"good pull: {pull}")
await db_post( # Map results back: rarity key -> list of players
"notifs", fetched_players = {}
payload={ for key, result in zip(fetch_keys, fetch_results):
"created": int( fetched_players[key] = result.get("players", [])
datetime.datetime.timestamp(datetime.datetime.now()) * 1000
), # Handle shortfalls — collect total MVP backfill needed
"title": "Rare Pull", total_mvp_shortfall = 0
"field_name": f"{player_desc(pull)} ({pull['rarity']['name']})", # Track per-tier shortfall for dupe-branch (cardset 23 exclusion)
"message": f"Pulled by {team['abbrev']}", tier_shortfalls = {}
"about": f"Player-{pull['player_id']}", 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: 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: except Exception as e:
logger.error(f"Could not grab sheets auth: {e}") logger.error(f"Could not grab sheets auth: {e}")
raise ConnectionError( 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: def get_rosters(team, bot, roster_num: Optional[int] = None) -> list:
sheets = get_sheets(bot) sheets = get_sheets(bot)
this_sheet = sheets.open_by_key(team["gsheet"]) 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}") logger.debug(f"this_sheet: {this_sheet} / r_sheet = {r_sheet}")
all_rosters = [None, None, None] all_rosters = [None, None, None]
@ -1153,11 +1222,11 @@ def get_roster_lineups(team, bot, roster_num, lineup_num) -> list:
try: try:
lineup_cells = [(row[0].value, int(row[1].value)) for row in raw_cells] 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") logger.error(f"Could not pull roster for {team['abbrev']} due to a ValueError")
raise ValueError( raise ValueError(
f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to " "Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
f"get the card IDs" "get the card IDs"
) )
logger.debug(f"lineup_cells: {lineup_cells}") logger.debug(f"lineup_cells: {lineup_cells}")
@ -1552,7 +1621,7 @@ def get_ratings_guide(sheets):
} }
for x in p_data for x in p_data
] ]
except Exception as e: except Exception:
return {"valid": False} return {"valid": False}
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers} 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) pack_ids = await roll_for_cards(all_packs)
if not pack_ids: if not pack_ids:
logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}") 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 = [] all_cards = []
for p_id in pack_ids: 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: if not all_cards:
logger.error(f"open_packs - unable to get cards for packs: {pack_ids}") 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 # Present cards to opening channel
if type(context) == commands.Context: if type(context) == commands.Context:
@ -1834,7 +1903,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30) view = Pagination([interaction.user], timeout=30)
view.left_button.disabled = True view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}" 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.style = discord.ButtonStyle.success
view.cancel_button.disabled = True view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}" 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 = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: -/{len(card_embeds)}" view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.left_button.disabled = True 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.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}" 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 = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}" 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.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}" view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1: if page_num == 1:
@ -1941,7 +2010,7 @@ async def open_choice_pack(
players = pl["players"] players = pl["players"]
elif pack_type == "Team Choice": elif pack_type == "Team Choice":
if this_pack["pack_team"] is None: 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) d1000 = random.randint(1, 1000)
pack_cover = this_pack["pack_team"]["logo"] pack_cover = this_pack["pack_team"]["logo"]
@ -1980,7 +2049,7 @@ async def open_choice_pack(
rarity_id += 1 rarity_id += 1
elif pack_type == "Promo Choice": elif pack_type == "Promo Choice":
if this_pack["pack_cardset"] is None: 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) d1000 = random.randint(1, 1000)
pack_cover = IMAGES["mvp-hype"] pack_cover = IMAGES["mvp-hype"]
@ -2037,8 +2106,8 @@ async def open_choice_pack(
rarity_id += 3 rarity_id += 3
if len(players) == 0: if len(players) == 0:
logger.error(f"Could not create choice pack") logger.error("Could not create choice pack")
raise ConnectionError(f"Could not create choice pack") raise ConnectionError("Could not create choice pack")
if type(context) == commands.Context: if type(context) == commands.Context:
author = context.author author = context.author
@ -2061,7 +2130,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30) view = Pagination([author], timeout=30)
view.left_button.disabled = True view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}" 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.style = discord.ButtonStyle.success
view.cancel_button.disabled = True view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}" view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -2079,10 +2148,10 @@ async def open_choice_pack(
) )
if rarity_id >= 5: if rarity_id >= 5:
tmp_msg = await pack_channel.send( tmp_msg = await pack_channel.send(
content=f"<@&1163537676885033010> we've got an MVP!" content="<@&1163537676885033010> we've got an MVP!"
) )
else: 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: while True:
await view.wait() await view.wait()
@ -2097,7 +2166,7 @@ async def open_choice_pack(
) )
except Exception as e: except Exception as e:
logger.error(f"failed to create cards: {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( await db_patch(
"packs", "packs",
@ -2131,7 +2200,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30) view = Pagination([author], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}" 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.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}" view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1: if page_num == 1:

View File

@ -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=<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