During the draft, teams draft 32 players and then drop to 26. The cap calculation must account for remaining draft picks: - max_zeroes = 32 - projected_roster_size (remaining draft picks) - players_counted = 26 - max_zeroes (how many current players count) This allows teams to draft expensive players mid-draft knowing they'll drop cheap ones later. Previously the code was using min(roster_size, 26) which didn't account for future picks, causing false cap violations. Example: WAI with 18 players drafting 19th: - Old (broken): players_counted = 19, sum all players - New (fixed): max_zeroes = 13, players_counted = 13, only cheapest 13 count 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
267 lines
8.1 KiB
Python
267 lines
8.1 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 for draft:
|
|
# - Teams draft up to 32 players total, then drop down to 26
|
|
# - During draft, we calculate how many "zeroes" (future picks) team can still add
|
|
# - max_zeroes = 32 - projected_roster_size (remaining draft slots)
|
|
# - players_counted = 26 - max_zeroes (current players that count toward cap)
|
|
# - This allows teams to draft expensive players knowing they'll drop cheap ones later
|
|
#
|
|
# Example: Team has 18 players, drafting 19th:
|
|
# - projected_roster_size = 19
|
|
# - max_zeroes = 32 - 19 = 13 (can still draft 13 more)
|
|
# - players_counted = 26 - 13 = 13 (only 13 cheapest current players count)
|
|
#
|
|
# Post-draft (32 players): max_zeroes = 0, players_counted = 26 (normal cap rules)
|
|
max_roster_size = 32 # Maximum players during draft
|
|
max_zeroes = max(0, max_roster_size - projected_roster_size)
|
|
players_counted = max(0, cap_player_count - max_zeroes)
|
|
|
|
# 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 that count toward cap)
|
|
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}"
|