diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index a4a0ceb..9fae8dc 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -12,6 +12,7 @@ on: push: branches: - main + - next-release pull_request: branches: - main @@ -39,35 +40,25 @@ jobs: id: calver uses: cal/gitea-actions/calver@main - # Dev build: push with dev + dev-SHA tags (PR/feature branches) - - name: Build Docker image (dev) - if: github.ref != 'refs/heads/main' - uses: https://github.com/docker/build-push-action@v5 + - name: Resolve Docker tags + id: tags + uses: cal/gitea-actions/docker-tags@main with: - context: . - push: true - tags: | - manticorum67/paper-dynasty-discordapp:dev - manticorum67/paper-dynasty-discordapp:dev-${{ steps.calver.outputs.sha_short }} - cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache - cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max + image: manticorum67/paper-dynasty-discordapp + version: ${{ steps.calver.outputs.version }} + sha_short: ${{ steps.calver.outputs.sha_short }} - # Production build: push with latest + CalVer tags (main only) - - name: Build Docker image (production) - if: github.ref == 'refs/heads/main' + - name: Build and push Docker image uses: https://github.com/docker/build-push-action@v5 with: context: . push: true - tags: | - manticorum67/paper-dynasty-discordapp:latest - manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }} - manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }} + tags: ${{ steps.tags.outputs.tags }} cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max - name: Tag release - if: success() && github.ref == 'refs/heads/main' + if: success() && steps.tags.outputs.channel == 'stable' uses: cal/gitea-actions/gitea-tag@main with: version: ${{ steps.calver.outputs.version }} @@ -77,26 +68,23 @@ jobs: run: | echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY - echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY + IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}" + for tag in "${TAG_ARRAY[@]}"; do + echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY + done echo "" >> $GITHUB_STEP_SUMMARY echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ github.ref }}" == "refs/heads/main" ]; then - echo "Pushed to Docker Hub!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY - else - echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY - fi + echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY - name: Discord Notification - Success - if: success() && github.ref == 'refs/heads/main' + if: success() && steps.tags.outputs.channel != 'dev' uses: cal/gitea-actions/discord-notify@main with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} @@ -108,7 +96,7 @@ jobs: timestamp: ${{ steps.calver.outputs.timestamp }} - name: Discord Notification - Failure - if: failure() && github.ref == 'refs/heads/main' + if: failure() && steps.tags.outputs.channel != 'dev' uses: cal/gitea-actions/discord-notify@main with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/cogs/economy.py b/cogs/economy.py index b7f2fe2..b3ee210 100644 --- a/cogs/economy.py +++ b/cogs/economy.py @@ -50,71 +50,83 @@ class Economy(commands.Cog): # self.pd_ticker.cancel() async def cog_command_error(self, ctx, error): - await ctx.send(f'{error}\n\nRun .help to see the command requirements') + await ctx.send( + f"{error}\n\nRun .help to see the command requirements" + ) - async def on_app_command_error(self, interaction: discord.Interaction, error: discord.app_commands.AppCommandError): - await interaction.channel.send(f'{error}') + async def on_app_command_error( + self, + interaction: discord.Interaction, + error: discord.app_commands.AppCommandError, + ): + await interaction.channel.send(f"{error}") - async def buy_card(self, interaction: discord.Interaction, this_player: dict, owner_team: dict): - c_query = await db_get('cards', - params=[('player_id', this_player['player_id']), ('team_id', owner_team["id"])]) - num_copies = c_query['count'] if c_query else 0 + async def buy_card( + self, interaction: discord.Interaction, this_player: dict, owner_team: dict + ): + c_query = await db_get( + "cards", + params=[ + ("player_id", this_player["player_id"]), + ("team_id", owner_team["id"]), + ], + ) + num_copies = c_query["count"] if c_query else 0 - if not this_player['cardset']['for_purchase']: + if not this_player["cardset"]["for_purchase"]: await interaction.response.send_message( content=f'Ope - looks like singles from the {this_player["cardset"]["name"]} cardset are not available ' - f'for purchase.' + f"for purchase." ) return - if this_player['cost'] > owner_team['wallet']: + if this_player["cost"] > owner_team["wallet"]: await interaction.response.send_message( content=None, - embeds=await get_card_embeds(get_blank_team_card(this_player)) + embeds=await get_card_embeds(get_blank_team_card(this_player)), ) await interaction.channel.send( content=f'You currently have {num_copies} cop{"ies" if num_copies != 1 else "y"} of this card.\n\n' - f'Your Wallet: {owner_team["wallet"]}₼\n' - f'Card Price: {this_player["cost"]}₼\n' - f'After Purchase: {await get_emoji(interaction.guild, "dead", False)}\n\n' - f'You will have to save up a little more.' + f'Your Wallet: {owner_team["wallet"]}₼\n' + f'Card Price: {this_player["cost"]}₼\n' + f'After Purchase: {await get_emoji(interaction.guild, "dead", False)}\n\n' + f"You will have to save up a little more." ) return view = Confirm(responders=[interaction.user]) await interaction.response.send_message( - content=None, - embeds=await get_card_embeds(get_blank_team_card(this_player)) + content=None, embeds=await get_card_embeds(get_blank_team_card(this_player)) ) question = await interaction.channel.send( content=f'You currently have {num_copies} cop{"ies" if num_copies != 1 else "y"} of this card.\n\n' - f'Your Wallet: {owner_team["wallet"]}₼\n' - f'Card Price: {this_player["cost"]}₼\n' - f'After Purchase: {owner_team["wallet"] - this_player["cost"]}₼\n\n' - f'Would you like to make this purchase?', - view=view + f'Your Wallet: {owner_team["wallet"]}₼\n' + f'Card Price: {this_player["cost"]}₼\n' + f'After Purchase: {owner_team["wallet"] - this_player["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 purchase = await db_get( f'teams/{owner_team["id"]}/buy/players', - params=[('ts', team_hash(owner_team)), ('ids', f'{this_player["player_id"]}')], - timeout=10 + params=[ + ("ts", team_hash(owner_team)), + ("ids", f'{this_player["player_id"]}'), + ], + timeout=10, ) if not purchase: await question.edit( - content=f'That didn\'t go through for some reason. If this happens again, go ping the shit out of Cal.', - view=None + content=f"That didn't go through for some reason. If this happens again, go ping the shit out of Cal.", + view=None, ) return - await question.edit(content=f'It\'s all yours!', view=None) + await question.edit(content=f"It's all yours!", view=None) # async def slash_error(self, ctx, error): # await ctx.send(f'{error}') @@ -181,343 +193,325 @@ class Economy(commands.Cog): @tasks.loop(minutes=10) async def notif_check(self): # Check for notifications - all_notifs = await db_get('notifs', params=[('ack', False)]) + all_notifs = await db_get("notifs", params=[("ack", False)]) if not all_notifs: - logger.debug(f'No notifications') + logger.debug(f"No notifications") return topics = { - 'Price Change': { - 'channel_name': 'pd-market-watch', - 'desc': 'Modified by buying and selling', - 'notifs': [] + "Price Change": { + "channel_name": "pd-market-watch", + "desc": "Modified by buying and selling", + "notifs": [], + }, + "Rare Pull": { + "channel_name": "pd-network-news", + "desc": "MVP and All-Star cards pulled from packs", + "notifs": [], }, - 'Rare Pull': { - 'channel_name': 'pd-network-news', - 'desc': 'MVP and All-Star cards pulled from packs', - 'notifs': [] - } } - for line in all_notifs['notifs']: - if line['title'] in topics: - topics[line['title']]['notifs'].append(line) + for line in all_notifs["notifs"]: + if line["title"] in topics: + topics[line["title"]]["notifs"].append(line) - logger.info(f'topics:\n{topics}') + logger.info(f"topics:\n{topics}") for topic in topics: - embed = get_team_embed(title=f'{topic}{"s" if len(topics[topic]["notifs"]) > 1 else ""}') - embed.description = topics[topic]['desc'] + embed = get_team_embed( + title=f'{topic}{"s" if len(topics[topic]["notifs"]) > 1 else ""}' + ) + embed.description = topics[topic]["desc"] p_list = {} - if topics[topic]['notifs']: - for x in topics[topic]['notifs']: - if x['field_name'] not in p_list: - p_list[x['field_name']] = { - 'field_name': x['field_name'], - 'message': f'{x["message"]}', - 'count': 1 + if topics[topic]["notifs"]: + for x in topics[topic]["notifs"]: + if x["field_name"] not in p_list: + p_list[x["field_name"]] = { + "field_name": x["field_name"], + "message": f'{x["message"]}', + "count": 1, } else: - p_list[x['field_name']]['message'] = f'{x["message"]}' - p_list[x['field_name']]['count'] += 1 - await db_patch('notifs', object_id=x['id'], params=[('ack', True)]) - logger.debug(f'p_list: {p_list}') + p_list[x["field_name"]]["message"] = f'{x["message"]}' + p_list[x["field_name"]]["count"] += 1 + await db_patch("notifs", object_id=x["id"], params=[("ack", True)]) + logger.debug(f"p_list: {p_list}") this_embed = copy.deepcopy(embed) counter = 1 for player in p_list: if counter % 25 == 0: counter = 1 - await send_to_channel(self.bot, topics[topic]['channel_name'], embed=this_embed) + await send_to_channel( + self.bot, topics[topic]["channel_name"], embed=this_embed + ) this_embed = copy.deepcopy(embed) this_embed.add_field( - name=p_list[player]['field_name'], value=p_list[player]['message'], inline=False) + name=p_list[player]["field_name"], + value=p_list[player]["message"], + inline=False, + ) if len(p_list) > 0: - await send_to_channel(self.bot, topics[topic]['channel_name'], embed=this_embed) + await send_to_channel( + self.bot, topics[topic]["channel_name"], embed=this_embed + ) @notif_check.before_loop async def before_notif_check(self): await self.bot.wait_until_ready() - @commands.hybrid_group(name='help-pd', help='FAQ for Paper Dynasty and the bot', aliases=['helppd']) + @commands.hybrid_group( + name="help-pd", help="FAQ for Paper Dynasty and the bot", aliases=["helppd"] + ) @commands.check(legal_channel) async def pd_help_command(self, ctx: commands.Context): if ctx.invoked_subcommand is None: - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'Frequently Asked Questions' + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "Frequently Asked Questions" embed.add_field( - name='What the Heck is Paper Dynasty', - value=f'Well, whipper snapper, have a seat and I\'ll tell you. We\'re running a diamond dynasty / ' - f'perfect team style game with electronic card and dice baseball!\n\nGet a starter pack, play ' - f'games at your leisure either solo or against another player, and collect cards from the ' - f'custom 2021 player set.', - inline=False + name="What the Heck is Paper Dynasty", + value=f"Well, whipper snapper, have a seat and I'll tell you. We're running a diamond dynasty / " + f"perfect team style game with electronic card and dice baseball!\n\nGet a starter pack, play " + f"games at your leisure either solo or against another player, and collect cards from the " + f"custom 2021 player set.", + inline=False, ) embed.add_field( - name='How Do I Get Started', - value=f'Run the `.in` command - that\'s a period followed by the word "in". That\'ll get you the ' - f'Paper Dynasty Players role so you can run all of the other PD commands!\n\nOnce you get your ' - f'role, run `/newteam` and follow the prompts to get your starter team.', - inline=False + name="How Do I Get Started", + value=f"Run the `.in` command - that's a period followed by the word \"in\". That'll get you the " + f"Paper Dynasty Players role so you can run all of the other PD commands!\n\nOnce you get your " + f"role, run `/newteam` and follow the prompts to get your starter team.", + inline=False, ) embed.add_field( - name='How Do I Play', - value='A step-by-step of how to play was written by Riles [starting here](https://discord.com/channels' - '/613880856032968834/633456305830625303/985968300272001054). ' - 'In addition, you can find the Rules Reference [right here](https://docs.google.com/document/d/' - '1yGZcHy9zN2MUi4hnce12dAzlFpIApbn7zR24vCkPl1o).\n\nThere are three key differences from league ' - 'play:\n1) Injuries: there are no injuries in Paper Dynasty!\n2) sWAR: there is no sWAR "salary ' - 'cap" for your team like in league play. Some events will have roster construction rules to ' - 'follow, though!\n3) The Universal DH is in effect; teams may forfeit the DH at their ' - 'discretion.', - inline=False - ) - await ctx.send( - content=None, - embed=embed + name="How Do I Play", + value="A step-by-step of how to play was written by Riles [starting here](https://discord.com/channels" + "/613880856032968834/633456305830625303/985968300272001054). " + "In addition, you can find the Rules Reference [right here](https://docs.google.com/document/d/" + "1yGZcHy9zN2MUi4hnce12dAzlFpIApbn7zR24vCkPl1o).\n\nThere are three key differences from league " + 'play:\n1) Injuries: there are no injuries in Paper Dynasty!\n2) sWAR: there is no sWAR "salary ' + 'cap" for your team like in league play. Some events will have roster construction rules to ' + "follow, though!\n3) The Universal DH is in effect; teams may forfeit the DH at their " + "discretion.", + inline=False, ) + await ctx.send(content=None, embed=embed) - @pd_help_command.command(name='start', help='FAQ for Paper Dynasty and the bot', aliases=['faq']) + @pd_help_command.command( + name="start", help="FAQ for Paper Dynasty and the bot", aliases=["faq"] + ) @commands.check(legal_channel) async def help_faq(self, ctx: commands.Context): - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'Frequently Asked Questions' + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "Frequently Asked Questions" embed.add_field( - name='What the Heck is Paper Dynasty', - value=HELP_START_WHAT, - inline=False + name="What the Heck is Paper Dynasty", value=HELP_START_WHAT, inline=False ) + embed.add_field(name="How Do I Get Started", value=HELP_START_HOW, inline=False) + embed.add_field(name="How Do I Play", value=HELP_START_PLAY, inline=False) embed.add_field( - name='How Do I Get Started', - value=HELP_START_HOW, - inline=False - ) - embed.add_field( - name='How Do I Play', - value=HELP_START_PLAY, - inline=False - ) - embed.add_field( - name='Other Questions?', + name="Other Questions?", value=f'Feel free to ask any questions down in {get_channel(ctx, "paper-dynasty-chat")} or check out ' - f'the other `/help-pd` commands for the FAQs!' - ) - await ctx.send( - content=None, - embed=embed + f"the other `/help-pd` commands for the FAQs!", ) + await ctx.send(content=None, embed=embed) - @pd_help_command.command(name='links', help='Helpful links for Paper Dynasty') + @pd_help_command.command(name="links", help="Helpful links for Paper Dynasty") @commands.check(legal_channel) async def help_links(self, ctx: commands.Context): - current = await db_get('current') - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'Resources & Links' + current = await db_get("current") + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "Resources & Links" embed.add_field( - name='Team Sheet Template', - value=f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}' + name="Team Sheet Template", + value=f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}', ) embed.add_field( - name='Paper Dynasty Guidelines', - value='https://docs.google.com/document/d/1ngsjbz8wYv7heSiPMJ21oKPa6JLStTsw6wNdLDnt-k4/edit?usp=sharing', - inline=False - ) - embed.add_field( - name='Rules Reference', - value='https://docs.google.com/document/d/1wu63XSgfQE2wadiegWaaDda11QvqkN0liRurKm0vcTs/edit?usp=sharing', - inline=False - ) - await ctx.send(content=None, embed=embed) - - @pd_help_command.command(name='rewards', help='How to Earn Rewards in Paper Dynasty') - @commands.check(legal_channel) - async def help_rewards(self, ctx: commands.Context): - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'How to Earn Rewards' - embed.add_field( - name='Premium Pack', - value=HELP_REWARDS_PREMIUM, - inline=False - ) - embed.add_field( - name='Standard Pack', - value=HELP_REWARDS_STANDARD, - inline=False - ) - embed.add_field( - name='MantiBucks ₼', - value=HELP_REWARDS_MONEY, - inline=False - ) - embed.add_field( - name='Ko-fi Shop', - value=HELP_REWARDS_SHOP, - inline=False - ) - await ctx.send(content=None, embed=embed) - - @pd_help_command.command(name='team-sheet', help='How to Use Your Team Sheet') - @commands.check(legal_channel) - async def help_team_sheet(self, ctx: commands.Context): - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'How to Use Your Team Sheet' - embed.add_field( - name='Your Dashboard', - value=HELP_TS_DASH, - inline=False - ) - embed.add_field( - name='Roster Management', - value=HELP_TS_ROSTER, - inline=False - ) - embed.add_field( - name='Marketplace', - value=HELP_TS_MARKET, - inline=False - ) - embed.add_field( - name='Paper Dynasty Menu', - value=HELP_TS_MENU, - inline=False - ) - embed.set_footer( - text='More details to come', - icon_url=IMAGES['logo'] - ) - await ctx.send(content=None, embed=embed) - - @pd_help_command.command(name='gameplay', help='How to Play Paper Dynasty') - @commands.check(legal_channel) - async def help_gameplay(self, ctx: commands.Context): - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'How to Play Paper Dynasty' - embed.add_field( - name='Game Modes', - value=HELP_GAMEMODES, - inline=False - ) - embed.add_field( - name='Start a New Game', - value=HELP_NEWGAME, + name="Paper Dynasty Guidelines", + value="https://docs.google.com/document/d/1ngsjbz8wYv7heSiPMJ21oKPa6JLStTsw6wNdLDnt-k4/edit?usp=sharing", inline=False, ) embed.add_field( - name='Playing the Game', - value=HELP_PLAYGAME, - inline=False - ) - embed.add_field( - name='Ending the Game', - value=f'{HELP_ENDGAME}\n' - f'- Go post highlights in {get_channel(ctx, "pd-news-ticker").mention}', - inline=False + name="Rules Reference", + value="https://docs.google.com/document/d/1wu63XSgfQE2wadiegWaaDda11QvqkN0liRurKm0vcTs/edit?usp=sharing", + inline=False, ) await ctx.send(content=None, embed=embed) - @pd_help_command.command(name='cardsets', help='Show Cardset Requirements') + @pd_help_command.command( + name="rewards", help="How to Earn Rewards in Paper Dynasty" + ) + @commands.check(legal_channel) + async def help_rewards(self, ctx: commands.Context): + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "How to Earn Rewards" + embed.add_field(name="Premium Pack", value=HELP_REWARDS_PREMIUM, inline=False) + embed.add_field(name="Standard Pack", value=HELP_REWARDS_STANDARD, inline=False) + embed.add_field(name="MantiBucks ₼", value=HELP_REWARDS_MONEY, inline=False) + embed.add_field(name="Ko-fi Shop", value=HELP_REWARDS_SHOP, inline=False) + await ctx.send(content=None, embed=embed) + + @pd_help_command.command(name="team-sheet", help="How to Use Your Team Sheet") + @commands.check(legal_channel) + async def help_team_sheet(self, ctx: commands.Context): + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "How to Use Your Team Sheet" + embed.add_field(name="Your Dashboard", value=HELP_TS_DASH, inline=False) + embed.add_field(name="Roster Management", value=HELP_TS_ROSTER, inline=False) + embed.add_field(name="Marketplace", value=HELP_TS_MARKET, inline=False) + embed.add_field(name="Paper Dynasty Menu", value=HELP_TS_MENU, inline=False) + embed.set_footer(text="More details to come", icon_url=IMAGES["logo"]) + await ctx.send(content=None, embed=embed) + + @pd_help_command.command(name="gameplay", help="How to Play Paper Dynasty") + @commands.check(legal_channel) + async def help_gameplay(self, ctx: commands.Context): + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "How to Play Paper Dynasty" + embed.add_field(name="Game Modes", value=HELP_GAMEMODES, inline=False) + embed.add_field( + name="Start a New Game", + value=HELP_NEWGAME, + inline=False, + ) + embed.add_field(name="Playing the Game", value=HELP_PLAYGAME, inline=False) + embed.add_field( + name="Ending the Game", + value=f"{HELP_ENDGAME}\n" + f'- Go post highlights in {get_channel(ctx, "pd-news-ticker").mention}', + inline=False, + ) + await ctx.send(content=None, embed=embed) + + @pd_help_command.command(name="cardsets", help="Show Cardset Requirements") @commands.check(legal_channel) async def help_cardsets(self, ctx: commands.Context): - embed = get_team_embed(f'Paper Dynasty Help') - embed.description = 'Cardset Requirements' + embed = get_team_embed(f"Paper Dynasty Help") + embed.description = "Cardset Requirements" embed.add_field( - name='Ranked Legal', - value='2005, 2025 Seasons + Promos', - inline=False + name="Ranked Legal", value="2005, 2025 Seasons + Promos", inline=False ) embed.add_field( - name='Minor League', - value='Humans: Unlimited\nAI: 2005 Season / 2025 Season as backup', - inline=False + name="Minor League", + value="Humans: Unlimited\nAI: 2005 Season / 2025 Season as backup", + inline=False, ) embed.add_field( - name='Major League', - value='Humans: Ranked Legal\nAI: 2005, 2025, 2018, 2012 Seasons + Promos', - inline=False + name="Major League", + value="Humans: Ranked Legal\nAI: 2005, 2025, 2018, 2012 Seasons + Promos", + inline=False, ) embed.add_field( - name='Flashback', - value='2018, 2019, 2021, 2022 Seasons', - inline=False + name="Flashback", value="2018, 2019, 2021, 2022 Seasons", inline=False ) embed.add_field( - name='Hall of Fame', - value='Humans: Ranked Legal\nAI: Unlimited', - inline=False + name="Hall of Fame", + value="Humans: Ranked Legal\nAI: Unlimited", + inline=False, ) await ctx.send(content=None, embed=embed) - @commands.hybrid_group(name='donation', help='Mod: Give packs for PD donations') + @commands.hybrid_group(name="donation", help="Mod: Give packs for PD donations") @commands.has_any_role(PD_PLAYERS_ROLE_NAME) async def donation(self, ctx: commands.Context): if ctx.invoked_subcommand is None: - await ctx.send('To buy packs, visit https://ko-fi.com/manticorum/shop and include your discord username!') + await ctx.send( + "To buy packs, visit https://ko-fi.com/manticorum/shop and include your discord username!" + ) - @donation.command(name='premium', help='Mod: Give premium packs', aliases=['p', 'prem']) + @donation.command( + name="premium", help="Mod: Give premium packs", aliases=["p", "prem"] + ) async def donation_premium(self, ctx: commands.Context, num_packs: int, gm: Member): if ctx.author.id != self.bot.owner_id: - await ctx.send('Wait a second. You\'re not in charge here!') + await ctx.send("Wait a second. You're not in charge here!") return team = await get_team_by_owner(gm.id) - p_query = await db_get('packtypes', params=[('name', 'Premium')]) - if p_query['count'] == 0: - await ctx.send('Oof. I couldn\'t find a Premium Pack') + p_query = await db_get("packtypes", params=[("name", "Premium")]) + if p_query["count"] == 0: + await ctx.send("Oof. I couldn't find a Premium Pack") return - total_packs = await give_packs(team, num_packs, pack_type=p_query['packtypes'][0]) - await ctx.send(f'The {team["lname"]} now have {total_packs["count"]} total packs!') + total_packs = await give_packs( + team, num_packs, pack_type=p_query["packtypes"][0] + ) + await ctx.send( + f'The {team["lname"]} now have {total_packs["count"]} total packs!' + ) - @donation.command(name='standard', help='Mod: Give standard packs', aliases=['s', 'sta']) - async def donation_standard(self, ctx: commands.Context, num_packs: int, gm: Member): + @donation.command( + name="standard", help="Mod: Give standard packs", aliases=["s", "sta"] + ) + async def donation_standard( + self, ctx: commands.Context, num_packs: int, gm: Member + ): if ctx.author.id != self.bot.owner_id: - await ctx.send('Wait a second. You\'re not in charge here!') + await ctx.send("Wait a second. You're not in charge here!") return team = await get_team_by_owner(gm.id) - p_query = await db_get('packtypes', params=[('name', 'Standard')]) - if p_query['count'] == 0: - await ctx.send('Oof. I couldn\'t find a Standard Pack') + p_query = await db_get("packtypes", params=[("name", "Standard")]) + if p_query["count"] == 0: + await ctx.send("Oof. I couldn't find a Standard Pack") return - total_packs = await give_packs(team, num_packs, pack_type=p_query['packtypes'][0]) - await ctx.send(f'The {team["lname"]} now have {total_packs["count"]} total packs!') + total_packs = await give_packs( + team, num_packs, pack_type=p_query["packtypes"][0] + ) + await ctx.send( + f'The {team["lname"]} now have {total_packs["count"]} total packs!' + ) - @commands.hybrid_command(name='lastpack', help='Replay your last pack') + @commands.hybrid_command(name="lastpack", help="Replay your last pack") @commands.check(legal_channel) @commands.has_any_role(PD_PLAYERS_ROLE_NAME) async def last_pack_command(self, ctx: commands.Context): team = await get_team_by_owner(ctx.author.id) if not team: - await ctx.send(f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!') + await ctx.send( + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" + ) return p_query = await db_get( - 'packs', - params=[('opened', True), ('team_id', team['id']), ('new_to_old', True), ('limit', 1)] + "packs", + params=[ + ("opened", True), + ("team_id", team["id"]), + ("new_to_old", True), + ("limit", 1), + ], ) - if not p_query['count']: - await ctx.send(f'I do not see any packs for you, bub.') + if not p_query["count"]: + await ctx.send(f"I do not see any packs for you, bub.") return - pack_name = p_query['packs'][0]['pack_type']['name'] - if pack_name == 'Standard': - pack_cover = IMAGES['pack-sta'] - elif pack_name == 'Premium': - pack_cover = IMAGES['pack-pre'] + pack_name = p_query["packs"][0]["pack_type"]["name"] + if pack_name == "Standard": + pack_cover = IMAGES["pack-sta"] + elif pack_name == "Premium": + pack_cover = IMAGES["pack-pre"] else: pack_cover = None - c_query = await db_get( - 'cards', - params=[('pack_id', p_query['packs'][0]['id'])] - ) - if not c_query['count']: - await ctx.send(f'Hmm...I didn\'t see any cards in that pack.') + c_query = await db_get("cards", params=[("pack_id", p_query["packs"][0]["id"])]) + if not c_query["count"]: + await ctx.send(f"Hmm...I didn't see any cards in that pack.") return - await display_cards(c_query['cards'], team, ctx.channel, ctx.author, self.bot, pack_cover=pack_cover) + await display_cards( + c_query["cards"], + team, + ctx.channel, + ctx.author, + self.bot, + pack_cover=pack_cover, + ) - @app_commands.command(name='comeonmanineedthis', description='Daily check-in for cards, currency, and packs') + @app_commands.command( + name="comeonmanineedthis", + description="Daily check-in for cards, currency, and packs", + ) @app_commands.checks.has_any_role(PD_PLAYERS) @app_legal_channel() async def daily_checkin(self, interaction: discord.Interaction): @@ -525,35 +519,53 @@ class Economy(commands.Cog): team = await get_team_by_owner(interaction.user.id) if not team: await interaction.edit_original_response( - content=f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' + content=f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" ) return - current = await db_get('current') + current = await db_get("current") now = datetime.datetime.now() - midnight = int_timestamp(datetime.datetime(now.year, now.month, now.day, 0, 0, 0)) - daily = await db_get('rewards', params=[ - ('name', 'Daily Check-in'), ('team_id', team['id']), ('created_after', midnight) - ]) - logger.debug(f'midnight: {midnight} / now: {int_timestamp(now)}') - logger.debug(f'daily_return: {daily}') + midnight = int_timestamp( + datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + ) + daily = await db_get( + "rewards", + params=[ + ("name", "Daily Check-in"), + ("team_id", team["id"]), + ("created_after", midnight), + ], + ) + logger.debug(f"midnight: {midnight} / now: {int_timestamp(now)}") + logger.debug(f"daily_return: {daily}") if daily: await interaction.edit_original_response( - content=f'Looks like you already checked in today - come back at midnight Central!' + content=f"Looks like you already checked in today - come back at midnight Central!" ) return - await db_post('rewards', payload={ - 'name': 'Daily Check-in', 'team_id': team['id'], 'season': current['season'], 'week': current['week'], - 'created': int_timestamp(now) - }) - current = await db_get('current') - check_ins = await db_get('rewards', params=[ - ('name', 'Daily Check-in'), ('team_id', team['id']), ('season', current['season']) - ]) + await db_post( + "rewards", + payload={ + "name": "Daily Check-in", + "team_id": team["id"], + "season": current["season"], + "week": current["week"], + "created": int_timestamp(now), + }, + ) + current = await db_get("current") + check_ins = await db_get( + "rewards", + params=[ + ("name", "Daily Check-in"), + ("team_id", team["id"]), + ("season", current["season"]), + ], + ) - check_count = check_ins['count'] % 5 + check_count = check_ins["count"] % 5 # TODO: complete the migration to an interaction # 2nd, 4th, and 5th check-ins @@ -561,44 +573,54 @@ class Economy(commands.Cog): # Every fifth check-in if check_count == 0: greeting = await interaction.edit_original_response( - content=f'Hey, you just earned a Standard pack of cards!' + content=f"Hey, you just earned a Standard pack of cards!" ) - pack_channel = get_channel(interaction, 'pack-openings') + pack_channel = get_channel(interaction, "pack-openings") - p_query = await db_get('packtypes', params=[('name', 'Standard')]) + p_query = await db_get("packtypes", params=[("name", "Standard")]) if not p_query: await interaction.edit_original_response( - content=f'I was not able to pull this pack for you. ' - f'Maybe ping {get_cal_user(interaction).mention}?' + content=f"I was not able to pull this pack for you. " + f"Maybe ping {get_cal_user(interaction).mention}?" ) return # Every second and fourth check-in else: greeting = await interaction.edit_original_response( - content=f'Hey, you just earned a player card!' + content=f"Hey, you just earned a player card!" ) pack_channel = interaction.channel - p_query = await db_get('packtypes', params=[('name', 'Check-In Player')]) + p_query = await db_get( + "packtypes", params=[("name", "Check-In Player")] + ) if not p_query: await interaction.edit_original_response( - content=f'I was not able to pull this card for you. ' - f'Maybe ping {get_cal_user(interaction).mention}?' + content=f"I was not able to pull this card for you. " + f"Maybe ping {get_cal_user(interaction).mention}?" ) return - await give_packs(team, 1, p_query['packtypes'][0]) + await give_packs(team, 1, p_query["packtypes"][0]) p_query = await db_get( - 'packs', - params=[('opened', False), ('team_id', team['id']), ('new_to_old', True), ('limit', 1)] + "packs", + params=[ + ("opened", False), + ("team_id", team["id"]), + ("new_to_old", True), + ("limit", 1), + ], ) - if not p_query['count']: + if not p_query["count"]: await interaction.edit_original_response( - content=f'I do not see any packs in here. {await get_emoji(interaction, "ConfusedPsyduck")}') + content=f'I do not see any packs in here. {await get_emoji(interaction, "ConfusedPsyduck")}' + ) return - pack_ids = await roll_for_cards(p_query['packs'], extra_val=check_ins['count']) + pack_ids = await roll_for_cards( + p_query["packs"], extra_val=check_ins["count"] + ) if not pack_ids: await greeting.edit( content=f'I was not able to create these cards {await get_emoji(interaction, "slight_frown")}' @@ -607,8 +629,8 @@ class Economy(commands.Cog): all_cards = [] for p_id in pack_ids: - new_cards = await db_get('cards', params=[('pack_id', p_id)]) - all_cards.extend(new_cards['cards']) + new_cards = await db_get("cards", params=[("pack_id", p_id)]) + all_cards.extend(new_cards["cards"]) if not all_cards: await interaction.edit_original_response( @@ -616,7 +638,9 @@ class Economy(commands.Cog): ) return - await display_cards(all_cards, team, pack_channel, interaction.user, self.bot) + await display_cards( + all_cards, team, pack_channel, interaction.user, self.bot + ) await refresh_sheet(team, self.bot) return @@ -636,85 +660,92 @@ class Economy(commands.Cog): team = await db_post(f'teams/{team["id"]}/money/{m_reward}') await interaction.edit_original_response( - content=f'You just earned {m_reward}₼! That brings your wallet to {team["wallet"]}₼!') + content=f'You just earned {m_reward}₼! That brings your wallet to {team["wallet"]}₼!' + ) - @app_commands.command(name='open-packs', description='Open packs from your inventory') + @app_commands.command( + name="open-packs", description="Open packs from your inventory" + ) @app_commands.checks.has_any_role(PD_PLAYERS) async def open_packs_slash(self, interaction: discord.Interaction): - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']: + if interaction.channel.name in [ + "paper-dynasty-chat", + "pd-news-ticker", + "pd-network-news", + ]: await interaction.response.send_message( f'Please head to down to {get_channel(interaction, "pd-bot-hole")} to run this command.', - ephemeral=True + ephemeral=True, ) return owner_team = await get_team_by_owner(interaction.user.id) if not owner_team: await interaction.response.send_message( - f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" ) return - p_query = await db_get('packs', params=[ - ('team_id', owner_team['id']), ('opened', False) - ]) - if p_query['count'] == 0: + p_query = await db_get( + "packs", params=[("team_id", owner_team["id"]), ("opened", False)] + ) + if p_query["count"] == 0: await interaction.response.send_message( - f'Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by ' - f'donating to the league.' + f"Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by " + f"donating to the league." ) return # Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium) p_count = 0 p_data = { - 'Standard': [], - 'Premium': [], - 'Daily': [], - 'MVP': [], - 'All Star': [], - 'Mario': [], - 'Team Choice': [] + "Standard": [], + "Premium": [], + "Daily": [], + "MVP": [], + "All Star": [], + "Mario": [], + "Team Choice": [], } - logger.debug(f'Parsing packs...') - for pack in p_query['packs']: + logger.debug(f"Parsing packs...") + for pack in p_query["packs"]: p_group = None - logger.debug(f'pack: {pack}') + logger.debug(f"pack: {pack}") logger.debug(f'pack cardset: {pack["pack_cardset"]}') - if pack['pack_team'] is None and pack['pack_cardset'] is None: - p_group = pack['pack_type']['name'] + if pack["pack_team"] is None and pack["pack_cardset"] is None: + p_group = pack["pack_type"]["name"] # Add to p_data if this is a new pack type if p_group not in p_data: p_data[p_group] = [] - elif pack['pack_team'] is not None: - if pack['pack_type']['name'] == 'Standard': + elif pack["pack_team"] is not None: + if pack["pack_type"]["name"] == "Standard": p_group = f'Standard-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' - elif pack['pack_type']['name'] == 'Premium': + elif pack["pack_type"]["name"] == "Premium": p_group = f'Premium-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' - elif pack['pack_type']['name'] == 'Team Choice': + elif pack["pack_type"]["name"] == "Team Choice": p_group = f'Team Choice-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' - elif pack['pack_type']['name'] == 'MVP': + elif pack["pack_type"]["name"] == "MVP": p_group = f'MVP-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' - if pack['pack_cardset'] is not None: + if pack["pack_cardset"] is not None: p_group += f'-Cardset-{pack["pack_cardset"]["id"]}' - elif pack['pack_cardset'] is not None: - if pack['pack_type']['name'] == 'Standard': + elif pack["pack_cardset"] is not None: + if pack["pack_type"]["name"] == "Standard": p_group = f'Standard-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' - elif pack['pack_type']['name'] == 'Premium': + elif pack["pack_type"]["name"] == "Premium": p_group = f'Premium-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' - elif pack['pack_type']['name'] == 'Team Choice': + elif pack["pack_type"]["name"] == "Team Choice": p_group = f'Team Choice-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' - elif pack['pack_type']['name'] == 'All Star': + elif pack["pack_type"]["name"] == "All Star": p_group = f'All Star-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' - elif pack['pack_type']['name'] == 'MVP': + elif pack["pack_type"]["name"] == "MVP": p_group = f'MVP-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' - elif pack['pack_type']['name'] == 'Promo Choice': + elif pack["pack_type"]["name"] == "Promo Choice": p_group = f'Promo Choice-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' - logger.info(f'p_group: {p_group}') + logger.info(f"p_group: {p_group}") if p_group is not None: p_count += 1 if p_group not in p_data: @@ -724,327 +755,390 @@ class Economy(commands.Cog): if p_count == 0: await interaction.response.send_message( - f'Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by ' - f'donating to the league.' + f"Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by " + f"donating to the league." ) return # Display options and ask which group to open - embed = get_team_embed(f'Unopened Packs', team=owner_team) - embed.description = owner_team['lname'] + embed = get_team_embed(f"Unopened Packs", team=owner_team) + embed.description = owner_team["lname"] select_options = [] for key in p_data: if len(p_data[key]) > 0: pretty_name = None # Not a specific pack - if '-' not in key: + if "-" not in key: pretty_name = key - elif 'Team' in key: + elif "Team" in key: pretty_name = f'{key.split("-")[0]} - {key.split("-")[3]}' - elif 'Cardset' in key: + elif "Cardset" in key: pretty_name = f'{key.split("-")[0]} - {key.split("-")[3]}' if pretty_name is not None: - embed.add_field(name=pretty_name, value=f'Qty: {len(p_data[key])}') - select_options.append(discord.SelectOption(label=pretty_name, value=key)) + embed.add_field(name=pretty_name, value=f"Qty: {len(p_data[key])}") + select_options.append( + discord.SelectOption(label=pretty_name, value=key) + ) - view = SelectView(select_objects=[SelectOpenPack(select_options, owner_team)], timeout=15) + view = SelectView( + select_objects=[SelectOpenPack(select_options, owner_team)], timeout=15 + ) await interaction.response.send_message(embed=embed, view=view) - group_buy = app_commands.Group(name='buy', description='Make a purchase from the marketplace') + group_buy = app_commands.Group( + name="buy", description="Make a purchase from the marketplace" + ) - @group_buy.command(name='card-by-id', description='Buy a player card from the marketplace') + @group_buy.command( + name="card-by-id", description="Buy a player card from the marketplace" + ) @app_commands.checks.has_any_role(PD_PLAYERS) async def buy_card_id_slash(self, interaction: discord.Interaction, player_id: int): - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']: + if interaction.channel.name in [ + "paper-dynasty-chat", + "pd-news-ticker", + "pd-network-news", + ]: await interaction.response.send_message( f'Please head to down to {get_channel(interaction, "pd-bot-hole")} to run this command.', - ephemeral=True + ephemeral=True, ) return owner_team = await get_team_by_owner(interaction.user.id) if not owner_team: await interaction.response.send_message( - f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" ) - p_query = await db_get('players', object_id=player_id, none_okay=False) - logger.debug(f'this_player: {p_query}') + p_query = await db_get("players", object_id=player_id, none_okay=False) + logger.debug(f"this_player: {p_query}") await self.buy_card(interaction, p_query, owner_team) - @group_buy.command(name='card-by-name', description='Buy a player card from the marketplace') + @group_buy.command( + name="card-by-name", description="Buy a player card from the marketplace" + ) @app_commands.checks.has_any_role(PD_PLAYERS) @app_commands.describe( - player_name='Name of the player you want to purchase', - player_cardset='Optional: Name of the cardset the player is from' + player_name="Name of the player you want to purchase", + player_cardset="Optional: Name of the cardset the player is from", ) async def buy_card_slash( - self, interaction: discord.Interaction, player_name: str, player_cardset: Optional[str] = None): - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']: + self, + interaction: discord.Interaction, + player_name: str, + player_cardset: Optional[str] = None, + ): + if interaction.channel.name in [ + "paper-dynasty-chat", + "pd-news-ticker", + "pd-network-news", + ]: await interaction.response.send_message( f'Please head to down to {get_channel(interaction, "pd-bot-hole")} to run this command.', - ephemeral=True + ephemeral=True, ) return owner_team = await get_team_by_owner(interaction.user.id) if not owner_team: await interaction.response.send_message( - f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" ) - player_cog = self.bot.get_cog('Players') + player_cog = self.bot.get_cog("Players") proper_name = fuzzy_search(player_name, player_cog.player_list) if not proper_name: - await interaction.response.send_message(f'No clue who that is.') + await interaction.response.send_message(f"No clue who that is.") return - all_params = [('name', proper_name)] + all_params = [("name", proper_name)] if player_cardset: this_cardset = await cardset_search(player_cardset, player_cog.cardset_list) - all_params.append(('cardset_id', this_cardset['id'])) + all_params.append(("cardset_id", this_cardset["id"])) - p_query = await db_get('players', params=all_params) + p_query = await db_get("players", params=all_params) - if p_query['count'] == 0: + if p_query["count"] == 0: await interaction.response.send_message( - f'I didn\'t find any cards for {proper_name}' + f"I didn't find any cards for {proper_name}" ) return - if p_query['count'] > 1: + if p_query["count"] > 1: await interaction.response.send_message( f'I found {p_query["count"]} different cards for {proper_name}. Would you please run this again ' - f'with the cardset specified?' + f"with the cardset specified?" ) return - this_player = p_query['players'][0] - logger.debug(f'this_player: {this_player}') + this_player = p_query["players"][0] + logger.debug(f"this_player: {this_player}") await self.buy_card(interaction, this_player, owner_team) - @group_buy.command(name='pack', description='Buy a pack or 7 from the marketplace') + @group_buy.command(name="pack", description="Buy a pack or 7 from the marketplace") @app_commands.checks.has_any_role(PD_PLAYERS) @app_commands.describe() async def buy_pack_slash(self, interaction: discord.Interaction): - if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']: + if interaction.channel.name in [ + "paper-dynasty-chat", + "pd-news-ticker", + "pd-network-news", + ]: await interaction.response.send_message( f'Please head to down to {get_channel(interaction, "pd-bot-hole")} to run this command.', - ephemeral=True + ephemeral=True, ) return owner_team = await get_team_by_owner(interaction.user.id) if not owner_team: await interaction.response.send_message( - f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" ) - p_query = await db_get('packtypes', params=[('available', True)]) - if 'count' not in p_query: + p_query = await db_get("packtypes", params=[("available", True)]) + if "count" not in p_query: await interaction.response.send_message( - f'Welp, I couldn\'t find any packs in my database. Should probably go ping ' - f'{get_cal_user(interaction).mention} about that.' + f"Welp, I couldn't find any packs in my database. Should probably go ping " + f"{get_cal_user(interaction).mention} about that." ) return - embed = get_team_embed('Packs for Purchase') + embed = get_team_embed("Packs for Purchase") # embed.description = 'Run `/buy pack `' - for x in p_query['packtypes']: - embed.add_field(name=f'{x["name"]} - {x["cost"]}₼', value=f'{x["description"]}') + for x in p_query["packtypes"]: + embed.add_field( + name=f'{x["name"]} - {x["cost"]}₼', value=f'{x["description"]}' + ) - pack_options = [x['name'] for x in p_query['packtypes'][:5] if x['available'] and x['cost']] + pack_options = [ + x["name"] for x in p_query["packtypes"][:5] if x["available"] and x["cost"] + ] if len(pack_options) < 5: - pack_options.extend(['na' for x in range(5 - len(pack_options))]) + pack_options.extend(["na" for x in range(5 - len(pack_options))]) view = ButtonOptions( - responders=[interaction.user], timeout=60, - labels=pack_options + responders=[interaction.user], timeout=60, labels=pack_options ) - await interaction.response.send_message( - content=None, - embed=embed - ) + await interaction.response.send_message(content=None, embed=embed) question = await interaction.channel.send( - f'Which pack would you like to purchase?', view=view + f"Which pack would you like to purchase?", view=view ) await view.wait() if view.value: pack_name = view.value await question.delete() - this_q = Question(self.bot, interaction.channel, 'How many would you like?', 'int', 60) + this_q = Question( + self.bot, interaction.channel, "How many would you like?", "int", 60 + ) num_packs = await this_q.ask([interaction.user]) else: await question.delete() - await interaction.channel.send('Hm. Another window shopper. I\'ll be here when you\'re serious.') + await interaction.channel.send( + "Hm. Another window shopper. I'll be here when you're serious." + ) return p_query = await db_get( - 'packtypes', params=[('name', pack_name.lower().replace('pack', '')), ('available', True)] + "packtypes", + params=[ + ("name", pack_name.lower().replace("pack", "")), + ("available", True), + ], ) - if 'count' not in p_query: + if "count" not in p_query: await interaction.channel.send( - f'Hmm...I don\'t recognize {pack_name.title()} as a pack type. Check on that and get back to me.', - ephemeral=True + f"Hmm...I don't recognize {pack_name.title()} as a pack type. Check on that and get back to me.", + ephemeral=True, ) return - pack_type = p_query['packtypes'][0] + pack_type = p_query["packtypes"][0] - pack_cover = IMAGES['logo'] - if pack_type['name'] == 'Standard': - pack_cover = IMAGES['pack-sta'] - elif pack_type['name'] == 'Premium': - pack_cover = IMAGES['pack-pre'] - elif pack_type['name'] == 'Promo Choice': - pack_cover = IMAGES['mvp-hype'] + pack_cover = IMAGES["logo"] + if pack_type["name"] == "Standard": + pack_cover = IMAGES["pack-sta"] + elif pack_type["name"] == "Premium": + pack_cover = IMAGES["pack-pre"] + elif pack_type["name"] == "Promo Choice": + pack_cover = IMAGES["mvp-hype"] - total_cost = pack_type['cost'] * num_packs + total_cost = pack_type["cost"] * num_packs pack_embed = image_embed( pack_cover, title=f'{owner_team["lname"]}', desc=f'{num_packs if num_packs > 1 else ""}{"x " if num_packs > 1 else ""}' - f'{pack_type["name"]} Pack{"s" if num_packs != 1 else ""}', + f'{pack_type["name"]} Pack{"s" if num_packs != 1 else ""}', ) - if total_cost > owner_team['wallet']: - await interaction.channel.send( - content=None, - embed=pack_embed - ) + if total_cost > owner_team["wallet"]: + await interaction.channel.send(content=None, embed=pack_embed) await interaction.channel.send( content=f'Your Wallet: {owner_team["wallet"]}₼\n' - f'Pack{"s" if num_packs > 1 else ""} Price: {total_cost}₼\n' - f'After Purchase: {await get_emoji(interaction.guild, "dead", False)}\n\n' - f'You will have to save up a little more.' + f'Pack{"s" if num_packs > 1 else ""} Price: {total_cost}₼\n' + f'After Purchase: {await get_emoji(interaction.guild, "dead", False)}\n\n' + f"You will have to save up a little more." ) return # Get Customization and make purchase - if pack_name in ['Standard', 'Premium']: + if pack_name in ["Standard", "Premium"]: view = ButtonOptions( [interaction.user], timeout=15, - labels=['No Customization', 'Cardset', 'Franchise', None, None] + labels=["No Customization", "Cardset", "Franchise", None, None], ) view.option1.style = discord.ButtonStyle.danger await interaction.channel.send( - content='Would you like to apply a pack customization?', + content="Would you like to apply a pack customization?", embed=pack_embed, - view=view + view=view, ) await view.wait() if not view.value: - await interaction.channel.send(f'You think on it and get back to me.') + await interaction.channel.send(f"You think on it and get back to me.") return - elif view.value == 'Cardset': - # await interaction.delete_original_response() - view = SelectView([SelectBuyPacksCardset(owner_team, num_packs, pack_type['id'], pack_embed, total_cost)]) - await interaction.channel.send( - content=None, - view=view - ) - return - elif view.value == 'Franchise': + elif view.value == "Cardset": # await interaction.delete_original_response() view = SelectView( [ - SelectBuyPacksTeam('AL', owner_team, num_packs, pack_type['id'], pack_embed, total_cost), - SelectBuyPacksTeam('NL', owner_team, num_packs, pack_type['id'], pack_embed, total_cost) + SelectBuyPacksCardset( + owner_team, + num_packs, + pack_type["id"], + pack_embed, + total_cost, + ) + ] + ) + await interaction.channel.send(content=None, view=view) + return + elif view.value == "Franchise": + # await interaction.delete_original_response() + view = SelectView( + [ + SelectBuyPacksTeam( + "AL", + owner_team, + num_packs, + pack_type["id"], + pack_embed, + total_cost, + ), + SelectBuyPacksTeam( + "NL", + owner_team, + num_packs, + pack_type["id"], + pack_embed, + total_cost, + ), ], - timeout=30 - ) - await interaction.channel.send( - content=None, - view=view + timeout=30, ) + await interaction.channel.send(content=None, view=view) return - question = await confirm_pack_purchase(interaction, owner_team, num_packs, total_cost, pack_embed) + question = await confirm_pack_purchase( + interaction, owner_team, num_packs, total_cost, pack_embed + ) if question is None: return purchase = await db_get( f'teams/{owner_team["id"]}/buy/pack/{pack_type["id"]}', - params=[('ts', team_hash(owner_team)), ('quantity', num_packs)] + params=[("ts", team_hash(owner_team)), ("quantity", num_packs)], ) if not purchase: await question.edit( - f'That didn\'t go through for some reason. If this happens again, go ping the shit out of Cal.', - view=None + f"That didn't go through for some reason. If this happens again, go ping the shit out of Cal.", + view=None, ) return await question.edit( content=f'{"They are" if num_packs > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', - view=None + view=None, ) return - @app_commands.command(name='selldupes', description='Sell all of your duplicate cards') + @app_commands.command( + name="selldupes", description="Sell all of your duplicate cards" + ) @app_commands.checks.has_any_role(PD_PLAYERS) @app_legal_channel() @app_commands.describe( - immediately='Skip all prompts and sell dupes immediately; default False', - skip_live='Skip all live series cards; default True' + immediately="Skip all prompts and sell dupes immediately; default False", + skip_live="Skip all live series cards; default True", ) async def sell_dupes_command( - self, interaction: discord.Interaction, skip_live: bool = True, immediately: bool = False): + self, + interaction: discord.Interaction, + skip_live: bool = True, + immediately: bool = False, + ): team = await get_team_by_owner(interaction.user.id) if not team: await interaction.response.send_message( - f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!', - ephemeral=True + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!", + ephemeral=True, ) return await interaction.response.send_message( - f'Let me flip through your cards. This could take a while if you have a ton of cards...' + f"Let me flip through your cards. This could take a while if you have a ton of cards..." ) try: - c_query = await db_get('cards', params=[('team_id', team['id']), ('dupes', True)], timeout=15) + c_query = await db_get( + "cards", params=[("team_id", team["id"]), ("dupes", True)], timeout=15 + ) except Exception as e: await interaction.edit_original_response( - content=f'{e}\n\nSounds like a {get_cal_user(interaction).mention} problem tbh' + content=f"{e}\n\nSounds like a {get_cal_user(interaction).mention} problem tbh" ) return player_ids = [] - dupe_ids = '' + dupe_ids = "" dupe_cards = [] - dupe_strings = ['' for x in range(20)] + dupe_strings = ["" for x in range(20)] str_count = 0 - for card in c_query['cards']: + for card in c_query["cards"]: if len(dupe_strings[str_count]) > 1500: str_count += 1 - logger.debug(f'card: {card}') - if skip_live and (card['player']['cardset']['id'] == LIVE_CARDSET_ID): - logger.debug(f'live series card - skipping') - elif card['player']['player_id'] not in player_ids: - logger.debug(f'not a dupe') - player_ids.append(card['player']['player_id']) + logger.debug(f"card: {card}") + if skip_live and (card["player"]["cardset"]["id"] == LIVE_CARDSET_ID): + logger.debug(f"live series card - skipping") + elif card["player"]["player_id"] not in player_ids: + logger.debug(f"not a dupe") + player_ids.append(card["player"]["player_id"]) else: logger.info(f'{team["abbrev"]} duplicate card: {card["id"]}') dupe_cards.append(card) dupe_ids += f'{card["id"]},' - dupe_strings[str_count] += f'{card["player"]["rarity"]["name"]} {card["player"]["p_name"]} - ' \ - f'{card["player"]["cardset"]["name"]}\n' + dupe_strings[str_count] += ( + f'{card["player"]["rarity"]["name"]} {card["player"]["p_name"]} - ' + f'{card["player"]["cardset"]["name"]}\n' + ) if len(dupe_cards) == 0: - await interaction.edit_original_response(content=f'You currently have 0 duplicate cards!') + await interaction.edit_original_response( + content=f"You currently have 0 duplicate cards!" + ) return - logger.info(f'sending first message / length {len(dupe_strings[0])}') + logger.info(f"sending first message / length {len(dupe_strings[0])}") await interaction.edit_original_response( - content=f'You currently have {len(dupe_cards)} duplicate cards:\n\n{dupe_strings[0]}' + content=f"You currently have {len(dupe_cards)} duplicate cards:\n\n{dupe_strings[0]}" ) for x in dupe_strings[1:]: - logger.info(f'checking string: {len(x)}') + logger.info(f"checking string: {len(x)}") if len(x) > 0: await interaction.channel.send(x) else: @@ -1052,139 +1146,152 @@ class Economy(commands.Cog): if not immediately: view = Confirm(responders=[interaction.user]) - question = await interaction.channel.send('Would you like to sell all of them?', view=view) + question = await interaction.channel.send( + "Would you like to sell all of them?", view=view + ) await view.wait() if not view.value: - await question.edit( - content='We can leave them be for now.', - view=None - ) + await question.edit(content="We can leave them be for now.", view=None) return - await question.edit(content=f'The sale is going through...', view=None) + await question.edit(content=f"The sale is going through...", view=None) # for card in dupe_cards: sale = await db_get( f'teams/{team["id"]}/sell/cards', - params=[('ts', team_hash(team)), ('ids', dupe_ids)], - timeout=10 + params=[("ts", team_hash(team)), ("ids", dupe_ids)], + timeout=10, ) if not sale: await interaction.channel.send( - f'That didn\'t go through for some reason. Go ping the shit out of {get_cal_user(interaction).mention}.' + f"That didn't go through for some reason. Go ping the shit out of {get_cal_user(interaction).mention}." ) return - team = await db_get('teams', object_id=team['id']) + team = await db_get("teams", object_id=team["id"]) await interaction.channel.send(f'Your Wallet: {team["wallet"]}₼') - @app_commands.command(name='newteam', description='Get your fresh team for a new season') + @app_commands.command( + name="newteam", description="Get your fresh team for a new season" + ) @app_commands.checks.has_any_role(PD_PLAYERS) @app_commands.describe( - gm_name='The fictional name of your team\'s GM', - team_abbrev='2, 3, or 4 character abbreviation (e.g. WV, ATL, MAD)', - team_full_name='City/location and name (e.g. Baltimore Orioles)', - team_short_name='Name of team (e.g. Yankees)', - mlb_anchor_team='2 or 3 character abbreviation of your anchor MLB team (e.g. NYM, MKE)', - team_logo_url='[Optional] URL ending in .png or .jpg for your team logo', - color='[Optional] Hex color code to highlight your team' + gm_name="The fictional name of your team's GM", + team_abbrev="2, 3, or 4 character abbreviation (e.g. WV, ATL, MAD)", + team_full_name="City/location and name (e.g. Baltimore Orioles)", + team_short_name="Name of team (e.g. Yankees)", + mlb_anchor_team="2 or 3 character abbreviation of your anchor MLB team (e.g. NYM, MKE)", + team_logo_url="[Optional] URL ending in .png or .jpg for your team logo", + color="[Optional] Hex color code to highlight your team", ) async def new_team_slash( - self, interaction: discord.Interaction, gm_name: str, team_abbrev: str, team_full_name: str, - team_short_name: str, mlb_anchor_team: str, team_logo_url: str = None, color: str = None): + self, + interaction: discord.Interaction, + gm_name: str, + team_abbrev: str, + team_full_name: str, + team_short_name: str, + mlb_anchor_team: str, + team_logo_url: str = None, + color: str = None, + ): owner_team = await get_team_by_owner(interaction.user.id) - current = await db_get('current') + current = await db_get("current") # Check for existing team - if owner_team and not os.environ.get('TESTING'): + if owner_team and not os.environ.get("TESTING"): await interaction.response.send_message( f'Whoa there, bucko. I already have you down as GM of the {owner_team["sname"]}.' ) return # Check for duplicate team data - dupes = await db_get('teams', params=[('abbrev', team_abbrev)]) - if dupes['count']: + dupes = await db_get("teams", params=[("abbrev", team_abbrev)]) + if dupes["count"]: await interaction.response.send_message( - f'Yikes! {team_abbrev.upper()} is a popular abbreviation - it\'s already in use by the ' + f"Yikes! {team_abbrev.upper()} is a popular abbreviation - it's already in use by the " f'{dupes["teams"][0]["sname"]}. No worries, though, you can run the `/newteam` command again to get ' - f'started!' + f"started!" ) return # Check for duplicate team data - dupes = await db_get('teams', params=[('lname', team_full_name)]) - if dupes['count']: + dupes = await db_get("teams", params=[("lname", team_full_name)]) + if dupes["count"]: await interaction.response.send_message( - f'Yikes! {team_full_name.title()} is a popular name - it\'s already in use by ' + f"Yikes! {team_full_name.title()} is a popular name - it's already in use by " f'{dupes["teams"][0]["abbrev"]}. No worries, though, you can run the `/newteam` command again to get ' - f'started!' + f"started!" ) return # Get personal bot channel hello_channel = discord.utils.get( interaction.guild.text_channels, - name=f'hello-{interaction.user.name.lower()}' + name=f"hello-{interaction.user.name.lower()}", ) if hello_channel: op_ch = hello_channel else: op_ch = await helpers.create_channel( interaction, - channel_name=f'hello-{interaction.user.name}', - category_name='Paper Dynasty Team', + channel_name=f"hello-{interaction.user.name}", + category_name="Paper Dynasty Team", everyone_read=False, - read_send_members=[interaction.user] + read_send_members=[interaction.user], ) await share_channel(op_ch, interaction.guild.me) await share_channel(op_ch, interaction.user) try: - poke_role = get_role(interaction, 'Pokétwo') + poke_role = get_role(interaction, "Pokétwo") await share_channel(op_ch, poke_role, read_only=True) except Exception as e: - logger.error(f'unable to share sheet with Poketwo') + logger.error(f"unable to share sheet with Poketwo") await interaction.response.send_message( - f'Let\'s head down to your private channel: {op_ch.mention}', - ephemeral=True + f"Let's head down to your private channel: {op_ch.mention}", ephemeral=True + ) + await op_ch.send( + f"Hey there, {interaction.user.mention}! I am Paper Domo - welcome to season " + f'{current["season"]} of Paper Dynasty! We\'ve got a lot of special updates in store for this ' + f"season including live cards, throwback cards, and special events." ) - await op_ch.send(f'Hey there, {interaction.user.mention}! I am Paper Domo - welcome to season ' - f'{current["season"]} of Paper Dynasty! We\'ve got a lot of special updates in store for this ' - f'season including live cards, throwback cards, and special events.') # Confirm user is happy with branding embed = get_team_embed( - f'Branding Check', + f"Branding Check", { - 'logo': team_logo_url if team_logo_url else None, - 'color': color if color else 'a6ce39', - 'season': 4 - } + "logo": team_logo_url if team_logo_url else None, + "color": color if color else "a6ce39", + "season": 4, + }, ) - embed.add_field(name='GM Name', value=gm_name, inline=False) - embed.add_field(name='Full Team Name', value=team_full_name) - embed.add_field(name='Short Team Name', value=team_short_name) - embed.add_field(name='Team Abbrev', value=team_abbrev.upper()) + embed.add_field(name="GM Name", value=gm_name, inline=False) + embed.add_field(name="Full Team Name", value=team_full_name) + embed.add_field(name="Short Team Name", value=team_short_name) + embed.add_field(name="Team Abbrev", value=team_abbrev.upper()) view = Confirm(responders=[interaction.user]) - question = await op_ch.send('Are you happy with this branding? Don\'t worry - you can update it later!', - embed=embed, view=view) + question = await op_ch.send( + "Are you happy with this branding? Don't worry - you can update it later!", + embed=embed, + view=view, + ) await view.wait() if not view.value: await question.edit( - content='~~Are you happy with this branding?~~\n\nI gotta go, but when you\'re ready to start again ' - 'run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the ' - 'command from last time and make edits.', - view=None + content="~~Are you happy with this branding?~~\n\nI gotta go, but when you're ready to start again " + "run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the " + "command from last time and make edits.", + view=None, ) return await question.edit( - content='Looking good, champ in the making! Let\'s get you your starter team!', - view=None + content="Looking good, champ in the making! Let's get you your starter team!", + view=None, ) team_choice = None @@ -1192,26 +1299,31 @@ class Economy(commands.Cog): team_choice = mlb_anchor_team.title() else: for x in ALL_MLB_TEAMS: - if mlb_anchor_team.upper() in ALL_MLB_TEAMS[x] or mlb_anchor_team.title() in ALL_MLB_TEAMS[x]: + if ( + mlb_anchor_team.upper() in ALL_MLB_TEAMS[x] + or mlb_anchor_team.title() in ALL_MLB_TEAMS[x] + ): team_choice = x break team_string = mlb_anchor_team - logger.debug(f'team_string: {team_string} / team_choice: {team_choice}') + logger.debug(f"team_string: {team_string} / team_choice: {team_choice}") if not team_choice: # Get MLB anchor team while True: - prompt = f'I don\'t recognize **{team_string}**. I try to recognize abbreviations (BAL), ' \ - f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you ' \ - f'like to use as your anchor team?' - this_q = Question(self.bot, op_ch, prompt, 'text', 120) + prompt = ( + f"I don't recognize **{team_string}**. I try to recognize abbreviations (BAL), " + f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you ' + f"like to use as your anchor team?" + ) + this_q = Question(self.bot, op_ch, prompt, "text", 120) team_string = await this_q.ask([interaction.user]) if not team_string: await op_ch.send( - f'Tell you hwat. You think on it and come back I gotta go, but when you\'re ready to start again ' - 'run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the ' - 'command from last time and make edits.' + f"Tell you hwat. You think on it and come back I gotta go, but when you're ready to start again " + "run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the " + "command from last time and make edits." ) return @@ -1221,166 +1333,267 @@ class Economy(commands.Cog): else: match = False for x in ALL_MLB_TEAMS: - if team_string.upper() in ALL_MLB_TEAMS[x] or team_string.title() in ALL_MLB_TEAMS[x]: + if ( + team_string.upper() in ALL_MLB_TEAMS[x] + or team_string.title() in ALL_MLB_TEAMS[x] + ): team_choice = x match = True break if not match: - await op_ch.send(f'Got it!') + await op_ch.send(f"Got it!") - team = await db_post('teams', payload={ - 'abbrev': team_abbrev.upper(), - 'sname': team_short_name, - 'lname': team_full_name, - 'gmid': interaction.user.id, - 'gmname': gm_name, - 'gsheet': 'None', - 'season': current['season'], - 'wallet': 100, - 'color': color if color else 'a6ce39', - 'logo': team_logo_url if team_logo_url else None - }) + team = await db_post( + "teams", + payload={ + "abbrev": team_abbrev.upper(), + "sname": team_short_name, + "lname": team_full_name, + "gmid": interaction.user.id, + "gmname": gm_name, + "gsheet": "None", + "season": current["season"], + "wallet": 100, + "color": color if color else "a6ce39", + "logo": team_logo_url if team_logo_url else None, + }, + ) if not team: - await op_ch.send(f'Frick. {get_cal_user(interaction).mention}, can you help? I can\'t find this team.') + await op_ch.send( + f"Frick. {get_cal_user(interaction).mention}, can you help? I can't find this team." + ) return - t_role = await get_or_create_role(interaction, f'{team_abbrev} - {team_full_name}') + t_role = await get_or_create_role( + interaction, f"{team_abbrev} - {team_full_name}" + ) await interaction.user.add_roles(t_role) anchor_players = [] anchor_all_stars = await db_get( - 'players/random', + "players/random", params=[ - ('min_rarity', 3), ('max_rarity', 3), ('franchise', normalize_franchise(team_choice)), ('pos_exclude', 'RP'), ('limit', 1), - ('in_packs', True) - ] + ("min_rarity", 3), + ("max_rarity", 3), + ("franchise", normalize_franchise(team_choice)), + ("pos_exclude", "RP"), + ("limit", 1), + ("in_packs", True), + ], ) anchor_starters = await db_get( - 'players/random', + "players/random", params=[ - ('min_rarity', 2), ('max_rarity', 2), ('franchise', normalize_franchise(team_choice)), ('pos_exclude', 'RP'), ('limit', 2), - ('in_packs', True) - ] + ("min_rarity", 2), + ("max_rarity", 2), + ("franchise", normalize_franchise(team_choice)), + ("pos_exclude", "RP"), + ("limit", 2), + ("in_packs", True), + ], ) if not anchor_all_stars: - await op_ch.send(f'I am so sorry, but the {team_choice} do not have an All-Star to ' - f'provide as your anchor player. Let\'s start this process over - will you please ' - f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the ' - 'command from last time and make edits.') - await db_delete('teams', object_id=team['id']) + await op_ch.send( + f"I am so sorry, but the {team_choice} do not have an All-Star to " + f"provide as your anchor player. Let's start this process over - will you please " + f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the " + "command from last time and make edits." + ) + await db_delete("teams", object_id=team["id"]) return - if not anchor_starters or anchor_starters['count'] <= 1: - await op_ch.send(f'I am so sorry, but the {team_choice} do not have two Starters to ' - f'provide as your anchor players. Let\'s start this process over - will you please ' - f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the ' - 'command from last time and make edits.') - await db_delete('teams', object_id=team['id']) + if not anchor_starters or anchor_starters["count"] <= 1: + await op_ch.send( + f"I am so sorry, but the {team_choice} do not have two Starters to " + f"provide as your anchor players. Let's start this process over - will you please " + f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the " + "command from last time and make edits." + ) + await db_delete("teams", object_id=team["id"]) return - anchor_players.append(anchor_all_stars['players'][0]) - anchor_players.append(anchor_starters['players'][0]) - anchor_players.append(anchor_starters['players'][1]) + anchor_players.append(anchor_all_stars["players"][0]) + anchor_players.append(anchor_starters["players"][0]) + anchor_players.append(anchor_starters["players"][1]) - this_pack = await db_post('packs/one', - payload={'team_id': team['id'], 'pack_type_id': 2, - 'open_time': datetime.datetime.timestamp(datetime.datetime.now())*1000}) + this_pack = await db_post( + "packs/one", + payload={ + "team_id": team["id"], + "pack_type_id": 2, + "open_time": datetime.datetime.timestamp(datetime.datetime.now()) + * 1000, + }, + ) roster_counts = { - 'SP': 0, - 'RP': 0, - 'CP': 0, - 'C': 0, - '1B': 0, - '2B': 0, - '3B': 0, - 'SS': 0, - 'LF': 0, - 'CF': 0, - 'RF': 0, - 'DH': 0, - 'All-Star': 0, - 'Starter': 0, - 'Reserve': 0, - 'Replacement': 0, + "SP": 0, + "RP": 0, + "CP": 0, + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "All-Star": 0, + "Starter": 0, + "Reserve": 0, + "Replacement": 0, } def update_roster_counts(players: list): for pl in players: - roster_counts[pl['rarity']['name']] += 1 + roster_counts[pl["rarity"]["name"]] += 1 for x in get_all_pos(pl): roster_counts[x] += 1 logger.warning(f'Roster counts for {team["sname"]}: {roster_counts}') # Add anchor position coverage update_roster_counts(anchor_players) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in anchor_players] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in anchor_players + ] + }, + timeout=10, + ) # Get 10 pitchers to seed team - five_sps = await db_get('players/random', params=[('pos_include', 'SP'), ('max_rarity', 1), ('limit', 5)]) - five_rps = await db_get('players/random', params=[('pos_include', 'RP'), ('max_rarity', 1), ('limit', 5)]) - team_sp = [x for x in five_sps['players']] - team_rp = [x for x in five_rps['players']] + five_sps = await db_get( + "players/random", + params=[("pos_include", "SP"), ("max_rarity", 1), ("limit", 5)], + ) + five_rps = await db_get( + "players/random", + params=[("pos_include", "RP"), ("max_rarity", 1), ("limit", 5)], + ) + team_sp = [x for x in five_sps["players"]] + team_rp = [x for x in five_rps["players"]] update_roster_counts([*team_sp, *team_rp]) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in [*team_sp, *team_rp]] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in [*team_sp, *team_rp] + ] + }, + timeout=10, + ) - # TODO: track reserve vs replacement and if rep < res, get rep, else get res # Collect infielders team_infielders = [] - for pos in ['C', '1B', '2B', '3B', 'SS']: - max_rar = 1 - if roster_counts['Replacement'] < roster_counts['Reserve']: - max_rar = 0 - - r_draw = await db_get( - 'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False + for pos in ["C", "1B", "2B", "3B", "SS"]: + rar = ( + RARITY["Replacement"] + if roster_counts["Replacement"] < roster_counts["Reserve"] + else RARITY["Reserve"] ) - team_infielders.extend(r_draw['players']) + r_draw = await db_get( + "players/random", + params=[ + ("pos_include", pos), + ("min_rarity", rar), + ("max_rarity", rar), + ("limit", 2), + ], + none_okay=False, + ) + team_infielders.extend(r_draw["players"]) update_roster_counts(team_infielders) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_infielders] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in team_infielders + ] + }, + timeout=10, + ) # Collect outfielders team_outfielders = [] - for pos in ['LF', 'CF', 'RF']: - max_rar = 1 - if roster_counts['Replacement'] < roster_counts['Reserve']: - max_rar = 0 - - r_draw = await db_get( - 'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False + for pos in ["LF", "CF", "RF"]: + rar = ( + RARITY["Replacement"] + if roster_counts["Replacement"] < roster_counts["Reserve"] + else RARITY["Reserve"] ) - team_outfielders.extend(r_draw['players']) + r_draw = await db_get( + "players/random", + params=[ + ("pos_include", pos), + ("min_rarity", rar), + ("max_rarity", rar), + ("limit", 2), + ], + none_okay=False, + ) + team_outfielders.extend(r_draw["players"]) update_roster_counts(team_outfielders) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_outfielders] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in team_outfielders + ] + }, + timeout=10, + ) async with op_ch.typing(): done_anc = await display_cards( - [{'player': x, 'team': team} for x in anchor_players], team, op_ch, interaction.user, self.bot, - cust_message=f'Let\'s take a look at your three {team_choice} anchor players.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in anchor_players], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Let's take a look at your three {team_choice} anchor players.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) - error_text = f'Yikes - I can\'t display the rest of your team. {get_cal_user(interaction).mention} plz halp' + error_text = f"Yikes - I can't display the rest of your team. {get_cal_user(interaction).mention} plz halp" if not done_anc: await op_ch.send(error_text) async with op_ch.typing(): done_sp = await display_cards( - [{'player': x, 'team': team} for x in team_sp], team, op_ch, interaction.user, self.bot, - cust_message=f'Here are your starting pitchers.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_sp], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Here are your starting pitchers.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_sp: @@ -1388,10 +1601,14 @@ class Economy(commands.Cog): async with op_ch.typing(): done_rp = await display_cards( - [{'player': x, 'team': team} for x in team_rp], team, op_ch, interaction.user, self.bot, - cust_message=f'And now for your bullpen.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_rp], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"And now for your bullpen.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_rp: @@ -1399,10 +1616,14 @@ class Economy(commands.Cog): async with op_ch.typing(): done_inf = await display_cards( - [{'player': x, 'team': team} for x in team_infielders], team, op_ch, interaction.user, self.bot, - cust_message=f'Next let\'s take a look at your infielders.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_infielders], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Next let's take a look at your infielders.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_inf: @@ -1410,10 +1631,14 @@ class Economy(commands.Cog): async with op_ch.typing(): done_out = await display_cards( - [{'player': x, 'team': team} for x in team_outfielders], team, op_ch, interaction.user, self.bot, - cust_message=f'Now let\'s take a look at your outfielders.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_outfielders], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Now let's take a look at your outfielders.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_out: @@ -1421,223 +1646,296 @@ class Economy(commands.Cog): await give_packs(team, 1) await op_ch.send( - f'To get you started, I\'ve spotted you 100₼ and a pack of cards. You can rip that with the ' - f'`/open` command once your google sheet is set up!' + f"To get you started, I've spotted you 100₼ and a pack of cards. You can rip that with the " + f"`/open` command once your google sheet is set up!" ) await op_ch.send( - f'{t_role.mention}\n\n' - f'There\'s your roster! We have one more step and you will be ready to play.\n\n{SHEET_SHARE_STEPS}\n\n' + f"{t_role.mention}\n\n" + f"There's your roster! We have one more step and you will be ready to play.\n\n{SHEET_SHARE_STEPS}\n\n" f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}' ) - new_team_embed = await team_summary_embed(team, interaction, include_roster=False) + new_team_embed = await team_summary_embed( + team, interaction, include_roster=False + ) await send_to_channel( - self.bot, "pd-network-news", content='A new challenger approaches...', embed=new_team_embed + self.bot, + "pd-network-news", + content="A new challenger approaches...", + embed=new_team_embed, ) - @commands.command(name='mlbteam', help='Mod: Load MLB team data') + @commands.command(name="mlbteam", help="Mod: Load MLB team data") @commands.is_owner() async def mlb_team_command( - self, ctx: commands.Context, abbrev: str, sname: str, lname: str, gmid: int, gmname: str, gsheet: str, - logo: str, color: str, ranking: int): + self, + ctx: commands.Context, + abbrev: str, + sname: str, + lname: str, + gmid: int, + gmname: str, + gsheet: str, + logo: str, + color: str, + ranking: int, + ): # Check for duplicate team data - dupes = await db_get('teams', params=[('abbrev', abbrev)]) - if dupes['count']: + dupes = await db_get("teams", params=[("abbrev", abbrev)]) + if dupes["count"]: await ctx.send( - f'Yikes! {abbrev.upper()} is a popular abbreviation - it\'s already in use by the ' + f"Yikes! {abbrev.upper()} is a popular abbreviation - it's already in use by the " f'{dupes["teams"][0]["sname"]}. No worries, though, you can run the `/newteam` command again to get ' - f'started!' + f"started!" ) return # Check for duplicate team data - dupes = await db_get('teams', params=[('lname', lname)]) - if dupes['count']: + dupes = await db_get("teams", params=[("lname", lname)]) + if dupes["count"]: await ctx.send( - f'Yikes! {lname.title()} is a popular name - it\'s already in use by ' + f"Yikes! {lname.title()} is a popular name - it's already in use by " f'{dupes["teams"][0]["abbrev"]}. No worries, though, you can run the `/newteam` command again to get ' - f'started!' + f"started!" ) return - current = await db_get('current') + current = await db_get("current") - team = await db_post('teams', payload={ - 'abbrev': abbrev.upper(), - 'sname': sname, - 'lname': lname, - 'gmid': gmid, - 'gmname': gmname, - 'gsheet': gsheet, - 'season': current['season'], - 'wallet': 100, - 'ranking': ranking, - 'color': color if color else 'a6ce39', - 'logo': logo if logo else None, - 'is_ai': True - }) - - p_query = await db_get('players', params=[('franchise', sname)]) - - this_pack = await db_post( - 'packs/one', - payload={'team_id': team['id'], 'pack_type_id': 2, - 'open_time': datetime.datetime.timestamp(datetime.datetime.now())*1000} + team = await db_post( + "teams", + payload={ + "abbrev": abbrev.upper(), + "sname": sname, + "lname": lname, + "gmid": gmid, + "gmname": gmname, + "gsheet": gsheet, + "season": current["season"], + "wallet": 100, + "ranking": ranking, + "color": color if color else "a6ce39", + "logo": logo if logo else None, + "is_ai": True, + }, ) - team_players = p_query['players'] + p_query['players'] - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_players] - }, timeout=10) + p_query = await db_get("players", params=[("franchise", sname)]) + + this_pack = await db_post( + "packs/one", + payload={ + "team_id": team["id"], + "pack_type_id": 2, + "open_time": datetime.datetime.timestamp(datetime.datetime.now()) + * 1000, + }, + ) + + team_players = p_query["players"] + p_query["players"] + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in team_players + ] + }, + timeout=10, + ) embed = get_team_embed(f'{team["lname"]}', team) await ctx.send(content=None, embed=embed) - @commands.hybrid_command(name='mlb-update', help='Distribute MLB cards to AI teams') + @commands.hybrid_command(name="mlb-update", help="Distribute MLB cards to AI teams") @commands.is_owner() async def mlb_update_command(self, ctx: commands.Context): - ai_teams = await db_get('teams', params=[('is_ai', True)]) - if ai_teams['count'] == 0: - await ctx.send(f'I could not find any AI teams.') + ai_teams = await db_get("teams", params=[("is_ai", True)]) + if ai_teams["count"] == 0: + await ctx.send(f"I could not find any AI teams.") return total_cards = 0 total_teams = 0 - for team in ai_teams['teams']: - all_players = await db_get('players', params=[('franchise', team['sname'])]) + for team in ai_teams["teams"]: + all_players = await db_get("players", params=[("franchise", team["sname"])]) new_players = [] if all_players: - for player in all_players['players']: - owned_by_team_ids = [entry['team'] for entry in player['paperdex']['paperdex']] + for player in all_players["players"]: + owned_by_team_ids = [ + entry["team"] for entry in player["paperdex"]["paperdex"] + ] - if team['id'] not in owned_by_team_ids: + if team["id"] not in owned_by_team_ids: new_players.append(player) if new_players: - await ctx.send(f'Posting {len(new_players)} new cards for {team["gmname"]}\'s {team["sname"]}...') + await ctx.send( + f'Posting {len(new_players)} new cards for {team["gmname"]}\'s {team["sname"]}...' + ) total_cards += len(new_players) total_teams += 1 this_pack = await db_post( - 'packs/one', - payload={'team_id': team['id'], 'pack_type_id': 2, - 'open_time': datetime.datetime.timestamp(datetime.datetime.now()) * 1000} + "packs/one", + payload={ + "team_id": team["id"], + "pack_type_id": 2, + "open_time": datetime.datetime.timestamp( + datetime.datetime.now() + ) + * 1000, + }, + ) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in new_players + ] + }, + timeout=10, ) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in - new_players - ]}, timeout=10) await refresh_sheet(team, self.bot) - await ctx.send(f'All done! I added {total_cards} across {total_teams} teams.') + await ctx.send(f"All done! I added {total_cards} across {total_teams} teams.") - @commands.hybrid_command(name='newsheet', help='Link a new team sheet with your team') + @commands.hybrid_command( + name="newsheet", help="Link a new team sheet with your team" + ) @commands.has_any_role(PD_PLAYERS) async def share_sheet_command( - self, ctx, google_sheet_url: str, team_abbrev: Optional[str], copy_rosters: Optional[bool] = True): + self, + ctx, + google_sheet_url: str, + team_abbrev: Optional[str], + copy_rosters: Optional[bool] = True, + ): owner_team = await get_team_by_owner(ctx.author.id) if not owner_team: - await ctx.send(f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!') + await ctx.send( + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" + ) return team = owner_team - if team_abbrev and team_abbrev != owner_team['abbrev']: + if team_abbrev and team_abbrev != owner_team["abbrev"]: if ctx.author.id != 258104532423147520: - await ctx.send(f'You can only update the team sheet for your own team, you goober.') + await ctx.send( + f"You can only update the team sheet for your own team, you goober." + ) return else: team = await get_team_by_abbrev(team_abbrev) - current = await db_get('current') - if current['gsheet_template'] in google_sheet_url: - await ctx.send(f'Ope, looks like that is the template sheet. Would you please make a copy and then share?') + current = await db_get("current") + if current["gsheet_template"] in google_sheet_url: + await ctx.send( + f"Ope, looks like that is the template sheet. Would you please make a copy and then share?" + ) return gauntlet_team = await get_team_by_abbrev(f'Gauntlet-{owner_team["abbrev"]}') if gauntlet_team: - view = ButtonOptions([ctx.author], timeout=30, labels=['Main Team', 'Gauntlet Team', None, None, None]) - question = await ctx.send(f'Is this sheet for your main PD team or your active Gauntlet team?', view=view) + view = ButtonOptions( + [ctx.author], + timeout=30, + labels=["Main Team", "Gauntlet Team", None, None, None], + ) + question = await ctx.send( + f"Is this sheet for your main PD team or your active Gauntlet team?", + view=view, + ) await view.wait() if not view.value: await question.edit( - content=f'Okay you keep thinking on it and get back to me when you\'re ready.', view=None + content=f"Okay you keep thinking on it and get back to me when you're ready.", + view=None, ) return - elif view.value == 'Gauntlet Team': + elif view.value == "Gauntlet Team": await question.delete() team = gauntlet_team sheets = get_sheets(self.bot) - response = await ctx.send(f'I\'ll go grab that sheet...') + response = await ctx.send(f"I'll go grab that sheet...") try: new_sheet = sheets.open_by_url(google_sheet_url) except Exception as e: logger.error(f'Error accessing {team["abbrev"]} sheet: {e}') - current = await db_get('current') - await ctx.send(f'I wasn\'t able to access that sheet. Did you remember to share it with my PD email?' - f'\n\nHere\'s a quick refresher:\n{SHEET_SHARE_STEPS}\n\n' - f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}') + current = await db_get("current") + await ctx.send( + f"I wasn't able to access that sheet. Did you remember to share it with my PD email?" + f"\n\nHere's a quick refresher:\n{SHEET_SHARE_STEPS}\n\n" + f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}' + ) return - team_data = new_sheet.worksheet_by_title('Team Data') + team_data = new_sheet.worksheet_by_title("Team Data") if not gauntlet_team or owner_team != gauntlet_team: team_data.update_values( - crange='B1:B2', - values=[[f'{team["id"]}'], [f'\'{team_hash(team)}']] + crange="B1:B2", values=[[f'{team["id"]}'], [f"'{team_hash(team)}"]] ) - if copy_rosters and team['gsheet'].lower() != 'none': - old_sheet = sheets.open_by_key(team['gsheet']) - r_sheet = old_sheet.worksheet_by_title(f'My Rosters') - roster_ids = r_sheet.range('B3:B80') - lineups_data = r_sheet.range('H4:M26') + if copy_rosters and team["gsheet"].lower() != "none": + old_sheet = sheets.open_by_key(team["gsheet"]) + r_sheet = old_sheet.worksheet_by_title(f"My Rosters") + roster_ids = r_sheet.range("B3:B80") + lineups_data = r_sheet.range("H4:M26") new_r_data, new_l_data = [], [] for row in roster_ids: - if row[0].value != '': + if row[0].value != "": new_r_data.append([int(row[0].value)]) else: new_r_data.append([None]) - logger.debug(f'new_r_data: {new_r_data}') + logger.debug(f"new_r_data: {new_r_data}") for row in lineups_data: - logger.debug(f'row: {row}') - new_l_data.append([ - row[0].value if row[0].value != '' else None, - int(row[1].value) if row[1].value != '' else None, - row[2].value if row[2].value != '' else None, - int(row[3].value) if row[3].value != '' else None, - row[4].value if row[4].value != '' else None, - int(row[5].value) if row[5].value != '' else None - ]) - logger.debug(f'new_l_data: {new_l_data}') + logger.debug(f"row: {row}") + new_l_data.append( + [ + row[0].value if row[0].value != "" else None, + int(row[1].value) if row[1].value != "" else None, + row[2].value if row[2].value != "" else None, + int(row[3].value) if row[3].value != "" else None, + row[4].value if row[4].value != "" else None, + int(row[5].value) if row[5].value != "" else None, + ] + ) + logger.debug(f"new_l_data: {new_l_data}") - new_r_sheet = new_sheet.worksheet_by_title(f'My Rosters') - new_r_sheet.update_values( - crange='B3:B80', - values=new_r_data - ) - new_r_sheet.update_values( - crange='H4:M26', - values=new_l_data - ) + new_r_sheet = new_sheet.worksheet_by_title(f"My Rosters") + new_r_sheet.update_values(crange="B3:B80", values=new_r_data) + new_r_sheet.update_values(crange="H4:M26", values=new_l_data) - if team['has_guide']: + if team["has_guide"]: post_ratings_guide(team, self.bot, this_sheet=new_sheet) - team = await db_patch('teams', object_id=team['id'], params=[('gsheet', new_sheet.id)]) + team = await db_patch( + "teams", object_id=team["id"], params=[("gsheet", new_sheet.id)] + ) await refresh_sheet(team, self.bot, sheets) - conf_message = f'Alright, your sheet is linked to your team - good luck' + conf_message = f"Alright, your sheet is linked to your team - good luck" if owner_team == team: - conf_message += ' this season!' + conf_message += " this season!" else: - conf_message += ' on your run!' - conf_message += f'\n\n{HELP_SHEET_SCRIPTS}' - await response.edit(content=f'{conf_message}') + conf_message += " on your run!" + conf_message += f"\n\n{HELP_SHEET_SCRIPTS}" + await response.edit(content=f"{conf_message}") # @commands.hybrid_command(name='refresh', help='Refresh team data in Sheets') # @commands.has_any_role(PD_PLAYERS) @@ -1692,89 +1990,103 @@ class Economy(commands.Cog): # # # # db.close() - @commands.hybrid_command(name='give-card', help='Mod: Give free card to team') + @commands.hybrid_command(name="give-card", help="Mod: Give free card to team") # @commands.is_owner() @commands.has_any_role("PD Gift Players") async def give_card_command(self, ctx, player_ids: str, team_abbrev: str): - if ctx.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']: - await ctx.send(f'Please head to down to {get_channel(ctx, "pd-bot-hole")} to run this command.') + if ctx.channel.name in [ + "paper-dynasty-chat", + "pd-news-ticker", + "pd-network-news", + ]: + await ctx.send( + f'Please head to down to {get_channel(ctx, "pd-bot-hole")} to run this command.' + ) return - question = await ctx.send(f'I\'ll go put that card on their roster...') + question = await ctx.send(f"I'll go put that card on their roster...") all_player_ids = player_ids.split(" ") - t_query = await db_get('teams', params=[('abbrev', team_abbrev)]) - if not t_query['count']: - await ctx.send(f'I could not find {team_abbrev}') + t_query = await db_get("teams", params=[("abbrev", team_abbrev)]) + if not t_query["count"]: + await ctx.send(f"I could not find {team_abbrev}") return - team = t_query['teams'][0] + team = t_query["teams"][0] this_pack = await db_post( - 'packs/one', + "packs/one", payload={ - 'team_id': team['id'], - 'pack_type_id': 4, - 'open_time': datetime.datetime.timestamp(datetime.datetime.now()) * 1000} + "team_id": team["id"], + "pack_type_id": 4, + "open_time": datetime.datetime.timestamp(datetime.datetime.now()) + * 1000, + }, ) try: - await give_cards_to_team(team, player_ids=all_player_ids, pack_id=this_pack['id']) + await give_cards_to_team( + team, player_ids=all_player_ids, pack_id=this_pack["id"] + ) except Exception as e: - logger.error(f'failed to create cards: {e}') - raise ConnectionError(f'Failed to distribute these cards.') + logger.error(f"failed to create cards: {e}") + raise ConnectionError(f"Failed to distribute these cards.") - await question.edit(content=f'Alrighty, now I\'ll refresh their sheet...') + await question.edit(content=f"Alrighty, now I'll refresh their sheet...") await refresh_sheet(team, self.bot) - await question.edit(content=f'All done!') + await question.edit(content=f"All done!") await send_to_channel( self.bot, - channel_name='commissioners-office', - content=f'Just sent {len(all_player_ids)} players to {ctx.message.author.mention}:\n{all_player_ids}' + channel_name="commissioners-office", + content=f"Just sent {len(all_player_ids)} players to {ctx.message.author.mention}:\n{all_player_ids}", ) - @commands.command(name='cleartest', hidden=True) + @commands.command(name="cleartest", hidden=True) @commands.is_owner() async def clear_test_command(self, ctx): team = await get_team_by_owner(ctx.author.id) - msg = await ctx.send('Alright, let\'s go find your cards...') - all_cards = await db_get( - 'cards', - params=[('team_id', team['id'])] - ) + msg = await ctx.send("Alright, let's go find your cards...") + all_cards = await db_get("cards", params=[("team_id", team["id"])]) if all_cards: - await msg.edit(content=f'I found {len(all_cards["cards"])} cards; deleting now...') - for x in all_cards['cards']: - await db_delete( - 'cards', - object_id=x['id'] - ) + await msg.edit( + content=f'I found {len(all_cards["cards"])} cards; deleting now...' + ) + for x in all_cards["cards"]: + await db_delete("cards", object_id=x["id"]) - await msg.edit(content=f'All done with cards. Now I\'ll wipe out your packs...') - p_query = await db_get('packs', params=[('team_id', team['id'])]) - if p_query['count']: - for x in p_query['packs']: - await db_delete('packs', object_id=x['id']) + await msg.edit(content=f"All done with cards. Now I'll wipe out your packs...") + p_query = await db_get("packs", params=[("team_id", team["id"])]) + if p_query["count"]: + for x in p_query["packs"]: + await db_delete("packs", object_id=x["id"]) - await msg.edit(content=f'All done with packs. Now I\'ll wipe out your paperdex...') - p_query = await db_get('paperdex', params=[('team_id', team['id'])]) - if p_query['count']: - for x in p_query['paperdex']: - await db_delete('paperdex', object_id=x['id']) + await msg.edit( + content=f"All done with packs. Now I'll wipe out your paperdex..." + ) + p_query = await db_get("paperdex", params=[("team_id", team["id"])]) + if p_query["count"]: + for x in p_query["paperdex"]: + await db_delete("paperdex", object_id=x["id"]) - await msg.edit(content=f'All done with paperdex. Now I\'ll wipe out your team...') - if db_delete('teams', object_id=team['id']): - await msg.edit(content=f'All done!') + await msg.edit( + content=f"All done with paperdex. Now I'll wipe out your team..." + ) + if db_delete("teams", object_id=team["id"]): + await msg.edit(content=f"All done!") - @commands.command(name='packtest', hidden=True) + @commands.command(name="packtest", hidden=True) @commands.is_owner() async def pack_test_command(self, ctx): team = await get_team_by_owner(ctx.author.id) await display_cards( - await get_test_pack(ctx, team), team, ctx.channel, ctx.author, self.bot, - pack_cover=IMAGES['pack-sta'], - pack_name='Standard Pack' + await get_test_pack(ctx, team), + team, + ctx.channel, + ctx.author, + self.bot, + pack_cover=IMAGES["pack-sta"], + pack_name="Standard Pack", ) diff --git a/cogs/economy_new/scouting.py b/cogs/economy_new/scouting.py new file mode 100644 index 0000000..118927a --- /dev/null +++ b/cogs/economy_new/scouting.py @@ -0,0 +1,90 @@ +""" +Scouting Cog — Scout token management and expired opportunity cleanup. +""" + +import datetime +import logging + +import discord +from discord import app_commands +from discord.ext import commands, tasks + +from api_calls import db_get +from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed +from helpers.main import get_team_by_owner +from helpers.constants import PD_SEASON, IMAGES + +logger = logging.getLogger("discord_app") + + +class Scouting(commands.Cog): + """Scout token tracking and expired opportunity cleanup.""" + + def __init__(self, bot): + self.bot = bot + self.cleanup_expired.start() + + async def cog_unload(self): + self.cleanup_expired.cancel() + + @app_commands.command( + name="scout-tokens", + description="Check how many scout tokens you have left today", + ) + async def scout_tokens_command(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + team = await get_team_by_owner(interaction.user.id) + if not team: + await interaction.followup.send( + "You need a Paper Dynasty team first!", + ephemeral=True, + ) + return + + tokens_used = await get_scout_tokens_used(team["id"]) + tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used) + + embed = get_team_embed(title="Scout Tokens", team=team) + embed.description = ( + f"**{tokens_remaining}** of **{SCOUT_TOKENS_PER_DAY}** tokens remaining today.\n\n" + f"Tokens reset at midnight Central." + ) + + if tokens_remaining == 0: + embed.description += "\n\nYou've used all your tokens! Check back tomorrow." + + await interaction.followup.send(embed=embed, ephemeral=True) + + @tasks.loop(minutes=15) + async def cleanup_expired(self): + """Log expired unclaimed scout opportunities. + + This is a safety net — the ScoutView's on_timeout handles the UI side. + If the bot restarted mid-scout, those views are lost; this just logs it. + """ + try: + now = int_timestamp(datetime.datetime.now()) + expired = await db_get( + "scout_opportunities", + params=[ + ("claimed", False), + ("expired_before", now), + ], + ) + if expired and expired.get("count", 0) > 0: + logger.info( + f"Found {expired['count']} expired unclaimed scout opportunities" + ) + except Exception as e: + logger.debug(f"Scout cleanup check failed (API may not be ready): {e}") + + @cleanup_expired.before_loop + async def before_cleanup(self): + await self.bot.wait_until_ready() + + +async def setup(bot): + await bot.add_cog(Scouting(bot)) diff --git a/cogs/economy_new/team_setup.py b/cogs/economy_new/team_setup.py index 043d52e..ea05ddc 100644 --- a/cogs/economy_new/team_setup.py +++ b/cogs/economy_new/team_setup.py @@ -13,133 +13,162 @@ from typing import Optional import pygsheets from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev from help_text import SHEET_SHARE_STEPS, HELP_SHEET_SCRIPTS -from helpers.constants import PD_PLAYERS, ALL_MLB_TEAMS +from helpers.constants import PD_PLAYERS, ALL_MLB_TEAMS, RARITY from helpers import ( - get_team_by_owner, share_channel, get_role, get_cal_user, get_or_create_role, - display_cards, give_packs, get_all_pos, get_sheets, refresh_sheet, - post_ratings_guide, team_summary_embed, get_roster_sheet, Question, Confirm, - ButtonOptions, legal_channel, get_channel, create_channel, get_context_user + get_team_by_owner, + share_channel, + get_role, + get_cal_user, + get_or_create_role, + display_cards, + give_packs, + get_all_pos, + get_sheets, + refresh_sheet, + post_ratings_guide, + team_summary_embed, + get_roster_sheet, + Question, + Confirm, + ButtonOptions, + legal_channel, + get_channel, + create_channel, + get_context_user, ) from api_calls import team_hash from helpers.discord_utils import get_team_embed, send_to_channel - -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") class TeamSetup(commands.Cog): """Team creation and Google Sheets integration functionality for Paper Dynasty.""" - + def __init__(self, bot): self.bot = bot - @app_commands.command(name='newteam', description='Get your fresh team for a new season') + @app_commands.command( + name="newteam", description="Get your fresh team for a new season" + ) @app_commands.checks.has_any_role(PD_PLAYERS) @app_commands.describe( - gm_name='The fictional name of your team\'s GM', - team_abbrev='2, 3, or 4 character abbreviation (e.g. WV, ATL, MAD)', - team_full_name='City/location and name (e.g. Baltimore Orioles)', - team_short_name='Name of team (e.g. Yankees)', - mlb_anchor_team='2 or 3 character abbreviation of your anchor MLB team (e.g. NYM, MKE)', - team_logo_url='[Optional] URL ending in .png or .jpg for your team logo', - color='[Optional] Hex color code to highlight your team' + gm_name="The fictional name of your team's GM", + team_abbrev="2, 3, or 4 character abbreviation (e.g. WV, ATL, MAD)", + team_full_name="City/location and name (e.g. Baltimore Orioles)", + team_short_name="Name of team (e.g. Yankees)", + mlb_anchor_team="2 or 3 character abbreviation of your anchor MLB team (e.g. NYM, MKE)", + team_logo_url="[Optional] URL ending in .png or .jpg for your team logo", + color="[Optional] Hex color code to highlight your team", ) async def new_team_slash( - self, interaction: discord.Interaction, gm_name: str, team_abbrev: str, team_full_name: str, - team_short_name: str, mlb_anchor_team: str, team_logo_url: str = None, color: str = None): + self, + interaction: discord.Interaction, + gm_name: str, + team_abbrev: str, + team_full_name: str, + team_short_name: str, + mlb_anchor_team: str, + team_logo_url: str = None, + color: str = None, + ): owner_team = await get_team_by_owner(interaction.user.id) - current = await db_get('current') + current = await db_get("current") # Check for existing team - if owner_team and not os.environ.get('TESTING'): + if owner_team and not os.environ.get("TESTING"): await interaction.response.send_message( f'Whoa there, bucko. I already have you down as GM of the {owner_team["sname"]}.' ) return # Check for duplicate team data - dupes = await db_get('teams', params=[('abbrev', team_abbrev)]) - if dupes['count']: + dupes = await db_get("teams", params=[("abbrev", team_abbrev)]) + if dupes["count"]: await interaction.response.send_message( - f'Yikes! {team_abbrev.upper()} is a popular abbreviation - it\'s already in use by the ' + f"Yikes! {team_abbrev.upper()} is a popular abbreviation - it's already in use by the " f'{dupes["teams"][0]["sname"]}. No worries, though, you can run the `/newteam` command again to get ' - f'started!' + f"started!" ) return # Check for duplicate team data - dupes = await db_get('teams', params=[('lname', team_full_name)]) - if dupes['count']: + dupes = await db_get("teams", params=[("lname", team_full_name)]) + if dupes["count"]: await interaction.response.send_message( - f'Yikes! {team_full_name.title()} is a popular name - it\'s already in use by ' + f"Yikes! {team_full_name.title()} is a popular name - it's already in use by " f'{dupes["teams"][0]["abbrev"]}. No worries, though, you can run the `/newteam` command again to get ' - f'started!' + f"started!" ) return # Get personal bot channel hello_channel = discord.utils.get( interaction.guild.text_channels, - name=f'hello-{interaction.user.name.lower()}' + name=f"hello-{interaction.user.name.lower()}", ) if hello_channel: op_ch = hello_channel else: op_ch = await create_channel( interaction, - channel_name=f'hello-{interaction.user.name}', - category_name='Paper Dynasty Team', + channel_name=f"hello-{interaction.user.name}", + category_name="Paper Dynasty Team", everyone_read=False, - read_send_members=[interaction.user] + read_send_members=[interaction.user], ) await share_channel(op_ch, interaction.guild.me) await share_channel(op_ch, interaction.user) try: - poke_role = get_role(interaction, 'Pokétwo') + poke_role = get_role(interaction, "Pokétwo") await share_channel(op_ch, poke_role, read_only=True) except Exception as e: - logger.error(f'unable to share sheet with Poketwo') + logger.error(f"unable to share sheet with Poketwo") await interaction.response.send_message( - f'Let\'s head down to your private channel: {op_ch.mention}', - ephemeral=True + f"Let's head down to your private channel: {op_ch.mention}", ephemeral=True + ) + await op_ch.send( + f"Hey there, {interaction.user.mention}! I am Paper Domo - welcome to season " + f'{current["season"]} of Paper Dynasty! We\'ve got a lot of special updates in store for this ' + f"season including live cards, throwback cards, and special events." ) - await op_ch.send(f'Hey there, {interaction.user.mention}! I am Paper Domo - welcome to season ' - f'{current["season"]} of Paper Dynasty! We\'ve got a lot of special updates in store for this ' - f'season including live cards, throwback cards, and special events.') # Confirm user is happy with branding embed = get_team_embed( - f'Branding Check', + f"Branding Check", { - 'logo': team_logo_url if team_logo_url else None, - 'color': color if color else 'a6ce39', - 'season': 4 - } + "logo": team_logo_url if team_logo_url else None, + "color": color if color else "a6ce39", + "season": 4, + }, ) - embed.add_field(name='GM Name', value=gm_name, inline=False) - embed.add_field(name='Full Team Name', value=team_full_name) - embed.add_field(name='Short Team Name', value=team_short_name) - embed.add_field(name='Team Abbrev', value=team_abbrev.upper()) + embed.add_field(name="GM Name", value=gm_name, inline=False) + embed.add_field(name="Full Team Name", value=team_full_name) + embed.add_field(name="Short Team Name", value=team_short_name) + embed.add_field(name="Team Abbrev", value=team_abbrev.upper()) view = Confirm(responders=[interaction.user]) - question = await op_ch.send('Are you happy with this branding? Don\'t worry - you can update it later!', - embed=embed, view=view) + question = await op_ch.send( + "Are you happy with this branding? Don't worry - you can update it later!", + embed=embed, + view=view, + ) await view.wait() if not view.value: await question.edit( - content='~~Are you happy with this branding?~~\n\nI gotta go, but when you\'re ready to start again ' - 'run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the ' - 'command from last time and make edits.', - view=None + content="~~Are you happy with this branding?~~\n\nI gotta go, but when you're ready to start again " + "run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the " + "command from last time and make edits.", + view=None, ) return await question.edit( - content='Looking good, champ in the making! Let\'s get you your starter team!', - view=None + content="Looking good, champ in the making! Let's get you your starter team!", + view=None, ) team_choice = None @@ -147,26 +176,31 @@ class TeamSetup(commands.Cog): team_choice = mlb_anchor_team.title() else: for x in ALL_MLB_TEAMS: - if mlb_anchor_team.upper() in ALL_MLB_TEAMS[x] or mlb_anchor_team.title() in ALL_MLB_TEAMS[x]: + if ( + mlb_anchor_team.upper() in ALL_MLB_TEAMS[x] + or mlb_anchor_team.title() in ALL_MLB_TEAMS[x] + ): team_choice = x break team_string = mlb_anchor_team - logger.debug(f'team_string: {team_string} / team_choice: {team_choice}') + logger.debug(f"team_string: {team_string} / team_choice: {team_choice}") if not team_choice: # Get MLB anchor team while True: - prompt = f'I don\'t recognize **{team_string}**. I try to recognize abbreviations (BAL), ' \ - f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you ' \ - f'like to use as your anchor team?' - this_q = Question(self.bot, op_ch, prompt, 'text', 120) + prompt = ( + f"I don't recognize **{team_string}**. I try to recognize abbreviations (BAL), " + f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you ' + f"like to use as your anchor team?" + ) + this_q = Question(self.bot, op_ch, prompt, "text", 120) team_string = await this_q.ask([interaction.user]) if not team_string: await op_ch.send( - f'Tell you hwat. You think on it and come back I gotta go, but when you\'re ready to start again ' - 'run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the ' - 'command from last time and make edits.' + f"Tell you hwat. You think on it and come back I gotta go, but when you're ready to start again " + "run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the " + "command from last time and make edits." ) return @@ -176,166 +210,267 @@ class TeamSetup(commands.Cog): else: match = False for x in ALL_MLB_TEAMS: - if team_string.upper() in ALL_MLB_TEAMS[x] or team_string.title() in ALL_MLB_TEAMS[x]: + if ( + team_string.upper() in ALL_MLB_TEAMS[x] + or team_string.title() in ALL_MLB_TEAMS[x] + ): team_choice = x match = True break if not match: - await op_ch.send(f'Got it!') + await op_ch.send(f"Got it!") - team = await db_post('teams', payload={ - 'abbrev': team_abbrev.upper(), - 'sname': team_short_name, - 'lname': team_full_name, - 'gmid': interaction.user.id, - 'gmname': gm_name, - 'gsheet': 'None', - 'season': current['season'], - 'wallet': 100, - 'color': color if color else 'a6ce39', - 'logo': team_logo_url if team_logo_url else None - }) + team = await db_post( + "teams", + payload={ + "abbrev": team_abbrev.upper(), + "sname": team_short_name, + "lname": team_full_name, + "gmid": interaction.user.id, + "gmname": gm_name, + "gsheet": "None", + "season": current["season"], + "wallet": 100, + "color": color if color else "a6ce39", + "logo": team_logo_url if team_logo_url else None, + }, + ) if not team: - await op_ch.send(f'Frick. {get_cal_user(interaction).mention}, can you help? I can\'t find this team.') + await op_ch.send( + f"Frick. {get_cal_user(interaction).mention}, can you help? I can't find this team." + ) return - t_role = await get_or_create_role(interaction, f'{team_abbrev} - {team_full_name}') + t_role = await get_or_create_role( + interaction, f"{team_abbrev} - {team_full_name}" + ) await interaction.user.add_roles(t_role) anchor_players = [] anchor_all_stars = await db_get( - 'players/random', + "players/random", params=[ - ('min_rarity', 3), ('max_rarity', 3), ('franchise', team_choice), ('pos_exclude', 'RP'), ('limit', 1), - ('in_packs', True) - ] + ("min_rarity", 3), + ("max_rarity", 3), + ("franchise", team_choice), + ("pos_exclude", "RP"), + ("limit", 1), + ("in_packs", True), + ], ) anchor_starters = await db_get( - 'players/random', + "players/random", params=[ - ('min_rarity', 2), ('max_rarity', 2), ('franchise', team_choice), ('pos_exclude', 'RP'), ('limit', 2), - ('in_packs', True) - ] + ("min_rarity", 2), + ("max_rarity", 2), + ("franchise", team_choice), + ("pos_exclude", "RP"), + ("limit", 2), + ("in_packs", True), + ], ) if not anchor_all_stars: - await op_ch.send(f'I am so sorry, but the {team_choice} do not have an All-Star to ' - f'provide as your anchor player. Let\'s start this process over - will you please ' - f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the ' - 'command from last time and make edits.') - await db_delete('teams', object_id=team['id']) + await op_ch.send( + f"I am so sorry, but the {team_choice} do not have an All-Star to " + f"provide as your anchor player. Let's start this process over - will you please " + f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the " + "command from last time and make edits." + ) + await db_delete("teams", object_id=team["id"]) return - if not anchor_starters or anchor_starters['count'] <= 1: - await op_ch.send(f'I am so sorry, but the {team_choice} do not have two Starters to ' - f'provide as your anchor players. Let\'s start this process over - will you please ' - f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the ' - 'command from last time and make edits.') - await db_delete('teams', object_id=team['id']) + if not anchor_starters or anchor_starters["count"] <= 1: + await op_ch.send( + f"I am so sorry, but the {team_choice} do not have two Starters to " + f"provide as your anchor players. Let's start this process over - will you please " + f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the " + "command from last time and make edits." + ) + await db_delete("teams", object_id=team["id"]) return - anchor_players.append(anchor_all_stars['players'][0]) - anchor_players.append(anchor_starters['players'][0]) - anchor_players.append(anchor_starters['players'][1]) + anchor_players.append(anchor_all_stars["players"][0]) + anchor_players.append(anchor_starters["players"][0]) + anchor_players.append(anchor_starters["players"][1]) - this_pack = await db_post('packs/one', - payload={'team_id': team['id'], 'pack_type_id': 2, - 'open_time': datetime.datetime.timestamp(datetime.datetime.now())*1000}) + this_pack = await db_post( + "packs/one", + payload={ + "team_id": team["id"], + "pack_type_id": 2, + "open_time": datetime.datetime.timestamp(datetime.datetime.now()) + * 1000, + }, + ) roster_counts = { - 'SP': 0, - 'RP': 0, - 'CP': 0, - 'C': 0, - '1B': 0, - '2B': 0, - '3B': 0, - 'SS': 0, - 'LF': 0, - 'CF': 0, - 'RF': 0, - 'DH': 0, - 'All-Star': 0, - 'Starter': 0, - 'Reserve': 0, - 'Replacement': 0, + "SP": 0, + "RP": 0, + "CP": 0, + "C": 0, + "1B": 0, + "2B": 0, + "3B": 0, + "SS": 0, + "LF": 0, + "CF": 0, + "RF": 0, + "DH": 0, + "All-Star": 0, + "Starter": 0, + "Reserve": 0, + "Replacement": 0, } def update_roster_counts(players: list): for pl in players: - roster_counts[pl['rarity']['name']] += 1 + roster_counts[pl["rarity"]["name"]] += 1 for x in get_all_pos(pl): roster_counts[x] += 1 logger.warning(f'Roster counts for {team["sname"]}: {roster_counts}') # Add anchor position coverage update_roster_counts(anchor_players) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in anchor_players] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in anchor_players + ] + }, + timeout=10, + ) # Get 10 pitchers to seed team - five_sps = await db_get('players/random', params=[('pos_include', 'SP'), ('max_rarity', 1), ('limit', 5)]) - five_rps = await db_get('players/random', params=[('pos_include', 'RP'), ('max_rarity', 1), ('limit', 5)]) - team_sp = [x for x in five_sps['players']] - team_rp = [x for x in five_rps['players']] + five_sps = await db_get( + "players/random", + params=[("pos_include", "SP"), ("max_rarity", 1), ("limit", 5)], + ) + five_rps = await db_get( + "players/random", + params=[("pos_include", "RP"), ("max_rarity", 1), ("limit", 5)], + ) + team_sp = [x for x in five_sps["players"]] + team_rp = [x for x in five_rps["players"]] update_roster_counts([*team_sp, *team_rp]) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in [*team_sp, *team_rp]] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in [*team_sp, *team_rp] + ] + }, + timeout=10, + ) - # TODO: track reserve vs replacement and if rep < res, get rep, else get res # Collect infielders team_infielders = [] - for pos in ['C', '1B', '2B', '3B', 'SS']: - max_rar = 1 - if roster_counts['Replacement'] < roster_counts['Reserve']: - max_rar = 0 - - r_draw = await db_get( - 'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False + for pos in ["C", "1B", "2B", "3B", "SS"]: + rar = ( + RARITY["Replacement"] + if roster_counts["Replacement"] < roster_counts["Reserve"] + else RARITY["Reserve"] ) - team_infielders.extend(r_draw['players']) + r_draw = await db_get( + "players/random", + params=[ + ("pos_include", pos), + ("min_rarity", rar), + ("max_rarity", rar), + ("limit", 2), + ], + none_okay=False, + ) + team_infielders.extend(r_draw["players"]) update_roster_counts(team_infielders) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_infielders] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in team_infielders + ] + }, + timeout=10, + ) # Collect outfielders team_outfielders = [] - for pos in ['LF', 'CF', 'RF']: - max_rar = 1 - if roster_counts['Replacement'] < roster_counts['Reserve']: - max_rar = 0 - - r_draw = await db_get( - 'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False + for pos in ["LF", "CF", "RF"]: + rar = ( + RARITY["Replacement"] + if roster_counts["Replacement"] < roster_counts["Reserve"] + else RARITY["Reserve"] ) - team_outfielders.extend(r_draw['players']) + r_draw = await db_get( + "players/random", + params=[ + ("pos_include", pos), + ("min_rarity", rar), + ("max_rarity", rar), + ("limit", 2), + ], + none_okay=False, + ) + team_outfielders.extend(r_draw["players"]) update_roster_counts(team_outfielders) - await db_post('cards', payload={'cards': [ - {'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_outfielders] - }, timeout=10) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": x["player_id"], + "team_id": team["id"], + "pack_id": this_pack["id"], + } + for x in team_outfielders + ] + }, + timeout=10, + ) async with op_ch.typing(): done_anc = await display_cards( - [{'player': x, 'team': team} for x in anchor_players], team, op_ch, interaction.user, self.bot, - cust_message=f'Let\'s take a look at your three {team_choice} anchor players.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in anchor_players], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Let's take a look at your three {team_choice} anchor players.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) - error_text = f'Yikes - I can\'t display the rest of your team. {get_cal_user(interaction).mention} plz halp' + error_text = f"Yikes - I can't display the rest of your team. {get_cal_user(interaction).mention} plz halp" if not done_anc: await op_ch.send(error_text) async with op_ch.typing(): done_sp = await display_cards( - [{'player': x, 'team': team} for x in team_sp], team, op_ch, interaction.user, self.bot, - cust_message=f'Here are your starting pitchers.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_sp], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Here are your starting pitchers.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_sp: @@ -343,10 +478,14 @@ class TeamSetup(commands.Cog): async with op_ch.typing(): done_rp = await display_cards( - [{'player': x, 'team': team} for x in team_rp], team, op_ch, interaction.user, self.bot, - cust_message=f'And now for your bullpen.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_rp], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"And now for your bullpen.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_rp: @@ -354,10 +493,14 @@ class TeamSetup(commands.Cog): async with op_ch.typing(): done_inf = await display_cards( - [{'player': x, 'team': team} for x in team_infielders], team, op_ch, interaction.user, self.bot, - cust_message=f'Next let\'s take a look at your infielders.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_infielders], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Next let's take a look at your infielders.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_inf: @@ -365,10 +508,14 @@ class TeamSetup(commands.Cog): async with op_ch.typing(): done_out = await display_cards( - [{'player': x, 'team': team} for x in team_outfielders], team, op_ch, interaction.user, self.bot, - cust_message=f'Now let\'s take a look at your outfielders.\n' - f'Press `Close Pack` to continue.', - add_roster=False + [{"player": x, "team": team} for x in team_outfielders], + team, + op_ch, + interaction.user, + self.bot, + cust_message=f"Now let's take a look at your outfielders.\n" + f"Press `Close Pack` to continue.", + add_roster=False, ) if not done_out: @@ -376,129 +523,154 @@ class TeamSetup(commands.Cog): await give_packs(team, 1) await op_ch.send( - f'To get you started, I\'ve spotted you 100₼ and a pack of cards. You can rip that with the ' - f'`/open` command once your google sheet is set up!' + f"To get you started, I've spotted you 100₼ and a pack of cards. You can rip that with the " + f"`/open` command once your google sheet is set up!" ) await op_ch.send( - f'{t_role.mention}\n\n' - f'There\'s your roster! We have one more step and you will be ready to play.\n\n{SHEET_SHARE_STEPS}\n\n' + f"{t_role.mention}\n\n" + f"There's your roster! We have one more step and you will be ready to play.\n\n{SHEET_SHARE_STEPS}\n\n" f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}' ) - new_team_embed = await team_summary_embed(team, interaction, include_roster=False) + new_team_embed = await team_summary_embed( + team, interaction, include_roster=False + ) await send_to_channel( - self.bot, "pd-network-news", content='A new challenger approaches...', embed=new_team_embed + self.bot, + "pd-network-news", + content="A new challenger approaches...", + embed=new_team_embed, ) - @commands.hybrid_command(name='newsheet', help='Link a new team sheet with your team') + @commands.hybrid_command( + name="newsheet", help="Link a new team sheet with your team" + ) @commands.has_any_role(PD_PLAYERS) async def share_sheet_command( - self, ctx, google_sheet_url: str, team_abbrev: Optional[str], copy_rosters: Optional[bool] = True): + self, + ctx, + google_sheet_url: str, + team_abbrev: Optional[str], + copy_rosters: Optional[bool] = True, + ): owner_team = await get_team_by_owner(get_context_user(ctx).id) if not owner_team: - await ctx.send(f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!') + await ctx.send( + f"I don't see a team for you, yet. You can sign up with the `/newteam` command!" + ) return team = owner_team - if team_abbrev and team_abbrev != owner_team['abbrev']: + if team_abbrev and team_abbrev != owner_team["abbrev"]: if get_context_user(ctx).id != 258104532423147520: - await ctx.send(f'You can only update the team sheet for your own team, you goober.') + await ctx.send( + f"You can only update the team sheet for your own team, you goober." + ) return else: team = await get_team_by_abbrev(team_abbrev) - current = await db_get('current') - if current['gsheet_template'] in google_sheet_url: - await ctx.send(f'Ope, looks like that is the template sheet. Would you please make a copy and then share?') + current = await db_get("current") + if current["gsheet_template"] in google_sheet_url: + await ctx.send( + f"Ope, looks like that is the template sheet. Would you please make a copy and then share?" + ) return gauntlet_team = await get_team_by_abbrev(f'Gauntlet-{owner_team["abbrev"]}') if gauntlet_team: - view = ButtonOptions([ctx.author], timeout=30, labels=['Main Team', 'Gauntlet Team', None, None, None]) - question = await ctx.send(f'Is this sheet for your main PD team or your active Gauntlet team?', view=view) + view = ButtonOptions( + [ctx.author], + timeout=30, + labels=["Main Team", "Gauntlet Team", None, None, None], + ) + question = await ctx.send( + f"Is this sheet for your main PD team or your active Gauntlet team?", + view=view, + ) await view.wait() if not view.value: await question.edit( - content=f'Okay you keep thinking on it and get back to me when you\'re ready.', view=None + content=f"Okay you keep thinking on it and get back to me when you're ready.", + view=None, ) return - elif view.value == 'Gauntlet Team': + elif view.value == "Gauntlet Team": await question.delete() team = gauntlet_team sheets = get_sheets(self.bot) - response = await ctx.send(f'I\'ll go grab that sheet...') + response = await ctx.send(f"I'll go grab that sheet...") try: new_sheet = sheets.open_by_url(google_sheet_url) except Exception as e: logger.error(f'Error accessing {team["abbrev"]} sheet: {e}') - current = await db_get('current') - await ctx.send(f'I wasn\'t able to access that sheet. Did you remember to share it with my PD email?' - f'\n\nHere\'s a quick refresher:\n{SHEET_SHARE_STEPS}\n\n' - f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}') + current = await db_get("current") + await ctx.send( + f"I wasn't able to access that sheet. Did you remember to share it with my PD email?" + f"\n\nHere's a quick refresher:\n{SHEET_SHARE_STEPS}\n\n" + f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}' + ) return - team_data = new_sheet.worksheet_by_title('Team Data') + team_data = new_sheet.worksheet_by_title("Team Data") if not gauntlet_team or owner_team != gauntlet_team: team_data.update_values( - crange='B1:B2', - values=[[f'{team["id"]}'], [f'{team_hash(team)}']] + crange="B1:B2", values=[[f'{team["id"]}'], [f"{team_hash(team)}"]] ) - if copy_rosters and team['gsheet'].lower() != 'none': - old_sheet = sheets.open_by_key(team['gsheet']) - r_sheet = old_sheet.worksheet_by_title(f'My Rosters') - roster_ids = r_sheet.range('B3:B80') - lineups_data = r_sheet.range('H4:M26') + if copy_rosters and team["gsheet"].lower() != "none": + old_sheet = sheets.open_by_key(team["gsheet"]) + r_sheet = old_sheet.worksheet_by_title(f"My Rosters") + roster_ids = r_sheet.range("B3:B80") + lineups_data = r_sheet.range("H4:M26") new_r_data, new_l_data = [], [] for row in roster_ids: - if row[0].value != '': + if row[0].value != "": new_r_data.append([int(row[0].value)]) else: new_r_data.append([None]) - logger.debug(f'new_r_data: {new_r_data}') + logger.debug(f"new_r_data: {new_r_data}") for row in lineups_data: - logger.debug(f'row: {row}') - new_l_data.append([ - row[0].value if row[0].value != '' else None, - int(row[1].value) if row[1].value != '' else None, - row[2].value if row[2].value != '' else None, - int(row[3].value) if row[3].value != '' else None, - row[4].value if row[4].value != '' else None, - int(row[5].value) if row[5].value != '' else None - ]) - logger.debug(f'new_l_data: {new_l_data}') + logger.debug(f"row: {row}") + new_l_data.append( + [ + row[0].value if row[0].value != "" else None, + int(row[1].value) if row[1].value != "" else None, + row[2].value if row[2].value != "" else None, + int(row[3].value) if row[3].value != "" else None, + row[4].value if row[4].value != "" else None, + int(row[5].value) if row[5].value != "" else None, + ] + ) + logger.debug(f"new_l_data: {new_l_data}") - new_r_sheet = new_sheet.worksheet_by_title(f'My Rosters') - new_r_sheet.update_values( - crange='B3:B80', - values=new_r_data - ) - new_r_sheet.update_values( - crange='H4:M26', - values=new_l_data - ) + new_r_sheet = new_sheet.worksheet_by_title(f"My Rosters") + new_r_sheet.update_values(crange="B3:B80", values=new_r_data) + new_r_sheet.update_values(crange="H4:M26", values=new_l_data) - if team['has_guide']: + if team["has_guide"]: post_ratings_guide(team, self.bot, this_sheet=new_sheet) - team = await db_patch('teams', object_id=team['id'], params=[('gsheet', new_sheet.id)]) + team = await db_patch( + "teams", object_id=team["id"], params=[("gsheet", new_sheet.id)] + ) await refresh_sheet(team, self.bot, sheets) - conf_message = f'Alright, your sheet is linked to your team - good luck' + conf_message = f"Alright, your sheet is linked to your team - good luck" if owner_team == team: - conf_message += ' this season!' + conf_message += " this season!" else: - conf_message += ' on your run!' - conf_message += f'\n\n{HELP_SHEET_SCRIPTS}' - await response.edit(content=f'{conf_message}') + conf_message += " on your run!" + conf_message += f"\n\n{HELP_SHEET_SCRIPTS}" + await response.edit(content=f"{conf_message}") async def setup(bot): """Setup function for the TeamSetup cog.""" - await bot.add_cog(TeamSetup(bot)) \ No newline at end of file + await bot.add_cog(TeamSetup(bot)) diff --git a/discord_ui/__init__.py b/discord_ui/__init__.py index 6e40231..ffc1390 100644 --- a/discord_ui/__init__.py +++ b/discord_ui/__init__.py @@ -7,17 +7,34 @@ This package contains all Discord UI classes and components used throughout the from .confirmations import Question, Confirm, ButtonOptions from .pagination import Pagination from .selectors import ( - SelectChoicePackTeam, SelectOpenPack, SelectPaperdexCardset, - SelectPaperdexTeam, SelectBuyPacksCardset, SelectBuyPacksTeam, - SelectUpdatePlayerTeam, SelectView + SelectChoicePackTeam, + SelectOpenPack, + SelectPaperdexCardset, + SelectPaperdexTeam, + SelectBuyPacksCardset, + SelectBuyPacksTeam, + SelectUpdatePlayerTeam, + SelectView, ) from .dropdowns import Dropdown, DropdownView +# ScoutView intentionally NOT imported here to avoid circular import: +# helpers.main → discord_ui → scout_view → helpers.main +# Import directly: from discord_ui.scout_view import ScoutView + __all__ = [ - 'Question', 'Confirm', 'ButtonOptions', - 'Pagination', - 'SelectChoicePackTeam', 'SelectOpenPack', 'SelectPaperdexCardset', - 'SelectPaperdexTeam', 'SelectBuyPacksCardset', 'SelectBuyPacksTeam', - 'SelectUpdatePlayerTeam', 'SelectView', - 'Dropdown', 'DropdownView' -] \ No newline at end of file + "Question", + "Confirm", + "ButtonOptions", + "Pagination", + "SelectChoicePackTeam", + "SelectOpenPack", + "SelectPaperdexCardset", + "SelectPaperdexTeam", + "SelectBuyPacksCardset", + "SelectBuyPacksTeam", + "SelectUpdatePlayerTeam", + "SelectView", + "Dropdown", + "DropdownView", +] diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py new file mode 100644 index 0000000..a5a64ba --- /dev/null +++ b/discord_ui/scout_view.py @@ -0,0 +1,272 @@ +""" +Scout View — Face-down card button UI for the Scouting feature. + +When a player opens a pack, a ScoutView is posted with one button per card. +Other players can click a button to "scout" (blind-pick) one card, receiving +a copy. The opener keeps all their cards. Multiple players can scout the same +card — each gets their own copy. +""" + +import logging + +import discord + +from api_calls import db_post +from helpers.main import get_team_by_owner, get_card_embeds +from helpers.scouting import ( + SCOUT_TOKENS_PER_DAY, + build_scout_embed, + get_scout_tokens_used, +) +from helpers.utils import int_timestamp +from helpers.discord_utils import get_team_embed, send_to_channel +from helpers.constants import IMAGES, PD_SEASON + +logger = logging.getLogger("discord_app") + + +class ScoutView(discord.ui.View): + """Displays face-down card buttons for a scout opportunity. + + - One button per card, labeled "Card 1" ... "Card N" + - Any player EXCEPT the pack opener can interact + - Any card can be scouted multiple times by different players + - One scout per player per pack + - Timeout: 30 minutes + """ + + def __init__( + self, + scout_opp_id: int, + cards: list[dict], + opener_team: dict, + opener_user_id: int, + bot, + expires_unix: int = None, + ): + super().__init__(timeout=1800.0) + self.scout_opp_id = scout_opp_id + self.cards = cards + self.opener_team = opener_team + self.opener_user_id = opener_user_id + self.bot = bot + self.expires_unix = expires_unix + self.message: discord.Message | None = None + self.card_lines: list[tuple[int, str]] = [] + + # Per-card claim tracking: player_id -> list of scouter team names + self.claims: dict[int, list[str]] = {} + # Per-user lock: user IDs who have already scouted this pack + self.scouted_users: set[int] = set() + # Users currently being processed (prevent double-click race) + self.processing_users: set[int] = set() + + for i, card in enumerate(cards): + button = ScoutButton( + card=card, + position=i, + scout_view=self, + ) + self.add_item(button) + + @property + def total_scouts(self) -> int: + return sum(len(v) for v in self.claims.values()) + + async def update_message(self): + """Refresh the embed with current claim state.""" + if not self.message: + return + + embed, _ = build_scout_embed( + self.opener_team, + card_lines=self.card_lines, + expires_unix=self.expires_unix, + claims=self.claims, + total_scouts=self.total_scouts, + ) + + try: + await self.message.edit(embed=embed, view=self) + except Exception as e: + logger.error(f"Failed to update scout message: {e}") + + async def on_timeout(self): + """Disable all buttons and update the embed when the window expires.""" + for item in self.children: + item.disabled = True + + if self.message: + try: + embed, _ = build_scout_embed( + self.opener_team, + card_lines=self.card_lines, + claims=self.claims, + total_scouts=self.total_scouts, + closed=True, + ) + await self.message.edit(embed=embed, view=self) + except Exception as e: + logger.error(f"Failed to edit expired scout message: {e}") + + +class ScoutButton(discord.ui.Button): + """A single face-down card button in a ScoutView.""" + + def __init__(self, card: dict, position: int, scout_view: ScoutView): + super().__init__( + label=f"Card {position + 1}", + style=discord.ButtonStyle.secondary, + row=0, + ) + self.card = card + self.position = position + self.scout_view: ScoutView = scout_view + + async def callback(self, interaction: discord.Interaction): + view = self.scout_view + + # Block the opener + if interaction.user.id == view.opener_user_id: + await interaction.response.send_message( + "You can't scout your own pack!", + ephemeral=True, + ) + return + + # One scout per player per pack + if interaction.user.id in view.scouted_users: + await interaction.response.send_message( + "You already scouted a card from this pack!", + ephemeral=True, + ) + return + + # Prevent double-click race for same user + if interaction.user.id in view.processing_users: + await interaction.response.send_message( + "Your scout is already being processed!", + ephemeral=True, + ) + return + + view.processing_users.add(interaction.user.id) + await interaction.response.defer(ephemeral=True) + + try: + # Get scouting player's team + scouter_team = await get_team_by_owner(interaction.user.id) + if not scouter_team: + await interaction.followup.send( + "You need a Paper Dynasty team to scout! Ask an admin to set one up.", + ephemeral=True, + ) + return + + # Check scout token balance + tokens_used = await get_scout_tokens_used(scouter_team["id"]) + + if tokens_used >= SCOUT_TOKENS_PER_DAY: + await interaction.followup.send( + "You're out of scout tokens for today! You get 2 per day, resetting at midnight Central.", + ephemeral=True, + ) + return + + # Record the claim in the database + try: + await db_post( + "scout_claims", + payload={ + "scout_opportunity_id": view.scout_opp_id, + "card_id": self.card["id"], + "claimed_by_team_id": scouter_team["id"], + }, + ) + except Exception as e: + logger.error(f"Failed to record scout claim: {e}") + await interaction.followup.send( + "Something went wrong claiming this scout. Try again!", + ephemeral=True, + ) + return + + # Create a copy of the card for the scouter (before consuming token + # so a failure here doesn't cost the player a token for nothing) + await db_post( + "cards", + payload={ + "cards": [ + { + "player_id": self.card["player"]["player_id"], + "team_id": scouter_team["id"], + "pack_id": self.card["pack"]["id"], + } + ], + }, + ) + + # Consume a scout token + await db_post( + "rewards", + payload={ + "name": "Scout Token", + "team_id": scouter_team["id"], + "season": PD_SEASON, + "created": int_timestamp(), + }, + ) + + # Track the claim + player_id = self.card["player"]["player_id"] + if player_id not in view.claims: + view.claims[player_id] = [] + view.claims[player_id].append(scouter_team["lname"]) + view.scouted_users.add(interaction.user.id) + + # Update the shared embed + await view.update_message() + + # Send the scouter their card details (ephemeral) + player_name = self.card["player"]["p_name"] + rarity_name = self.card["player"]["rarity"]["name"] + + card_for_embed = { + "player": self.card["player"], + "team": scouter_team, + } + card_embeds = await get_card_embeds(card_for_embed) + await interaction.followup.send( + content=f"You scouted a **{rarity_name}** {player_name}!", + embeds=card_embeds, + ephemeral=True, + ) + + # Notify for shiny scouts (rarity >= 5) + if self.card["player"]["rarity"]["value"] >= 5: + try: + notif_embed = get_team_embed(title="Rare Scout!", team=scouter_team) + notif_embed.description = ( + f"**{scouter_team['lname']}** scouted a " + f"**{rarity_name}** {player_name}!" + ) + notif_embed.set_thumbnail( + url=self.card["player"].get("headshot", IMAGES["logo"]) + ) + await send_to_channel( + view.bot, "pd-network-news", embed=notif_embed + ) + except Exception as e: + logger.error(f"Failed to send shiny scout notification: {e}") + + except Exception as e: + logger.error(f"Unexpected error in scout callback: {e}", exc_info=True) + try: + await interaction.followup.send( + "Something went wrong. Please try again.", + ephemeral=True, + ) + except Exception: + pass + finally: + view.processing_users.discard(interaction.user.id) diff --git a/helpers/__init__.py b/helpers/__init__.py index 4b62f4e..8c7e39d 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -6,7 +6,7 @@ The package is organized into logical modules for better maintainability. Modules: - constants: Application constants and configuration -- utils: General utility functions +- utils: General utility functions - random_content: Random content generators - search_utils: Search and fuzzy matching functionality - discord_utils: Discord helper functions @@ -21,9 +21,10 @@ Modules: # This allows existing code to continue working during the migration from helpers.main import * -# Import from migrated modules +# Import from migrated modules from .constants import * from .utils import * from .random_content import * -from .search_utils import * -from .discord_utils import * \ No newline at end of file +from .search_utils import * +from .discord_utils import * +from .scouting import * diff --git a/helpers/main.py b/helpers/main.py index ed16a3b..0b989a5 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -8,7 +8,7 @@ import traceback import discord import pygsheets -import requests +import aiohttp from discord.ext import commands from api_calls import * @@ -43,17 +43,21 @@ async def get_player_photo(player): ) try: - resp = requests.get(req_url, timeout=0.5) - except Exception as e: + async with aiohttp.ClientSession() as session: + async with session.get( + req_url, timeout=aiohttp.ClientTimeout(total=0.5) + ) as resp: + if resp.status == 200: + data = await resp.json() + if data["player"] and data["player"][0]["strSport"] == "Baseball": + await db_patch( + "players", + object_id=player["player_id"], + params=[("headshot", data["player"][0]["strThumb"])], + ) + return data["player"][0]["strThumb"] + except Exception: return None - if resp.status_code == 200 and resp.json()["player"]: - if resp.json()["player"][0]["strSport"] == "Baseball": - await db_patch( - "players", - object_id=player["player_id"], - params=[("headshot", resp.json()["player"][0]["strThumb"])], - ) - return resp.json()["player"][0]["strThumb"] return None @@ -1681,9 +1685,9 @@ async def paperdex_team_embed(team: dict, mlb_team: dict) -> list[discord.Embed] for cardset_id in coll_data: if cardset_id != "total_owned": if coll_data[cardset_id]["players"]: - coll_data[cardset_id]["embeds"][0].description = ( - f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}" - ) + coll_data[cardset_id]["embeds"][ + 0 + ].description = f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}" coll_data[cardset_id]["embeds"][0].add_field( name="# Collected / # Total Cards", value=f"{coll_data[cardset_id]['owned']} / {len(coll_data[cardset_id]['players'])}", @@ -1749,6 +1753,8 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): all_cards = [] for p_id in pack_ids: new_cards = await db_get("cards", params=[("pack_id", p_id)]) + for card in new_cards["cards"]: + card.setdefault("pack_id", p_id) all_cards.extend(new_cards["cards"]) if not all_cards: @@ -1764,6 +1770,20 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): await context.channel.send(content=f"Let's head down to {pack_channel.mention}!") await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover) + # Create scout opportunities for each pack (Standard/Premium only) + from helpers.scouting import create_scout_opportunity, SCOUTABLE_PACK_TYPES + + pack_type_name = all_packs[0].get("pack_type", {}).get("name") + if pack_type_name in SCOUTABLE_PACK_TYPES: + for p_id in pack_ids: + pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] + if pack_cards: + await create_scout_opportunity( + pack_cards, team, pack_channel, author, context + ) + if len(pack_ids) > 1: + await asyncio.sleep(2) + async def get_choice_from_cards( interaction: discord.Interaction, diff --git a/helpers/scouting.py b/helpers/scouting.py new file mode 100644 index 0000000..6c926bb --- /dev/null +++ b/helpers/scouting.py @@ -0,0 +1,243 @@ +""" +Scouting Helper Functions + +Handles creation of scout opportunities after pack openings +and embed formatting for the scouting feature. +""" + +import datetime +import logging +import os +import random + +import discord + +from api_calls import db_get, db_post +from helpers.utils import int_timestamp, midnight_timestamp +from helpers.discord_utils import get_team_embed +from helpers.constants import IMAGES, PD_SEASON + +logger = logging.getLogger("discord_app") + +SCOUT_TOKENS_PER_DAY = 2 +SCOUT_WINDOW_SECONDS = 1800 # 30 minutes +_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium") +SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()} + +# Rarity value → display symbol +RARITY_SYMBOLS = { + 8: "\U0001f7e3", # HoF — purple (#751cea) + 5: "\U0001f535", # MVP — cyan/blue (#56f1fa) + 3: "\U0001f7e1", # All-Star — gold (#FFD700) + 2: "\u26aa", # Starter — silver (#C0C0C0) + 1: "\U0001f7e4", # Reserve — bronze (#CD7F32) + 0: "\u26ab", # Replacement — dark gray (#454545) +} + + +async def get_scout_tokens_used(team_id: int) -> int: + """Return how many scout tokens a team has used today.""" + used_today = await db_get( + "rewards", + params=[ + ("name", "Scout Token"), + ("team_id", team_id), + ("created_after", midnight_timestamp()), + ], + ) + return used_today["count"] if used_today else 0 + + +def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: + """Build a shuffled list of (player_id, display_line) tuples.""" + lines = [] + for card in cards: + player = card["player"] + rarity_val = player["rarity"]["value"] + symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab") + desc = player.get("description", "") + image_url = player.get("image", "") + name_display = ( + f"[{desc} {player['p_name']}]({image_url})" + if image_url + else f"{desc} {player['p_name']}" + ) + lines.append( + ( + player["player_id"], + f"{symbol} {player['rarity']['name']} — {name_display}", + ) + ) + random.shuffle(lines) + return lines + + +def build_scout_embed( + opener_team: dict, + cards: list[dict] = None, + card_lines: list[tuple[int, str]] = None, + expires_unix: int = None, + claims: dict[int, list[str]] = None, + total_scouts: int = 0, + closed: bool = False, +) -> tuple[discord.Embed, list[tuple[int, str]]]: + """Build the embed shown above the scout buttons. + + Shows a shuffled list of cards (rarity + player name) so scouters + know what's in the pack but not which button maps to which card. + Returns (embed, card_lines) so the view can store the shuffled order. + + Parameters + ---------- + closed : if True, renders the "Scout Window Closed" variant + claims : scouted card tracking dict for build_scouted_card_list + total_scouts : number of scouts so far (for title display) + """ + if card_lines is None and cards is not None: + card_lines = _build_card_lines(cards) + + if claims and card_lines: + card_list = build_scouted_card_list(card_lines, claims) + elif card_lines: + card_list = "\n".join(line for _, line in card_lines) + else: + card_list = "" + + if closed: + if total_scouts > 0: + title = f"Scout Window Closed ({total_scouts} scouted)" + else: + title = "Scout Window Closed" + elif total_scouts > 0: + title = f"Scout Opportunity! ({total_scouts} scouted)" + else: + title = "Scout Opportunity!" + + embed = get_team_embed(title=title, team=opener_team) + + if closed: + embed.description = f"**{opener_team['lname']}**'s pack\n\n" f"{card_list}" + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", + icon_url=IMAGES["logo"], + ) + else: + if expires_unix: + time_line = f"Scout window closes ." + else: + time_line = "Scout window closes in **30 minutes**." + + embed.description = ( + f"**{opener_team['lname']}**'s pack\n\n" + f"{card_list}\n\n" + f"Pick a card — but which is which?\n" + f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n" + f"{time_line}" + ) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player", + icon_url=IMAGES["logo"], + ) + return embed, card_lines + + +def build_scouted_card_list( + card_lines: list[tuple[int, str]], + scouted_cards: dict[int, list[str]], +) -> str: + """Rebuild the card list marking scouted cards with scouter team names. + + Parameters + ---------- + card_lines : shuffled list of (player_id, display_line) tuples + scouted_cards : {player_id: [team_name, ...]} for each claimed card + """ + result = [] + for player_id, line in card_lines: + teams = scouted_cards.get(player_id) + if teams: + count = len(teams) + names = ", ".join(f"*{t}*" for t in teams) + if count == 1: + result.append(f"{line} \u2014 \u2714\ufe0f {names}") + else: + result.append(f"{line} \u2014 \u2714\ufe0f x{count} ({names})") + else: + result.append(line) + return "\n".join(result) + + +async def create_scout_opportunity( + pack_cards: list[dict], + opener_team: dict, + channel: discord.TextChannel, + opener_user, + context, +) -> None: + """Create a scout opportunity and post the ScoutView to the channel. + + Called after display_cards() completes in open_st_pr_packs(). + Wrapped in try/except so scouting failures never crash pack opening. + + Parameters + ---------- + pack_cards : list of card dicts from a single pack + opener_team : team dict for the pack opener + channel : the #pack-openings channel + opener_user : discord.Member or discord.User who opened the pack + context : the command context (Context or Interaction), used to get bot + """ + from discord_ui.scout_view import ScoutView + + # Only create scout opportunities in the pack-openings channel + if not channel or channel.name != "pack-openings": + return + + if not pack_cards: + return + + now = datetime.datetime.now() + expires_dt = now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS) + expires_at = int_timestamp(expires_dt) + expires_unix = int(expires_dt.timestamp()) + created = int_timestamp(now) + + card_ids = [c["id"] for c in pack_cards] + + try: + scout_opp = await db_post( + "scout_opportunities", + payload={ + "pack_id": pack_cards[0].get("pack_id"), + "opener_team_id": opener_team["id"], + "card_ids": card_ids, + "expires_at": expires_at, + "created": created, + }, + ) + except Exception as e: + logger.error(f"Failed to create scout opportunity: {e}") + return + + embed, card_lines = build_scout_embed( + opener_team, pack_cards, expires_unix=expires_unix + ) + + # Get bot reference from context + bot = getattr(context, "bot", None) or getattr(context, "client", None) + + view = ScoutView( + scout_opp_id=scout_opp["id"], + cards=pack_cards, + opener_team=opener_team, + opener_user_id=opener_user.id, + bot=bot, + expires_unix=expires_unix, + ) + view.card_lines = card_lines + + try: + msg = await channel.send(embed=embed, view=view) + view.message = msg + except Exception as e: + logger.error(f"Failed to post scout opportunity message: {e}") diff --git a/helpers/utils.py b/helpers/utils.py index 9fc70e3..6b6185f 100644 --- a/helpers/utils.py +++ b/helpers/utils.py @@ -4,60 +4,71 @@ General Utilities This module contains standalone utility functions with minimal dependencies, including timestamp conversion, position abbreviations, and simple helpers. """ + import datetime from typing import Optional import discord def int_timestamp(datetime_obj: Optional[datetime.datetime] = None): - """Convert current datetime to integer timestamp.""" - if datetime_obj: - return int(datetime.datetime.timestamp(datetime_obj) * 1000) - return int(datetime.datetime.now().timestamp()) + """Convert a datetime to an integer millisecond timestamp. + + If no argument is given, uses the current time. + """ + if datetime_obj is None: + datetime_obj = datetime.datetime.now() + return int(datetime.datetime.timestamp(datetime_obj) * 1000) + + +def midnight_timestamp() -> int: + """Return today's midnight (00:00:00) as an integer millisecond timestamp.""" + now = datetime.datetime.now() + midnight = datetime.datetime(now.year, now.month, now.day, 0, 0, 0) + return int_timestamp(midnight) def get_pos_abbrev(field_pos: str) -> str: """Convert position name to standard abbreviation.""" - if field_pos.lower() == 'catcher': - return 'C' - elif field_pos.lower() == 'first baseman': - return '1B' - elif field_pos.lower() == 'second baseman': - return '2B' - elif field_pos.lower() == 'third baseman': - return '3B' - elif field_pos.lower() == 'shortstop': - return 'SS' - elif field_pos.lower() == 'left fielder': - return 'LF' - elif field_pos.lower() == 'center fielder': - return 'CF' - elif field_pos.lower() == 'right fielder': - return 'RF' + if field_pos.lower() == "catcher": + return "C" + elif field_pos.lower() == "first baseman": + return "1B" + elif field_pos.lower() == "second baseman": + return "2B" + elif field_pos.lower() == "third baseman": + return "3B" + elif field_pos.lower() == "shortstop": + return "SS" + elif field_pos.lower() == "left fielder": + return "LF" + elif field_pos.lower() == "center fielder": + return "CF" + elif field_pos.lower() == "right fielder": + return "RF" else: - return 'P' + return "P" def position_name_to_abbrev(position_name): """Convert position name to abbreviation (alternate format).""" - if position_name == 'Catcher': - return 'C' - elif position_name == 'First Base': - return '1B' - elif position_name == 'Second Base': - return '2B' - elif position_name == 'Third Base': - return '3B' - elif position_name == 'Shortstop': - return 'SS' - elif position_name == 'Left Field': - return 'LF' - elif position_name == 'Center Field': - return 'CF' - elif position_name == 'Right Field': - return 'RF' - elif position_name == 'Pitcher': - return 'P' + if position_name == "Catcher": + return "C" + elif position_name == "First Base": + return "1B" + elif position_name == "Second Base": + return "2B" + elif position_name == "Third Base": + return "3B" + elif position_name == "Shortstop": + return "SS" + elif position_name == "Left Field": + return "LF" + elif position_name == "Center Field": + return "CF" + elif position_name == "Right Field": + return "RF" + elif position_name == "Pitcher": + return "P" else: return position_name @@ -67,13 +78,13 @@ def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool: for x in user.roles: if x.name == role_name: return True - + return False def get_roster_sheet_legacy(team): """Get legacy roster sheet URL for a team.""" - return f'https://docs.google.com/spreadsheets/d/{team.gsheet}/edit' + return f"https://docs.google.com/spreadsheets/d/{team.gsheet}/edit" def get_roster_sheet(team): @@ -83,13 +94,15 @@ def get_roster_sheet(team): Handles both dict and Team object formats. """ # Handle both dict (team["gsheet"]) and object (team.gsheet) formats - gsheet = team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None) - return f'https://docs.google.com/spreadsheets/d/{gsheet}/edit' + gsheet = ( + team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None) + ) + return f"https://docs.google.com/spreadsheets/d/{gsheet}/edit" def get_player_url(team, player) -> str: """Generate player URL for SBA or Baseball Reference.""" - if team.get('league') == 'SBA': + if team.get("league") == "SBA": return f'https://statsplus.net/super-baseball-association/player/{player["player_id"]}' else: return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml' @@ -98,10 +111,11 @@ def get_player_url(team, player) -> str: def owner_only(ctx) -> bool: """Check if user is the bot owner.""" # ID for discord User Cal - owners = [287463767924137994, 1087936030899347516] + owners = [258104532423147520] + # owners += [287463767924137994, 1087936030899347516] # Handle both Context (has .author) and Interaction (has .user) objects - user = getattr(ctx, 'user', None) or getattr(ctx, 'author', None) + user = getattr(ctx, "user", None) or getattr(ctx, "author", None) if user and user.id in owners: return True @@ -121,35 +135,36 @@ def get_context_user(ctx): discord.User or discord.Member: The user who invoked the command """ # Handle both Context (has .author) and Interaction (has .user) objects - return getattr(ctx, 'user', None) or getattr(ctx, 'author', None) + return getattr(ctx, "user", None) or getattr(ctx, "author", None) def get_cal_user(ctx): """Get the Cal user from context. Always returns an object with .mention attribute.""" import logging - logger = logging.getLogger('discord_app') - + + logger = logging.getLogger("discord_app") + # Define placeholder user class first class PlaceholderUser: def __init__(self): self.mention = "<@287463767924137994>" self.id = 287463767924137994 - + # Handle both Context and Interaction objects - if hasattr(ctx, 'bot'): # Context object + if hasattr(ctx, "bot"): # Context object bot = ctx.bot logger.debug("get_cal_user: Using Context object") - elif hasattr(ctx, 'client'): # Interaction object + elif hasattr(ctx, "client"): # Interaction object bot = ctx.client logger.debug("get_cal_user: Using Interaction object") else: logger.error("get_cal_user: No bot or client found in context") return PlaceholderUser() - + if not bot: logger.error("get_cal_user: bot is None") return PlaceholderUser() - + logger.debug(f"get_cal_user: Searching among members") try: for user in bot.get_all_members(): @@ -158,7 +173,7 @@ def get_cal_user(ctx): return user except Exception as e: logger.error(f"get_cal_user: Exception in get_all_members: {e}") - + # Fallback: try to get user directly by ID logger.debug("get_cal_user: User not found in get_all_members, trying get_user") try: @@ -170,7 +185,7 @@ def get_cal_user(ctx): logger.debug("get_cal_user: get_user returned None") except Exception as e: logger.error(f"get_cal_user: Exception in get_user: {e}") - + # Last resort: return a placeholder user object with mention logger.debug("get_cal_user: Using placeholder user") - return PlaceholderUser() \ No newline at end of file + return PlaceholderUser() diff --git a/paperdynasty.py b/paperdynasty.py index d7d9ea1..951654a 100644 --- a/paperdynasty.py +++ b/paperdynasty.py @@ -12,12 +12,12 @@ from in_game.gameplay_queries import get_channel_game_or_none from health_server import run_health_server from notify_restart import send_restart_notification -raw_log_level = os.getenv('LOG_LEVEL') -if raw_log_level == 'DEBUG': +raw_log_level = os.getenv("LOG_LEVEL") +if raw_log_level == "DEBUG": log_level = logging.DEBUG -elif raw_log_level == 'INFO': +elif raw_log_level == "INFO": log_level = logging.INFO -elif raw_log_level == 'WARN': +elif raw_log_level == "WARN": log_level = logging.WARNING else: log_level = logging.ERROR @@ -29,17 +29,17 @@ else: # level=log_level # ) # logger.getLogger('discord.http').setLevel(logger.INFO) -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") logger.setLevel(log_level) handler = RotatingFileHandler( - filename='logs/discord.log', + filename="logs/discord.log", # encoding='utf-8', maxBytes=32 * 1024 * 1024, # 32 MiB backupCount=5, # Rotate through 5 files ) -formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler.setFormatter(formatter) # dt_fmt = '%Y-%m-%d %H:%M:%S' @@ -48,27 +48,30 @@ handler.setFormatter(formatter) logger.addHandler(handler) COGS = [ - 'cogs.owner', - 'cogs.admins', - 'cogs.economy', - 'cogs.players', - 'cogs.gameplay', + "cogs.owner", + "cogs.admins", + "cogs.economy", + "cogs.players", + "cogs.gameplay", + "cogs.economy_new.scouting", ] intents = discord.Intents.default() intents.members = True intents.message_content = True -bot = commands.Bot(command_prefix='.', - intents=intents, - # help_command=None, - description='The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.', - case_insensitive=True, - owner_id=258104532423147520) +bot = commands.Bot( + command_prefix=".", + intents=intents, + # help_command=None, + description="The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.", + case_insensitive=True, + owner_id=258104532423147520, +) @bot.event async def on_ready(): - logger.info('Logged in as:') + logger.info("Logged in as:") logger.info(bot.user.name) logger.info(bot.user.id) @@ -77,9 +80,11 @@ async def on_ready(): @bot.tree.error -async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError): +async def on_app_command_error( + interaction: discord.Interaction, error: discord.app_commands.AppCommandError +): """Global error handler for all app commands (slash commands).""" - logger.error(f'App command error in {interaction.command}: {error}', exc_info=error) + logger.error(f"App command error in {interaction.command}: {error}", exc_info=error) # CRITICAL: Release play lock if command failed during gameplay # This prevents permanent user lockouts when exceptions occur @@ -97,22 +102,23 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord. session.add(current_play) session.commit() except Exception as lock_error: - logger.error(f'Failed to release play lock after error: {lock_error}', exc_info=lock_error) + logger.error( + f"Failed to release play lock after error: {lock_error}", + exc_info=lock_error, + ) # Try to respond to the user try: if not interaction.response.is_done(): await interaction.response.send_message( - f'❌ An error occurred: {str(error)}', - ephemeral=True + f"❌ An error occurred: {str(error)}", ephemeral=True ) else: await interaction.followup.send( - f'❌ An error occurred: {str(error)}', - ephemeral=True + f"❌ An error occurred: {str(error)}", ephemeral=True ) except Exception as e: - logger.error(f'Failed to send error message to user: {e}') + logger.error(f"Failed to send error message to user: {e}") async def main(): @@ -120,10 +126,10 @@ async def main(): for c in COGS: try: await bot.load_extension(c) - logger.info(f'Loaded cog: {c}') + logger.info(f"Loaded cog: {c}") except Exception as e: - logger.error(f'Failed to load cog: {c}') - logger.error(f'{e}') + logger.error(f"Failed to load cog: {c}") + logger.error(f"{e}") # Start health server and bot concurrently async with bot: @@ -132,7 +138,7 @@ async def main(): try: # Start bot (this blocks until bot stops) - await bot.start(os.environ.get('BOT_TOKEN', 'NONE')) + await bot.start(os.environ.get("BOT_TOKEN", "NONE")) finally: # Cleanup: cancel health server when bot stops health_task.cancel() @@ -141,4 +147,5 @@ async def main(): except asyncio.CancelledError: pass + asyncio.run(main()) diff --git a/tests/scouting/__init__.py b/tests/scouting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scouting/conftest.py b/tests/scouting/conftest.py new file mode 100644 index 0000000..22164fd --- /dev/null +++ b/tests/scouting/conftest.py @@ -0,0 +1,170 @@ +"""Shared fixtures for scouting feature tests.""" + +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock, Mock + +import discord +from discord.ext import commands + +# --------------------------------------------------------------------------- +# Sample data factories +# --------------------------------------------------------------------------- + + +def _make_player( + player_id, + name, + rarity_name, + rarity_value, + headshot=None, + description="2023", + image=None, +): + """Build a minimal player dict matching the API shape used by scouting.""" + return { + "player_id": player_id, + "p_name": name, + "rarity": {"name": rarity_name, "value": rarity_value, "color": "ffffff"}, + "headshot": headshot or "https://example.com/headshot.jpg", + "description": description, + "image": image or f"https://example.com/cards/{player_id}/battingcard.png", + } + + +def _make_card(card_id, player, pack_id=100): + """Wrap a player dict inside a card dict (as returned by the cards API).""" + return {"id": card_id, "player": player, "pack": {"id": pack_id}} + + +@pytest.fixture +def sample_players(): + """Five players spanning different rarities for a realistic pack.""" + return [ + _make_player(101, "Mike Trout", "MVP", 5), + _make_player(102, "Juan Soto", "All-Star", 3), + _make_player(103, "Marcus Semien", "Starter", 2), + _make_player(104, "Willy Adames", "Reserve", 1), + _make_player(105, "Generic Bench", "Replacement", 0), + ] + + +@pytest.fixture +def sample_cards(sample_players): + """Five card dicts wrapping the sample players.""" + return [_make_card(i + 1, p) for i, p in enumerate(sample_players)] + + +@pytest.fixture +def opener_team(): + """Team dict for the pack opener.""" + return { + "id": 10, + "abbrev": "OPN", + "sname": "Openers", + "lname": "Opening Squad", + "gm_id": 99999, + "gmname": "Opener GM", + "color": "a6ce39", + "logo": "https://example.com/logo.png", + "season": 4, + } + + +@pytest.fixture +def scouter_team(): + """Team dict for a player who scouts a card.""" + return { + "id": 20, + "abbrev": "SCT", + "sname": "Scouts", + "lname": "Scouting Squad", + "gm_id": 88888, + "gmname": "Scout GM", + "color": "3498db", + "logo": "https://example.com/scout_logo.png", + "season": 4, + } + + +@pytest.fixture +def scouter_team_2(): + """Second scouter team for multi-scout tests.""" + return { + "id": 30, + "abbrev": "SC2", + "sname": "Scouts2", + "lname": "Second Scouts", + "gm_id": 77777, + "gmname": "Scout GM 2", + "color": "e74c3c", + "logo": "https://example.com/scout2_logo.png", + "season": 4, + } + + +# --------------------------------------------------------------------------- +# Discord mocks +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_bot(): + """Mock Discord bot.""" + bot = AsyncMock(spec=commands.Bot) + bot.get_cog = Mock(return_value=None) + bot.add_cog = AsyncMock() + bot.wait_until_ready = AsyncMock() + + # Mock guild / channel lookup for send_to_channel + channel_mock = AsyncMock(spec=discord.TextChannel) + channel_mock.send = AsyncMock() + guild_mock = Mock(spec=discord.Guild) + guild_mock.text_channels = [channel_mock] + channel_mock.name = "pd-network-news" + bot.guilds = [guild_mock] + return bot + + +@pytest.fixture +def mock_interaction(): + """Mock Discord interaction for slash commands.""" + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.response.is_done = Mock(return_value=False) + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + + interaction.user = Mock(spec=discord.Member) + interaction.user.id = 12345 + interaction.user.mention = "<@12345>" + + interaction.channel = Mock(spec=discord.TextChannel) + interaction.channel.name = "pack-openings" + interaction.channel.send = AsyncMock() + + return interaction + + +@pytest.fixture +def mock_channel(): + """Mock #pack-openings channel.""" + channel = AsyncMock(spec=discord.TextChannel) + channel.name = "pack-openings" + channel.send = AsyncMock() + return channel + + +# --------------------------------------------------------------------------- +# Logging suppression +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def silence_logging(): + """Suppress log noise during tests.""" + import logging + + logging.getLogger("discord_app").setLevel(logging.CRITICAL) diff --git a/tests/scouting/test_scout_view.py b/tests/scouting/test_scout_view.py new file mode 100644 index 0000000..186c0b1 --- /dev/null +++ b/tests/scouting/test_scout_view.py @@ -0,0 +1,1015 @@ +"""Tests for discord_ui/scout_view.py — ScoutView and ScoutButton behavior. + +Covers view initialization, button callbacks (guard rails, claim flow, +token checks, multi-scout), embed updates, and timeout handling. + +Note: All tests that instantiate ScoutView must be async because +discord.ui.View.__init__ requires a running event loop. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import discord + +from discord_ui.scout_view import ScoutView, ScoutButton, SCOUT_TOKENS_PER_DAY + +# --------------------------------------------------------------------------- +# ScoutView initialization +# --------------------------------------------------------------------------- + + +class TestScoutViewInit: + """Tests for ScoutView construction and initial state.""" + + @pytest.mark.asyncio + async def test_creates_one_button_per_card( + self, sample_cards, opener_team, mock_bot + ): + """Should add exactly one button per card in the pack.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + buttons = [c for c in view.children if isinstance(c, discord.ui.Button)] + assert len(buttons) == len(sample_cards) + + @pytest.mark.asyncio + async def test_buttons_labeled_sequentially( + self, sample_cards, opener_team, mock_bot + ): + """Buttons should be labeled 'Card 1', 'Card 2', etc.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + labels = [c.label for c in view.children if isinstance(c, discord.ui.Button)] + expected = [f"Card {i + 1}" for i in range(len(sample_cards))] + assert labels == expected + + @pytest.mark.asyncio + async def test_buttons_are_secondary_style( + self, sample_cards, opener_team, mock_bot + ): + """All buttons should start with the gray/secondary style (face-down).""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + for btn in view.children: + if isinstance(btn, discord.ui.Button): + assert btn.style == discord.ButtonStyle.secondary + + @pytest.mark.asyncio + async def test_initial_state_is_clean(self, sample_cards, opener_team, mock_bot): + """Claims, scouted_users, and processing_users should all start empty.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + assert view.claims == {} + assert view.scouted_users == set() + assert view.processing_users == set() + assert view.total_scouts == 0 + + @pytest.mark.asyncio + async def test_timeout_is_30_minutes(self, sample_cards, opener_team, mock_bot): + """The view timeout should be 1800 seconds (30 minutes).""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + assert view.timeout == 1800.0 + + +# --------------------------------------------------------------------------- +# ScoutButton callback — guard rails +# --------------------------------------------------------------------------- + + +class TestScoutButtonGuards: + """Tests for the access control checks in ScoutButton.callback.""" + + def _make_view(self, sample_cards, opener_team, mock_bot): + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + return view + + @pytest.mark.asyncio + async def test_opener_blocked(self, sample_cards, opener_team, mock_bot): + """The pack opener should be rejected with an ephemeral message.""" + view = self._make_view(sample_cards, opener_team, mock_bot) + button = view.children[0] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 99999 # same as opener + + await button.callback(interaction) + + interaction.response.send_message.assert_called_once() + call_kwargs = interaction.response.send_message.call_args[1] + assert call_kwargs["ephemeral"] is True + assert "own pack" in interaction.response.send_message.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_already_scouted_blocked(self, sample_cards, opener_team, mock_bot): + """A user who already scouted this pack should be rejected.""" + view = self._make_view(sample_cards, opener_team, mock_bot) + view.scouted_users.add(12345) + button = view.children[0] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await button.callback(interaction) + + interaction.response.send_message.assert_called_once() + assert ( + "already scouted" + in interaction.response.send_message.call_args[0][0].lower() + ) + + @pytest.mark.asyncio + async def test_double_click_silently_ignored( + self, sample_cards, opener_team, mock_bot + ): + """If a user is already being processed, they should get an ephemeral rejection.""" + view = self._make_view(sample_cards, opener_team, mock_bot) + view.processing_users.add(12345) + button = view.children[0] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await button.callback(interaction) + + interaction.response.send_message.assert_called_once() + call_kwargs = interaction.response.send_message.call_args[1] + assert call_kwargs["ephemeral"] is True + assert ( + "already being processed" + in interaction.response.send_message.call_args[0][0].lower() + ) + + +# --------------------------------------------------------------------------- +# ScoutButton callback — successful scout flow +# --------------------------------------------------------------------------- + + +class TestScoutButtonSuccess: + """Tests for the happy-path scout claim flow.""" + + def _make_view_with_message(self, sample_cards, opener_team, mock_bot): + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + return view + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_successful_scout_creates_card_copy( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """A valid scout should POST a scout_claim, consume a token, and create a card copy.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + button = view.children[0] + await button.callback(interaction) + + # Should have deferred + interaction.response.defer.assert_called_once_with(ephemeral=True) + + # db_post should be called 3 times: scout_claims, cards, rewards + assert mock_db_post.call_count == 3 + + # Verify scout_claims POST + claim_call = mock_db_post.call_args_list[0] + assert claim_call[0][0] == "scout_claims" + + # Verify cards POST (card copy — created before token consumption) + card_call = mock_db_post.call_args_list[1] + assert card_call[0][0] == "cards" + + # Verify rewards POST (token consumption — after card is safely created) + reward_call = mock_db_post.call_args_list[2] + assert reward_call[0][0] == "rewards" + assert reward_call[1]["payload"]["name"] == "Scout Token" + + # User should be marked as scouted + assert 12345 in view.scouted_users + assert view.total_scouts == 1 + + # Ephemeral follow-up with card details + interaction.followup.send.assert_called() + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_no_team_rejects( + self, + mock_get_team, + mock_db_post, + sample_cards, + opener_team, + mock_bot, + ): + """A user without a PD team should be rejected with an ephemeral message.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_team.return_value = None + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + button = view.children[0] + await button.callback(interaction) + + interaction.followup.send.assert_called_once() + msg = interaction.followup.send.call_args[0][0] + assert "team" in msg.lower() + assert mock_db_post.call_count == 0 + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_out_of_tokens_rejects( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """A user who has used all daily tokens should be rejected.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY # all used + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + button = view.children[0] + await button.callback(interaction) + + interaction.followup.send.assert_called_once() + msg = interaction.followup.send.call_args[0][0] + assert "out of scout tokens" in msg.lower() + assert mock_db_post.call_count == 0 + + +# --------------------------------------------------------------------------- +# Multi-scout behavior +# --------------------------------------------------------------------------- + + +class TestMultiScout: + """Tests for the multi-scout-per-card design. + + Any card can be scouted by multiple different players, but each player + can only scout one card per pack. + """ + + def _make_view_with_message(self, sample_cards, opener_team, mock_bot): + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + return view + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_two_users_can_scout_same_card( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + scouter_team_2, + mock_bot, + ): + """Two different users should both be able to scout the same card.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + button = view.children[0] # both pick the same card + + # First scouter + mock_get_team.return_value = scouter_team + interaction1 = AsyncMock(spec=discord.Interaction) + interaction1.response = AsyncMock() + interaction1.response.defer = AsyncMock() + interaction1.followup = AsyncMock() + interaction1.followup.send = AsyncMock() + interaction1.user = Mock() + interaction1.user.id = 11111 + + await button.callback(interaction1) + assert 11111 in view.scouted_users + assert view.total_scouts == 1 + + # Second scouter — same card + mock_get_team.return_value = scouter_team_2 + interaction2 = AsyncMock(spec=discord.Interaction) + interaction2.response = AsyncMock() + interaction2.response.defer = AsyncMock() + interaction2.followup = AsyncMock() + interaction2.followup.send = AsyncMock() + interaction2.user = Mock() + interaction2.user.id = 22222 + + await button.callback(interaction2) + assert 22222 in view.scouted_users + assert view.total_scouts == 2 + + # Claims should track both teams under the same player_id + player_id = sample_cards[0]["player"]["player_id"] + assert player_id in view.claims + assert len(view.claims[player_id]) == 2 + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_same_user_cannot_scout_twice( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """The same user should be blocked from scouting a second card.""" + view = self._make_view_with_message(sample_cards, opener_team, mock_bot) + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + # First scout succeeds + interaction1 = AsyncMock(spec=discord.Interaction) + interaction1.response = AsyncMock() + interaction1.response.defer = AsyncMock() + interaction1.followup = AsyncMock() + interaction1.followup.send = AsyncMock() + interaction1.user = Mock() + interaction1.user.id = 12345 + + await view.children[0].callback(interaction1) + assert view.total_scouts == 1 + + # Second scout by same user is blocked + interaction2 = AsyncMock(spec=discord.Interaction) + interaction2.response = AsyncMock() + interaction2.response.send_message = AsyncMock() + interaction2.user = Mock() + interaction2.user.id = 12345 + + await view.children[1].callback(interaction2) + + interaction2.response.send_message.assert_called_once() + assert ( + "already scouted" + in interaction2.response.send_message.call_args[0][0].lower() + ) + assert view.total_scouts == 1 # unchanged + + @pytest.mark.asyncio + async def test_buttons_never_disabled_after_scout( + self, sample_cards, opener_team, mock_bot + ): + """All buttons should remain enabled regardless of how many scouts happen. + + This verifies the 'unlimited scouts per card' design — buttons + only disable on timeout, not on individual claims. + """ + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + # Simulate claims on every card + for card in sample_cards: + pid = card["player"]["player_id"] + view.claims[pid] = ["Team A", "Team B"] + + for btn in view.children: + if isinstance(btn, discord.ui.Button): + assert not btn.disabled + + +# --------------------------------------------------------------------------- +# ScoutView.on_timeout +# --------------------------------------------------------------------------- + + +class TestScoutViewTimeout: + """Tests for the timeout handler that closes the scout window.""" + + @pytest.mark.asyncio + async def test_timeout_disables_all_buttons( + self, sample_cards, opener_team, mock_bot + ): + """After timeout, every button should be disabled.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + await view.on_timeout() + + for btn in view.children: + if isinstance(btn, discord.ui.Button): + assert btn.disabled + + @pytest.mark.asyncio + async def test_timeout_updates_embed_title( + self, sample_cards, opener_team, mock_bot + ): + """The embed title should change to 'Scout Window Closed' on timeout.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + await view.on_timeout() + + view.message.edit.assert_called_once() + call_kwargs = view.message.edit.call_args[1] + embed = call_kwargs["embed"] + assert "closed" in embed.title.lower() + + @pytest.mark.asyncio + async def test_timeout_with_scouts_shows_count( + self, sample_cards, opener_team, mock_bot + ): + """When there were scouts, the closed title should include the count.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + # Set up claims so total_scouts property returns 5 + pid = sample_cards[0]["player"]["player_id"] + view.claims[pid] = ["Team A", "Team B", "Team C", "Team D", "Team E"] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + await view.on_timeout() + + embed = view.message.edit.call_args[1]["embed"] + assert "5" in embed.title + + @pytest.mark.asyncio + async def test_timeout_without_message_is_safe( + self, sample_cards, opener_team, mock_bot + ): + """Timeout should not crash if the message reference is None.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.message = None + + # Should not raise + await view.on_timeout() + + +# --------------------------------------------------------------------------- +# Processing user cleanup +# --------------------------------------------------------------------------- + + +class TestProcessingUserCleanup: + """Verify the processing_users set is cleaned up in all code paths.""" + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_processing_cleared_on_success( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """After a successful scout, the user should be removed from processing_users.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + assert 12345 not in view.processing_users + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_processing_cleared_on_claim_db_failure( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """If db_post('scout_claims') raises, processing_users should still be cleared.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.side_effect = Exception("DB down") + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + assert 12345 not in view.processing_users + # Scout should not have been recorded + assert view.total_scouts == 0 + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_processing_cleared_on_no_team( + self, + mock_get_team, + sample_cards, + opener_team, + mock_bot, + ): + """If the user has no team, they should still be removed from processing_users.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + mock_get_team.return_value = None + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + assert 12345 not in view.processing_users + + +# --------------------------------------------------------------------------- +# Rewards use PD_SEASON constant +# --------------------------------------------------------------------------- + + +class TestRewardsSeason: + """Tests that reward records always use the PD_SEASON constant.""" + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_rewards_use_pd_season( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """Reward records should always use the PD_SEASON constant for season.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await view.children[0].callback(interaction) + + # Should still complete successfully + assert view.total_scouts == 1 + assert 12345 in view.scouted_users + + # Verify the rewards POST uses PD_SEASON + # Order: scout_claims (0), cards (1), rewards (2) + from helpers.constants import PD_SEASON + + reward_call = mock_db_post.call_args_list[2] + assert reward_call[0][0] == "rewards" + assert reward_call[1]["payload"]["season"] == PD_SEASON + + +# --------------------------------------------------------------------------- +# Shiny scout notification +# --------------------------------------------------------------------------- + + +class TestShinyScoutNotification: + """Tests for the rare-card notification path (rarity >= 5).""" + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_shiny_card_sends_notification( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + mock_send_to_channel, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """Scouting a card with rarity >= 5 should post to #pd-network-news.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, # card 0 is MVP (rarity 5) + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + # Card 0 is MVP (rarity value 5) — should trigger notification + await view.children[0].callback(interaction) + + mock_send_to_channel.assert_called_once() + call_args = mock_send_to_channel.call_args + assert call_args[0][1] == "pd-network-news" + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_non_shiny_card_no_notification( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + mock_send_to_channel, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """Scouting a card with rarity < 5 should NOT post a notification.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, # card 2 is Starter (rarity 2) + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + # Card 2 is Starter (rarity value 2) — no notification + await view.children[2].callback(interaction) + + mock_send_to_channel.assert_not_called() + + @pytest.mark.asyncio + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) + @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) + @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) + async def test_shiny_notification_failure_does_not_crash( + self, + mock_get_team, + mock_get_tokens, + mock_db_post, + mock_card_embeds, + mock_send_to_channel, + sample_cards, + opener_team, + scouter_team, + mock_bot, + ): + """If sending the shiny notification fails, the scout should still succeed.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock() + + mock_get_team.return_value = scouter_team + mock_get_tokens.return_value = 0 + mock_db_post.return_value = {"id": 100} + mock_card_embeds.return_value = [Mock(spec=discord.Embed)] + mock_send_to_channel.side_effect = Exception("Channel not found") + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + # Should not raise even though notification fails + await view.children[0].callback(interaction) + + # Scout should still complete + assert view.total_scouts == 1 + assert 12345 in view.scouted_users + + +# --------------------------------------------------------------------------- +# update_message edge cases +# --------------------------------------------------------------------------- + + +class TestUpdateMessage: + """Tests for ScoutView.update_message edge cases.""" + + @pytest.mark.asyncio + async def test_update_message_with_no_message_is_noop( + self, sample_cards, opener_team, mock_bot + ): + """update_message should silently return if self.message is None.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = None + + # Should not raise + await view.update_message() + + @pytest.mark.asyncio + async def test_update_message_edit_failure_is_caught( + self, sample_cards, opener_team, mock_bot + ): + """If message.edit raises, it should be caught and logged, not re-raised.""" + view = ScoutView( + scout_opp_id=1, + cards=sample_cards, + opener_team=opener_team, + opener_user_id=99999, + bot=mock_bot, + ) + view.card_lines = [ + (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) + ] + view.message = AsyncMock(spec=discord.Message) + view.message.edit = AsyncMock( + side_effect=discord.HTTPException(Mock(status=500), "Server error") + ) + + # Should not raise + await view.update_message() diff --git a/tests/scouting/test_scouting_cog.py b/tests/scouting/test_scouting_cog.py new file mode 100644 index 0000000..5bca155 --- /dev/null +++ b/tests/scouting/test_scouting_cog.py @@ -0,0 +1,269 @@ +"""Tests for cogs/economy_new/scouting.py — the Scouting cog. + +Covers the /scout-tokens command and the cleanup_expired background task. + +Note: Scouting.__init__ calls self.cleanup_expired.start() which requires +a running event loop. All tests that instantiate the cog must be async. +""" + +import datetime + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import discord +from discord.ext import commands + +from cogs.economy_new.scouting import Scouting, SCOUT_TOKENS_PER_DAY + + +def _make_team(): + return { + "id": 1, + "lname": "Test Team", + "color": "a6ce39", + "logo": "https://example.com/logo.png", + "season": 4, + } + + +# --------------------------------------------------------------------------- +# Cog setup +# --------------------------------------------------------------------------- + + +class TestScoutingCogSetup: + """Tests for cog initialization and lifecycle.""" + + @pytest.mark.asyncio + async def test_cog_initializes(self, mock_bot): + """The Scouting cog should initialize without errors.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + assert cog.bot is mock_bot + + @pytest.mark.asyncio + async def test_cleanup_task_starts(self, mock_bot): + """The cleanup_expired loop task should be started on init.""" + cog = Scouting(mock_bot) + assert cog.cleanup_expired.is_running() + cog.cleanup_expired.cancel() + + @pytest.mark.asyncio + async def test_cog_unload_calls_cancel(self, mock_bot): + """Unloading the cog should call cancel on the cleanup task.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + # Verify cog_unload runs without error + await cog.cog_unload() + + +# --------------------------------------------------------------------------- +# /scout-tokens command +# --------------------------------------------------------------------------- + + +class TestScoutTokensCommand: + """Tests for the /scout-tokens slash command.""" + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_shows_remaining_tokens( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """Should display the correct number of remaining tokens.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = 1 # 1 used today + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + interaction.response.defer.assert_called_once_with(ephemeral=True) + interaction.followup.send.assert_called_once() + + call_kwargs = interaction.followup.send.call_args[1] + embed = call_kwargs["embed"] + remaining = SCOUT_TOKENS_PER_DAY - 1 + assert str(remaining) in embed.description + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_no_team_rejects(self, mock_get_team, mock_get_tokens, mock_bot): + """A user without a PD team should get a rejection message.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = None + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + msg = interaction.followup.send.call_args[0][0] + assert "team" in msg.lower() + mock_get_tokens.assert_not_called() + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_all_tokens_used_shows_zero( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """When all tokens are used, should show 0 remaining with extra message.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + assert "0" in embed.description + assert ( + "used all" in embed.description.lower() + or "tomorrow" in embed.description.lower() + ) + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_no_tokens_used_shows_full( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """When no tokens have been used, should show the full daily allowance.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = 0 + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + assert str(SCOUT_TOKENS_PER_DAY) in embed.description + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_db_get_returns_none(self, mock_get_team, mock_get_tokens, mock_bot): + """If get_scout_tokens_used returns 0 (API failure handled internally), should show full tokens.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = ( + 0 # get_scout_tokens_used handles None internally + ) + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + assert str(SCOUT_TOKENS_PER_DAY) in embed.description + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock) + @patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock) + async def test_over_limit_tokens_shows_zero( + self, mock_get_team, mock_get_tokens, mock_bot + ): + """If somehow more tokens than the daily limit were used, should show 0 not negative.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_get_team.return_value = _make_team() + mock_get_tokens.return_value = 5 # more than SCOUT_TOKENS_PER_DAY + + interaction = AsyncMock(spec=discord.Interaction) + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 12345 + + await cog.scout_tokens_command.callback(cog, interaction) + + embed = interaction.followup.send.call_args[1]["embed"] + # Should show "0" not "-3" + assert "0" in embed.description + assert "-" not in embed.description.split("remaining")[0] + + +# --------------------------------------------------------------------------- +# cleanup_expired task +# --------------------------------------------------------------------------- + + +class TestCleanupExpired: + """Tests for the background cleanup task.""" + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock) + async def test_cleanup_logs_expired_opportunities(self, mock_db_get, mock_bot): + """The cleanup task should query for expired unclaimed opportunities.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_db_get.return_value = {"count": 3} + + # Call the coroutine directly (not via the loop) + await cog.cleanup_expired.coro(cog) + + mock_db_get.assert_called_once() + call_args = mock_db_get.call_args + assert call_args[0][0] == "scout_opportunities" + + @pytest.mark.asyncio + @patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock) + async def test_cleanup_handles_api_failure(self, mock_db_get, mock_bot): + """Cleanup should not crash if the API is unavailable.""" + cog = Scouting(mock_bot) + cog.cleanup_expired.cancel() + + mock_db_get.side_effect = Exception("API not ready") + + # Should not raise + await cog.cleanup_expired.coro(cog) diff --git a/tests/scouting/test_scouting_helpers.py b/tests/scouting/test_scouting_helpers.py new file mode 100644 index 0000000..dc635da --- /dev/null +++ b/tests/scouting/test_scouting_helpers.py @@ -0,0 +1,374 @@ +"""Tests for helpers/scouting.py — embed builders and scout opportunity creation. + +Covers the pure functions (_build_card_lines, build_scout_embed, +build_scouted_card_list) and the async create_scout_opportunity flow. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import discord + +from helpers.scouting import ( + _build_card_lines, + build_scout_embed, + build_scouted_card_list, + create_scout_opportunity, + RARITY_SYMBOLS, +) + +# --------------------------------------------------------------------------- +# _build_card_lines +# --------------------------------------------------------------------------- + + +class TestBuildCardLines: + """Tests for the shuffled card line builder.""" + + def test_returns_correct_count(self, sample_cards): + """Should produce one line per card in the pack.""" + lines = _build_card_lines(sample_cards) + assert len(lines) == len(sample_cards) + + def test_each_line_contains_player_id(self, sample_cards): + """Each tuple's first element should be the player_id from the card.""" + lines = _build_card_lines(sample_cards) + ids = {pid for pid, _ in lines} + expected_ids = {c["player"]["player_id"] for c in sample_cards} + assert ids == expected_ids + + def test_each_line_contains_player_name(self, sample_cards): + """The display string should include the player's name.""" + lines = _build_card_lines(sample_cards) + for pid, display in lines: + card = next(c for c in sample_cards if c["player"]["player_id"] == pid) + assert card["player"]["p_name"] in display + + def test_each_line_contains_rarity_name(self, sample_cards): + """The display string should include the rarity tier name.""" + lines = _build_card_lines(sample_cards) + for pid, display in lines: + card = next(c for c in sample_cards if c["player"]["player_id"] == pid) + assert card["player"]["rarity"]["name"] in display + + def test_rarity_symbol_present(self, sample_cards): + """Each line should start with the appropriate rarity emoji.""" + lines = _build_card_lines(sample_cards) + for pid, display in lines: + card = next(c for c in sample_cards if c["player"]["player_id"] == pid) + rarity_val = card["player"]["rarity"]["value"] + expected_symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab") + assert display.startswith(expected_symbol) + + def test_output_is_shuffled(self, sample_cards): + """Over many runs, the order should not always match the input order. + + We run 20 iterations — if it comes out sorted every time, the shuffle + is broken (probability ~1/20! per run, effectively zero). + """ + input_order = [c["player"]["player_id"] for c in sample_cards] + saw_different = False + for _ in range(20): + lines = _build_card_lines(sample_cards) + output_order = [pid for pid, _ in lines] + if output_order != input_order: + saw_different = True + break + assert saw_different, "Card lines were never shuffled across 20 runs" + + def test_empty_cards(self): + """Empty input should produce an empty list.""" + assert _build_card_lines([]) == [] + + def test_unknown_rarity_uses_fallback_symbol(self): + """A rarity value not in RARITY_SYMBOLS should get the black circle fallback.""" + card = { + "id": 99, + "player": { + "player_id": 999, + "p_name": "Unknown Rarity", + "rarity": {"name": "Legendary", "value": 99, "color": "gold"}, + }, + } + lines = _build_card_lines([card]) + assert lines[0][1].startswith("\u26ab") # black circle fallback + + +# --------------------------------------------------------------------------- +# build_scout_embed +# --------------------------------------------------------------------------- + + +class TestBuildScoutEmbed: + """Tests for the embed builder shown above scout buttons.""" + + def test_returns_embed_and_card_lines(self, opener_team, sample_cards): + """Should return a (discord.Embed, list) tuple.""" + embed, card_lines = build_scout_embed(opener_team, sample_cards) + assert isinstance(embed, discord.Embed) + assert isinstance(card_lines, list) + assert len(card_lines) == len(sample_cards) + + def test_embed_description_contains_team_name(self, opener_team, sample_cards): + """The embed body should mention the opener's team name.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + assert opener_team["lname"] in embed.description + + def test_embed_description_contains_all_player_names( + self, opener_team, sample_cards + ): + """Every player name from the pack should appear in the embed.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + for card in sample_cards: + assert card["player"]["p_name"] in embed.description + + def test_embed_mentions_token_cost(self, opener_team, sample_cards): + """The embed should tell users about the scout token cost.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + assert "Scout Token" in embed.description + + def test_embed_mentions_time_limit(self, opener_team, sample_cards): + """The embed should mention the 30-minute window.""" + embed, _ = build_scout_embed(opener_team, sample_cards) + assert "30 minutes" in embed.description + + def test_prebuilt_card_lines_are_reused(self, opener_team, sample_cards): + """When card_lines are passed in, they should be reused (not rebuilt).""" + prebuilt = [(101, "Custom Line 1"), (102, "Custom Line 2")] + embed, returned_lines = build_scout_embed( + opener_team, sample_cards, card_lines=prebuilt + ) + assert returned_lines is prebuilt + assert "Custom Line 1" in embed.description + assert "Custom Line 2" in embed.description + + +# --------------------------------------------------------------------------- +# build_scouted_card_list +# --------------------------------------------------------------------------- + + +class TestBuildScoutedCardList: + """Tests for the card list formatter that marks scouted cards.""" + + def test_no_scouts_returns_plain_lines(self): + """With no scouts, output should match the raw card lines.""" + card_lines = [ + (101, "\U0001f7e3 MVP — Mike Trout"), + (102, "\U0001f535 All-Star — Juan Soto"), + ] + result = build_scouted_card_list(card_lines, {}) + assert result == "\U0001f7e3 MVP — Mike Trout\n\U0001f535 All-Star — Juan Soto" + + def test_single_scout_shows_team_name(self): + """A card scouted once should show a checkmark and the team name.""" + card_lines = [ + (101, "\U0001f7e3 MVP — Mike Trout"), + (102, "\U0001f535 All-Star — Juan Soto"), + ] + scouted = {101: ["Scouting Squad"]} + result = build_scouted_card_list(card_lines, scouted) + assert "\u2714\ufe0f" in result # checkmark + assert "*Scouting Squad*" in result + # Unscouted card should appear plain + lines = result.split("\n") + assert "\u2714" not in lines[1] + + def test_multiple_scouts_shows_count_and_names(self): + """A card scouted multiple times should show the count and all team names.""" + card_lines = [(101, "\U0001f7e3 MVP — Mike Trout")] + scouted = {101: ["Team A", "Team B", "Team C"]} + result = build_scouted_card_list(card_lines, scouted) + assert "x3" in result + assert "*Team A*" in result + assert "*Team B*" in result + assert "*Team C*" in result + + def test_mixed_scouted_and_unscouted(self): + """Only scouted cards should have marks; unscouted cards stay plain.""" + card_lines = [ + (101, "Line A"), + (102, "Line B"), + (103, "Line C"), + ] + scouted = {102: ["Some Team"]} + result = build_scouted_card_list(card_lines, scouted) + lines = result.split("\n") + assert "\u2714" not in lines[0] + assert "\u2714" in lines[1] + assert "\u2714" not in lines[2] + + def test_empty_input(self): + """Empty card lines should produce an empty string.""" + assert build_scouted_card_list([], {}) == "" + + def test_two_scouts_shows_count(self): + """Two scouts on the same card should show x2.""" + card_lines = [(101, "Line A")] + scouted = {101: ["Team X", "Team Y"]} + result = build_scouted_card_list(card_lines, scouted) + assert "x2" in result + + +# --------------------------------------------------------------------------- +# create_scout_opportunity +# --------------------------------------------------------------------------- + + +class TestCreateScoutOpportunity: + """Tests for the async scout opportunity creation flow.""" + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_posts_to_api_and_sends_message( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """Should POST to scout_opportunities and send a message to the channel.""" + mock_db_post.return_value = {"id": 42} + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + # API was called to create the opportunity + mock_db_post.assert_called_once() + call_args = mock_db_post.call_args + assert call_args[0][0] == "scout_opportunities" + assert call_args[1]["payload"]["opener_team_id"] == opener_team["id"] + + # Message was sent to the channel + mock_channel.send.assert_called_once() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_skips_wrong_channel( + self, mock_db_post, sample_cards, opener_team, mock_bot + ): + """Should silently return when the channel is not #pack-openings.""" + channel = AsyncMock(spec=discord.TextChannel) + channel.name = "general" + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, channel, opener_user, context + ) + + mock_db_post.assert_not_called() + channel.send.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_skips_empty_pack( + self, mock_db_post, opener_team, mock_channel, mock_bot + ): + """Should silently return when pack_cards is empty.""" + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + [], opener_team, mock_channel, opener_user, context + ) + + mock_db_post.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_skips_none_channel( + self, mock_db_post, sample_cards, opener_team, mock_bot + ): + """Should handle None channel without crashing.""" + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, None, opener_user, context + ) + + mock_db_post.assert_not_called() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_api_failure_does_not_raise( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """Scout creation failure must never crash the pack opening flow.""" + mock_db_post.side_effect = Exception("API down") + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + # Should not raise + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_channel_send_failure_does_not_raise( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """If the channel.send fails, it should be caught gracefully.""" + mock_db_post.return_value = {"id": 42} + mock_channel.send.side_effect = discord.HTTPException( + Mock(status=500), "Server error" + ) + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + # Should not raise + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_context_client_fallback( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """When context.bot is None, should fall back to context.client for the bot ref.""" + mock_db_post.return_value = {"id": 42} + opener_user = Mock() + opener_user.id = 99999 + context = Mock(spec=[]) # empty spec — no .bot attribute + context.client = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + ) + + mock_channel.send.assert_called_once() + + @pytest.mark.asyncio + @patch("helpers.scouting.db_post", new_callable=AsyncMock) + async def test_view_message_is_assigned( + self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot + ): + """The message returned by channel.send should be assigned to view.message. + + This linkage is required for update_message and on_timeout to work. + """ + mock_db_post.return_value = {"id": 42} + sent_msg = AsyncMock(spec=discord.Message) + mock_channel.send.return_value = sent_msg + opener_user = Mock() + opener_user.id = 99999 + context = Mock() + context.bot = mock_bot + + await create_scout_opportunity( + sample_cards, opener_team, mock_channel, opener_user, context + )