Add draft monitor auto-start and on-clock announcement embed

- Start draft monitor when timer enabled via /draft-admin timer
- Auto-start monitor when /draft-admin set-pick is used with active timer
- Add _ensure_monitor_running() helper for consistent monitor management
- Create on-clock announcement embed with:
  - Team name, pick info, and deadline
  - Team sWAR and cap space
  - Last 5 picks
  - Top 5 roster players by sWAR
- Implement smart polling intervals:
  - 30s when >60s remaining
  - 15s when 30-60s remaining
  - 5s when <30s remaining
- Add get_top_free_agents() to player service
- Fix DraftAdminGroup to accept bot parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-10 23:04:39 -06:00
parent c43f32fb41
commit ada4feef3e
5 changed files with 309 additions and 21 deletions

View File

@ -56,7 +56,7 @@ async def setup_draft(bot: commands.Bot):
# Load draft admin group (app_commands.Group pattern)
try:
bot.tree.add_command(DraftAdminGroup())
bot.tree.add_command(DraftAdminGroup(bot))
logger.info("✅ Loaded DraftAdminGroup")
successful += 1
except Exception as e:

View File

@ -22,13 +22,35 @@ from views.embeds import EmbedTemplate
class DraftAdminGroup(app_commands.Group):
"""Draft administration command group."""
def __init__(self):
def __init__(self, bot: commands.Bot):
super().__init__(
name="draft-admin",
description="Admin commands for draft management"
)
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.DraftAdminGroup')
def _ensure_monitor_running(self) -> str:
"""
Ensure the draft monitor task is running.
Returns:
Status message about the monitor state
"""
from tasks.draft_monitor import setup_draft_monitor
if not hasattr(self.bot, 'draft_monitor') or self.bot.draft_monitor is None:
self.bot.draft_monitor = setup_draft_monitor(self.bot)
self.logger.info("Draft monitor task started")
return "\n\n🤖 **Draft monitor started** - auto-draft and warnings active"
elif not self.bot.draft_monitor.monitor_loop.is_running():
# Task exists but was stopped/cancelled - create a new one
self.bot.draft_monitor = setup_draft_monitor(self.bot)
self.logger.info("Draft monitor task recreated")
return "\n\n🤖 **Draft monitor restarted** - auto-draft and warnings active"
else:
return "\n\n🤖 Draft monitor already running"
@app_commands.command(name="info", description="View current draft configuration")
@league_admin_only()
@logged_command("/draft-admin info")
@ -94,14 +116,29 @@ class DraftAdminGroup(app_commands.Group):
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Start draft monitor task if timer is enabled
monitor_status = ""
if enabled:
monitor_status = self._ensure_monitor_running()
# Success message
status = "enabled" if enabled else "disabled"
description = f"Draft timer has been **{status}**."
if enabled and minutes:
description += f"\n\nPick duration: **{minutes} minutes**"
elif enabled:
description += f"\n\nPick duration: **{updated.pick_minutes} minutes**"
if enabled:
# Show pick duration
pick_mins = minutes if minutes else updated.pick_minutes
description += f"\n\n**Pick duration:** {pick_mins} minutes"
# Show current pick number
description += f"\n**Current Pick:** #{updated.currentpick}"
# Show deadline
if updated.pick_deadline:
deadline_timestamp = int(updated.pick_deadline.timestamp())
description += f"\n**Deadline:** <t:{deadline_timestamp}:T> (<t:{deadline_timestamp}:R>)"
description += monitor_status
embed = EmbedTemplate.success("Timer Updated", description)
await interaction.followup.send(embed=embed)
@ -173,10 +210,13 @@ class DraftAdminGroup(app_commands.Group):
if pick.owner:
description += f"\n\n{pick.owner.abbrev} {pick.owner.sname} is now on the clock."
# Add timer status
# Add timer status and ensure monitor is running if timer is active
if updated.timer and updated.pick_deadline:
deadline_timestamp = int(updated.pick_deadline.timestamp())
description += f"\n\n⏱️ **Timer Active** - Deadline <t:{deadline_timestamp}:R>"
# Ensure monitor is running
monitor_status = self._ensure_monitor_running()
description += monitor_status
else:
description += "\n\n⏸️ **Timer Inactive**"
@ -298,4 +338,4 @@ class DraftAdminGroup(app_commands.Group):
async def setup(bot: commands.Bot):
"""Setup function for loading the draft admin commands."""
bot.tree.add_command(DraftAdminGroup())
bot.tree.add_command(DraftAdminGroup(bot))

View File

