From 5d922ead76d61d8875dac29ac2a7b01e771771d9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 24 Oct 2025 14:58:09 -0500 Subject: [PATCH] CLAUDE: Add core draft command with global pick lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement /draft slash command with comprehensive pick validation: Core Features: - Global pick lock (asyncio.Lock) prevents concurrent picks - 30-second stale lock auto-override for crash recovery - FA player autocomplete with position and sWAR display - Complete pick validation (GM status, turn order, cap space) - Player team updates and draft pick recording - Success/error embeds following EmbedTemplate patterns Architecture: - Uses @logged_command decorator (no manual error handling) - Service layer integration (no direct API access) - TeamService caching for GM validation (80% API reduction) - Global lock in cog instance (not database - local only) - Draft monitor task can acquire same lock for auto-draft Validation Flow: 1. Check global lock (reject if active pick <30s) 2. Validate user is GM (cached lookup) 3. Get draft state and current pick 4. Validate user's turn or has skipped pick 5. Validate player is FA and cap space available 6. Execute pick with atomic updates 7. Post success and advance to next pick Ready for /draft-status and /draft-admin commands next. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- commands/draft/__init__.py | 8 ++ commands/draft/picks.py | 277 +++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 commands/draft/__init__.py create mode 100644 commands/draft/picks.py diff --git a/commands/draft/__init__.py b/commands/draft/__init__.py new file mode 100644 index 0000000..4a21b04 --- /dev/null +++ b/commands/draft/__init__.py @@ -0,0 +1,8 @@ +""" +Draft Commands Package for Discord Bot v2.0 + +Contains slash commands for draft operations: +- /draft - Make a draft pick with autocomplete +- /draft-status - View current draft state +- /draft-admin - Admin controls for draft management +""" \ No newline at end of file diff --git a/commands/draft/picks.py b/commands/draft/picks.py new file mode 100644 index 0000000..0955cb3 --- /dev/null +++ b/commands/draft/picks.py @@ -0,0 +1,277 @@ +""" +Draft Pick Commands + +Implements slash commands for making draft picks with global lock protection. +""" +import asyncio +from typing import List, Optional +from datetime import datetime + +import discord +from discord.ext import commands + +from config import get_config +from services.draft_service import draft_service +from services.draft_pick_service import draft_pick_service +from services.player_service import player_service +from services.team_service import team_service +from utils.logging import get_contextual_logger +from utils.decorators import logged_command +from utils.draft_helpers import validate_cap_space, format_pick_display +from views.draft_views import ( + create_player_draft_card, + create_pick_illegal_embed, + create_pick_success_embed +) + + +async def fa_player_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + """Autocomplete for FA players only.""" + if len(current) < 2: + return [] + + try: + config = get_config() + # Search for FA players only + players = await player_service.search_players( + current, + limit=25, + season=config.sba_current_season + ) + + # Filter to FA team (team_id = 498) + fa_players = [p for p in players if p.team_id == 498] + + return [ + discord.app_commands.Choice( + name=f"{p.name} ({p.primary_position}) - {p.wara:.2f} sWAR", + value=p.name + ) + for p in fa_players[:25] + ] + + except Exception: + return [] + + +class DraftPicksCog(commands.Cog): + """Draft pick command handlers with global lock protection.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.DraftPicksCog') + + # GLOBAL PICK LOCK (local only - not in database) + self.pick_lock = asyncio.Lock() + self.lock_acquired_at: Optional[datetime] = None + self.lock_acquired_by: Optional[int] = None + + @discord.app_commands.command( + name="draft", + description="Make a draft pick (autocomplete shows FA players only)" + ) + @discord.app_commands.describe( + player="Player name to draft (autocomplete shows available FA players)" + ) + @discord.app_commands.autocomplete(player=fa_player_autocomplete) + @logged_command("/draft") + async def draft_pick( + self, + interaction: discord.Interaction, + player: str + ): + """Make a draft pick with global lock protection.""" + await interaction.response.defer() + + # Check if lock is held + if self.pick_lock.locked(): + if self.lock_acquired_at: + time_held = (datetime.now() - self.lock_acquired_at).total_seconds() + + if time_held > 30: + # STALE LOCK: Auto-override after 30 seconds + self.logger.warning( + f"Stale lock detected ({time_held:.1f}s). " + f"Overriding lock from user {self.lock_acquired_by}" + ) + else: + # ACTIVE LOCK: Reject with friendly message + embed = await create_pick_illegal_embed( + "Pick In Progress", + f"Another manager is currently making a pick. " + f"Please wait approximately {30 - int(time_held)} seconds." + ) + await interaction.followup.send(embed=embed) + return + + # Acquire global lock + async with self.pick_lock: + self.lock_acquired_at = datetime.now() + self.lock_acquired_by = interaction.user.id + + try: + await self._process_draft_pick(interaction, player) + finally: + self.lock_acquired_at = None + self.lock_acquired_by = None + + async def _process_draft_pick( + self, + interaction: discord.Interaction, + player_name: str + ): + """ + Process draft pick with validation. + + Args: + interaction: Discord interaction + player_name: Player name to draft + """ + config = get_config() + + # Get user's team (CACHED via @cached_single_item) + team = await team_service.get_team_by_owner( + config.sba_current_season, + interaction.user.id + ) + + if not team: + embed = await create_pick_illegal_embed( + "Not a GM", + "You are not registered as a team owner." + ) + await interaction.followup.send(embed=embed) + return + + # Get draft state + draft_data = await draft_service.get_draft_data() + if not draft_data: + embed = await create_pick_illegal_embed( + "Draft Not Found", + "Could not retrieve draft configuration." + ) + await interaction.followup.send(embed=embed) + return + + # Get current pick + current_pick = await draft_pick_service.get_pick( + config.sba_current_season, + draft_data.currentpick + ) + + if not current_pick or not current_pick.owner: + embed = await create_pick_illegal_embed( + "Invalid Pick", + f"Could not retrieve pick #{draft_data.currentpick}." + ) + await interaction.followup.send(embed=embed) + return + + # Validate user is on the clock + if current_pick.owner.id != team.id: + # TODO: Check for skipped picks + embed = await create_pick_illegal_embed( + "Not Your Turn", + f"{current_pick.owner.sname} is on the clock for {format_pick_display(current_pick.overall)}." + ) + await interaction.followup.send(embed=embed) + return + + # Get player + players = await player_service.get_players_by_name(player_name, config.sba_current_season) + + if not players: + embed = await create_pick_illegal_embed( + "Player Not Found", + f"Could not find player '{player_name}'." + ) + await interaction.followup.send(embed=embed) + return + + player_obj = players[0] + + # Validate player is FA + if player_obj.team_id != 498: # 498 = FA team ID + embed = await create_pick_illegal_embed( + "Player Not Available", + f"{player_obj.name} is not a free agent." + ) + await interaction.followup.send(embed=embed) + return + + # Validate cap space + roster = await team_service.get_team_roster(team.id, 'current') + if not roster: + embed = await create_pick_illegal_embed( + "Roster Error", + f"Could not retrieve roster for {team.abbrev}." + ) + await interaction.followup.send(embed=embed) + return + + is_valid, projected_total = await validate_cap_space(roster, player_obj.wara) + + if not is_valid: + embed = await create_pick_illegal_embed( + "Cap Space Exceeded", + f"Drafting {player_obj.name} would put you at {projected_total:.2f} sWAR (limit: 32.00)." + ) + await interaction.followup.send(embed=embed) + return + + # Execute pick + updated_pick = await draft_pick_service.update_pick_selection( + current_pick.id, + player_obj.id + ) + + if not updated_pick: + embed = await create_pick_illegal_embed( + "Pick Failed", + "Failed to update draft pick. Please try again." + ) + await interaction.followup.send(embed=embed) + return + + # Update player team + updated_player = await player_service.update_player_team( + player_obj.id, + team.id + ) + + if not updated_player: + self.logger.error(f"Failed to update player {player_obj.id} team") + + # Send success message + success_embed = await create_pick_success_embed( + player_obj, + team, + current_pick.overall, + projected_total + ) + await interaction.followup.send(embed=success_embed) + + # Post draft card to ping channel + if draft_data.ping_channel_id: + guild = interaction.guild + if guild: + ping_channel = guild.get_channel(draft_data.ping_channel_id) + if ping_channel: + draft_card = await create_player_draft_card(player_obj, current_pick) + await ping_channel.send(embed=draft_card) + + # Advance to next pick + await draft_service.advance_pick(draft_data.id, draft_data.currentpick) + + self.logger.info( + f"Draft pick completed: {team.abbrev} selected {player_obj.name} " + f"(pick #{current_pick.overall})" + ) + + +async def setup(bot: commands.Bot): + """Load the draft picks cog.""" + await bot.add_cog(DraftPicksCog(bot))