major-domo-v2/utils/draft_helpers.py
Cal Corum 483f1f492f Fix draft pick API parsing and enhance admin command feedback
Root Cause Fixes:
- Add _extract_items_and_count_from_response() override to DraftPickService
  to handle API returning 'picks' key instead of 'draftpicks'
- Add custom from_api_data() to DraftPick model to handle API field mapping
  (origowner/owner/player -> origowner_id/owner_id/player_id)

Enhancements:
- Add timer status to /draft-admin set-pick success message
  - Shows relative deadline timestamp when timer active
  - Shows "Timer Inactive" when timer not running

Also includes related draft module improvements from prior work.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:33:52 -06:00

257 lines
7.5 KiB
Python

"""
Draft utility functions for Discord Bot v2.0
Provides helper functions for draft order calculation and cap space validation.
"""
import math
from typing import Tuple
from utils.logging import get_contextual_logger
from config import get_config
logger = get_contextual_logger(__name__)
def calculate_pick_details(overall: int) -> Tuple[int, int]:
"""
Calculate round number and pick position from overall pick number.
Hybrid draft format:
- Rounds 1-10: Linear (same order every round)
- Rounds 11+: Snake (reverse order on even rounds)
Special rule: Round 11, Pick 1 belongs to the team that had Round 10, Pick 16
(last pick of linear rounds transitions to first pick of snake rounds).
Args:
overall: Overall pick number (1-512 for 32-round, 16-team draft)
Returns:
(round_num, position): Round number (1-32) and position within round (1-16)
Examples:
>>> calculate_pick_details(1)
(1, 1) # Round 1, Pick 1
>>> calculate_pick_details(16)
(1, 16) # Round 1, Pick 16
>>> calculate_pick_details(160)
(10, 16) # Round 10, Pick 16 (last linear pick)
>>> calculate_pick_details(161)
(11, 1) # Round 11, Pick 1 (first snake pick - same team as 160)
>>> calculate_pick_details(176)
(11, 16) # Round 11, Pick 16
>>> calculate_pick_details(177)
(12, 16) # Round 12, Pick 16 (snake reverses)
"""
config = get_config()
team_count = config.draft_team_count
linear_rounds = config.draft_linear_rounds
round_num = math.ceil(overall / team_count)
if round_num <= linear_rounds:
# Linear draft: position is same calculation every round
position = ((overall - 1) % team_count) + 1
else:
# Snake draft: reverse on even rounds
if round_num % 2 == 1: # Odd rounds (11, 13, 15...)
position = ((overall - 1) % team_count) + 1
else: # Even rounds (12, 14, 16...)
position = team_count - ((overall - 1) % team_count)
return round_num, position
def calculate_overall_from_round_position(round_num: int, position: int) -> int:
"""
Calculate overall pick number from round and position.
Inverse operation of calculate_pick_details().
Args:
round_num: Round number (1-32)
position: Position within round (1-16)
Returns:
Overall pick number
Examples:
>>> calculate_overall_from_round_position(1, 1)
1
>>> calculate_overall_from_round_position(10, 16)
160
>>> calculate_overall_from_round_position(11, 1)
161
>>> calculate_overall_from_round_position(12, 16)
177
"""
config = get_config()
team_count = config.draft_team_count
linear_rounds = config.draft_linear_rounds
if round_num <= linear_rounds:
# Linear draft
return (round_num - 1) * team_count + position
else:
# Snake draft
picks_before_round = (round_num - 1) * team_count
if round_num % 2 == 1: # Odd snake rounds
return picks_before_round + position
else: # Even snake rounds (reversed)
return picks_before_round + (team_count + 1 - position)
async def validate_cap_space(
roster: dict,
new_player_wara: float,
team=None
) -> Tuple[bool, float, float]:
"""
Validate team has cap space to draft player.
Cap calculation:
- Maximum 32 players on active roster
- Only top 26 players count toward cap
- Cap limit: Team-specific or default 32.00 sWAR
Args:
roster: Roster dictionary from API with structure:
{
'active': {
'players': [{'id': int, 'name': str, 'wara': float}, ...],
'WARa': float # Current roster sWAR
}
}
new_player_wara: sWAR value of player being drafted
team: Optional team object/dict for team-specific salary cap
Returns:
(valid, projected_total, cap_limit): True if under cap, projected total sWAR, and cap limit used
Raises:
ValueError: If roster structure is invalid
"""
from utils.helpers import get_team_salary_cap, SALARY_CAP_TOLERANCE
config = get_config()
cap_limit = get_team_salary_cap(team)
cap_player_count = config.cap_player_count
if not roster or not roster.get('active'):
raise ValueError("Invalid roster structure - missing 'active' key")
active_roster = roster['active']
current_players = active_roster.get('players', [])
# Calculate how many players count toward cap after adding new player
current_roster_size = len(current_players)
projected_roster_size = current_roster_size + 1
# Cap counting rules:
# - The 26 CHEAPEST (lowest WAR) players on the roster count toward the cap
# - If roster has fewer than 26 players, all of them count
# - If roster has 26+ players, only the bottom 26 by WAR count
# - This allows expensive stars to be "excluded" if you have enough cheap depth
players_counted = min(projected_roster_size, cap_player_count)
# Sort all players (including new) by sWAR ASCENDING (cheapest first)
all_players_wara = [p['wara'] for p in current_players] + [new_player_wara]
sorted_wara = sorted(all_players_wara) # Ascending order
# Sum bottom N players (the cheapest ones)
projected_total = sum(sorted_wara[:players_counted])
# Allow tiny floating point tolerance
is_valid = projected_total <= (cap_limit + SALARY_CAP_TOLERANCE)
logger.debug(
f"Cap validation: roster_size={current_roster_size}, "
f"projected_size={projected_roster_size}, "
f"players_counted={players_counted}, "
f"new_player_wara={new_player_wara:.2f}, "
f"projected_total={projected_total:.2f}, "
f"cap_limit={cap_limit:.2f}, "
f"valid={is_valid}"
)
return is_valid, projected_total, cap_limit
def format_pick_display(overall: int) -> str:
"""
Format pick number for display.
Args:
overall: Overall pick number
Returns:
Formatted string like "Round 1, Pick 3 (Overall #3)"
Examples:
>>> format_pick_display(1)
"Round 1, Pick 1 (Overall #1)"
>>> format_pick_display(45)
"Round 3, Pick 13 (Overall #45)"
"""
round_num, position = calculate_pick_details(overall)
return f"Round {round_num}, Pick {position} (Overall #{overall})"
def get_next_pick_overall(current_overall: int) -> int:
"""
Get the next overall pick number.
Simply increments by 1, but provided for completeness and future logic changes.
Args:
current_overall: Current overall pick number
Returns:
Next overall pick number
"""
return current_overall + 1
def is_draft_complete(current_overall: int, total_picks: int = None) -> bool:
"""
Check if draft is complete.
Args:
current_overall: Current overall pick number
total_picks: Total number of picks in draft (None uses config value)
Returns:
True if draft is complete
"""
if total_picks is None:
config = get_config()
total_picks = config.draft_total_picks
return current_overall > total_picks
def get_round_name(round_num: int) -> str:
"""
Get display name for round.
Args:
round_num: Round number
Returns:
Display name like "Round 1" or "Round 11 (Snake Draft Begins)"
"""
if round_num == 1:
return "Round 1"
elif round_num == 11:
return "Round 11 (Snake Draft Begins)"
else:
return f"Round {round_num}"