@ -254,6 +254,26 @@ class PlayerService(BaseService[Player]):
"""
return player.team_id == get_config().free_agent_team_id
async def get_top_free_agents(self, season: int, limit: int = 5) -> List[Player]:
"""
Get top free agents sorted by sWAR (wara) descending.
Args:
season: Season number (required)
limit: Maximum number of players to return (default 5)
Returns:
List of top free agent players sorted by sWAR
"""
try:
free_agents = await self.get_free_agents(season)
# Sort by wara descending and take top N
sorted_fa = sorted(free_agents, key=lambda p: p.wara if p.wara else 0.0, reverse=True)
return sorted_fa[:limit]
except Exception as e:
logger.error(f"Failed to get top free agents: {e}")
return []
async def get_players_by_position(self, position: str, season: int) -> List[Player]:
"""
Get players by position.

View File

@ -16,8 +16,11 @@ from services.draft_pick_service import draft_pick_service
from services.draft_list_service import draft_list_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.helpers import get_team_salary_cap
from views.embeds import EmbedTemplate, EmbedColors
from views.draft_views import create_on_clock_announcement_embed
from config import get_config
@ -50,10 +53,35 @@ class DraftMonitorTask:
"""Stop the task when cog is unloaded."""
self.monitor_loop.cancel()
@tasks.loop(seconds=15)
def _get_poll_interval(self, time_remaining: float) -> int:
"""
Get the appropriate polling interval based on time remaining.
Args:
time_remaining: Seconds until deadline
Returns:
Poll interval in seconds:
- 30s when > 60s remaining
- 15s when 30-60s remaining
- 5s when < 30s remaining
"""
if time_remaining > 60:
return 30
elif time_remaining > 30:
return 15
else:
return 5
@tasks.loop(seconds=30)
async def monitor_loop(self):
"""
Main monitoring loop - checks draft state every 15 seconds.
Main monitoring loop - checks draft state with dynamic intervals.
Polling frequency increases as deadline approaches:
- Every 30s when > 60s remaining
- Every 15s when 30-60s remaining
- Every 5s when < 30s remaining
Self-terminates when draft timer is disabled.
"""
@ -82,6 +110,12 @@ class DraftMonitorTask:
# Calculate time remaining
time_remaining = (deadline - now).total_seconds()
# Adjust polling interval based on time remaining
new_interval = self._get_poll_interval(time_remaining)
if self.monitor_loop.seconds != new_interval:
self.monitor_loop.change_interval(seconds=new_interval)
self.logger.debug(f"Adjusted poll interval to {new_interval}s (time remaining: {time_remaining:.0f}s)")
if time_remaining <= 0:
# Timer expired - auto-draft
await self._handle_expired_timer(draft_data)
@ -180,6 +214,11 @@ class DraftMonitorTask:
)
# Advance to next pick
await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
# Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags
self.warning_60s_sent = False
self.warning_30s_sent = False
return
# Try each player in order
@ -207,6 +246,8 @@ class DraftMonitorTask:
)
# Advance to next pick
await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
# Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags
self.warning_60s_sent = False
self.warning_30s_sent = False
@ -219,6 +260,11 @@ class DraftMonitorTask:
)
# Advance to next pick anyway
await draft_service.advance_pick(draft_data.id, draft_data.currentpick)
# Post on-clock announcement for next team
await self._post_on_clock_announcement(ping_channel, draft_data)
# Reset warning flags
self.warning_60s_sent = False
self.warning_30s_sent = False
except Exception as e:
self.logger.error("Error auto-drafting player", error=e)
@ -294,6 +340,80 @@ class DraftMonitorTask:
self.logger.error(f"Error attempting to draft {player.name}", error=e)
return False
async def _post_on_clock_announcement(self, ping_channel, draft_data) -> None:
"""
Post the on-clock announcement embed for the next team.
Called after advance_pick() to announce who is now on the clock.
Args:
ping_channel: Discord channel to post in
draft_data: Current draft configuration (will be refreshed)
"""
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]
# 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
)
# Mention the team's GM if available
gm_mention = ""
if next_pick.owner.gmid:
gm_mention = f"<@{next_pick.owner.gmid}> "
await ping_channel.send(content=gm_mention, embed=embed)
self.logger.info(f"Posted on-clock announcement for pick #{updated_draft_data.currentpick}")
# Reset poll interval to 30s for new pick
if self.monitor_loop.seconds != 30:
self.monitor_loop.change_interval(seconds=30)
self.logger.debug("Reset poll interval to 30s for new pick")
except Exception as e:
self.logger.error("Error posting on-clock announcement", error=e)
async def _send_warnings_if_needed(self, draft_data, time_remaining: float):
"""
Send warnings at 60s and 30s remaining.

View File

@ -300,7 +300,10 @@ async def create_draft_board_embed(
player_display = "TBD"
team_display = pick.owner.abbrev if pick.owner else "???"
picks_str += f"**Pick {pick.overall % 16 or 16}:** {team_display} - {player_display}\n"
round_pick = pick.overall % 16 or 16
# Format: `RR.PP (#OOO)` - padded for alignment (rounds 1-99, picks 1-16, overall 1-999)
pick_info = f"{round_num:>2}.{round_pick:<2} (#{pick.overall:>3})"
picks_str += f"`{pick_info}` {team_display} - {player_display}\n"
embed.add_field(
name="Picks",
@ -347,7 +350,7 @@ async def create_pick_success_embed(
team: Team,
pick_overall: int,
projected_swar: float,
cap_limit: float = None
cap_limit: float | None = None
) -> discord.Embed:
"""
Create embed for successful pick.
@ -365,19 +368,24 @@ async def create_pick_success_embed(
from utils.helpers import get_team_salary_cap
embed = EmbedTemplate.success(
title="Pick Confirmed",
description=f"{team.abbrev} selects **{player.name}**"
title=f"{team.sname} select **{player.name}**",
description=format_pick_display(pick_overall)
)
if team.thumbnail is not None:
embed.set_thumbnail(url=team.thumbnail)
embed.set_image(url=player.image)
embed.add_field(
name="Pick",
value=format_pick_display(pick_overall),
name="Player ID",
value=f"{player.id}",
inline=True
)
if hasattr(player, 'wara') and player.wara is not None:
embed.add_field(
name="Player sWAR",
name="sWAR",
value=f"{player.wara:.2f}",
inline=True
)
@ -389,7 +397,7 @@ async def create_pick_success_embed(
embed.add_field(
name="Projected Team sWAR",
value=f"{projected_swar:.2f} / {cap_limit:.2f}",
inline=True
inline=False
)
return embed
@ -472,3 +480,103 @@ async def create_admin_draft_info_embed(
embed.set_footer(text="Use /draft-admin to modify draft settings")
return embed
async def create_on_clock_announcement_embed(
current_pick: DraftPick,
draft_data: DraftData,
recent_picks: List[DraftPick],
roster_swar: float,
cap_limit: float,
top_roster_players: List[Player]
) -> discord.Embed:
"""
Create announcement embed for when a team is on the clock.
Used to post in the ping channel when:
- Timer is enabled and pick advances
- Auto-draft completes
- Pick is skipped
Args:
current_pick: The current DraftPick (team now on the clock)
draft_data: Current draft configuration (for timer/deadline info)
recent_picks: Last 5 completed picks
roster_swar: Team's current total sWAR
cap_limit: Team's salary cap limit
top_roster_players: Top 5 most expensive players on the team's roster
Returns:
Discord embed announcing team is on the clock
"""
if not current_pick.owner:
raise ValueError("Pick must have owner")
team = current_pick.owner
# Create embed with team color if available
team_color = int(team.color, 16) if team.color else EmbedColors.PRIMARY
embed = EmbedTemplate.create_base_embed(
title=f"{team.lname} On The Clock",
description=format_pick_display(current_pick.overall),
color=team_color
)
# Set team thumbnail
if team.thumbnail:
embed.set_thumbnail(url=team.thumbnail)
# Deadline field (if timer active)
if draft_data.timer and draft_data.pick_deadline:
deadline_timestamp = int(draft_data.pick_deadline.timestamp())
embed.add_field(
name="⏱️ Deadline",
value=f"<t:{deadline_timestamp}:T> (<t:{deadline_timestamp}:R>)",
inline=True
)
# Team sWAR
embed.add_field(
name="💰 Team sWAR",
value=f"{roster_swar:.2f} / {cap_limit:.2f}",
inline=True
)
# Cap space remaining
cap_remaining = cap_limit - roster_swar
embed.add_field(
name="📊 Cap Space",
value=f"{cap_remaining:.2f}",
inline=True
)
# Last 5 picks
if recent_picks:
recent_str = ""
for pick in recent_picks[:5]:
if pick.player and pick.owner:
recent_str += f"**#{pick.overall}** {pick.owner.abbrev} - {pick.player.name}\n"
if recent_str:
embed.add_field(
name="📋 Last 5 Picks",
value=recent_str,
inline=False
)
# Top 5 most expensive players on team roster
if top_roster_players:
expensive_str = ""
for player in top_roster_players[:5]:
pos = player.pos_1 if hasattr(player, 'pos_1') and player.pos_1 else "?"
expensive_str += f"**{player.name}** ({pos}) - {player.wara:.2f}\n"
embed.add_field(
name="🌟 Top Roster sWAR",
value=expensive_str,
inline=False
)
# Footer with pick info
if current_pick.is_traded:
embed.set_footer(text="📝 This pick was acquired via trade")
return embed