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_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/gameplay.py b/cogs/gameplay.py index 224bb2f..9aa743b 100644 --- a/cogs/gameplay.py +++ b/cogs/gameplay.py @@ -14,24 +14,92 @@ import sqlalchemy from sqlmodel import func, or_ from api_calls import db_get -from command_logic.logic_gameplay import bunts, chaos, complete_game, defender_dropdown_view, doubles, flyballs, frame_checks, get_full_roster_from_sheets, checks_log_interaction, complete_play, get_scorebug_embed, groundballs, hit_by_pitch, homeruns, is_game_over, lineouts, manual_end_game, new_game_checks, new_game_conflicts, popouts, read_lineup, relief_pitcher_dropdown_view, select_ai_reliever, show_defense_cards, singles, starting_pitcher_dropdown_view, steals, strikeouts, sub_batter_dropdown_view, substitute_player, triples, undo_play, update_game_settings, walks, xchecks, activate_last_play +from command_logic.logic_gameplay import ( + bunts, + chaos, + complete_game, + defender_dropdown_view, + doubles, + flyballs, + frame_checks, + get_full_roster_from_sheets, + checks_log_interaction, + complete_play, + get_scorebug_embed, + groundballs, + hit_by_pitch, + homeruns, + is_game_over, + lineouts, + manual_end_game, + new_game_checks, + new_game_conflicts, + popouts, + read_lineup, + relief_pitcher_dropdown_view, + select_ai_reliever, + show_defense_cards, + singles, + starting_pitcher_dropdown_view, + steals, + strikeouts, + sub_batter_dropdown_view, + substitute_player, + triples, + undo_play, + update_game_settings, + walks, + xchecks, + activate_last_play, +) from command_logic.play_context import locked_play from dice import ab_roll from exceptions import * import gauntlets -from helpers import CARDSETS, DEFENSE_LITERAL, DEFENSE_NO_PITCHER_LITERAL, PD_PLAYERS_ROLE_NAME, SELECT_CARDSET_OPTIONS, Dropdown, get_channel, send_to_channel, team_role, user_has_role, random_gif, random_from_list +from helpers import ( + CARDSETS, + DEFENSE_LITERAL, + DEFENSE_NO_PITCHER_LITERAL, + PD_PLAYERS_ROLE_NAME, + SELECT_CARDSET_OPTIONS, + Dropdown, + get_channel, + send_to_channel, + team_role, + user_has_role, + random_gif, + random_from_list, +) # from in_game import ai_manager from in_game.ai_manager import get_starting_pitcher, get_starting_lineup from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check -from in_game.gameplay_models import GameCardsetLink, Lineup, Play, Session, engine, player_description, select, Game -from in_game.gameplay_queries import get_all_positions, get_cardset_or_none, get_one_lineup, get_plays_by_pitcher, get_position, get_channel_game_or_none, get_active_games_by_team, get_game_lineups, get_team_or_none +from in_game.gameplay_models import ( + GameCardsetLink, + Lineup, + Play, + Session, + engine, + player_description, + select, + Game, +) +from in_game.gameplay_queries import ( + get_all_positions, + get_cardset_or_none, + get_one_lineup, + get_plays_by_pitcher, + get_position, + get_channel_game_or_none, + get_active_games_by_team, + get_game_lineups, + get_team_or_none, +) from utilities.buttons import Confirm, ScorebugButtons, ask_confirm, ask_with_buttons from utilities.dropdown import DropdownView - -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") CLASSIC_EMBED = True CARDSETS @@ -44,75 +112,97 @@ class Gameplay(commands.Cog): self.get_sheets.start() self.live_scorecard.start() - + @tasks.loop(count=1) async def get_sheets(self): - logger.info(f'Getting sheets') - self.sheets = pygsheets.authorize(service_file='storage/paper-dynasty-service-creds.json', retries=1) + logger.info(f"Getting sheets") + self.sheets = pygsheets.authorize( + service_file="storage/paper-dynasty-service-creds.json", retries=1 + ) @tasks.loop(minutes=1) async def live_scorecard(self): try: - logger.info(f'Checking live scorecard loop') - - guild = self.bot.get_guild(int(os.environ.get('GUILD_ID'))) - score_channel = discord.utils.get(guild.text_channels, name='live-pd-scores') + logger.info(f"Checking live scorecard loop") + + guild_id = os.environ.get("GUILD_ID") + if not guild_id: + logger.error("GUILD_ID environment variable is not set") + return + guild = self.bot.get_guild(int(guild_id)) + score_channel = discord.utils.get( + guild.text_channels, name="live-pd-scores" + ) if score_channel is None: - logger.error(f'Could not find live-pd-channel') + logger.error(f"Could not find live-pd-channel") return if len(self.game_states) == 0: - logger.info(f'No active game_states') - return - + logger.info(f"No active game_states") + return + player_role = discord.utils.get(guild.roles, name=PD_PLAYERS_ROLE_NAME) all_embeds = [] - logger.info(f'player role: {player_role}') + logger.info(f"player role: {player_role}") with Session(engine) as session: for key in self.game_states: - if not self.game_states[key]['ack']: + if not self.game_states[key]["ack"]: this_game = session.get(Game, key) if this_game is None: - log_exception(GameNotFoundException, f'Could not pull game #{key} for live scorecard') - + log_exception( + GameNotFoundException, + f"Could not pull game #{key} for live scorecard", + ) + if not this_game.active: - logger.info(f'Game {this_game.id} is complete, removing from game_states') + logger.info( + f"Game {this_game.id} is complete, removing from game_states" + ) del self.game_states[key] - + else: try: - logger.info(f'Appending scorebug for Game {this_game.id}') - this_channel = discord.utils.get(guild.text_channels, id=this_game.channel_id) - logger.info(f'this_channel: {this_channel}') - - this_embed = await get_scorebug_embed(session, this_game, full_length=False, live_scorecard=True) + logger.info( + f"Appending scorebug for Game {this_game.id}" + ) + this_channel = discord.utils.get( + guild.text_channels, id=this_game.channel_id + ) + logger.info(f"this_channel: {this_channel}") + + this_embed = await get_scorebug_embed( + session, + this_game, + full_length=False, + live_scorecard=True, + ) this_embed.set_image(url=None) this_embed.insert_field_at( index=0, - name='Ballpark', - value=f'{this_channel.mention}' + name="Ballpark", + value=f"{this_channel.mention}", ) all_embeds.append(this_embed) - self.game_states[key]['ack'] = True + self.game_states[key]["ack"] = True except Exception as e: - logger.error(f'Unable to add to game_states: {e}') - logger.error(f'Game: {this_game.id}') - + logger.error(f"Unable to add to game_states: {e}") + logger.error(f"Game: {this_game.id}") + if len(all_embeds) == 0: - logger.info(f'No active game embeds, returning') + logger.info(f"No active game embeds, returning") await score_channel.set_permissions(player_role, read_messages=False) return - + async for message in score_channel.history(limit=25): await message.delete() await score_channel.set_permissions(player_role, read_messages=True) await score_channel.send(content=None, embeds=all_embeds) except Exception as e: - logger.error(f'Failed running live scorecard: {e}') + logger.error(f"Failed running live scorecard: {e}") # try: # await send_to_channel(self.bot, 'commissioners-office', f'PD Live Scorecard just failed: {e}') # except Exception as e: @@ -124,22 +214,33 @@ class Gameplay(commands.Cog): @get_sheets.before_loop async def before_get_sheets(self): - logger.info(f'Waiting to get sheets') + logger.info(f"Waiting to get sheets") await self.bot.wait_until_ready() - + async def cog_command_error(self, ctx, error): logger.error(msg=error, stack_info=True) - 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 slash_error(self, ctx, error): logger.error(msg=error, stack_info=True) - await ctx.send(f'{error[:1600]}') - - async def post_play(self, session: Session, interaction: discord.Interaction, this_play: Play, buffer_message: str = None, full_length: bool = False): - logger.info(f'post_play - Posting new play: {this_play}') + await ctx.send(f"{error[:1600]}") + + async def post_play( + self, + session: Session, + interaction: discord.Interaction, + this_play: Play, + buffer_message: str = None, + full_length: bool = False, + ): + logger.info(f"post_play - Posting new play: {this_play}") if this_play is None: - logger.info(f'this_play is None, searching for game in channel {interaction.channel.id}') + logger.info( + f"this_play is None, searching for game in channel {interaction.channel.id}" + ) this_game = get_channel_game_or_none(session, interaction.channel.id) try: this_play = activate_last_play(session, this_game) @@ -147,294 +248,384 @@ class Gameplay(commands.Cog): this_play = this_game.initialize_play(session) finally: if this_play is None: - log_exception(PlayNotFoundException, f'Attempting to display gamestate, but cannot find current play') + log_exception( + PlayNotFoundException, + f"Attempting to display gamestate, but cannot find current play", + ) if is_game_over(this_play): - logger.info(f'Game {this_play.game.id} seems to be over') - await interaction.edit_original_response(content=f'Looks like this one is over!') + logger.info(f"Game {this_play.game.id} seems to be over") + await interaction.edit_original_response( + content=f"Looks like this one is over!" + ) submit_game = await ask_confirm( interaction=interaction, - question=f'Final score: {this_play.game.away_team.abbrev} {this_play.away_score} - {this_play.home_score} {this_play.game.home_team.abbrev}\n{this_play.scorebug_ascii}\nShould I go ahead and submit this game or roll it back a play?', - custom_confirm_label='Submit', - custom_cancel_label='Roll Back' + question=f"Final score: {this_play.game.away_team.abbrev} {this_play.away_score} - {this_play.home_score} {this_play.game.home_team.abbrev}\n{this_play.scorebug_ascii}\nShould I go ahead and submit this game or roll it back a play?", + custom_confirm_label="Submit", + custom_cancel_label="Roll Back", ) if submit_game: - logger.info(f'post_play - is_game_over - {interaction.user.display_name} rejected game completion') + logger.info( + f"post_play - is_game_over - {interaction.user.display_name} rejected game completion" + ) await complete_game(session, interaction, this_play, self.bot) return else: - logger.warning(f'post_play - is_game_over - {interaction.user.display_name} rejected game completion in Game {this_play.game.id}') + logger.warning( + f"post_play - is_game_over - {interaction.user.display_name} rejected game completion in Game {this_play.game.id}" + ) - cal_channel = get_channel(interaction, 'commissioners-office') - await cal_channel.send(content=f'{interaction.user.display_name} just rejected game completion down in {interaction.channel.mention}') + cal_channel = get_channel(interaction, "commissioners-office") + await cal_channel.send( + content=f"{interaction.user.display_name} just rejected game completion down in {interaction.channel.mention}" + ) this_play = undo_play(session, this_play) await self.post_play(session, interaction, this_play) - await interaction.channel.send(content=f'I let Cal know his bot is stupid') - + await interaction.channel.send( + content=f"I let Cal know his bot is stupid" + ) + if this_play.pitcher.is_fatigued and not this_play.ai_is_batting: if this_play.managerai.replace_pitcher(session, this_play.game): - logger.info(f'Running a pitcher sub') - await interaction.edit_original_response(content='The AI is making a pitching change...') - new_pitcher_card = await select_ai_reliever(session, this_play.pitcher.team, this_play) - new_pitcher_lineup = substitute_player(session, this_play, this_play.pitcher, new_pitcher_card, 'P') - logger.info(f'Sub complete') + logger.info(f"Running a pitcher sub") + await interaction.edit_original_response( + content="The AI is making a pitching change..." + ) + new_pitcher_card = await select_ai_reliever( + session, this_play.pitcher.team, this_play + ) + new_pitcher_lineup = substitute_player( + session, this_play, this_play.pitcher, new_pitcher_card, "P" + ) + logger.info(f"Sub complete") scorebug_buttons, this_ab_roll = None, None - scorebug_embed = await get_scorebug_embed(session, this_play.game, full_length=full_length, classic=CLASSIC_EMBED) - - if this_play.game.roll_buttons and interaction.user.id in [this_play.game.away_team.gmid, this_play.game.home_team.gmid]: - logger.info(f'Including scorebug buttons') + scorebug_embed = await get_scorebug_embed( + session, this_play.game, full_length=full_length, classic=CLASSIC_EMBED + ) + + if this_play.game.roll_buttons and interaction.user.id in [ + this_play.game.away_team.gmid, + this_play.game.home_team.gmid, + ]: + logger.info(f"Including scorebug buttons") scorebug_buttons = ScorebugButtons(this_play, scorebug_embed, timeout=8) - - if this_play.on_base_code == 0 and this_play.game.auto_roll and not this_play.batter.team.is_ai and not this_play.is_new_inning: - logger.info(f'Rolling ab') - this_ab_roll = ab_roll(this_play.batter.team, this_play.game, allow_chaos=False) + + if ( + this_play.on_base_code == 0 + and this_play.game.auto_roll + and not this_play.batter.team.is_ai + and not this_play.is_new_inning + ): + logger.info(f"Rolling ab") + this_ab_roll = ab_roll( + this_play.batter.team, this_play.game, allow_chaos=False + ) scorebug_buttons = None if this_ab_roll is not None and this_ab_roll.d_six_one > 3: - logger.info(f'Setting embed image to pitcher') + logger.info(f"Setting embed image to pitcher") scorebug_embed.set_image(url=this_play.pitcher.player.pitcher_card_url) - + if buffer_message is not None: - logger.info(f'Posting buffered message') - await interaction.edit_original_response( - content=buffer_message - ) + logger.info(f"Posting buffered message") + await interaction.edit_original_response(content=buffer_message) sb_message = await interaction.channel.send( - content=None, - embed=scorebug_embed, - view=scorebug_buttons + content=None, embed=scorebug_embed, view=scorebug_buttons ) else: - logger.info(f'Posting unbuffered message') + logger.info(f"Posting unbuffered message") sb_message = await interaction.edit_original_response( - content=None, - embed=scorebug_embed, - view=scorebug_buttons + content=None, embed=scorebug_embed, view=scorebug_buttons ) - + if this_ab_roll is not None: - logger.info(f'Posting ab roll') - await interaction.channel.send( - content=None, - embeds=this_ab_roll.embeds - ) - + logger.info(f"Posting ab roll") + await interaction.channel.send(content=None, embeds=this_ab_roll.embeds) + if scorebug_buttons is not None: - logger.info(f'Posting scorebug buttons roll') + logger.info(f"Posting scorebug buttons roll") await scorebug_buttons.wait() if not scorebug_buttons.value: await sb_message.edit(view=None) - async def complete_and_post_play(self, session: Session, interaction: discord.Interaction, this_play: Play, buffer_message: str = None): + async def complete_and_post_play( + self, + session: Session, + interaction: discord.Interaction, + this_play: Play, + buffer_message: str = None, + ): next_play = complete_play(session, this_play) - logger.info(f'Completed play {this_play.id}') - - logger.info(f'Updating self.game_states') - self.game_states[this_play.game.id] = {'play': this_play, 'ack': False} - logger.info(f'New state: {self.game_states}') + logger.info(f"Completed play {this_play.id}") + + logger.info(f"Updating self.game_states") + self.game_states[this_play.game.id] = {"play": this_play, "ack": False} + logger.info(f"New state: {self.game_states}") await self.post_play(session, interaction, next_play, buffer_message) - + def kickstart_live_scorecard(self): try: self.live_scorecard.start() - logger.info(f'Kick started the live scorecard') + logger.info(f"Kick started the live scorecard") except RuntimeError as e: - logger.info(f'Live scorecard is already running') + logger.info(f"Live scorecard is already running") - @commands.command(name='test-write', help='Test concurrent db writes', hidden=True) + @commands.command(name="test-write", help="Test concurrent db writes", hidden=True) @commands.is_owner() async def test_write_command(self, ctx): - await ctx.send(f'I am going to open a connection, delay, then try to write') + await ctx.send(f"I am going to open a connection, delay, then try to write") with Session(engine) as session: ncb_team = await get_team_or_none(session, team_id=31) - await ctx.send(f'The {ncb_team.lname} has_guide value is: {ncb_team.has_guide}. Now to delay for 10 seconds...') + await ctx.send( + f"The {ncb_team.lname} has_guide value is: {ncb_team.has_guide}. Now to delay for 10 seconds..." + ) ncb_team.has_guide = not ncb_team.has_guide session.add(ncb_team) await asyncio.sleep(10) - await ctx.send(f'Now to attempt committing the NCB change...') + await ctx.send(f"Now to attempt committing the NCB change...") session.commit() - await ctx.send(f'Am I alive? Did it work?') + await ctx.send(f"Am I alive? Did it work?") - group_new_game = app_commands.Group(name='new-game', description='Start a new baseball game') - - @group_new_game.command(name='mlb-campaign', description='Start a new MLB campaign game against an AI') + group_new_game = app_commands.Group( + name="new-game", description="Start a new baseball game" + ) + + @group_new_game.command( + name="mlb-campaign", description="Start a new MLB campaign game against an AI" + ) @app_commands.choices( league=[ - Choice(value='minor-league', name='Minor League'), - Choice(value='flashback', name='Flashback'), - Choice(value='major-league', name='Major League'), - Choice(value='hall-of-fame', name='Hall of Fame') + Choice(value="minor-league", name="Minor League"), + Choice(value="flashback", name="Flashback"), + Choice(value="major-league", name="Major League"), + Choice(value="hall-of-fame", name="Hall of Fame"), ], roster=[ - Choice(value='1', name='Primary'), - Choice(value='2', name='Secondary'), - Choice(value='3', name='Ranked') - ] + Choice(value="1", name="Primary"), + Choice(value="2", name="Secondary"), + Choice(value="3", name="Ranked"), + ], ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def new_game_mlb_campaign_command( - self, interaction: discord.Interaction, league: Choice[str], away_team_abbrev: str, home_team_abbrev: str, roster: Choice[str]): + self, + interaction: discord.Interaction, + league: Choice[str], + away_team_abbrev: str, + home_team_abbrev: str, + roster: Choice[str], + ): await interaction.response.defer() self.kickstart_live_scorecard() with Session(engine) as session: - teams = await new_game_checks(session, interaction, away_team_abbrev, home_team_abbrev) + teams = await new_game_checks( + session, interaction, away_team_abbrev, home_team_abbrev + ) if teams is None: - logger.error(f'Received None from new_game_checks, cancelling new game') + logger.error(f"Received None from new_game_checks, cancelling new game") return - - away_team = teams['away_team'] - home_team = teams['home_team'] + + away_team = teams["away_team"] + home_team = teams["home_team"] ai_team = away_team if away_team.is_ai else home_team human_team = away_team if home_team.is_ai else home_team + conflict_games = get_active_games_by_team(session, team=human_team) + if len(conflict_games) > 0: + await interaction.edit_original_response( + content=f"Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}" + ) + return conflict_games = get_active_games_by_team(session, team=human_team) if len(conflict_games) > 0: await interaction.edit_original_response( - content=f'Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}' + content=f"Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}" ) return - - - conflict_games = get_active_games_by_team(session, team=human_team) - if len(conflict_games) > 0: - await interaction.edit_original_response( - content=f'Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}' - ) - return - - current = await db_get('current') - week_num = current['week'] - logger.info(f'gameplay - new_game_mlb_campaign - Season: {current["season"]} / Week: {week_num} / Away Team: {away_team.description} / Home Team: {home_team.description}') + + current = await db_get("current") + week_num = current["week"] + logger.info( + f'gameplay - new_game_mlb_campaign - Season: {current["season"]} / Week: {week_num} / Away Team: {away_team.description} / Home Team: {home_team.description}' + ) def role_error(required_role: str, league_name: str, lower_league: str): - return f'Ope. Looks like you haven\'t received the **{required_role}** role, yet!\n\nTo play **{league_name}** games, you need to defeat all 30 MLB teams in the {lower_league} campaign. You can see your progress with `/record`.\n\nIf you have completed the {lower_league} campaign, go ping Cal to get your new role!' + return f"Ope. Looks like you haven't received the **{required_role}** role, yet!\n\nTo play **{league_name}** games, you need to defeat all 30 MLB teams in the {lower_league} campaign. You can see your progress with `/record`.\n\nIf you have completed the {lower_league} campaign, go ping Cal to get your new role!" - if league.value == 'flashback': - if not user_has_role(interaction.user, 'PD - Major League'): + if league.value == "flashback": + if not user_has_role(interaction.user, "PD - Major League"): await interaction.edit_original_response( - content=role_error('PD - Major League', league_name='Flashback', lower_league='Minor League') + content=role_error( + "PD - Major League", + league_name="Flashback", + lower_league="Minor League", + ) ) return - elif league.value == 'major-league': - if not user_has_role(interaction.user, 'PD - Major League'): + elif league.value == "major-league": + if not user_has_role(interaction.user, "PD - Major League"): await interaction.edit_original_response( - content=role_error('PD - Major League', league_name='Major League', lower_league='Minor League') + content=role_error( + "PD - Major League", + league_name="Major League", + lower_league="Minor League", + ) ) return - elif league.value == 'hall-of-fame': - if not user_has_role(interaction.user, 'PD - Hall of Fame'): + elif league.value == "hall-of-fame": + if not user_has_role(interaction.user, "PD - Hall of Fame"): await interaction.edit_original_response( - content=role_error('PD - Hall of Fame', league_name='Hall of Fame', lower_league='Major League') + content=role_error( + "PD - Hall of Fame", + league_name="Hall of Fame", + lower_league="Major League", + ) ) return - + this_game = Game( away_team_id=away_team.id, home_team_id=home_team.id, away_roster_id=69 if away_team.is_ai else int(roster.value), home_roster_id=69 if home_team.is_ai else int(roster.value), channel_id=interaction.channel_id, - season=current['season'], + season=current["season"], week=week_num, - first_message=None if interaction.message is None else interaction.message.channel.id, - ai_team='away' if away_team.is_ai else 'home', - game_type=league.value + first_message=( + None + if interaction.message is None + else interaction.message.channel.id + ), + ai_team="away" if away_team.is_ai else "home", + game_type=league.value, ) - game_info_log = f'{league.name} game between {away_team.description} and {home_team.description} / first message: {this_game.first_message}' + game_info_log = f"{league.name} game between {away_team.description} and {home_team.description} / first message: {this_game.first_message}" logger.info(game_info_log) # Get AI SP await interaction.edit_original_response( - content=f'{ai_team.gmname} is looking for a Starting Pitcher...' + content=f"{ai_team.gmname} is looking for a Starting Pitcher..." ) ai_sp_lineup = await get_starting_pitcher( session, ai_team, this_game, True if home_team.is_ai else False, - league.value + league.value, + ) + logger.info( + f"Chosen SP in Game {this_game.id}: {ai_sp_lineup.player.name_with_desc}" ) - logger.info(f'Chosen SP in Game {this_game.id}: {ai_sp_lineup.player.name_with_desc}') await interaction.edit_original_response( - content=f'The {ai_team.sname} are starting **{ai_sp_lineup.player.name_with_desc}**:\n\n{ai_sp_lineup.player.pitcher_card_url}' + content=f"The {ai_team.sname} are starting **{ai_sp_lineup.player.name_with_desc}**:\n\n{ai_sp_lineup.player.pitcher_card_url}" ) # Get AI Lineup final_message = await interaction.channel.send( - content=f'{ai_team.gmname} is filling out the {ai_team.sname} lineup card...' + content=f"{ai_team.gmname} is filling out the {ai_team.sname} lineup card..." ) - logger.info(f'Pulling lineup...') + logger.info(f"Pulling lineup...") batter_lineups = await get_starting_lineup( session, team=ai_team, game=this_game, league_name=this_game.league_name, - sp_name=ai_sp_lineup.player.name + sp_name=ai_sp_lineup.player.name, ) # Check for last game settings - logger.info(f'Checking human team\'s automation preferences...') - g_query = session.exec(select(Game).where(or_(Game.home_team == human_team, Game.away_team == human_team)).order_by(Game.id.desc()).limit(1)).all() + logger.info(f"Checking human team's automation preferences...") + g_query = session.exec( + select(Game) + .where(or_(Game.home_team == human_team, Game.away_team == human_team)) + .order_by(Game.id.desc()) + .limit(1) + ).all() if len(g_query) > 0: last_game = g_query[0] this_game.auto_roll = last_game.auto_roll this_game.roll_buttons = last_game.roll_buttons - logger.info(f'Setting auto_roll to {last_game.auto_roll} and roll_buttons to {last_game.roll_buttons}') - + logger.info( + f"Setting auto_roll to {last_game.auto_roll} and roll_buttons to {last_game.roll_buttons}" + ) + # Commit game and lineups session.add(this_game) session.commit() - - await final_message.edit(content=f'The {ai_team.sname} lineup is in, pulling in scouting data...') + + await final_message.edit( + content=f"The {ai_team.sname} lineup is in, pulling in scouting data..." + ) for batter in batter_lineups: pos_count = await get_all_positions( - session=session, - this_card=batter.card + session=session, this_card=batter.card ) if pos_count != 0: - logger.info(f'logged position ratings for {batter.player.name_with_desc}') + logger.info( + f"logged position ratings for {batter.player.name_with_desc}" + ) else: - logger.warning(f'received no positions for {batter.player.name_with_desc}') - if batter.position not in ['P', 'DH']: - log_exception(PositionNotFoundException, f'{batter.player.name_with_desc} is listed at {batter.position} but no ratings were found.') - - logger.info(f'Pulling team roles') + logger.warning( + f"received no positions for {batter.player.name_with_desc}" + ) + if batter.position not in ["P", "DH"]: + log_exception( + PositionNotFoundException, + f"{batter.player.name_with_desc} is listed at {batter.position} but no ratings were found.", + ) + + logger.info(f"Pulling team roles") away_role = await team_role(interaction, this_game.away_team) home_role = await team_role(interaction, this_game.home_team) - logger.info(f'Building scorebug embed') + logger.info(f"Building scorebug embed") embed = await get_scorebug_embed(session, this_game) embed.clear_fields() embed.add_field( - name=f'{ai_team.abbrev} Lineup', - value=this_game.team_lineup(session, ai_team) + name=f"{ai_team.abbrev} Lineup", + value=this_game.team_lineup(session, ai_team), + ) + + logger.info(f"Pulling and caching full {human_team.abbrev} roster") + done = await get_full_roster_from_sheets( + session, + interaction, + self.sheets, + this_game, + human_team, + int(roster.value), ) - - logger.info(f'Pulling and caching full {human_team.abbrev} roster') - done = await get_full_roster_from_sheets(session, interaction, self.sheets, this_game, human_team, int(roster.value)) roster_choice = await ask_with_buttons( interaction, - ['vs Left', 'vs Right'], - 'Which lineup will you be using?', + ["vs Left", "vs Right"], + "Which lineup will you be using?", delete_question=False, - confirmation_message='Got it!' + confirmation_message="Got it!", + ) + + sp_view = starting_pitcher_dropdown_view( + session, + this_game, + human_team, + this_game.league_name, + [interaction.user], + ) + await interaction.channel.send( + content=f"### {human_team.lname} Starting Pitcher", view=sp_view ) - sp_view = starting_pitcher_dropdown_view(session, this_game, human_team, this_game.league_name, [interaction.user]) - await interaction.channel.send(content=f'### {human_team.lname} Starting Pitcher', view=sp_view) - try: await asyncio.sleep(5) this_play = await read_lineup( @@ -443,38 +634,44 @@ class Gameplay(commands.Cog): this_game=this_game, lineup_team=human_team, sheets_auth=self.sheets, - lineup_num=1 if roster_choice == 'vs Right' else 2, - league_name=this_game.game_type + lineup_num=1 if roster_choice == "vs Right" else 2, + league_name=this_game.game_type, ) except PositionNotFoundException as e: - logger.error(f'Position validation failed during lineup load: {e}') + logger.error(f"Position validation failed during lineup load: {e}") this_game.active = False session.add(this_game) session.commit() await interaction.channel.send(content=str(e)) return except LineupsMissingException as e: - logger.error(f'Attempting to start game, pausing for 5 seconds: {e}') + logger.error(f"Attempting to start game, pausing for 5 seconds: {e}") await asyncio.sleep(5) - + try: this_play = this_game.current_play_or_none(session) - await self.post_play(session, interaction, this_play, buffer_message='Game on!') + await self.post_play( + session, interaction, this_play, buffer_message="Game on!" + ) except LineupsMissingException as e: await interaction.channel.send( - content=f'Run `/gamestate` once you have selected a Starting Pitcher to get going!' + content=f"Run `/gamestate` once you have selected a Starting Pitcher to get going!" ) - @group_new_game.command(name='gauntlet', description='Start a new Gauntlet game against an AI') + @group_new_game.command( + name="gauntlet", description="Start a new Gauntlet game against an AI" + ) @app_commands.choices( roster=[ - Choice(value='1', name='Primary'), - Choice(value='2', name='Secondary'), - Choice(value='3', name='Ranked') + Choice(value="1", name="Primary"), + Choice(value="2", name="Secondary"), + Choice(value="3", name="Ranked"), ] ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def new_game_gauntlet_command(self, interaction: discord.Interaction, roster: Choice[str]): + async def new_game_gauntlet_command( + self, interaction: discord.Interaction, roster: Choice[str] + ): await interaction.response.defer() self.kickstart_live_scorecard() @@ -485,168 +682,178 @@ class Gameplay(commands.Cog): return main_team = await get_team_or_none( - session, - gm_id=interaction.user.id, - main_team=True + session, gm_id=interaction.user.id, main_team=True ) human_team = await get_team_or_none( - session, - gm_id=interaction.user.id, - gauntlet_team=True + session, gm_id=interaction.user.id, gauntlet_team=True ) if not main_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 - + if not human_team: await interaction.edit_original_response( - content=f'I don\'t see an active run for you. You can get started with the `/gauntlets start` command!' + content=f"I don't see an active run for you. You can get started with the `/gauntlets start` command!" ) return - e_query = await db_get('events', params=[('active', True)]) - if e_query['count'] == 0: + e_query = await db_get("events", params=[("active", True)]) + if e_query["count"] == 0: await interaction.edit_original_response( - content=f'Hm. It looks like there aren\'t any active gauntlets. What do we even pay Cal for?' + content=f"Hm. It looks like there aren't any active gauntlets. What do we even pay Cal for?" ) return - - elif e_query['count'] == 1: - this_event = e_query['events'][0] + + elif e_query["count"] == 1: + this_event = e_query["events"][0] r_query = await db_get( - 'gauntletruns', - params=[('team_id', human_team.id), ('gauntlet_id', this_event['id']), ('is_active', True)] + "gauntletruns", + params=[ + ("team_id", human_team.id), + ("gauntlet_id", this_event["id"]), + ("is_active", True), + ], ) - if r_query['count'] == 0: + if r_query["count"] == 0: await interaction.edit_original_response( - content=f'I don\'t see an active run for you. If you would like to start a new one, run ' - f'`/gauntlets start {this_event["name"]}` and we can get you started in no time!' + content=f"I don't see an active run for you. If you would like to start a new one, run " + f'`/gauntlets start {this_event["name"]}` and we can get you started in no time!' ) return - this_run = r_query['runs'][0] - + this_run = r_query["runs"][0] + else: r_query = await db_get( - 'gauntletruns', - params=[('team_id', human_team.id), ('is_active', True)] + "gauntletruns", + params=[("team_id", human_team.id), ("is_active", True)], ) - if r_query['count'] == 0: + if r_query["count"] == 0: await interaction.edit_original_response( - content=f'I don\'t see an active run for you. If you would like to start a new one, run ' - f'`/gauntlets start {e_query["events"][0]["name"]}` and we can get you started in no time!' + content=f"I don't see an active run for you. If you would like to start a new one, run " + f'`/gauntlets start {e_query["events"][0]["name"]}` and we can get you started in no time!' ) return else: - this_run = r_query['runs'][0] - this_event = r_query['runs'][0]['gauntlet'] + this_run = r_query["runs"][0] + this_event = r_query["runs"][0]["gauntlet"] # If not new or after draft, create new AI game is_home = gauntlets.is_home_team(human_team, this_event, this_run) - ai_team = await gauntlets.get_opponent(session, human_team, this_event, this_run) + ai_team = await gauntlets.get_opponent( + session, human_team, this_event, this_run + ) if ai_team is None: await interaction.edit_original_response( - content=f'Yike. I\'m not sure who your next opponent is. Plz ping the shit out of Cal!' + content=f"Yike. I'm not sure who your next opponent is. Plz ping the shit out of Cal!" ) return else: - logger.info(f'opponent: {ai_team}') + logger.info(f"opponent: {ai_team}") ai_role = await team_role(interaction, ai_team) human_role = await team_role(interaction, main_team) away_role = ai_role if is_home else human_role home_role = human_role if is_home else ai_role - + conflict_games = get_active_games_by_team(session, team=human_team) if len(conflict_games) > 0: await interaction.edit_original_response( - content=f'Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}' + content=f"Ope. The {human_team.sname} are already playing over in {interaction.guild.get_channel(conflict_games[0].channel_id).mention}" ) return - - current = await db_get('current') + + current = await db_get("current") game_code = gauntlets.get_game_code(human_team, this_event, this_run) - + this_game = Game( away_team_id=ai_team.id if is_home else human_team.id, home_team_id=human_team.id if is_home else ai_team.id, channel_id=interaction.channel_id, - season=current['season'], - week=current['week'], - first_message=None if interaction.message is None else interaction.message.id, - ai_team='away' if is_home else 'home', + season=current["season"], + week=current["week"], + first_message=( + None if interaction.message is None else interaction.message.id + ), + ai_team="away" if is_home else "home", away_roster_id=69 if is_home else int(roster.value), home_roster_id=int(roster.value) if is_home else 69, - game_type=game_code + game_type=game_code, ) logger.info( - f'Game between {human_team.abbrev} and {ai_team.abbrev} is initializing!' + f"Game between {human_team.abbrev} and {ai_team.abbrev} is initializing!" ) - + # Get AI SP await interaction.edit_original_response( - content=f'{ai_team.gmname} is looking for a Starting Pitcher...' + content=f"{ai_team.gmname} is looking for a Starting Pitcher..." ) ai_sp_lineup = await gauntlets.get_starting_pitcher( - session, - ai_team, - this_game, - this_event, - this_run + session, ai_team, this_game, this_event, this_run + ) + logger.info( + f"Chosen SP in Game {this_game.id}: {ai_sp_lineup.player.name_with_desc}" ) - logger.info(f'Chosen SP in Game {this_game.id}: {ai_sp_lineup.player.name_with_desc}') await interaction.edit_original_response( - content=f'The {ai_team.sname} are starting **{ai_sp_lineup.player.name_with_desc}**:\n\n{ai_sp_lineup.player.pitcher_card_url}' + content=f"The {ai_team.sname} are starting **{ai_sp_lineup.player.name_with_desc}**:\n\n{ai_sp_lineup.player.pitcher_card_url}" ) # Get AI Lineup final_message = await interaction.channel.send( - content=f'{ai_team.gmname} is filling out the {ai_team.sname} lineup card...' + content=f"{ai_team.gmname} is filling out the {ai_team.sname} lineup card..." ) - logger.info(f'Pulling lineup in Game {this_game.id}') + logger.info(f"Pulling lineup in Game {this_game.id}") batter_lineups = await get_starting_lineup( session, team=ai_team, game=this_game, league_name=f'gauntlet-{this_event["id"]}', - sp_name=ai_sp_lineup.player.name + sp_name=ai_sp_lineup.player.name, ) # Check for last game settings - logger.info(f'Checking human team\'s automation preferences...') - g_query = session.exec(select(Game).where(or_(Game.home_team == human_team, Game.away_team == human_team)).order_by(Game.id.desc()).limit(1)).all() + logger.info(f"Checking human team's automation preferences...") + g_query = session.exec( + select(Game) + .where(or_(Game.home_team == human_team, Game.away_team == human_team)) + .order_by(Game.id.desc()) + .limit(1) + ).all() if len(g_query) > 0: last_game = g_query[0] this_game.auto_roll = last_game.auto_roll this_game.roll_buttons = last_game.roll_buttons - logger.info(f'Setting auto_roll to {last_game.auto_roll} and roll_buttons to {last_game.roll_buttons}') - + logger.info( + f"Setting auto_roll to {last_game.auto_roll} and roll_buttons to {last_game.roll_buttons}" + ) + # Commit game and lineups session.add(this_game) session.commit() - await final_message.edit(content=f'The {ai_team.sname} lineup is in, pulling in scouting data...') + await final_message.edit( + content=f"The {ai_team.sname} lineup is in, pulling in scouting data..." + ) for batter in batter_lineups: - await get_all_positions( - session=session, - this_card=batter.card - ) + await get_all_positions(session=session, this_card=batter.card) embed = await get_scorebug_embed(session, this_game) embed.clear_fields() embed.add_field( - name=f'{ai_team.abbrev} Lineup', - value=this_game.team_lineup(session, ai_team) + name=f"{ai_team.abbrev} Lineup", + value=this_game.team_lineup(session, ai_team), ) # Get pitchers from rosterlinks - done = await get_full_roster_from_sheets(session, interaction, self.sheets, this_game, human_team, 1) + done = await get_full_roster_from_sheets( + session, interaction, self.sheets, this_game, human_team, 1 + ) # if done: # sp_view = starting_pitcher_dropdown_view(session, this_game, human_team, game_type=this_game.league_name, responders=[interaction.user]) # sp_message = await interaction.channel.send(content=f'### {human_team.lname} Starting Pitcher', view=sp_view) @@ -658,10 +865,10 @@ class Gameplay(commands.Cog): roster_choice = await ask_with_buttons( interaction, - ['vs Left', 'vs Right'], - 'Which lineup will you be using?', + ["vs Left", "vs Right"], + "Which lineup will you be using?", delete_question=False, - confirmation_message='Got it!' + confirmation_message="Got it!", ) # Read the 9 field players from sheets (this will fail to initialize play without SP) @@ -672,11 +879,11 @@ class Gameplay(commands.Cog): this_game=this_game, lineup_team=human_team, sheets_auth=self.sheets, - lineup_num=1 if roster_choice == 'vs Right' else 2, - league_name=this_game.game_type + lineup_num=1 if roster_choice == "vs Right" else 2, + league_name=this_game.game_type, ) except PositionNotFoundException as e: - logger.error(f'Position validation failed during lineup load: {e}') + logger.error(f"Position validation failed during lineup load: {e}") this_game.active = False session.add(this_game) session.commit() @@ -684,48 +891,73 @@ class Gameplay(commands.Cog): return except LineupsMissingException as e: # Expected - can't initialize play without SP yet - logger.info(f'Field player lineup read from sheets, waiting for SP selection: {e}') + logger.info( + f"Field player lineup read from sheets, waiting for SP selection: {e}" + ) - sp_view = starting_pitcher_dropdown_view(session, this_game, human_team, this_game.league_name, [interaction.user]) - await interaction.channel.send(content=f'### {human_team.lname} Starting Pitcher', view=sp_view) + sp_view = starting_pitcher_dropdown_view( + session, + this_game, + human_team, + this_game.league_name, + [interaction.user], + ) + await interaction.channel.send( + content=f"### {human_team.lname} Starting Pitcher", view=sp_view + ) # Don't try to initialize play immediately - wait for user to select SP # The play will be initialized when they run /gamestate await interaction.channel.send( - content=f'Once you\'ve selected your Starting Pitcher, run `/gamestate` to get the game started!' + content=f"Once you've selected your Starting Pitcher, run `/gamestate` to get the game started!" ) - @group_new_game.command(name='exhibition', description='Start a new custom game against an AI') + @group_new_game.command( + name="exhibition", description="Start a new custom game against an AI" + ) @app_commands.choices( roster=[ - Choice(value='1', name='Primary'), - Choice(value='2', name='Secondary'), - Choice(value='3', name='Ranked') + Choice(value="1", name="Primary"), + Choice(value="2", name="Secondary"), + Choice(value="3", name="Ranked"), ] ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def new_game_exhibition_command(self, interaction: discord.Interaction, away_team_abbrev: str, home_team_abbrev: str, roster: Choice[str], cardsets: Literal['Minor League', 'Major League', 'Hall of Fame', 'Flashback', 'Custom'] = 'Custom'): + async def new_game_exhibition_command( + self, + interaction: discord.Interaction, + away_team_abbrev: str, + home_team_abbrev: str, + roster: Choice[str], + cardsets: Literal[ + "Minor League", "Major League", "Hall of Fame", "Flashback", "Custom" + ] = "Custom", + ): await interaction.response.defer() self.kickstart_live_scorecard() with Session(engine) as session: - teams = await new_game_checks(session, interaction, away_team_abbrev, home_team_abbrev) + teams = await new_game_checks( + session, interaction, away_team_abbrev, home_team_abbrev + ) if teams is None: - logger.error(f'Received None from new_game_checks, cancelling new game') + logger.error(f"Received None from new_game_checks, cancelling new game") return - - away_team = teams['away_team'] - home_team = teams['home_team'] + + away_team = teams["away_team"] + home_team = teams["home_team"] if not away_team.is_ai ^ home_team.is_ai: await interaction.edit_original_response( - content=f'I don\'t see an AI team in this Exhibition game. Run `/new-game exhibition` again with an AI for a custom game or `/new-game ` for a PvP game.' + content=f"I don't see an AI team in this Exhibition game. Run `/new-game exhibition` again with an AI for a custom game or `/new-game ` for a PvP game." ) return - current = await db_get('current') - week_num = current['week'] - logger.info(f'gameplay - new_game_mlb_campaign - Season: {current["season"]} / Week: {week_num} / Away Team: {away_team.description} / Home Team: {home_team.description}') + current = await db_get("current") + week_num = current["week"] + logger.info( + f'gameplay - new_game_mlb_campaign - Season: {current["season"]} / Week: {week_num} / Away Team: {away_team.description} / Home Team: {home_team.description}' + ) ai_team = away_team if away_team.is_ai else home_team human_team = away_team if home_team.is_ai else home_team @@ -736,130 +968,150 @@ class Gameplay(commands.Cog): away_roster_id=69 if away_team.is_ai else int(roster.value), home_roster_id=69 if home_team.is_ai else int(roster.value), channel_id=interaction.channel_id, - season=current['season'], + season=current["season"], week=week_num, - first_message=None if interaction.message is None else interaction.message.id, - ai_team='away' if away_team.is_ai else 'home', - game_type='exhibition' + first_message=( + None if interaction.message is None else interaction.message.id + ), + ai_team="away" if away_team.is_ai else "home", + game_type="exhibition", ) async def new_game_setup(): # Get AI SP await interaction.edit_original_response( - content=f'{ai_team.gmname} is looking for a Starting Pitcher...' + content=f"{ai_team.gmname} is looking for a Starting Pitcher..." ) ai_sp_lineup = await get_starting_pitcher( session, ai_team, this_game, True if home_team.is_ai else False, - 'exhibition' + "exhibition", ) - logger.info(f'Chosen SP: {ai_sp_lineup.player.name_with_desc}') + logger.info(f"Chosen SP: {ai_sp_lineup.player.name_with_desc}") session.add(ai_sp_lineup) await interaction.edit_original_response( - content=f'The {ai_team.sname} are starting **{ai_sp_lineup.player.name_with_desc}**:\n\n{ai_sp_lineup.player.pitcher_card_url}' - ) + content=f"The {ai_team.sname} are starting **{ai_sp_lineup.player.name_with_desc}**:\n\n{ai_sp_lineup.player.pitcher_card_url}" + ) # Get AI Lineup final_message = await interaction.channel.send( - content=f'{ai_team.gmname} is filling out the {ai_team.sname} lineup card...' + content=f"{ai_team.gmname} is filling out the {ai_team.sname} lineup card..." ) - logger.info(f'Pulling lineup...') + logger.info(f"Pulling lineup...") batter_lineups = await get_starting_lineup( session, team=ai_team, game=this_game, league_name=this_game.league_name, - sp_name=ai_sp_lineup.player.name + sp_name=ai_sp_lineup.player.name, ) for x in batter_lineups: session.add(x) # Check for last game settings - logger.info(f'Checking human team\'s automation preferences...') - g_query = session.exec(select(Game).where(or_(Game.home_team == human_team, Game.away_team == human_team)).order_by(Game.id.desc()).limit(1)).all() + logger.info(f"Checking human team's automation preferences...") + g_query = session.exec( + select(Game) + .where( + or_(Game.home_team == human_team, Game.away_team == human_team) + ) + .order_by(Game.id.desc()) + .limit(1) + ).all() if len(g_query) > 0: last_game = g_query[0] this_game.auto_roll = last_game.auto_roll this_game.roll_buttons = last_game.roll_buttons - logger.info(f'Setting auto_roll to {last_game.auto_roll} and roll_buttons to {last_game.roll_buttons}') - + logger.info( + f"Setting auto_roll to {last_game.auto_roll} and roll_buttons to {last_game.roll_buttons}" + ) + # Commit game and lineups session.add(this_game) session.commit() - - await final_message.edit(content=f'The {ai_team.sname} lineup is in, pulling in scouting data...') + + await final_message.edit( + content=f"The {ai_team.sname} lineup is in, pulling in scouting data..." + ) for batter in batter_lineups: - await get_all_positions( - session=session, - this_card=batter.card - ) - - logger.info(f'Pulling team roles') + await get_all_positions(session=session, this_card=batter.card) + + logger.info(f"Pulling team roles") away_role = await team_role(interaction, this_game.away_team) home_role = await team_role(interaction, this_game.home_team) - logger.info(f'Building scorebug embed') + logger.info(f"Building scorebug embed") embed = await get_scorebug_embed(session, this_game) embed.clear_fields() embed.add_field( - name=f'{ai_team.abbrev} Lineup', - value=this_game.team_lineup(session, ai_team) + name=f"{ai_team.abbrev} Lineup", + value=this_game.team_lineup(session, ai_team), + ) + + logger.info(f"Pulling and caching full {human_team.abbrev} roster") + done = await get_full_roster_from_sheets( + session, + interaction, + self.sheets, + this_game, + human_team, + int(roster.value), ) - - logger.info(f'Pulling and caching full {human_team.abbrev} roster') - done = await get_full_roster_from_sheets(session, interaction, self.sheets, this_game, human_team, int(roster.value)) if done: - sp_view = starting_pitcher_dropdown_view(session, this_game, human_team, this_game.league_name, [interaction.user]) - await interaction.channel.send(content=f'### {human_team.lname} Starting Pitcher', view=sp_view) + sp_view = starting_pitcher_dropdown_view( + session, + this_game, + human_team, + this_game.league_name, + [interaction.user], + ) + await interaction.channel.send( + content=f"### {human_team.lname} Starting Pitcher", view=sp_view + ) await final_message.edit( - content=f'{away_role.mention} @ {home_role.mention} is set!\n\n' - f'Go ahead and set your lineup with the `/set lineup` command!', - embed=embed + content=f"{away_role.mention} @ {home_role.mention} is set!\n\n" + f"Go ahead and set your lineup with the `/set lineup` command!", + embed=embed, ) - if cardsets != 'Custom': + if cardsets != "Custom": c_list = CARDSETS[cardsets] - for row in c_list['primary']: + for row in c_list["primary"]: this_cardset = await get_cardset_or_none(session, cardset_id=row) if this_cardset is not None: this_link = GameCardsetLink( - game=this_game, - cardset=this_cardset, - priority=1 + game=this_game, cardset=this_cardset, priority=1 ) session.add(this_link) - - for row in c_list['secondary']: + + for row in c_list["secondary"]: this_cardset = await get_cardset_or_none(session, cardset_id=row) this_link = GameCardsetLink( - game=this_game, - cardset=this_cardset, - priority=2 + game=this_game, cardset=this_cardset, priority=2 ) await new_game_setup() - + else: + async def my_callback(interaction: discord.Interaction, values): - logger.info(f'Setting custom cardsets inside callback') + logger.info(f"Setting custom cardsets inside callback") await interaction.response.defer(thinking=True) - logger.info(f'values: {values}') + logger.info(f"values: {values}") for cardset_id in values: - logger.info(f'Getting cardset: {cardset_id}') + logger.info(f"Getting cardset: {cardset_id}") this_cardset = await get_cardset_or_none(session, cardset_id) - logger.info(f'this_cardset: {this_cardset}') + logger.info(f"this_cardset: {this_cardset}") this_link = GameCardsetLink( - game=this_game, - cardset=this_cardset, - priority=1 + game=this_game, cardset=this_cardset, priority=1 ) session.add(this_link) - - logger.info(f'Done processing links') + + logger.info(f"Done processing links") session.commit() await interaction.edit_original_response(content="Got it...") @@ -867,41 +1119,49 @@ class Gameplay(commands.Cog): my_dropdown = Dropdown( option_list=SELECT_CARDSET_OPTIONS, - placeholder='Select up to 8 cardsets to include', + placeholder="Select up to 8 cardsets to include", callback=my_callback, - max_values=len(SELECT_CARDSET_OPTIONS) + max_values=len(SELECT_CARDSET_OPTIONS), ) view = DropdownView([my_dropdown]) - await interaction.edit_original_response( - content=None, - view=view - ) + await interaction.edit_original_response(content=None, view=view) # TODO: add new-game ranked - @group_new_game.command(name='unlimited', description='Start a new Unlimited game against another human') + @group_new_game.command( + name="unlimited", description="Start a new Unlimited game against another human" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def new_game_unlimited_command(self, interaction: discord.Interaction, away_team_abbrev: str, home_team_abbrev: str): + async def new_game_unlimited_command( + self, + interaction: discord.Interaction, + away_team_abbrev: str, + home_team_abbrev: str, + ): await interaction.response.defer() self.kickstart_live_scorecard() with Session(engine) as session: - teams = await new_game_checks(session, interaction, away_team_abbrev, home_team_abbrev) + teams = await new_game_checks( + session, interaction, away_team_abbrev, home_team_abbrev + ) if teams is None: - logger.error(f'Received None from new_game_checks, cancelling new game') + logger.error(f"Received None from new_game_checks, cancelling new game") return - - away_team = teams['away_team'] - home_team = teams['home_team'] + + away_team = teams["away_team"] + home_team = teams["home_team"] if away_team.is_ai or home_team.is_ai: await interaction.edit_original_response( - content=f'Unlimited games are for two human-run teams. To play against the AI, you can play `mlb-campaign`, `gauntlet`, or `exhibition` game modes.' + content=f"Unlimited games are for two human-run teams. To play against the AI, you can play `mlb-campaign`, `gauntlet`, or `exhibition` game modes." ) return - current = await db_get('current') - week_num = current['week'] - logger.info(f'gameplay - new_game_unlimited - Season: {current["season"]} / Week: {week_num} / Away Team: {away_team.description} / Home Team: {home_team.description}') + current = await db_get("current") + week_num = current["week"] + logger.info( + f'gameplay - new_game_unlimited - Season: {current["season"]} / Week: {week_num} / Away Team: {away_team.description} / Home Team: {home_team.description}' + ) this_game = Game( away_team_id=away_team.id, @@ -909,14 +1169,16 @@ class Gameplay(commands.Cog): away_roster_id=None, home_roster_id=None, channel_id=interaction.channel_id, - season=current['season'], + season=current["season"], week=week_num, - first_message=None if interaction.message is None else interaction.message.id, - game_type='exhibition' + first_message=( + None if interaction.message is None else interaction.message.id + ), + game_type="exhibition", ) await interaction.edit_original_response( - content=f'Let\'s get set up for **{away_team.lname}** @ **{home_team.lname}**!' + content=f"Let's get set up for **{away_team.lname}** @ **{home_team.lname}**!" ) away_role = await team_role(interaction, away_team) @@ -924,83 +1186,94 @@ class Gameplay(commands.Cog): away_roster_id = await ask_with_buttons( interaction=interaction, - button_options=[ - 'Primary', 'Secondary', 'Ranked' - ], - question=f'{away_role.mention}\nWhich roster should I pull for you?', + button_options=["Primary", "Secondary", "Ranked"], + question=f"{away_role.mention}\nWhich roster should I pull for you?", delete_question=False, - confirmation_message=f'Got it! As soon as the {home_team.sname} select their roster, I will pull them both in at once.' + confirmation_message=f"Got it! As soon as the {home_team.sname} select their roster, I will pull them both in at once.", ) home_roster_id = await ask_with_buttons( interaction=interaction, - button_options=[ - 'Primary', 'Secondary', 'Ranked' - ], - question=f'{home_role.mention}\nWhich roster should I pull for you?', + button_options=["Primary", "Secondary", "Ranked"], + question=f"{home_role.mention}\nWhich roster should I pull for you?", delete_question=False, - confirmation_message=f'Got it! Off to Sheets I go for the {away_team.abbrev} roster...' + confirmation_message=f"Got it! Off to Sheets I go for the {away_team.abbrev} roster...", ) if away_roster_id and home_roster_id: - if away_roster_id == 'Primary': + if away_roster_id == "Primary": away_roster_id = 1 - elif away_roster_id == 'Secondary': + elif away_roster_id == "Secondary": away_roster_id = 2 else: away_roster_id = 3 - if home_roster_id == 'Primary': + if home_roster_id == "Primary": home_roster_id = 1 - elif home_roster_id == 'Secondary': + elif home_roster_id == "Secondary": home_roster_id = 2 else: home_roster_id = 3 - logger.info(f'Setting roster IDs - away: {away_roster_id} / home: {home_roster_id}') + logger.info( + f"Setting roster IDs - away: {away_roster_id} / home: {home_roster_id}" + ) this_game.away_roster_id = away_roster_id this_game.home_roster_id = home_roster_id session.add(this_game) session.commit() - - logger.info(f'Pulling away team\'s roster') - away_roster = await get_full_roster_from_sheets(session, interaction, self.sheets, this_game, away_team, away_roster_id) + + logger.info(f"Pulling away team's roster") + away_roster = await get_full_roster_from_sheets( + session, interaction, self.sheets, this_game, away_team, away_roster_id + ) # if away_roster: - logger.info(f'Pulling home team\'s roster') + logger.info(f"Pulling home team's roster") await interaction.channel.send( - content=f'And now for the {home_team.abbrev} sheet...' + content=f"And now for the {home_team.abbrev} sheet..." ) - home_roster = await get_full_roster_from_sheets(session, interaction, self.sheets, this_game, home_team,home_roster_id) - - # if home_roster: - await interaction.channel.send( - content=f'{away_role.mention} @ {home_role.mention}\n\nThe game is set, both of you may run `/set ` to start!' + home_roster = await get_full_roster_from_sheets( + session, interaction, self.sheets, this_game, home_team, home_roster_id ) + # if home_roster: + await interaction.channel.send( + content=f"{away_role.mention} @ {home_role.mention}\n\nThe game is set, both of you may run `/set ` to start!" + ) - @commands.command(name='force-endgame', help='Mod: Force a game to end without stats') + @commands.command( + name="force-endgame", help="Mod: Force a game to end without stats" + ) async def force_end_game_command(self, ctx: commands.Context): with Session(engine) as session: this_game = get_channel_game_or_none(session, ctx.channel.id) if this_game is None: - await ctx.send(f'I do not see a game here - are you in the right place?') + await ctx.send( + f"I do not see a game here - are you in the right place?" + ) return - + try: await ctx.send( - content=None, - embed=await get_scorebug_embed(session, this_game, full_length=True) + content=None, + embed=await get_scorebug_embed( + session, this_game, full_length=True + ), ) except Exception as e: - logger.error(f'Unable to display scorebug while forcing game to end: {e}') - await ctx.send(content='This game is so boned that I can\'t display the scorebug.') + logger.error( + f"Unable to display scorebug while forcing game to end: {e}" + ) + await ctx.send( + content="This game is so boned that I can't display the scorebug." + ) nuke_game = await ask_confirm( ctx, - question=f'Is this the game I should nuke?', - label_type='yes', + question=f"Is this the game I should nuke?", + label_type="yes", timeout=15, ) @@ -1009,53 +1282,68 @@ class Gameplay(commands.Cog): this_game.active = False session.add(this_game) session.commit() - await ctx.channel.send(content=random_gif(random_from_list(['i killed it', 'deed is done', 'gone forever']))) + await ctx.channel.send( + content=random_gif( + random_from_list( + ["i killed it", "deed is done", "gone forever"] + ) + ) + ) else: - await ctx.send(f'It stays. For now.') + await ctx.send(f"It stays. For now.") - group_set_rosters = app_commands.Group(name='set', description='Set SP and lineup') + group_set_rosters = app_commands.Group(name="set", description="Set SP and lineup") - @group_set_rosters.command(name='lineup', description='Import a saved lineup for this channel\'s PD game.') - @app_commands.describe( - lineup='Which handedness lineup are you using?' + @group_set_rosters.command( + name="lineup", description="Import a saved lineup for this channel's PD game." ) + @app_commands.describe(lineup="Which handedness lineup are you using?") @app_commands.choices( - lineup=[ - Choice(value='1', name='v Right'), - Choice(value='2', name='v Left') - ] + lineup=[Choice(value="1", name="v Right"), Choice(value="2", name="v Left")] ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) - async def read_lineup_command(self, interaction: discord.Interaction, lineup: Choice[str]): + async def read_lineup_command( + self, interaction: discord.Interaction, lineup: Choice[str] + ): await interaction.response.defer() with Session(engine) as session: this_game = get_channel_game_or_none(session, interaction.channel_id) if this_game is None: await interaction.edit_original_response( - content=f'Hm. I don\'t see a game going on in this channel. Am I drunk?' + content=f"Hm. I don't see a game going on in this channel. Am I drunk?" ) return - + if this_game.away_team.gmid == interaction.user.id: this_team = this_game.away_team elif this_game.home_team.gmid == interaction.user.id: this_team = this_game.home_team else: - logger.info(f'{interaction.user.name} tried to run a command in Game {this_game.id} when they aren\'t a GM in the game.') - await interaction.edit_original_response(content='Bruh. Only GMs of the active teams can pull lineups.') + logger.info( + f"{interaction.user.name} tried to run a command in Game {this_game.id} when they aren't a GM in the game." + ) + await interaction.edit_original_response( + content="Bruh. Only GMs of the active teams can pull lineups." + ) return - + all_lineups = get_game_lineups(session, this_game, this_team) if len(all_lineups) > 1: - play_count = session.exec(select(func.count(Play.id)).where(Play.game == this_game, Play.complete == True)).one() + play_count = session.exec( + select(func.count(Play.id)).where( + Play.game == this_game, Play.complete == True + ) + ).one() if play_count > 0: await interaction.edit_original_response( content=f'Since {play_count} play{"s" if play_count != 1 else ""} ha{"ve" if play_count != 1 else "s"} been logged, you will have to run `/substitution batter` to replace any of your batters.' ) return - logger.info(f'lineup: {lineup} / value: {lineup.value} / name: {lineup.name}') + logger.info( + f"lineup: {lineup} / value: {lineup.value} / name: {lineup.name}" + ) try: this_play = await read_lineup( session, @@ -1064,20 +1352,22 @@ class Gameplay(commands.Cog): lineup_team=this_team, sheets_auth=self.sheets, lineup_num=int(lineup.value), - league_name=this_game.game_type + league_name=this_game.game_type, ) except PositionNotFoundException as e: - logger.error(f'Position validation failed during lineup load: {e}') + logger.error(f"Position validation failed during lineup load: {e}") await interaction.edit_original_response(content=str(e)) return except LineupsMissingException as e: - await interaction.edit_original_response(content='Run `/set starting-pitcher` to select your SP') + await interaction.edit_original_response( + content="Run `/set starting-pitcher` to select your SP" + ) return - + if this_play is not None: await self.post_play(session, interaction, this_play) - @group_set_rosters.command(name='starting-pitcher') + @group_set_rosters.command(name="starting-pitcher") @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def set_starting_pitcher(self, interaction: discord.Interaction): await interaction.response.defer() @@ -1086,417 +1376,709 @@ class Gameplay(commands.Cog): this_game = get_channel_game_or_none(session, interaction.channel_id) if this_game is None: await interaction.edit_original_response( - content=f'Hm. I don\'t see a game going on in this channel. Am I drunk?' + content=f"Hm. I don't see a game going on in this channel. Am I drunk?" ) return - + if this_game.away_team.gmid == interaction.user.id: this_team = this_game.away_team elif this_game.home_team.gmid == interaction.user.id: this_team = this_game.home_team else: - logger.info(f'{interaction.user.name} tried to run a command in Game {this_game.id} when they aren\'t a GM in the game.') - await interaction.edit_original_response(content='Bruh. Only GMs of the active teams can pull lineups.') + logger.info( + f"{interaction.user.name} tried to run a command in Game {this_game.id} when they aren't a GM in the game." + ) + await interaction.edit_original_response( + content="Bruh. Only GMs of the active teams can pull lineups." + ) return - + try: - check_sp = get_one_lineup(session, this_game, this_team, position='P') - play_count = session.exec(select(func.count(Play.id)).where(Play.game == this_game, Play.complete == True, Play.pitcher == check_sp)).one() + check_sp = get_one_lineup(session, this_game, this_team, position="P") + play_count = session.exec( + select(func.count(Play.id)).where( + Play.game == this_game, + Play.complete == True, + Play.pitcher == check_sp, + ) + ).one() if play_count > 0: await interaction.edit_original_response( content=f'Since {play_count} play{"s" if play_count != 1 else ""} ha{"ve" if play_count != 1 else "s"} been logged, you will have to run `/substitution pitcher` to replace {check_sp.player.name}.' ) return - + except sqlalchemy.exc.NoResultFound as e: # if 'NoResultFound' not in str(e): # logger.error(f'Error checking for existing sp: {e}') # log_exception(e, 'Unable to check your lineup for an existing SP') # else: - logger.info(f'No pitcher in game, good to go') + logger.info(f"No pitcher in game, good to go") check_sp = None if check_sp is not None: - logger.info(f'Already an SP in Game {this_game.id}, asking if we should swap') + logger.info( + f"Already an SP in Game {this_game.id}, asking if we should swap" + ) swap_sp = await ask_confirm( interaction, - question=f'{check_sp.player.name} is already scheduled to start this game - would you like to switch?', - label_type='yes' + question=f"{check_sp.player.name} is already scheduled to start this game - would you like to switch?", + label_type="yes", ) if not swap_sp: - logger.info(f'No swap being made') - await interaction.edit_original_response(content=f'We will leave {check_sp.player.name} on the lineup card.') + logger.info(f"No swap being made") + await interaction.edit_original_response( + content=f"We will leave {check_sp.player.name} on the lineup card." + ) return - + session.delete(check_sp) session.commit() - sp_view = starting_pitcher_dropdown_view(session, this_game, this_team, game_type=this_game.league_name, responders=[interaction.user]) - await interaction.edit_original_response(content=f'### {this_team.lname} Starting Pitcher', view=sp_view) - - @app_commands.command(name='gamestate', description='Post the current game state') - async def gamestate_command(self, interaction: discord.Interaction, include_lineups: bool = False): + sp_view = starting_pitcher_dropdown_view( + session, + this_game, + this_team, + game_type=this_game.league_name, + responders=[interaction.user], + ) + await interaction.edit_original_response( + content=f"### {this_team.lname} Starting Pitcher", view=sp_view + ) + + @app_commands.command(name="gamestate", description="Post the current game state") + async def gamestate_command( + self, interaction: discord.Interaction, include_lineups: bool = False + ): await interaction.response.defer(ephemeral=True, thinking=True) with Session(engine) as session: this_game = get_channel_game_or_none(session, interaction.channel_id) if this_game is None: await interaction.edit_original_response( - content=f'Hm. I don\'t see a game going on in this channel. Am I drunk?' + content=f"Hm. I don't see a game going on in this channel. Am I drunk?" ) return - + this_play = this_game.current_play_or_none(session) try: - await self.post_play(session, interaction, this_play, full_length=include_lineups, buffer_message=None if this_game.human_team.gmid != interaction.user.id else 'Posting current play') + await self.post_play( + session, + interaction, + this_play, + full_length=include_lineups, + buffer_message=( + None + if this_game.human_team.gmid != interaction.user.id + else "Posting current play" + ), + ) except LineupsMissingException as e: - logger.info(f'Could not post full scorebug embed, posting lineups') - ai_team = this_game.away_team if this_game.ai_team == 'away' else this_game.home_team + logger.info(f"Could not post full scorebug embed, posting lineups") + ai_team = ( + this_game.away_team + if this_game.ai_team == "away" + else this_game.home_team + ) embed = await get_scorebug_embed(session, this_game) embed.clear_fields() embed.add_field( - name=f'{ai_team.abbrev} Lineup', - value=this_game.team_lineup(session, ai_team) + name=f"{ai_team.abbrev} Lineup", + value=this_game.team_lineup(session, ai_team), ) - @app_commands.command(name='settings-ingame', description='Change in-game settings') + @app_commands.command(name="settings-ingame", description="Change in-game settings") @app_commands.describe( roll_buttons='Display the "Roll AB" and "Check Jump" buttons along with the scorebug', - auto_roll='When there are no baserunners, automatically roll the next AB' + auto_roll="When there are no baserunners, automatically roll the next AB", ) - async def game_settings_command(self, interaction: discord.Interaction, roll_buttons: bool = None, auto_roll: bool = None): + async def game_settings_command( + self, + interaction: discord.Interaction, + roll_buttons: bool = None, + auto_roll: bool = None, + ): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='settings-ingame', lock_play=False) + this_game, owner_team, this_play = await checks_log_interaction( + session, interaction, command_name="settings-ingame", lock_play=False + ) - await interaction.edit_original_response(content=None, embed=await update_game_settings( - session, - interaction, - this_game, - roll_buttons=roll_buttons, - auto_roll=auto_roll - )) + await interaction.edit_original_response( + content=None, + embed=await update_game_settings( + session, + interaction, + this_game, + roll_buttons=roll_buttons, + auto_roll=auto_roll, + ), + ) - @app_commands.command(name='end-game', description='End the current game in this channel') + @app_commands.command( + name="end-game", description="End the current game in this channel" + ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) async def end_game_command(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'end-game') as (this_game, owner_team, this_play): - await manual_end_game(session, interaction, this_game, current_play=this_play) + async with locked_play(session, interaction, "end-game") as ( + this_game, + owner_team, + this_play, + ): + await manual_end_game( + session, interaction, this_game, current_play=this_play + ) - group_substitution = app_commands.Group(name='substitute', description='Make a substitution in active game') + group_substitution = app_commands.Group( + name="substitute", description="Make a substitution in active game" + ) - @group_substitution.command(name='batter', description='Make a batter substitution') - async def sub_batter_command(self, interaction: discord.Interaction, batting_order: Literal['this-spot', '1', '2', '3', '4', '5', '6', '7', '8', '9'] = 'this-spot'): + @group_substitution.command(name="batter", description="Make a batter substitution") + async def sub_batter_command( + self, + interaction: discord.Interaction, + batting_order: Literal[ + "this-spot", "1", "2", "3", "4", "5", "6", "7", "8", "9" + ] = "this-spot", + ): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='substitute batter', lock_play=False) - - if batting_order == 'this-spot': - if this_play.batter.team != owner_team: - logger.info(f'Batting order not included while on defense; returning') + this_game, owner_team, this_play = await checks_log_interaction( + session, interaction, command_name="substitute batter", lock_play=False + ) - await interaction.edit_original_response(content=f'When you make a defensive substitution, please include the batting order where they should enter.') + if batting_order == "this-spot": + if this_play.batter.team != owner_team: + logger.info( + f"Batting order not included while on defense; returning" + ) + + await interaction.edit_original_response( + content=f"When you make a defensive substitution, please include the batting order where they should enter." + ) return this_order = this_play.batting_order else: this_order = int(batting_order) - logger.info(f'sub batter - this_play: {this_play}') - bat_view = sub_batter_dropdown_view(session, this_game, owner_team, this_order, [interaction.user]) - await interaction.edit_original_response(content=f'### {owner_team.lname} Substitution', view=bat_view) - - @group_substitution.command(name='pitcher', description='Make a pitching substitution') - async def sub_pitcher_command(self, interaction: discord.Interaction, batting_order: Literal['dh-spot', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] = '10'): + logger.info(f"sub batter - this_play: {this_play}") + bat_view = sub_batter_dropdown_view( + session, this_game, owner_team, this_order, [interaction.user] + ) + await interaction.edit_original_response( + content=f"### {owner_team.lname} Substitution", view=bat_view + ) + + @group_substitution.command( + name="pitcher", description="Make a pitching substitution" + ) + async def sub_pitcher_command( + self, + interaction: discord.Interaction, + batting_order: Literal[ + "dh-spot", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" + ] = "10", + ): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='substitute pitcher', lock_play=False) + this_game, owner_team, this_play = await checks_log_interaction( + session, interaction, command_name="substitute pitcher", lock_play=False + ) if owner_team != this_play.pitcher.team: - logger.warning(f'User {interaction.user.name} ({owner_team.abbrev}) tried to run a sub for the {this_play.pitcher.team.lname}') + logger.warning( + f"User {interaction.user.name} ({owner_team.abbrev}) tried to run a sub for the {this_play.pitcher.team.lname}" + ) await interaction.edit_original_response( - content=f'Please run pitcher subs when your team is on defense. If you are pinch-hitting for a pitcher already in the lineup, use `/substitute batter`' + content=f"Please run pitcher subs when your team is on defense. If you are pinch-hitting for a pitcher already in the lineup, use `/substitute batter`" ) return - - if batting_order != '10' and this_play.pitcher.batting_order == 10: + + if batting_order != "10" and this_play.pitcher.batting_order == 10: forfeit_dh = await ask_confirm( - interaction, - f'Are you sure you want to forfeit the DH?' + interaction, f"Are you sure you want to forfeit the DH?" ) if not forfeit_dh: await interaction.edit_original_response( - content=f'Fine, be that way.' + content=f"Fine, be that way." ) return - + if not this_play.is_new_inning: - pitcher_plays = get_plays_by_pitcher(session, this_game, this_play.pitcher) + pitcher_plays = get_plays_by_pitcher( + session, this_game, this_play.pitcher + ) batters_faced = sum(1 for x in pitcher_plays if x.pa == 1) if batters_faced < 3: await interaction.edit_original_response( - content=f'Looks like **{this_play.pitcher.player.name}** has only faced {batters_faced} of the 3-batter minimum.' + content=f"Looks like **{this_play.pitcher.player.name}** has only faced {batters_faced} of the 3-batter minimum." ) return - rp_view = relief_pitcher_dropdown_view(session, this_game, this_play.pitcher.team, batting_order, responders=[interaction.user]) + rp_view = relief_pitcher_dropdown_view( + session, + this_game, + this_play.pitcher.team, + batting_order, + responders=[interaction.user], + ) rp_message = await interaction.edit_original_response( - content=f'### {this_play.pitcher.team.lname} Relief Pitcher', - view=rp_view + content=f"### {this_play.pitcher.team.lname} Relief Pitcher", + view=rp_view, ) - @group_substitution.command(name='defense', description='Make a defensive substitution or move defenders between positions') - async def sub_defense_command(self, interaction: discord.Interaction, new_position: DEFENSE_NO_PITCHER_LITERAL): + @group_substitution.command( + name="defense", + description="Make a defensive substitution or move defenders between positions", + ) + async def sub_defense_command( + self, interaction: discord.Interaction, new_position: DEFENSE_NO_PITCHER_LITERAL + ): with Session(engine) as session: - this_game, owner_team, this_play = await checks_log_interaction(session, interaction, command_name='substitute defense', lock_play=False) + this_game, owner_team, this_play = await checks_log_interaction( + session, interaction, command_name="substitute defense", lock_play=False + ) defense_view = await defender_dropdown_view( session=session, this_game=this_game, human_team=owner_team, new_position=new_position, - responders=[interaction.user] + responders=[interaction.user], ) defense_message = await interaction.edit_original_response( - content=f'### {owner_team.lname} {new_position} Change', - view=defense_view + content=f"### {owner_team.lname} {new_position} Change", + view=defense_view, ) - group_log = app_commands.Group(name='log', description='Log a play in this channel\'s game') + group_log = app_commands.Group( + name="log", description="Log a play in this channel's game" + ) - @group_log.command(name='flyball', description='Flyballs: a, b, ballpark, bq, c') - async def log_flyball(self, interaction: discord.Interaction, flyball_type: Literal['a', 'b', 'ballpark', 'b?', 'c']): + @group_log.command(name="flyball", description="Flyballs: a, b, ballpark, bq, c") + async def log_flyball( + self, + interaction: discord.Interaction, + flyball_type: Literal["a", "b", "ballpark", "b?", "c"], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log flyball') as (this_game, owner_team, this_play): - logger.info(f'log flyball {flyball_type} - this_play: {this_play}') - this_play = await flyballs(session, interaction, this_play, flyball_type) + async with locked_play(session, interaction, "log flyball") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log flyball {flyball_type} - this_play: {this_play}") + this_play = await flyballs( + session, interaction, this_play, flyball_type + ) await self.complete_and_post_play( session, interaction, this_play, - buffer_message='Flyball logged' if this_play.starting_outs + this_play.outs < 3 and ((this_play.on_second and flyball_type in ['b', 'ballpark']) or (this_play.on_third and flyball_type == 'b?')) else None + buffer_message=( + "Flyball logged" + if this_play.starting_outs + this_play.outs < 3 + and ( + (this_play.on_second and flyball_type in ["b", "ballpark"]) + or (this_play.on_third and flyball_type == "b?") + ) + else None + ), ) - - @group_log.command(name='frame-pitch', description=f'Walk/strikeout split; determined by home plate umpire') + + @group_log.command( + name="frame-pitch", + description=f"Walk/strikeout split; determined by home plate umpire", + ) async def log_frame_check(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'log frame-check') as (this_game, owner_team, this_play): - logger.info(f'log frame-check - this_play: {this_play}') + async with locked_play(session, interaction, "log frame-check") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log frame-check - this_play: {this_play}") this_play = await frame_checks(session, interaction, this_play) + await self.complete_and_post_play( + session, interaction, this_play, buffer_message="Frame check logged" + ) + + @group_log.command( + name="lineout", description="Lineouts: one out, ballpark, max outs" + ) + async def log_lineout( + self, + interaction: discord.Interaction, + lineout_type: Literal["one-out", "ballpark", "max-outs"], + ): + with Session(engine) as session: + async with locked_play(session, interaction, "log lineout") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log lineout - this_play: {this_play}") + this_play = await lineouts( + session, interaction, this_play, lineout_type + ) + await self.complete_and_post_play( session, interaction, this_play, - buffer_message='Frame check logged' + buffer_message=( + "Lineout logged" if this_play.on_base_code > 3 else None + ), ) - - @group_log.command(name='lineout', description='Lineouts: one out, ballpark, max outs') - async def log_lineout(self, interaction: discord.Interaction, lineout_type: Literal['one-out', 'ballpark', 'max-outs']): - with Session(engine) as session: - async with locked_play(session, interaction, 'log lineout') as (this_game, owner_team, this_play): - logger.info(f'log lineout - this_play: {this_play}') - this_play = await lineouts(session, interaction, this_play, lineout_type) - await self.complete_and_post_play(session, interaction, this_play, buffer_message='Lineout logged' if this_play.on_base_code > 3 else None) - - @group_log.command(name='single', description='Singles: *, **, ballpark, uncapped') + @group_log.command(name="single", description="Singles: *, **, ballpark, uncapped") async def log_single( - self, interaction: discord.Interaction, single_type: Literal['*', '**', 'ballpark', 'uncapped']): + self, + interaction: discord.Interaction, + single_type: Literal["*", "**", "ballpark", "uncapped"], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log single') as (this_game, owner_team, this_play): - logger.info(f'log single {single_type} - this_play: {this_play}') + async with locked_play(session, interaction, "log single") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log single {single_type} - this_play: {this_play}") this_play = await singles(session, interaction, this_play, single_type) - await self.complete_and_post_play(session, interaction, this_play, buffer_message='Single logged' if ((this_play.on_first or this_play.on_second) and single_type == 'uncapped') else None) + await self.complete_and_post_play( + session, + interaction, + this_play, + buffer_message=( + "Single logged" + if ( + (this_play.on_first or this_play.on_second) + and single_type == "uncapped" + ) + else None + ), + ) - @group_log.command(name='double', description='Doubles: **, ***, uncapped') - async def log_double(self, interaction: discord.Interaction, double_type: Literal['**', '***', 'uncapped']): + @group_log.command(name="double", description="Doubles: **, ***, uncapped") + async def log_double( + self, + interaction: discord.Interaction, + double_type: Literal["**", "***", "uncapped"], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log double') as (this_game, owner_team, this_play): - logger.info(f'log double {double_type} - this_play: {this_play}') + async with locked_play(session, interaction, "log double") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log double {double_type} - this_play: {this_play}") this_play = await doubles(session, interaction, this_play, double_type) - await self.complete_and_post_play(session, interaction, this_play, buffer_message='Double logged' if (this_play.on_first and double_type == 'uncapped') else None) - - @group_log.command(name='triple', description='Triples: no sub-types') + await self.complete_and_post_play( + session, + interaction, + this_play, + buffer_message=( + "Double logged" + if (this_play.on_first and double_type == "uncapped") + else None + ), + ) + + @group_log.command(name="triple", description="Triples: no sub-types") async def log_triple(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'log triple') as (this_game, owner_team, this_play): - logger.info(f'log triple - this_play: {this_play}') + async with locked_play(session, interaction, "log triple") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log triple - this_play: {this_play}") this_play = await triples(session, interaction, this_play) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='homerun', description='Home Runs: ballpark, no-doubt') - async def log_homerun(self, interaction: discord.Interaction, homerun_type: Literal['ballpark', 'no-doubt']): + @group_log.command(name="homerun", description="Home Runs: ballpark, no-doubt") + async def log_homerun( + self, + interaction: discord.Interaction, + homerun_type: Literal["ballpark", "no-doubt"], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log homerun') as (this_game, owner_team, this_play): - logger.info(f'log homerun {homerun_type} - this_play: {this_play}') - this_play = await homeruns(session, interaction, this_play, homerun_type) + async with locked_play(session, interaction, "log homerun") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log homerun {homerun_type} - this_play: {this_play}") + this_play = await homeruns( + session, interaction, this_play, homerun_type + ) await self.complete_and_post_play(session, interaction, this_play) - - @group_log.command(name='walk', description='Walks: unintentional (default), intentional') - async def log_walk(self, interaction: discord.Interaction, walk_type: Literal['unintentional', 'intentional'] = 'unintentional'): + + @group_log.command( + name="walk", description="Walks: unintentional (default), intentional" + ) + async def log_walk( + self, + interaction: discord.Interaction, + walk_type: Literal["unintentional", "intentional"] = "unintentional", + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log walk') as (this_game, owner_team, this_play): - logger.info(f'log walk {walk_type} - this_play: {this_play}') + async with locked_play(session, interaction, "log walk") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log walk {walk_type} - this_play: {this_play}") this_play = await walks(session, interaction, this_play, walk_type) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='strikeout', description='Strikeout') + @group_log.command(name="strikeout", description="Strikeout") async def log_strikeout(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'log strikeout') as (this_game, owner_team, this_play): - logger.info(f'log strikeout - this_play: {this_play}') + async with locked_play(session, interaction, "log strikeout") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log strikeout - this_play: {this_play}") this_play = await strikeouts(session, interaction, this_play) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='popout', description='Popout') + @group_log.command(name="popout", description="Popout") async def log_popout(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'log popout') as (this_game, owner_team, this_play): - logger.info(f'log popout - this_play: {this_play}') + async with locked_play(session, interaction, "log popout") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log popout - this_play: {this_play}") this_play = await popouts(session, interaction, this_play) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='groundball', description='Groundballs: a, b, c') - async def log_groundball(self, interaction: discord.Interaction, groundball_type: Literal['a', 'b', 'c']): + @group_log.command(name="groundball", description="Groundballs: a, b, c") + async def log_groundball( + self, interaction: discord.Interaction, groundball_type: Literal["a", "b", "c"] + ): with Session(engine) as session: - async with locked_play(session, interaction, f'log groundball {groundball_type}') as (this_game, owner_team, this_play): - logger.info(f'log groundball {groundball_type} - this_play: {this_play}') - this_play = await groundballs(session, interaction, this_play, groundball_type) + async with locked_play( + session, interaction, f"log groundball {groundball_type}" + ) as (this_game, owner_team, this_play): + logger.info( + f"log groundball {groundball_type} - this_play: {this_play}" + ) + this_play = await groundballs( + session, interaction, this_play, groundball_type + ) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='hit-by-pitch', description='Hit by pitch: batter to first; runners advance if forced') + @group_log.command( + name="hit-by-pitch", + description="Hit by pitch: batter to first; runners advance if forced", + ) async def log_hit_by_pitch(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'log hit-by-pitch') as (this_game, owner_team, this_play): - logger.info(f'log hit-by-pitch - this_play: {this_play}') + async with locked_play(session, interaction, "log hit-by-pitch") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log hit-by-pitch - this_play: {this_play}") this_play = await hit_by_pitch(session, interaction, this_play) await self.complete_and_post_play(session, interaction, this_play) - - @group_log.command(name='chaos', description='Chaos: wild-pitch, passed-ball, balk, pickoff') - async def log_chaos(self, interaction: discord.Interaction, chaos_type: Literal['wild-pitch', 'passed-ball', 'balk', 'pickoff']): + + @group_log.command( + name="chaos", description="Chaos: wild-pitch, passed-ball, balk, pickoff" + ) + async def log_chaos( + self, + interaction: discord.Interaction, + chaos_type: Literal["wild-pitch", "passed-ball", "balk", "pickoff"], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log chaos') as (this_game, owner_team, this_play): + async with locked_play(session, interaction, "log chaos") as ( + this_game, + owner_team, + this_play, + ): if this_play.on_base_code == 0: await interaction.edit_original_response( - content=f'There cannot be chaos when the bases are empty.' + content=f"There cannot be chaos when the bases are empty." ) return - logger.info(f'log chaos - this_play: {this_play}') + logger.info(f"log chaos - this_play: {this_play}") this_play = await chaos(session, interaction, this_play, chaos_type) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='bunt', description='Bunts: sacrifice, bad, popout, double-play, defense') - async def log_sac_bunt(self, interaction: discord.Interaction, bunt_type: Literal['sacrifice', 'bad', 'popout', 'double-play', 'defense']): + @group_log.command( + name="bunt", description="Bunts: sacrifice, bad, popout, double-play, defense" + ) + async def log_sac_bunt( + self, + interaction: discord.Interaction, + bunt_type: Literal["sacrifice", "bad", "popout", "double-play", "defense"], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log bunt') as (this_game, owner_team, this_play): + async with locked_play(session, interaction, "log bunt") as ( + this_game, + owner_team, + this_play, + ): if this_play.on_base_code == 0: await interaction.edit_original_response( - content=f'You cannot bunt when the bases are empty.' + content=f"You cannot bunt when the bases are empty." ) return elif this_play.starting_outs == 2: await interaction.edit_original_response( - content=f'You cannot bunt with two outs.' + content=f"You cannot bunt with two outs." ) return - logger.info(f'log bunt - this_play: {this_play}') + logger.info(f"log bunt - this_play: {this_play}") this_play = await bunts(session, interaction, this_play, bunt_type) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='stealing', description='Running: stolen-base, caught-stealing') - @app_commands.describe(to_base='Base the runner is advancing to; 2 for 2nd, 3 for 3rd, 4 for Home') - async def log_stealing(self, interaction: discord.Interaction, running_type: Literal['stolen-base', 'caught-stealing', 'steal-plus-overthrow'], to_base: Literal[2, 3, 4]): + @group_log.command( + name="stealing", description="Running: stolen-base, caught-stealing" + ) + @app_commands.describe( + to_base="Base the runner is advancing to; 2 for 2nd, 3 for 3rd, 4 for Home" + ) + async def log_stealing( + self, + interaction: discord.Interaction, + running_type: Literal["stolen-base", "caught-stealing", "steal-plus-overthrow"], + to_base: Literal[2, 3, 4], + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log stealing') as (this_game, owner_team, this_play): - if (to_base == 2 and this_play.on_first is None) or (to_base == 3 and this_play.on_second is None) or (to_base == 4 and this_play.on_third is None): - logger.info(f'Illegal steal attempt') + async with locked_play(session, interaction, "log stealing") as ( + this_game, + owner_team, + this_play, + ): + if ( + (to_base == 2 and this_play.on_first is None) + or (to_base == 3 and this_play.on_second is None) + or (to_base == 4 and this_play.on_third is None) + ): + logger.info(f"Illegal steal attempt") await interaction.edit_original_response( - content=f'I don\'t see a runner there.' + content=f"I don't see a runner there." ) return - if (to_base == 3 and this_play.on_third is not None) or (to_base == 2 and this_play.on_second is not None): - logger.info(f'Stealing runner is blocked') + if (to_base == 3 and this_play.on_third is not None) or ( + to_base == 2 and this_play.on_second is not None + ): + logger.info(f"Stealing runner is blocked") if to_base == 3: - content = f'{this_play.on_second.player.name} is blocked by {this_play.on_third.player.name}' + content = f"{this_play.on_second.player.name} is blocked by {this_play.on_third.player.name}" else: - content = f'{this_play.on_first.player.name} is blocked by {this_play.on_second.player.name}' + content = f"{this_play.on_first.player.name} is blocked by {this_play.on_second.player.name}" - await interaction.edit_original_response( - content=content - ) + await interaction.edit_original_response(content=content) return - logger.info(f'log stealing - this_play: {this_play}') - this_play = await steals(session, interaction, this_play, running_type, to_base) + logger.info(f"log stealing - this_play: {this_play}") + this_play = await steals( + session, interaction, this_play, running_type, to_base + ) await self.complete_and_post_play(session, interaction, this_play) - @group_log.command(name='xcheck', description='Defender makes an x-check') - @app_commands.choices(position=[ - Choice(name='Pitcher', value='P'), - Choice(name='Catcher', value='C'), - Choice(name='First Base', value='1B'), - Choice(name='Second Base', value='2B'), - Choice(name='Third Base', value='3B'), - Choice(name='Shortstop', value='SS'), - Choice(name='Left Field', value='LF'), - Choice(name='Center Field', value='CF'), - Choice(name='Right Field', value='RF'), - ]) - async def log_xcheck_command(self, interaction: discord.Interaction, position: Choice[str]): + @group_log.command(name="xcheck", description="Defender makes an x-check") + @app_commands.choices( + position=[ + Choice(name="Pitcher", value="P"), + Choice(name="Catcher", value="C"), + Choice(name="First Base", value="1B"), + Choice(name="Second Base", value="2B"), + Choice(name="Third Base", value="3B"), + Choice(name="Shortstop", value="SS"), + Choice(name="Left Field", value="LF"), + Choice(name="Center Field", value="CF"), + Choice(name="Right Field", value="RF"), + ] + ) + async def log_xcheck_command( + self, interaction: discord.Interaction, position: Choice[str] + ): with Session(engine) as session: - async with locked_play(session, interaction, 'log xcheck') as (this_game, owner_team, this_play): - logger.info(f'log xcheck - this_play: {this_play}') - this_play = await xchecks(session, interaction, this_play, position.value) + async with locked_play(session, interaction, "log xcheck") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log xcheck - this_play: {this_play}") + this_play = await xchecks( + session, interaction, this_play, position.value + ) - await self.complete_and_post_play(session, interaction, this_play, buffer_message='X-Check logged') + await self.complete_and_post_play( + session, interaction, this_play, buffer_message="X-Check logged" + ) - - @group_log.command(name='undo-play', description='Roll back most recent play from the log') + @group_log.command( + name="undo-play", description="Roll back most recent play from the log" + ) async def log_undo_play_command(self, interaction: discord.Interaction): with Session(engine) as session: - async with locked_play(session, interaction, 'log undo-play') as (this_game, owner_team, this_play): - logger.info(f'log undo-play - this_play: {this_play}') + async with locked_play(session, interaction, "log undo-play") as ( + this_game, + owner_team, + this_play, + ): + logger.info(f"log undo-play - this_play: {this_play}") original_play = this_play this_play = undo_play(session, this_play) - original_play.locked = False # prevent finally from committing deleted row + original_play.locked = ( + False # prevent finally from committing deleted row + ) await self.post_play(session, interaction, this_play) - group_show = app_commands.Group(name='show-card', description='Display the player card for an active player') - - @group_show.command(name='defense', description='Display a defender\'s player card') - async def show_defense_command(self, interaction: discord.Interaction, position: DEFENSE_LITERAL): + group_show = app_commands.Group( + name="show-card", description="Display the player card for an active player" + ) + + @group_show.command(name="defense", description="Display a defender's player card") + async def show_defense_command( + self, interaction: discord.Interaction, position: DEFENSE_LITERAL + ): with Session(engine) as session: # Read-only command - no play locking needed await interaction.response.defer(thinking=True) this_game = get_channel_game_or_none(session, interaction.channel_id) if this_game is None: - await interaction.edit_original_response(content="I don't see an active game in this channel.") + await interaction.edit_original_response( + content="I don't see an active game in this channel." + ) return this_play = this_game.current_play_or_none(session) if this_play is None: - await interaction.edit_original_response(content="No active play found.") + await interaction.edit_original_response( + content="No active play found." + ) return - logger.info(f'show-card defense - position: {position}') + logger.info(f"show-card defense - position: {position}") await show_defense_cards(session, interaction, this_play, position) async def setup(bot): - await bot.add_cog(Gameplay(bot)) \ No newline at end of file + await bot.add_cog(Gameplay(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/discord_utils.py b/discord_utils.py index 3f9c2d5..2051190 100644 --- a/discord_utils.py +++ b/discord_utils.py @@ -4,6 +4,7 @@ Discord Utilities This module contains Discord helper functions for channels, roles, embeds, and other Discord-specific operations. """ + import logging import os import asyncio @@ -13,19 +14,21 @@ import discord from discord.ext import commands from helpers.constants import SBA_COLOR, PD_SEASON, IMAGES -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") async def send_to_bothole(ctx, content, embed): """Send a message to the pd-bot-hole channel.""" - await discord.utils.get(ctx.guild.text_channels, name='pd-bot-hole') \ - .send(content=content, embed=embed) + await discord.utils.get(ctx.guild.text_channels, name="pd-bot-hole").send( + content=content, embed=embed + ) async def send_to_news(ctx, content, embed): """Send a message to the pd-news-ticker channel.""" - await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \ - .send(content=content, embed=embed) + await discord.utils.get(ctx.guild.text_channels, name="pd-news-ticker").send( + content=content, embed=embed + ) async def typing_pause(ctx, seconds=1): @@ -43,23 +46,20 @@ async def pause_then_type(ctx, message): async def check_if_pdhole(ctx): """Check if the current channel is pd-bot-hole.""" - if ctx.message.channel.name != 'pd-bot-hole': - await ctx.send('Slide on down to my bot-hole for running commands.') - await ctx.message.add_reaction('❌') + if ctx.message.channel.name != "pd-bot-hole": + await ctx.send("Slide on down to my bot-hole for running commands.") + await ctx.message.add_reaction("❌") return False return True async def bad_channel(ctx): """Check if current channel is in the list of bad channels for commands.""" - bad_channels = ['paper-dynasty-chat', 'pd-news-ticker'] + bad_channels = ["paper-dynasty-chat", "pd-news-ticker"] if ctx.message.channel.name in bad_channels: - await ctx.message.add_reaction('❌') - bot_hole = discord.utils.get( - ctx.guild.text_channels, - name=f'pd-bot-hole' - ) - await ctx.send(f'Slide on down to the {bot_hole.mention} ;)') + await ctx.message.add_reaction("❌") + bot_hole = discord.utils.get(ctx.guild.text_channels, name=f"pd-bot-hole") + await ctx.send(f"Slide on down to the {bot_hole.mention} ;)") return True else: return False @@ -68,14 +68,11 @@ async def bad_channel(ctx): def get_channel(ctx, name) -> Optional[discord.TextChannel]: """Get a text channel by name.""" # Handle both Context and Interaction objects - guild = ctx.guild if hasattr(ctx, 'guild') else None + guild = ctx.guild if hasattr(ctx, "guild") else None if not guild: return None - - channel = discord.utils.get( - guild.text_channels, - name=name - ) + + channel = discord.utils.get(guild.text_channels, name=name) if channel: return channel return None @@ -87,7 +84,7 @@ async def get_emoji(ctx, name, return_empty=True): emoji = await commands.converter.EmojiConverter().convert(ctx, name) except: if return_empty: - emoji = '' + emoji = "" else: return name return emoji @@ -101,9 +98,13 @@ async def react_and_reply(ctx, reaction, message): async def send_to_channel(bot, channel_name, content=None, embed=None): """Send a message to a specific channel by name or ID.""" - guild = bot.get_guild(int(os.environ.get('GUILD_ID'))) + guild_id = os.environ.get("GUILD_ID") + if not guild_id: + logger.error("Cannot send to channel - GUILD_ID not set") + return + guild = bot.get_guild(int(guild_id)) if not guild: - logger.error('Cannot send to channel - bot not logged in') + logger.error("Cannot send to channel - bot not logged in") return this_channel = discord.utils.get(guild.text_channels, name=channel_name) @@ -111,7 +112,7 @@ async def send_to_channel(bot, channel_name, content=None, embed=None): if not this_channel: this_channel = discord.utils.get(guild.text_channels, id=channel_name) if not this_channel: - raise NameError(f'**{channel_name}** channel not found') + raise NameError(f"**{channel_name}** channel not found") return await this_channel.send(content=content, embed=embed) @@ -128,14 +129,16 @@ async def get_or_create_role(ctx, role_name, mentionable=True): def get_special_embed(special): """Create an embed for a special item.""" - embed = discord.Embed(title=f'{special.name} - Special #{special.get_id()}', - color=discord.Color.random(), - description=f'{special.short_desc}') - embed.add_field(name='Description', value=f'{special.long_desc}', inline=False) - if special.thumbnail.lower() != 'none': - embed.set_thumbnail(url=f'{special.thumbnail}') - if special.url.lower() != 'none': - embed.set_image(url=f'{special.url}') + embed = discord.Embed( + title=f"{special.name} - Special #{special.get_id()}", + color=discord.Color.random(), + description=f"{special.short_desc}", + ) + embed.add_field(name="Description", value=f"{special.long_desc}", inline=False) + if special.thumbnail.lower() != "none": + embed.set_thumbnail(url=f"{special.thumbnail}") + if special.url.lower() != "none": + embed.set_image(url=f"{special.url}") return embed @@ -154,99 +157,125 @@ def get_team_embed(title, team=None, thumbnail: bool = True): if team: embed = discord.Embed( title=title, - color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16) + color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16), + ) + embed.set_footer( + text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES["logo"] ) - embed.set_footer(text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES['logo']) if thumbnail: - embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES['logo']) + embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES["logo"]) else: - embed = discord.Embed( - title=title, - color=int(SBA_COLOR, 16) + embed = discord.Embed(title=title, color=int(SBA_COLOR, 16)) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", icon_url=IMAGES["logo"] ) - embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo']) if thumbnail: - embed.set_thumbnail(url=IMAGES['logo']) + embed.set_thumbnail(url=IMAGES["logo"]) return embed async def create_channel_old( - ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None, - allowed_roles=None): + ctx, + channel_name: str, + category_name: str, + everyone_send=False, + everyone_read=True, + allowed_members=None, + allowed_roles=None, +): """Create a text channel with specified permissions (legacy version).""" this_category = discord.utils.get(ctx.guild.categories, name=category_name) if not this_category: - raise ValueError(f'I couldn\'t find a category named **{category_name}**') + raise ValueError(f"I couldn't find a category named **{category_name}**") overwrites = { - ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True), - ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send) + ctx.guild.me: discord.PermissionOverwrite( + read_messages=True, send_messages=True + ), + ctx.guild.default_role: discord.PermissionOverwrite( + read_messages=everyone_read, send_messages=everyone_send + ), } if allowed_members: if isinstance(allowed_members, list): for member in allowed_members: - overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[member] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) if allowed_roles: if isinstance(allowed_roles, list): for role in allowed_roles: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[role] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) this_channel = await ctx.guild.create_text_channel( - channel_name, - overwrites=overwrites, - category=this_category + channel_name, overwrites=overwrites, category=this_category ) - logger.info(f'Creating channel ({channel_name}) in ({category_name})') + logger.info(f"Creating channel ({channel_name}) in ({category_name})") return this_channel async def create_channel( - ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, - read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None): + ctx, + channel_name: str, + category_name: str, + everyone_send=False, + everyone_read=True, + read_send_members: list = None, + read_send_roles: list = None, + read_only_roles: list = None, +): """Create a text channel with specified permissions.""" # Handle both Context and Interaction objects - guild = ctx.guild if hasattr(ctx, 'guild') else None + guild = ctx.guild if hasattr(ctx, "guild") else None if not guild: - raise ValueError(f'Unable to access guild from context object') - + raise ValueError(f"Unable to access guild from context object") + # Get bot member - different for Context vs Interaction - if hasattr(ctx, 'me'): # Context object + if hasattr(ctx, "me"): # Context object bot_member = ctx.me - elif hasattr(ctx, 'client'): # Interaction object + elif hasattr(ctx, "client"): # Interaction object bot_member = guild.get_member(ctx.client.user.id) else: # Fallback - try to find bot member by getting the first member with bot=True bot_member = next((m for m in guild.members if m.bot), None) if not bot_member: - raise ValueError(f'Unable to find bot member in guild') - + raise ValueError(f"Unable to find bot member in guild") + this_category = discord.utils.get(guild.categories, name=category_name) if not this_category: - raise ValueError(f'I couldn\'t find a category named **{category_name}**') + raise ValueError(f"I couldn't find a category named **{category_name}**") overwrites = { bot_member: discord.PermissionOverwrite(read_messages=True, send_messages=True), - guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send) + guild.default_role: discord.PermissionOverwrite( + read_messages=everyone_read, send_messages=everyone_send + ), } if read_send_members: for member in read_send_members: - overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[member] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) if read_send_roles: for role in read_send_roles: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[role] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) if read_only_roles: for role in read_only_roles: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False) + overwrites[role] = discord.PermissionOverwrite( + read_messages=True, send_messages=False + ) this_channel = await guild.create_text_channel( - channel_name, - overwrites=overwrites, - category=this_category + channel_name, overwrites=overwrites, category=this_category ) - logger.info(f'Creating channel ({channel_name}) in ({category_name})') + logger.info(f"Creating channel ({channel_name}) in ({category_name})") - return this_channel \ No newline at end of file + return this_channel 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/discord_utils.py b/helpers/discord_utils.py index 3f9c2d5..2051190 100644 --- a/helpers/discord_utils.py +++ b/helpers/discord_utils.py @@ -4,6 +4,7 @@ Discord Utilities This module contains Discord helper functions for channels, roles, embeds, and other Discord-specific operations. """ + import logging import os import asyncio @@ -13,19 +14,21 @@ import discord from discord.ext import commands from helpers.constants import SBA_COLOR, PD_SEASON, IMAGES -logger = logging.getLogger('discord_app') +logger = logging.getLogger("discord_app") async def send_to_bothole(ctx, content, embed): """Send a message to the pd-bot-hole channel.""" - await discord.utils.get(ctx.guild.text_channels, name='pd-bot-hole') \ - .send(content=content, embed=embed) + await discord.utils.get(ctx.guild.text_channels, name="pd-bot-hole").send( + content=content, embed=embed + ) async def send_to_news(ctx, content, embed): """Send a message to the pd-news-ticker channel.""" - await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \ - .send(content=content, embed=embed) + await discord.utils.get(ctx.guild.text_channels, name="pd-news-ticker").send( + content=content, embed=embed + ) async def typing_pause(ctx, seconds=1): @@ -43,23 +46,20 @@ async def pause_then_type(ctx, message): async def check_if_pdhole(ctx): """Check if the current channel is pd-bot-hole.""" - if ctx.message.channel.name != 'pd-bot-hole': - await ctx.send('Slide on down to my bot-hole for running commands.') - await ctx.message.add_reaction('❌') + if ctx.message.channel.name != "pd-bot-hole": + await ctx.send("Slide on down to my bot-hole for running commands.") + await ctx.message.add_reaction("❌") return False return True async def bad_channel(ctx): """Check if current channel is in the list of bad channels for commands.""" - bad_channels = ['paper-dynasty-chat', 'pd-news-ticker'] + bad_channels = ["paper-dynasty-chat", "pd-news-ticker"] if ctx.message.channel.name in bad_channels: - await ctx.message.add_reaction('❌') - bot_hole = discord.utils.get( - ctx.guild.text_channels, - name=f'pd-bot-hole' - ) - await ctx.send(f'Slide on down to the {bot_hole.mention} ;)') + await ctx.message.add_reaction("❌") + bot_hole = discord.utils.get(ctx.guild.text_channels, name=f"pd-bot-hole") + await ctx.send(f"Slide on down to the {bot_hole.mention} ;)") return True else: return False @@ -68,14 +68,11 @@ async def bad_channel(ctx): def get_channel(ctx, name) -> Optional[discord.TextChannel]: """Get a text channel by name.""" # Handle both Context and Interaction objects - guild = ctx.guild if hasattr(ctx, 'guild') else None + guild = ctx.guild if hasattr(ctx, "guild") else None if not guild: return None - - channel = discord.utils.get( - guild.text_channels, - name=name - ) + + channel = discord.utils.get(guild.text_channels, name=name) if channel: return channel return None @@ -87,7 +84,7 @@ async def get_emoji(ctx, name, return_empty=True): emoji = await commands.converter.EmojiConverter().convert(ctx, name) except: if return_empty: - emoji = '' + emoji = "" else: return name return emoji @@ -101,9 +98,13 @@ async def react_and_reply(ctx, reaction, message): async def send_to_channel(bot, channel_name, content=None, embed=None): """Send a message to a specific channel by name or ID.""" - guild = bot.get_guild(int(os.environ.get('GUILD_ID'))) + guild_id = os.environ.get("GUILD_ID") + if not guild_id: + logger.error("Cannot send to channel - GUILD_ID not set") + return + guild = bot.get_guild(int(guild_id)) if not guild: - logger.error('Cannot send to channel - bot not logged in') + logger.error("Cannot send to channel - bot not logged in") return this_channel = discord.utils.get(guild.text_channels, name=channel_name) @@ -111,7 +112,7 @@ async def send_to_channel(bot, channel_name, content=None, embed=None): if not this_channel: this_channel = discord.utils.get(guild.text_channels, id=channel_name) if not this_channel: - raise NameError(f'**{channel_name}** channel not found') + raise NameError(f"**{channel_name}** channel not found") return await this_channel.send(content=content, embed=embed) @@ -128,14 +129,16 @@ async def get_or_create_role(ctx, role_name, mentionable=True): def get_special_embed(special): """Create an embed for a special item.""" - embed = discord.Embed(title=f'{special.name} - Special #{special.get_id()}', - color=discord.Color.random(), - description=f'{special.short_desc}') - embed.add_field(name='Description', value=f'{special.long_desc}', inline=False) - if special.thumbnail.lower() != 'none': - embed.set_thumbnail(url=f'{special.thumbnail}') - if special.url.lower() != 'none': - embed.set_image(url=f'{special.url}') + embed = discord.Embed( + title=f"{special.name} - Special #{special.get_id()}", + color=discord.Color.random(), + description=f"{special.short_desc}", + ) + embed.add_field(name="Description", value=f"{special.long_desc}", inline=False) + if special.thumbnail.lower() != "none": + embed.set_thumbnail(url=f"{special.thumbnail}") + if special.url.lower() != "none": + embed.set_image(url=f"{special.url}") return embed @@ -154,99 +157,125 @@ def get_team_embed(title, team=None, thumbnail: bool = True): if team: embed = discord.Embed( title=title, - color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16) + color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16), + ) + embed.set_footer( + text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES["logo"] ) - embed.set_footer(text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES['logo']) if thumbnail: - embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES['logo']) + embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES["logo"]) else: - embed = discord.Embed( - title=title, - color=int(SBA_COLOR, 16) + embed = discord.Embed(title=title, color=int(SBA_COLOR, 16)) + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", icon_url=IMAGES["logo"] ) - embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo']) if thumbnail: - embed.set_thumbnail(url=IMAGES['logo']) + embed.set_thumbnail(url=IMAGES["logo"]) return embed async def create_channel_old( - ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None, - allowed_roles=None): + ctx, + channel_name: str, + category_name: str, + everyone_send=False, + everyone_read=True, + allowed_members=None, + allowed_roles=None, +): """Create a text channel with specified permissions (legacy version).""" this_category = discord.utils.get(ctx.guild.categories, name=category_name) if not this_category: - raise ValueError(f'I couldn\'t find a category named **{category_name}**') + raise ValueError(f"I couldn't find a category named **{category_name}**") overwrites = { - ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True), - ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send) + ctx.guild.me: discord.PermissionOverwrite( + read_messages=True, send_messages=True + ), + ctx.guild.default_role: discord.PermissionOverwrite( + read_messages=everyone_read, send_messages=everyone_send + ), } if allowed_members: if isinstance(allowed_members, list): for member in allowed_members: - overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[member] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) if allowed_roles: if isinstance(allowed_roles, list): for role in allowed_roles: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[role] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) this_channel = await ctx.guild.create_text_channel( - channel_name, - overwrites=overwrites, - category=this_category + channel_name, overwrites=overwrites, category=this_category ) - logger.info(f'Creating channel ({channel_name}) in ({category_name})') + logger.info(f"Creating channel ({channel_name}) in ({category_name})") return this_channel async def create_channel( - ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, - read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None): + ctx, + channel_name: str, + category_name: str, + everyone_send=False, + everyone_read=True, + read_send_members: list = None, + read_send_roles: list = None, + read_only_roles: list = None, +): """Create a text channel with specified permissions.""" # Handle both Context and Interaction objects - guild = ctx.guild if hasattr(ctx, 'guild') else None + guild = ctx.guild if hasattr(ctx, "guild") else None if not guild: - raise ValueError(f'Unable to access guild from context object') - + raise ValueError(f"Unable to access guild from context object") + # Get bot member - different for Context vs Interaction - if hasattr(ctx, 'me'): # Context object + if hasattr(ctx, "me"): # Context object bot_member = ctx.me - elif hasattr(ctx, 'client'): # Interaction object + elif hasattr(ctx, "client"): # Interaction object bot_member = guild.get_member(ctx.client.user.id) else: # Fallback - try to find bot member by getting the first member with bot=True bot_member = next((m for m in guild.members if m.bot), None) if not bot_member: - raise ValueError(f'Unable to find bot member in guild') - + raise ValueError(f"Unable to find bot member in guild") + this_category = discord.utils.get(guild.categories, name=category_name) if not this_category: - raise ValueError(f'I couldn\'t find a category named **{category_name}**') + raise ValueError(f"I couldn't find a category named **{category_name}**") overwrites = { bot_member: discord.PermissionOverwrite(read_messages=True, send_messages=True), - guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send) + guild.default_role: discord.PermissionOverwrite( + read_messages=everyone_read, send_messages=everyone_send + ), } if read_send_members: for member in read_send_members: - overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[member] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) if read_send_roles: for role in read_send_roles: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True) + overwrites[role] = discord.PermissionOverwrite( + read_messages=True, send_messages=True + ) if read_only_roles: for role in read_only_roles: - overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False) + overwrites[role] = discord.PermissionOverwrite( + read_messages=True, send_messages=False + ) this_channel = await guild.create_text_channel( - channel_name, - overwrites=overwrites, - category=this_category + channel_name, overwrites=overwrites, category=this_category ) - logger.info(f'Creating channel ({channel_name}) in ({category_name})') + logger.info(f"Creating channel ({channel_name}) in ({category_name})") - return this_channel \ No newline at end of file + return this_channel 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 + )