major-domo-v2/utils/draft_helpers.py
Cal Corum 151cf088da Fix draft cap validation using max_zeroes logic
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>
2025-12-12 21:42:42 -06:00

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}"