- Add optional dem_week parameter to PlayerService.update_player_team() - Transaction freeze sets dem_week to current.week + 2 - /ilmove command sets dem_week to current.week - Draft picks (manual and auto) set dem_week to current.week + 2 - Backwards compatible - admin commands don't set dem_week - Add 4 unit tests for dem_week scenarios - Enhanced logging shows dem_week values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
572 lines
20 KiB
Python
572 lines
20 KiB
Python
"""
|
|
Draft Pick Commands
|
|
|
|
Implements slash commands for making draft picks with global lock protection.
|
|
"""
|
|
import asyncio
|
|
import re
|
|
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.draft_sheet_service import get_draft_sheet_service
|
|
from services.player_service import player_service
|
|
from services.team_service import team_service
|
|
from services.roster_service import roster_service
|
|
from utils.logging import get_contextual_logger
|
|
from utils.decorators import logged_command, requires_draft_period
|
|
from utils.draft_helpers import validate_cap_space, format_pick_display
|
|
from utils.helpers import get_team_salary_cap
|
|
from utils.permissions import requires_team
|
|
from views.draft_views import (
|
|
create_player_draft_card,
|
|
create_pick_illegal_embed,
|
|
create_pick_success_embed,
|
|
create_on_clock_announcement_embed
|
|
)
|
|
|
|
|
|
def _parse_player_name(raw_input: str) -> str:
|
|
"""
|
|
Parse player name from raw input, handling autocomplete display format.
|
|
|
|
Discord sometimes sends the autocomplete display text instead of the value
|
|
when users type quickly. This function strips the position and sWAR info.
|
|
|
|
Examples:
|
|
"Mason Miller" -> "Mason Miller"
|
|
"Mason Miller (RP) - 2.50 sWAR" -> "Mason Miller"
|
|
"Geraldo Perdomo (SS) - 1.23 sWAR" -> "Geraldo Perdomo"
|
|
"Player Name (1B) - 0.00 sWAR" -> "Player Name"
|
|
|
|
Args:
|
|
raw_input: Raw player name input from command
|
|
|
|
Returns:
|
|
Cleaned player name
|
|
"""
|
|
# Pattern: "Player Name (POS) - X.XX sWAR"
|
|
# Position can be letters or numbers (e.g., SS, RP, 1B, 2B, 3B, OF)
|
|
# Extract just the player name before the opening parenthesis
|
|
match = re.match(r'^(.+?)\s*\([A-Z0-9]+\)\s*-\s*[\d.]+\s*sWAR$', raw_input)
|
|
if match:
|
|
return match.group(1).strip()
|
|
|
|
# No match - return original input (already clean)
|
|
return raw_input.strip()
|
|
|
|
|
|
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_season
|
|
)
|
|
|
|
# Filter to FA team
|
|
fa_players = [p for p in players if p.team_id == config.free_agent_team_id]
|
|
|
|
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)
|
|
@requires_draft_period
|
|
@requires_team()
|
|
@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 (may include autocomplete display format)
|
|
"""
|
|
config = get_config()
|
|
|
|
# Parse player name in case it includes autocomplete display format
|
|
# e.g., "Mason Miller (RP) - 2.50 sWAR" -> "Mason Miller"
|
|
player_name = _parse_player_name(player_name)
|
|
|
|
# Get user's team (CACHED via @cached_single_item)
|
|
team = await team_service.get_team_by_owner(
|
|
interaction.user.id,
|
|
config.sba_season
|
|
)
|
|
|
|
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
|
|
|
|
# Check if draft is paused
|
|
if draft_data.paused:
|
|
embed = await create_pick_illegal_embed(
|
|
"Draft Paused",
|
|
"The draft is currently paused. Please wait for an administrator to resume."
|
|
)
|
|
await interaction.followup.send(embed=embed)
|
|
return
|
|
|
|
# Get current pick
|
|
current_pick = await draft_pick_service.get_pick(
|
|
config.sba_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 OR has a skipped pick
|
|
pick_to_use = current_pick # Default: use current pick if on the clock
|
|
|
|
if current_pick.owner.id != team.id:
|
|
# Not on the clock - check for skipped picks
|
|
skipped_picks = await draft_pick_service.get_skipped_picks_for_team(
|
|
config.sba_season,
|
|
team.id,
|
|
draft_data.currentpick
|
|
)
|
|
|
|
if not skipped_picks:
|
|
# No skipped picks - can't draft
|
|
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
|
|
|
|
# Use the earliest skipped pick
|
|
pick_to_use = skipped_picks[0]
|
|
self.logger.info(
|
|
f"Team {team.abbrev} using skipped pick #{pick_to_use.overall} "
|
|
f"(current pick is #{current_pick.overall})"
|
|
)
|
|
|
|
# Get player
|
|
players = await player_service.get_players_by_name(player_name, config.sba_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 != config.free_agent_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, cap_limit = await validate_cap_space(roster, player_obj.wara, team)
|
|
|
|
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: {cap_limit:.2f})."
|
|
)
|
|
await interaction.followup.send(embed=embed)
|
|
return
|
|
|
|
# Execute pick (using pick_to_use which may be current or skipped pick)
|
|
updated_pick = await draft_pick_service.update_pick_selection(
|
|
pick_to_use.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
|
|
|
|
# Get current league state for dem_week calculation
|
|
from services.league_service import league_service
|
|
current = await league_service.get_current_state()
|
|
|
|
# Update player team with dem_week set to current.week + 2 for draft picks
|
|
updated_player = await player_service.update_player_team(
|
|
player_obj.id,
|
|
team.id,
|
|
dem_week=current.week + 2 if current else None
|
|
)
|
|
|
|
if not updated_player:
|
|
self.logger.error(f"Failed to update player {player_obj.id} team")
|
|
|
|
# Write pick to Google Sheets (fire-and-forget with notification on failure)
|
|
await self._write_pick_to_sheets(
|
|
draft_data=draft_data,
|
|
pick=pick_to_use,
|
|
player=player_obj,
|
|
team=team,
|
|
guild=interaction.guild
|
|
)
|
|
|
|
# Determine if this was a skipped pick
|
|
is_skipped_pick = pick_to_use.overall != current_pick.overall
|
|
|
|
# Send success message
|
|
success_embed = await create_pick_success_embed(
|
|
player_obj,
|
|
team,
|
|
pick_to_use.overall,
|
|
projected_total,
|
|
cap_limit
|
|
)
|
|
|
|
# Add note if this was a skipped pick
|
|
if is_skipped_pick:
|
|
success_embed.set_footer(
|
|
text=f"📝 Making up skipped pick (current pick is #{current_pick.overall})"
|
|
)
|
|
|
|
await interaction.followup.send(embed=success_embed)
|
|
|
|
# Post draft card to ping channel (only if different from command channel)
|
|
if draft_data.ping_channel and draft_data.ping_channel != interaction.channel_id:
|
|
guild = interaction.guild
|
|
if guild:
|
|
ping_channel = guild.get_channel(draft_data.ping_channel)
|
|
if ping_channel:
|
|
draft_card = await create_player_draft_card(player_obj, pick_to_use)
|
|
|
|
# Add skipped pick context to draft card
|
|
if is_skipped_pick:
|
|
draft_card.set_footer(
|
|
text=f"📝 Making up skipped pick (current pick is #{current_pick.overall})"
|
|
)
|
|
|
|
await ping_channel.send(embed=draft_card)
|
|
|
|
# Post draft card to result channel (separate from ping channel)
|
|
if draft_data.result_channel:
|
|
guild = interaction.guild
|
|
if guild:
|
|
result_channel = guild.get_channel(draft_data.result_channel)
|
|
if result_channel:
|
|
result_card = await create_player_draft_card(player_obj, pick_to_use)
|
|
|
|
# Add skipped pick context to result card
|
|
if is_skipped_pick:
|
|
result_card.set_footer(
|
|
text=f"📝 Making up skipped pick (current pick is #{current_pick.overall})"
|
|
)
|
|
|
|
await result_channel.send(embed=result_card)
|
|
else:
|
|
self.logger.warning(f"Could not find result channel {draft_data.result_channel}")
|
|
|
|
# Only advance the draft if this was the current pick (not a skipped pick)
|
|
if not is_skipped_pick:
|
|
await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
|
|
|
|
# Post on-clock announcement for next team
|
|
guild = interaction.guild
|
|
if guild and draft_data.ping_channel:
|
|
ping_channel = guild.get_channel(draft_data.ping_channel)
|
|
if ping_channel:
|
|
await self._post_on_clock_announcement(
|
|
ping_channel=ping_channel,
|
|
guild=guild
|
|
)
|
|
|
|
self.logger.info(
|
|
f"Draft pick completed: {team.abbrev} selected {player_obj.name} "
|
|
f"(pick #{pick_to_use.overall})"
|
|
+ (f" [skipped pick makeup]" if is_skipped_pick else "")
|
|
)
|
|
|
|
async def _write_pick_to_sheets(
|
|
self,
|
|
draft_data,
|
|
pick,
|
|
player,
|
|
team,
|
|
guild: Optional[discord.Guild]
|
|
):
|
|
"""
|
|
Write pick to Google Sheets (fire-and-forget with ping channel notification on failure).
|
|
|
|
Args:
|
|
draft_data: Current draft configuration
|
|
pick: The draft pick being used
|
|
player: Player being drafted
|
|
team: Team making the pick
|
|
guild: Discord guild for notification channel
|
|
"""
|
|
config = get_config()
|
|
|
|
try:
|
|
draft_sheet_service = get_draft_sheet_service()
|
|
success = await draft_sheet_service.write_pick(
|
|
season=config.sba_season,
|
|
overall=pick.overall,
|
|
orig_owner_abbrev=pick.origowner.abbrev if pick.origowner else team.abbrev,
|
|
owner_abbrev=team.abbrev,
|
|
player_name=player.name,
|
|
swar=player.wara
|
|
)
|
|
|
|
if not success:
|
|
# Write failed - notify in ping channel
|
|
await self._notify_sheet_failure(
|
|
guild=guild,
|
|
channel_id=draft_data.ping_channel,
|
|
pick_overall=pick.overall,
|
|
player_name=player.name,
|
|
reason="Sheet write returned failure"
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to write pick to sheets: {e}")
|
|
# Notify in ping channel
|
|
await self._notify_sheet_failure(
|
|
guild=guild,
|
|
channel_id=draft_data.ping_channel,
|
|
pick_overall=pick.overall,
|
|
player_name=player.name,
|
|
reason=str(e)
|
|
)
|
|
|
|
async def _notify_sheet_failure(
|
|
self,
|
|
guild: Optional[discord.Guild],
|
|
channel_id: Optional[int],
|
|
pick_overall: int,
|
|
player_name: str,
|
|
reason: str
|
|
):
|
|
"""
|
|
Post notification to ping channel when sheet write fails.
|
|
|
|
Args:
|
|
guild: Discord guild
|
|
channel_id: Ping channel ID
|
|
pick_overall: Pick number that failed
|
|
player_name: Player name
|
|
reason: Failure reason
|
|
"""
|
|
if not guild or not channel_id:
|
|
return
|
|
|
|
try:
|
|
channel = guild.get_channel(channel_id)
|
|
if channel and hasattr(channel, 'send'):
|
|
await channel.send(
|
|
f"⚠️ **Sheet Sync Failed** - Pick #{pick_overall} ({player_name}) "
|
|
f"was not written to the draft sheet. "
|
|
f"Use `/draft-admin resync-sheet` to manually sync."
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to send sheet failure notification: {e}")
|
|
|
|
async def _post_on_clock_announcement(
|
|
self,
|
|
ping_channel,
|
|
guild: discord.Guild
|
|
) -> None:
|
|
"""
|
|
Post the on-clock announcement embed for the next team with role ping.
|
|
|
|
Called after advance_pick() to announce who is now on the clock.
|
|
|
|
Args:
|
|
ping_channel: Discord channel to post in
|
|
guild: Discord guild for role lookup
|
|
"""
|
|
try:
|
|
config = get_config()
|
|
|
|
# Refresh draft data to get updated currentpick and deadline
|
|
updated_draft_data = await draft_service.get_draft_data()
|
|
if not updated_draft_data:
|
|
self.logger.error("Could not refresh draft data for announcement")
|
|
return
|
|
|
|
# Get the new current pick
|
|
next_pick = await draft_pick_service.get_pick(
|
|
config.sba_season,
|
|
updated_draft_data.currentpick
|
|
)
|
|
|
|
if not next_pick or not next_pick.owner:
|
|
self.logger.error(f"Could not get pick #{updated_draft_data.currentpick} for announcement")
|
|
return
|
|
|
|
# Get recent picks (last 5 completed)
|
|
recent_picks = await draft_pick_service.get_recent_picks(
|
|
config.sba_season,
|
|
updated_draft_data.currentpick - 1, # Start from previous pick
|
|
limit=5
|
|
)
|
|
|
|
# Get team roster for sWAR calculation
|
|
team_roster = await roster_service.get_team_roster(next_pick.owner.id, "current")
|
|
roster_swar = team_roster.total_wara if team_roster else 0.0
|
|
cap_limit = get_team_salary_cap(next_pick.owner)
|
|
|
|
# Get top 5 most expensive players on team roster
|
|
top_roster_players = []
|
|
if team_roster:
|
|
all_players = team_roster.all_players
|
|
sorted_players = sorted(all_players, key=lambda p: p.wara if p.wara else 0.0, reverse=True)
|
|
top_roster_players = sorted_players[:5]
|
|
|
|
# Get sheet URL
|
|
sheet_url = config.get_draft_sheet_url(config.sba_season)
|
|
|
|
# Create and send the embed
|
|
embed = await create_on_clock_announcement_embed(
|
|
current_pick=next_pick,
|
|
draft_data=updated_draft_data,
|
|
recent_picks=recent_picks if recent_picks else [],
|
|
roster_swar=roster_swar,
|
|
cap_limit=cap_limit,
|
|
top_roster_players=top_roster_players,
|
|
sheet_url=sheet_url
|
|
)
|
|
|
|
# Mention the team's role (using team.lname)
|
|
team_mention = ""
|
|
team_role = discord.utils.get(guild.roles, name=next_pick.owner.lname)
|
|
if team_role:
|
|
team_mention = f"{team_role.mention} "
|
|
else:
|
|
self.logger.warning(f"Could not find role for team {next_pick.owner.lname}")
|
|
|
|
await ping_channel.send(content=team_mention, embed=embed)
|
|
self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}")
|
|
|
|
except Exception as e:
|
|
self.logger.error("Error posting on-clock announcement", error=e)
|
|
|
|
|
|
async def setup(bot: commands.Bot):
|
|
"""Load the draft picks cog."""
|
|
await bot.add_cog(DraftPicksCog(bot))
|