Compare commits
10 Commits
b4d84b6125
...
cdfe54cdf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfe54cdf7 | ||
|
|
5496c96b32 | ||
|
|
4bd5a0b786 | ||
|
|
bbb4233b45 | ||
|
|
cd8cf0aee8 | ||
|
|
1aaf4ccb50 | ||
|
|
bb3894d6f1 | ||
|
|
4e71c33344 | ||
|
|
2b4b84e193 | ||
|
|
9bb84ce287 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -60,3 +60,5 @@ card-creation/
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
venv/
|
||||
CLAUDE.md
|
||||
/.claude**
|
||||
|
||||
@ -34,4 +34,12 @@ class Player(pydantic.BaseModel):
|
||||
strat_code: Optional[str] = None
|
||||
bbref_id: Optional[str] = None
|
||||
injury_rating: Optional[str] = None
|
||||
sbaplayer_id: Optional[int] = None
|
||||
sbaplayer_id: Optional[int] = None
|
||||
|
||||
|
||||
async def get_one_player(player_id: int) -> Player:
|
||||
data = await db_get('players', object_id=player_id)
|
||||
if not data:
|
||||
log_exception(ApiException(f'No player found with ID {player_id}'))
|
||||
|
||||
return Player(**data)
|
||||
|
||||
@ -22,4 +22,5 @@ class Team(pydantic.BaseModel):
|
||||
thumbnail: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
dice_color: Optional[str] = None
|
||||
season: int
|
||||
season: int
|
||||
salary_cap: Optional[float] = None
|
||||
@ -9,7 +9,7 @@ from api_calls.draft_pick import DraftPick, get_one_draftpick, patch_draftpick
|
||||
from api_calls.player import Player
|
||||
from exceptions import ApiException, log_exception
|
||||
from helpers import *
|
||||
from db_calls import db_get, db_patch, put_player, db_post, get_player_by_name
|
||||
from db_calls import db_delete, db_get, db_patch, put_player, db_post, get_player_by_name
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
from discord import TextChannel, app_commands
|
||||
@ -215,7 +215,7 @@ class Draft(commands.Cog):
|
||||
|
||||
for x in p_query['players']:
|
||||
if count < 5:
|
||||
core_players_string += f'{x["pos_1"]} {x["name"]} ({x["wara"]})\n'
|
||||
core_players_string += f'{x["pos_1"]} {x["name"]} ({x["wara"]:.2f})\n'
|
||||
else:
|
||||
break
|
||||
count += 1
|
||||
@ -439,16 +439,18 @@ class Draft(commands.Cog):
|
||||
|
||||
total_swar += x['wara']
|
||||
|
||||
if total_swar > 32.00001:
|
||||
team_cap = get_team_salary_cap(draft_pick.owner)
|
||||
if exceeds_salary_cap(total_swar, draft_pick.owner):
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Drafting {player["name"]} would put you at {total_swar:.2f} '
|
||||
f'sWAR, friendo.'
|
||||
f'sWAR (cap {team_cap:.1f}), friendo.'
|
||||
}
|
||||
logger.info(
|
||||
f'{draft_pick.owner.lname} selects {player["name"]} with the #{draft_pick.overall} overall pick'
|
||||
)
|
||||
draft_pick.player = Player(**player)
|
||||
logger.info(f'draft_pick test: {draft_pick}')
|
||||
await patch_draftpick(draft_pick) # TODO: uncomment for live draft
|
||||
|
||||
player['team']['id'] = draft_pick.owner.id
|
||||
@ -780,13 +782,34 @@ class Draft(commands.Cog):
|
||||
|
||||
temp_player['team'] = fa_team
|
||||
this_player = await put_player(temp_player)
|
||||
|
||||
# this_player = await get_player_by_name(current.season, this_pick['player']['id'])
|
||||
# this_player = await db_get('players', object_id=this_pick['player']['id'])
|
||||
await interaction.edit_original_response(
|
||||
content='Don\'t forget to delete the transaction from the database.',
|
||||
embed=await get_player_embed(this_player, current)
|
||||
|
||||
t_query = await db_get(
|
||||
'transactions',
|
||||
params=[('season', 12), ('move_id', f'draft-overall-{this_pick.overall}')]
|
||||
)
|
||||
if not t_query or t_query.get('count', 0) == 0:
|
||||
await interaction.edit_original_response(
|
||||
content='I couldn\'t find the transaction record of this move so that\'s Cal\'s problem now. The pick is wiped, though.',
|
||||
embed=await get_player_embed(this_player, current)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
del_query = await db_delete(
|
||||
'transactions',
|
||||
object_id=t_query['transactions'][0]['moveid']
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f'Could not delete draft transaction: {e}')
|
||||
await interaction.edit_original_response(
|
||||
content='I couldn\'t delete the transaction record of this move so that\'s Cal\'s problem now. The pick is wiped, though.',
|
||||
embed=await get_player_embed(this_player, current)
|
||||
)
|
||||
return
|
||||
await interaction.edit_original_response(
|
||||
content=f'[I gotchu]({random_gif("blows kiss")}) boo boo, like it never even happened.',
|
||||
embed=await get_player_embed(this_player, current)
|
||||
|
||||
)
|
||||
return
|
||||
|
||||
if pick_lock is not None:
|
||||
|
||||
@ -800,7 +800,7 @@ class Gameday(commands.Cog):
|
||||
@commands.has_any_role(SBA_PLAYERS_ROLE_NAME, PD_PLAYERS_ROLE_NAME)
|
||||
async def new_game_command(self, ctx):
|
||||
current = await db_get('current')
|
||||
this_team = await get_team_by_owner(current['season'], ctx.author.id)
|
||||
this_team = await get_team_by_owner(current.season, ctx.author.id)
|
||||
|
||||
await ctx.send('**Note:** Make sure this is the channel where you will be playing commands. Once this is set, '
|
||||
'I will only take gameplay commands here.')
|
||||
@ -828,9 +828,9 @@ class Gameday(commands.Cog):
|
||||
|
||||
try:
|
||||
this_matchup = await get_one_schedule(
|
||||
season=current['season'],
|
||||
season=current.season,
|
||||
team_abbrev1=this_team['abbrev'],
|
||||
week=current['week']
|
||||
week=current.week
|
||||
)
|
||||
except ValueError as e:
|
||||
home_team = await get_team('home')
|
||||
|
||||
@ -998,12 +998,15 @@ class Players(commands.Cog):
|
||||
param_list.append(('week', current.week))
|
||||
|
||||
g_query = await db_get('games', params=param_list)
|
||||
if g_query['count'] == 0:
|
||||
if not g_query or g_query['count'] == 0:
|
||||
await interaction.edit_original_response(
|
||||
content=f'Hm. I don\'t see any games then.'
|
||||
)
|
||||
return
|
||||
|
||||
# Sort games by game_id
|
||||
g_query['games'] = sorted(g_query['games'], key=lambda game: game['id'])
|
||||
|
||||
if team_abbrev is not None:
|
||||
title = f'{this_team["lname"]} Schedule'
|
||||
embed = get_team_embed(title=title, team=this_team)
|
||||
@ -1191,19 +1194,19 @@ class Players(commands.Cog):
|
||||
|
||||
async def get_division_standings(self, current) -> discord.Embed:
|
||||
d1_query = await db_get('standings', params=[
|
||||
('season', current.season), ('division_abbrev', 'SD')
|
||||
('season', current.season), ('division_abbrev', 'TC')
|
||||
])
|
||||
div_one = d1_query['standings']
|
||||
d2_query = await db_get('standings', params=[
|
||||
('season', current.season), ('division_abbrev', 'DC')
|
||||
('season', current.season), ('division_abbrev', 'ETSOS')
|
||||
])
|
||||
div_two = d2_query['standings']
|
||||
d3_query = await db_get('standings', params=[
|
||||
('season', current.season), ('division_abbrev', 'FIP')
|
||||
('season', current.season), ('division_abbrev', 'APL')
|
||||
])
|
||||
div_three = d3_query['standings']
|
||||
d4_query = await db_get('standings', params=[
|
||||
('season', current.season), ('division_abbrev', 'DOC')
|
||||
('season', current.season), ('division_abbrev', 'BBC')
|
||||
])
|
||||
div_four = d4_query['standings']
|
||||
|
||||
@ -1233,10 +1236,10 @@ class Players(commands.Cog):
|
||||
f'{progress["games_played"]}/{progress["game_count"]} games played',
|
||||
color=0xB70000)
|
||||
embed.add_field(name=f'**Full Standings**', value=SBA_STANDINGS_URL, inline=False)
|
||||
embed.add_field(name=f'**Snow Day**', value=div_one_standings, inline=False)
|
||||
embed.add_field(name=f'**Dinger Central**', value=div_two_standings, inline=False)
|
||||
embed.add_field(name=f'**Just the FIP**', value=div_three_standings, inline=False)
|
||||
embed.add_field(name=f'**Division of Cook**', value=div_four_standings, inline=False)
|
||||
embed.add_field(name=f'**Traveling Circus**', value=div_one_standings, inline=False)
|
||||
embed.add_field(name=f'**ETSOS**', value=div_two_standings, inline=False)
|
||||
embed.add_field(name=f'**Apple**', value=div_three_standings, inline=False)
|
||||
embed.add_field(name=f'**Big Chungus**', value=div_four_standings, inline=False)
|
||||
|
||||
return embed
|
||||
|
||||
@ -1520,7 +1523,7 @@ class Players(commands.Cog):
|
||||
setup_tab = scorecard.worksheet_by_title('Setup')
|
||||
|
||||
scorecard_version = setup_tab.get_value('V35')
|
||||
if int(scorecard_version) != current.bet_week:
|
||||
if scorecard_version != current.bet_week:
|
||||
await interaction.edit_original_response(
|
||||
content=f'It looks like this scorecard is out of date. Did you create a new card at the start of the '
|
||||
f'game? If you did, let Cal know about this error. If not, I\'ll need you to use an up to '
|
||||
@ -2337,8 +2340,7 @@ class Players(commands.Cog):
|
||||
|
||||
@app_commands.command(name='charts')
|
||||
async def chart_command(self, interaction: discord.Interaction, chart_name: Literal[
|
||||
'block-plate', 'defense-matters', 'fly-b', 'g1', 'g2', 'g3', 'groundball', 'hit-and-run',
|
||||
'rest', 'rob-hr', 'sac-bunt', 'squeeze-bunt']):
|
||||
'rob-hr', 'defense', 'fly-b', 'g1', 'g2', 'g3', 'groundball','hit-and-run', 'rest', 'sac-bunt', 'squeeze-bunt']):
|
||||
gb_url = 'https://sombaseball.ddns.net/static/images/season04/ground-ball-chart'
|
||||
all_charts = {
|
||||
'rest': [f'{SBA_IMAGE_URL}/season05/charts/rest.png'],
|
||||
@ -2350,8 +2352,8 @@ class Players(commands.Cog):
|
||||
f'{SBA_IMAGE_URL}/season05/charts/squeeze-bunt.png',
|
||||
f'{SBA_IMAGE_URL}/season05/charts/squeeze-bunt-help.png'
|
||||
],
|
||||
'rob-hr': [f'{SBA_IMAGE_URL}/season05/charts/rob-hr.png'],
|
||||
'defense-matters': [f'{SBA_IMAGE_URL}/season05/charts/defense-matters.png'],
|
||||
'rob-hr': [f'https://sba-cards-2024.s3.us-east-1.amazonaws.com/static-images/rob-hr.png'],
|
||||
'defense': [f'https://sba-cards-2024.s3.us-east-1.amazonaws.com/static-images/defense.png'],
|
||||
'block-plate': [f'{SBA_IMAGE_URL}/season05/charts/block-plate.png'],
|
||||
'hit-and-run': [
|
||||
f'{SBA_IMAGE_URL}/season05/charts/hit-and-run.png',
|
||||
@ -2854,7 +2856,7 @@ class Players(commands.Cog):
|
||||
|
||||
update_string = ''
|
||||
if swar is not None:
|
||||
update_string += f'sWAR: {this_player["wara"]} => {swar}\n'
|
||||
update_string += f'sWAR: {this_player["wara"]:.2f} => {swar:.2f}\n'
|
||||
this_player['wara'] = swar
|
||||
if injury_rating is not None:
|
||||
update_string += f'injury_rating: {this_player["injury_rating"]} => {injury_rating}\n'
|
||||
|
||||
@ -1,14 +1,232 @@
|
||||
import re
|
||||
import copy
|
||||
import random
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple, Optional, Dict, Set
|
||||
|
||||
from helpers import *
|
||||
from api_calls.current import get_current
|
||||
from db_calls import db_get, db_patch, get_team_by_owner, get_team_by_abbrev, get_player_by_name, put_player, db_post
|
||||
from discord.ext import commands, tasks
|
||||
OFFSEASON_FLAG = True
|
||||
OFFSEASON_FLAG = False
|
||||
USE_NEW_TRANSACTION_LOGIC = os.getenv('USE_NEW_TRANSACTION_LOGIC', 'true').lower() == 'true'
|
||||
logger = logging.getLogger('discord_app')
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransactionPriority:
|
||||
"""Data class to hold transaction priority calculation results"""
|
||||
roster_priority: int # 1 = major league, 2 = minor league
|
||||
win_percentage: float # Lower is better (worse teams get priority)
|
||||
random_tiebreaker: int # Random number for final tie resolution
|
||||
move_id: str
|
||||
major_league_team_abbrev: str # The major league team (without MiL)
|
||||
contested_players: List[str] # List of contested player names in this transaction
|
||||
|
||||
def get_sort_tuple(self) -> Tuple[int, float, int]:
|
||||
"""Return tuple for sorting - lower values have higher priority"""
|
||||
return (self.roster_priority, self.win_percentage, self.random_tiebreaker)
|
||||
|
||||
|
||||
def get_major_league_team_abbrev(old_team_abbrev: str, new_team_abbrev: str) -> str:
|
||||
"""
|
||||
Extract the major league team abbreviation from a transaction.
|
||||
|
||||
Args:
|
||||
old_team_abbrev: Source team abbreviation
|
||||
new_team_abbrev: Destination team abbreviation
|
||||
|
||||
Returns:
|
||||
Major league team abbreviation (the one that's not FA and doesn't end in MiL)
|
||||
"""
|
||||
# Find the team that is not FA and strip MiL if needed
|
||||
if old_team_abbrev != 'FA':
|
||||
return old_team_abbrev.replace('MiL', '') if old_team_abbrev.endswith('MiL') else old_team_abbrev
|
||||
elif new_team_abbrev != 'FA':
|
||||
return new_team_abbrev.replace('MiL', '') if new_team_abbrev.endswith('MiL') else new_team_abbrev
|
||||
else:
|
||||
raise ValueError("Both teams cannot be FA")
|
||||
|
||||
|
||||
def determine_roster_priority(moves: List[dict]) -> int:
|
||||
"""
|
||||
Determine roster priority for a transaction based on all movements.
|
||||
|
||||
Args:
|
||||
moves: List of all movements in the transaction
|
||||
|
||||
Returns:
|
||||
1 for major league priority, 2 for minor league priority
|
||||
"""
|
||||
# If ANY move involves major league roster (no MiL suffix), entire transaction gets major league priority
|
||||
for move in moves:
|
||||
old_abbrev = move['oldteam']['abbrev']
|
||||
new_abbrev = move['newteam']['abbrev']
|
||||
|
||||
# Check if either team is major league (not FA and doesn't end with MiL)
|
||||
if (old_abbrev != 'FA' and not old_abbrev.endswith('MiL')) or \
|
||||
(new_abbrev != 'FA' and not new_abbrev.endswith('MiL')):
|
||||
return 1 # Major league priority
|
||||
|
||||
return 2 # Minor league priority (all moves involve MiL teams)
|
||||
|
||||
|
||||
async def calculate_transaction_priority(move_id: str, moves: List[dict], current_season: int,
|
||||
contested_players: Set[str]) -> TransactionPriority:
|
||||
"""
|
||||
Calculate priority for an entire transaction (all moves with same move_id).
|
||||
|
||||
Args:
|
||||
move_id: The transaction ID
|
||||
moves: List of all movements in this transaction
|
||||
current_season: Current season number
|
||||
contested_players: Set of all contested player names
|
||||
|
||||
Returns:
|
||||
TransactionPriority object with calculated values
|
||||
"""
|
||||
if not moves:
|
||||
raise ValueError("No moves provided for transaction")
|
||||
|
||||
# Get major league team abbreviation from any move in the transaction
|
||||
first_move = moves[0]
|
||||
major_league_team_abbrev = get_major_league_team_abbrev(
|
||||
first_move['oldteam']['abbrev'],
|
||||
first_move['newteam']['abbrev']
|
||||
)
|
||||
|
||||
# Determine roster priority for entire transaction
|
||||
roster_priority = determine_roster_priority(moves)
|
||||
|
||||
# Calculate win percentage using major league team's record
|
||||
if major_league_team_abbrev == 'FA':
|
||||
win_percentage = 0.0
|
||||
else:
|
||||
major_league_team = await get_team_by_abbrev(major_league_team_abbrev, current_season)
|
||||
if major_league_team is None:
|
||||
raise ValueError(f'Team `{major_league_team_abbrev}` not found')
|
||||
standings_query = await db_get('standings', params=[
|
||||
('season', current_season),
|
||||
('team_id', major_league_team['id']),
|
||||
]
|
||||
)
|
||||
if standings_query is None:
|
||||
raise ValueError(f'Standings not found for `{major_league_team_abbrev}`')
|
||||
standings = standings_query['standings'][0]
|
||||
total_games = standings['wins'] + standings['losses']
|
||||
win_percentage = standings['wins'] / total_games if total_games > 0 else 0.0
|
||||
|
||||
# Find which players in this transaction are contested
|
||||
transaction_contested_players = [
|
||||
move['player']['name'] for move in moves
|
||||
if move['player']['name'] in contested_players
|
||||
]
|
||||
|
||||
return TransactionPriority(
|
||||
roster_priority=roster_priority,
|
||||
win_percentage=win_percentage,
|
||||
random_tiebreaker=random.randint(1, 100000),
|
||||
move_id=move_id,
|
||||
major_league_team_abbrev=major_league_team_abbrev,
|
||||
contested_players=transaction_contested_players
|
||||
)
|
||||
|
||||
|
||||
async def resolve_contested_transactions(moves: List[dict], current_season: int) -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Resolve disputes for contested players at the transaction level.
|
||||
|
||||
Args:
|
||||
moves: List of all transaction moves from database
|
||||
current_season: Current season number
|
||||
|
||||
Returns:
|
||||
Tuple of (winning_move_ids, losing_move_ids)
|
||||
"""
|
||||
# Group moves by move_id (transaction)
|
||||
transactions = {}
|
||||
for move in moves:
|
||||
move_id = move['moveid']
|
||||
if move_id not in transactions:
|
||||
transactions[move_id] = []
|
||||
transactions[move_id].append(move)
|
||||
|
||||
# Group moves by player name to identify contested players
|
||||
player_to_move_ids = {}
|
||||
for move in moves:
|
||||
player_name = move['player']['name']
|
||||
move_id = move['moveid']
|
||||
if player_name not in player_to_move_ids:
|
||||
player_to_move_ids[player_name] = set()
|
||||
player_to_move_ids[player_name].add(move_id)
|
||||
|
||||
# Find contested players (claimed by multiple transactions)
|
||||
contested_players = {
|
||||
player_name for player_name, move_ids in player_to_move_ids.items()
|
||||
if len(move_ids) > 1
|
||||
}
|
||||
|
||||
logger.info(f"Found {len(contested_players)} contested players: {list(contested_players)}")
|
||||
|
||||
# Find transactions that claim any contested player
|
||||
contested_move_ids = set()
|
||||
for player_name in contested_players:
|
||||
contested_move_ids.update(player_to_move_ids[player_name])
|
||||
|
||||
logger.info(f"Found {len(contested_move_ids)} transactions claiming contested players")
|
||||
|
||||
# Calculate priorities for contested transactions
|
||||
contested_priorities = []
|
||||
for move_id in contested_move_ids:
|
||||
priority = await calculate_transaction_priority(
|
||||
move_id, transactions[move_id], current_season, contested_players
|
||||
)
|
||||
contested_priorities.append(priority)
|
||||
|
||||
# Group contested transactions by the players they're fighting over
|
||||
player_disputes = {}
|
||||
for priority in contested_priorities:
|
||||
for player_name in priority.contested_players:
|
||||
if player_name not in player_disputes:
|
||||
player_disputes[player_name] = []
|
||||
player_disputes[player_name].append(priority)
|
||||
|
||||
# Resolve each player dispute
|
||||
winning_move_ids = set()
|
||||
losing_move_ids = set()
|
||||
|
||||
for player_name, competing_priorities in player_disputes.items():
|
||||
# Sort by priority tuple (lower values = higher priority)
|
||||
sorted_priorities = sorted(competing_priorities, key=lambda p: p.get_sort_tuple())
|
||||
|
||||
winner = sorted_priorities[0]
|
||||
losers = sorted_priorities[1:]
|
||||
|
||||
winning_move_ids.add(winner.move_id)
|
||||
for loser in losers:
|
||||
losing_move_ids.add(loser.move_id)
|
||||
|
||||
# Log the resolution decision
|
||||
logger.info(f"Contested player '{player_name}' resolution:")
|
||||
logger.info(f" Winner: {winner.major_league_team_abbrev} (move_id={winner.move_id}, "
|
||||
f"roster_priority={winner.roster_priority}, win_pct={winner.win_percentage:.3f}, "
|
||||
f"random={winner.random_tiebreaker})")
|
||||
for i, loser in enumerate(losers):
|
||||
logger.info(f" Loser {i+1}: {loser.major_league_team_abbrev} (move_id={loser.move_id}, "
|
||||
f"roster_priority={loser.roster_priority}, win_pct={loser.win_percentage:.3f}, "
|
||||
f"random={loser.random_tiebreaker})")
|
||||
|
||||
# Add non-contested transactions to winners
|
||||
non_contested_move_ids = [
|
||||
move_id for move_id in transactions.keys()
|
||||
if move_id not in contested_move_ids
|
||||
]
|
||||
winning_move_ids.update(non_contested_move_ids)
|
||||
|
||||
return list(winning_move_ids), list(losing_move_ids)
|
||||
|
||||
|
||||
class SBaTransaction:
|
||||
def __init__(self, channel, current, move_type, this_week=False, first_team=None, team_role=None):
|
||||
self.players = {} # Example: { <Player ID>: { "player": { Strat Player Dict }, "to": { Strat Team Dict } } }
|
||||
@ -76,13 +294,39 @@ class SBaTransaction:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_gms(self, bot, team=None):
|
||||
async def get_gms(self, bot, team=None):
|
||||
if team is None:
|
||||
return [bot.get_user(x) for x in self.gms]
|
||||
gms = []
|
||||
for gm_id in self.gms:
|
||||
try:
|
||||
user = await bot.fetch_user(gm_id)
|
||||
if user:
|
||||
gms.append(user)
|
||||
except discord.NotFound:
|
||||
logger.warning(f'User {gm_id} not found')
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching user {gm_id}: {e}')
|
||||
return gms
|
||||
else:
|
||||
these_gms = [bot.get_user(self.teams[team]['team']['gmid'])]
|
||||
these_gms = []
|
||||
try:
|
||||
user = await bot.fetch_user(self.teams[team]['team']['gmid'])
|
||||
if user:
|
||||
these_gms.append(user)
|
||||
except discord.NotFound:
|
||||
logger.warning(f'User {self.teams[team]["team"]["gmid"]} not found')
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching user {self.teams[team]["team"]["gmid"]}: {e}')
|
||||
|
||||
if self.teams[team]['team']['gmid2']:
|
||||
these_gms.append(bot.get_user(self.teams[team]['team']['gmid2']))
|
||||
try:
|
||||
user = await bot.fetch_user(self.teams[team]['team']['gmid2'])
|
||||
if user:
|
||||
these_gms.append(user)
|
||||
except discord.NotFound:
|
||||
logger.warning(f'User {self.teams[team]["team"]["gmid2"]} not found')
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching user {self.teams[team]["team"]["gmid2"]}: {e}')
|
||||
return these_gms
|
||||
|
||||
async def send(self, content=None, embed=None):
|
||||
@ -96,7 +340,7 @@ class SBaTransaction:
|
||||
# Get player string
|
||||
player_string = ''
|
||||
for x in self.players:
|
||||
player_string += f'**{self.players[x]["player"]["name"]}** ({self.players[x]["player"]["wara"]}) from ' \
|
||||
player_string += f'**{self.players[x]["player"]["name"]}** ({self.players[x]["player"]["wara"]:.2f}) from ' \
|
||||
f'{self.players[x]["player"]["team"]["abbrev"]} to {self.players[x]["to"]["abbrev"]}\n'
|
||||
|
||||
if len(player_string) == 0:
|
||||
@ -196,11 +440,11 @@ class SBaTransaction:
|
||||
|
||||
# If player is leaving this team, remove from roster and subtract WARa
|
||||
if x['oldteam'] == this_team:
|
||||
team_roster.remove(x['player'])
|
||||
team_roster = [p for p in team_roster if p['id'] != x['player']['id']]
|
||||
wara -= x['player']['wara']
|
||||
# If player is leaving MiL team, remove from roster and subtract WARa
|
||||
elif x['oldteam']['abbrev'] == f'{this_team["abbrev"]}MiL':
|
||||
mil_roster.remove(x['player'])
|
||||
mil_roster = [p for p in mil_roster if p['id'] != x['player']['id']]
|
||||
mil_wara -= x['player']['wara']
|
||||
|
||||
logger.info(f'updating rosters')
|
||||
@ -222,7 +466,7 @@ class SBaTransaction:
|
||||
if self.players[x]['player']['team'] == this_team:
|
||||
logger.info(f'major league player')
|
||||
# logger.info(f'team roster: {team_roster}')
|
||||
team_roster.remove(self.players[x]['player'])
|
||||
team_roster = [p for p in team_roster if p['id'] != self.players[x]['player']['id']]
|
||||
# 06-13: COMMENTED OUT TO RESOLVE MID-WEEK IL REPLACEMENT BEING SENT BACK DOWN
|
||||
# if self.effective_week != self.current.week:
|
||||
wara -= self.players[x]['player']['wara']
|
||||
@ -230,7 +474,7 @@ class SBaTransaction:
|
||||
# If player is leaving MiL team next week, remove from roster and subtract WARa
|
||||
if self.players[x]['player']['team']['abbrev'] == f'{this_team["abbrev"]}MiL':
|
||||
logger.info(f'minor league player')
|
||||
mil_roster.remove(self.players[x]['player'])
|
||||
mil_roster = [p for p in mil_roster if p['id'] != self.players[x]['player']['id']]
|
||||
# logger.info(f'mil roster: {mil_roster}')
|
||||
if self.effective_week != self.current.week:
|
||||
mil_wara -= self.players[x]['player']['wara']
|
||||
@ -329,6 +573,7 @@ class Transactions(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.trade_season = False
|
||||
self.weekly_warning_sent = False
|
||||
|
||||
self.weekly_loop.start()
|
||||
|
||||
@ -342,47 +587,64 @@ class Transactions(commands.Cog):
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def weekly_loop(self):
|
||||
if OFFSEASON_FLAG:
|
||||
return
|
||||
try:
|
||||
logger.info(f'Inside weekly_loop')
|
||||
if OFFSEASON_FLAG:
|
||||
logger.info(f'Exiting weekly_loop; OFFSEASON_FLAG: {OFFSEASON_FLAG}')
|
||||
return
|
||||
|
||||
current = await get_current()
|
||||
now = datetime.datetime.now()
|
||||
logger.debug(f'Datetime: {now} / weekday: {now.weekday()}')
|
||||
current = await get_current()
|
||||
now = datetime.datetime.now()
|
||||
logger.info(f'Datetime: {now} / weekday: {now.weekday()} / current: {current}')
|
||||
|
||||
# Begin Freeze
|
||||
# if now.weekday() == 0 and now.hour == 5 and not current.freeze: # Spring/Summer
|
||||
if now.weekday() == 0 and now.hour == 0 and not current.freeze: # Fall/Winter
|
||||
current.week += 1
|
||||
await db_patch('current', object_id=current['id'], params=[('week', current.week), ('freeze', True)])
|
||||
await self.run_transactions(current)
|
||||
# Begin Freeze
|
||||
# if now.weekday() == 0 and now.hour == 5 and not current.freeze: # Spring/Summer
|
||||
if now.weekday() == 0 and now.hour == 0 and not current.freeze: # Fall/Winter
|
||||
logger.info(f'weekly_loop - setting the freeze')
|
||||
current.week += 1
|
||||
await db_patch('current', object_id=current.id, params=[('week', current.week), ('freeze', True)])
|
||||
await self.run_transactions(current)
|
||||
|
||||
logger.debug(f'Building freeze string')
|
||||
week_num = f'Week {current.week}'
|
||||
stars = f'{"":*<32}'
|
||||
freeze_message = f'```\n' \
|
||||
f'{stars}\n'\
|
||||
f'{week_num: >9} Freeze Period Begins\n' \
|
||||
f'{stars}\n```'
|
||||
logger.debug(f'Freeze string:\n\n{freeze_message}')
|
||||
await send_to_channel(self.bot, 'transaction-log', freeze_message)
|
||||
logger.debug(f'Building freeze string')
|
||||
week_num = f'Week {current.week}'
|
||||
stars = f'{"":*<32}'
|
||||
freeze_message = f'```\n' \
|
||||
f'{stars}\n'\
|
||||
f'{week_num: >9} Freeze Period Begins\n' \
|
||||
f'{stars}\n```'
|
||||
logger.debug(f'Freeze string:\n\n{freeze_message}')
|
||||
await send_to_channel(self.bot, 'transaction-log', freeze_message)
|
||||
|
||||
if current.week > 0 and current.week <= 18:
|
||||
await self.post_weekly_info(current)
|
||||
if current.week > 0 and current.week <= 18:
|
||||
await self.post_weekly_info(current)
|
||||
|
||||
# End Freeze
|
||||
# elif now.weekday() == 5 and now.hour == 5 and current.freeze: # Spring/Summer
|
||||
elif now.weekday() == 5 and now.hour == 0 and current.freeze: # Fall/Winter
|
||||
await db_patch('current', object_id=current['id'], params=[('freeze', False)])
|
||||
# End Freeze
|
||||
# elif now.weekday() == 5 and now.hour == 5 and current.freeze: # Spring/Summer
|
||||
elif now.weekday() == 5 and now.hour == 0 and current.freeze: # Fall/Winter
|
||||
logger.info(f'weekly_loop - running the un-freeze')
|
||||
await db_patch('current', object_id=current.id, params=[('freeze', False)])
|
||||
|
||||
week_num = f'Week {current.week}'
|
||||
stars = f'{"":*<30}'
|
||||
freeze_message = f'```\n' \
|
||||
f'{stars}\n'\
|
||||
f'{week_num: >9} Freeze Period Ends\n' \
|
||||
f'{stars}\n```'
|
||||
await self.process_freeze_moves(current)
|
||||
await send_to_channel(self.bot, 'transaction-log', freeze_message)
|
||||
self.trade_season = False
|
||||
week_num = f'Week {current.week}'
|
||||
stars = f'{"":*<30}'
|
||||
freeze_message = f'```\n' \
|
||||
f'{stars}\n'\
|
||||
f'{week_num: >9} Freeze Period Ends\n' \
|
||||
f'{stars}\n```'
|
||||
await self.process_freeze_moves(current)
|
||||
await send_to_channel(self.bot, 'transaction-log', freeze_message)
|
||||
self.trade_season = False
|
||||
|
||||
else:
|
||||
logger.info(f'weekly_loop - No freeze actions being taken')
|
||||
except Exception as e:
|
||||
logger.error(f'Unhandled exception in weekly_loop: {e}')
|
||||
error_message = f"⚠️ **Weekly Freeze Task Failed**\n```\nError: {str(e)}\nTime: {datetime.datetime.now()}\nTask: weekly_loop in transactions.py\n```"
|
||||
try:
|
||||
if not self.weekly_warning_sent:
|
||||
await send_owner_notification(error_message)
|
||||
self.weekly_warning_sent = True
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send error message :( {e}')
|
||||
|
||||
@weekly_loop.before_loop
|
||||
async def before_notif_check(self):
|
||||
@ -443,7 +705,8 @@ class Transactions(commands.Cog):
|
||||
except Exception as e:
|
||||
logger.error(f'Could not notifiy GM2 of {team["abbrev"]} ({team["gmid2"]})of move cancellation: {e}')
|
||||
|
||||
async def process_freeze_moves(self, current):
|
||||
async def process_freeze_moves_backup(self, current):
|
||||
"""Original implementation - kept for rollback purposes"""
|
||||
# all_moves = await get_transactions(
|
||||
# season=current.season,
|
||||
# week_start=current.week,
|
||||
@ -538,6 +801,84 @@ class Transactions(commands.Cog):
|
||||
await db_patch('transactions', object_id=move_id, params=[('frozen', False)])
|
||||
await self.post_move_to_transaction_log(move_id)
|
||||
|
||||
async def process_freeze_moves(self, current):
|
||||
"""
|
||||
Route to either new or backup implementation based on feature flag.
|
||||
Set USE_NEW_TRANSACTION_LOGIC=false to use original implementation.
|
||||
"""
|
||||
if USE_NEW_TRANSACTION_LOGIC:
|
||||
logger.info("Using new transaction priority logic")
|
||||
await self.process_freeze_moves_new(current)
|
||||
else:
|
||||
logger.info("Using backup transaction priority logic")
|
||||
await self.process_freeze_moves_backup(current)
|
||||
|
||||
async def process_freeze_moves_new(self, current):
|
||||
"""
|
||||
New implementation of freeze move processing with proper transaction-level priority resolution.
|
||||
|
||||
This function is designed to be easily testable by extracting business logic
|
||||
into pure functions.
|
||||
"""
|
||||
# Get all frozen transactions for this week
|
||||
moves_query = await db_get('transactions', params=[
|
||||
('season', current.season),
|
||||
('week_start', current.week),
|
||||
('week_end', current.week + 1),
|
||||
('frozen', True)
|
||||
])
|
||||
|
||||
if moves_query['count'] == 0:
|
||||
logger.warning(f'No transactions to process for the freeze in week {current.week}')
|
||||
return
|
||||
|
||||
moves = moves_query['transactions']
|
||||
logger.info(f'Processing {len(moves)} frozen moves across multiple transactions for week {current.week}')
|
||||
|
||||
try:
|
||||
# Resolve contested transactions using extracted business logic
|
||||
winning_move_ids, losing_move_ids = await resolve_contested_transactions(moves, current.season)
|
||||
|
||||
# Cancel losing transactions (entire transactions, not just individual moves)
|
||||
for losing_move_id in losing_move_ids:
|
||||
# Get all moves in this losing transaction for notification
|
||||
losing_moves = [m for m in moves if m['moveid'] == losing_move_id]
|
||||
|
||||
# Cancel all moves in this transaction
|
||||
await db_patch('transactions', object_id=losing_move_id,
|
||||
params=[('frozen', False), ('cancelled', True)])
|
||||
|
||||
# Notify the losing team about the cancelled transaction
|
||||
if losing_moves:
|
||||
# Use first move to identify the team for notification
|
||||
first_move = losing_moves[0]
|
||||
# Find the non-FA team for notification
|
||||
team_for_notification = (first_move['newteam']
|
||||
if first_move['newteam']['abbrev'] != 'FA'
|
||||
else first_move['oldteam'])
|
||||
|
||||
await self.notify_cancel([first_move['player'], team_for_notification])
|
||||
|
||||
contested_players_in_transaction = [
|
||||
move['player']['name'] for move in losing_moves
|
||||
]
|
||||
logger.info(f"Cancelled entire transaction {losing_move_id} due to contested players: "
|
||||
f"{contested_players_in_transaction}")
|
||||
|
||||
# Unfreeze winning transactions
|
||||
for winning_move_id in winning_move_ids:
|
||||
await db_patch('transactions', object_id=winning_move_id, params=[('frozen', False)])
|
||||
await self.post_move_to_transaction_log(winning_move_id)
|
||||
|
||||
logger.info(f"Processed successful transaction {winning_move_id}")
|
||||
|
||||
logger.info(f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
|
||||
f"{len(losing_move_ids)} cancelled transactions")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during freeze processing: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def post_move_to_transaction_log(self, move_id):
|
||||
current = await get_current()
|
||||
# all_moves = await get_transactions(
|
||||
@ -566,7 +907,7 @@ class Transactions(commands.Cog):
|
||||
if week_num is None:
|
||||
week_num = move['week']
|
||||
|
||||
move_string += f'**{move["player"]["name"]}** ({move["player"]["wara"]}) from ' \
|
||||
move_string += f'**{move["player"]["name"]}** ({move["player"]["wara"]:.2f}) from ' \
|
||||
f'{move["oldteam"]["abbrev"]} to {move["newteam"]["abbrev"]}\n'
|
||||
|
||||
embed = get_team_embed(f'Week {week_num} Transaction', this_team)
|
||||
@ -624,7 +965,7 @@ class Transactions(commands.Cog):
|
||||
@commands.is_owner()
|
||||
async def process_freeze_helper_command(self, ctx):
|
||||
current = await get_current()
|
||||
await self.process_freeze_moves(current)
|
||||
await self.process_freeze_moves_new(current)
|
||||
await ctx.send(random_conf_gif())
|
||||
|
||||
@commands.command(name='post-weekly')
|
||||
@ -643,7 +984,7 @@ class Transactions(commands.Cog):
|
||||
await ctx.send(f'The trade deadline is **week {current.trade_deadline}**. Since it is currently **week '
|
||||
f'{current.week}** I am just going to stop you right there.')
|
||||
return
|
||||
if current.week < -2:
|
||||
if current.week < -2 or current.week == -1:
|
||||
await ctx.send(await get_emoji(ctx, 'oof', False))
|
||||
await ctx.send(f'Patience, grasshopper. Trades open soon.')
|
||||
return
|
||||
@ -704,7 +1045,7 @@ class Transactions(commands.Cog):
|
||||
while True:
|
||||
prompt = 'Are you adding a team to this deal? (Yes/No)'
|
||||
this_q = Question(self.bot, trade.channel, prompt, 'yesno', 30)
|
||||
resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -715,7 +1056,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Please enter the team\'s abbreviation.'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
|
||||
if not resp:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -736,7 +1077,7 @@ class Transactions(commands.Cog):
|
||||
while True:
|
||||
prompt = f'Are you trading a player between teams?'
|
||||
this_q = Question(self.bot, trade.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -747,7 +1088,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Which player is being traded?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
|
||||
if not resp:
|
||||
pass
|
||||
@ -773,7 +1114,7 @@ class Transactions(commands.Cog):
|
||||
await trade.send(f'Ope. {player["name"]} is already one the move next week.')
|
||||
else:
|
||||
this_q.prompt = 'Where are they going? Please enter the destination team\'s abbreviation.'
|
||||
resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -799,7 +1140,7 @@ class Transactions(commands.Cog):
|
||||
# while True and current.pick_trade_end >= current.week >= current.pick_trade_start:
|
||||
# prompt = f'Are you trading any draft picks?'
|
||||
# this_q = Question(self.bot, trade.channel, prompt, 'yesno', 300)
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
# effective_season = current.season if OFFSEASON_FLAG else current.season + 1
|
||||
# team_season = current.season
|
||||
#
|
||||
@ -813,7 +1154,7 @@ class Transactions(commands.Cog):
|
||||
# # Get first pick
|
||||
# this_q.prompt = 'Enter the pick\'s original owner and round number like this: TIT 17'
|
||||
# this_q.qtype = 'text'
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
#
|
||||
# if not resp:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -834,7 +1175,7 @@ class Transactions(commands.Cog):
|
||||
# f'round number, please.')
|
||||
# else:
|
||||
# this_q.prompt = 'Now enter the return pick\'s original owner and round number like this: TIT 17'
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
#
|
||||
# if not resp:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -884,7 +1225,7 @@ class Transactions(commands.Cog):
|
||||
while True:
|
||||
this_q.prompt = f'{trade.teams[team]["role"].mention}\nAre you making an FA drop?'
|
||||
this_q.qtype = 'yesno'
|
||||
resp = await this_q.ask(trade.get_gms(self.bot, team))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot, team))
|
||||
|
||||
if resp is None:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -895,7 +1236,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you dropping?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(trade.get_gms(self.bot, team))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot, team))
|
||||
|
||||
if resp is None:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -937,20 +1278,22 @@ class Transactions(commands.Cog):
|
||||
roster_errors = []
|
||||
for team in trade.teams:
|
||||
data = await trade.check_major_league_errors(team)
|
||||
team_obj = trade.teams[team]["team"]
|
||||
team_cap = get_team_salary_cap(team_obj)
|
||||
logger.warning(f'Done checking data - checking sWAR now ({data["wara"]}')
|
||||
|
||||
if data['wara'] > 32.001 and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {trade.teams[team]["team"]["abbrev"]} would have {data["wara"]:.2f} WARa')
|
||||
if exceeds_salary_cap(data['wara'], team_obj) and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {team_obj["abbrev"]} would have {data["wara"]:.2f} WARa (cap {team_cap:.1f})')
|
||||
|
||||
logger.warning(f'Now checking roster {len(data["roster"])}')
|
||||
if len(data['roster']) > 26 and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {trade.teams[team]["team"]["abbrev"]} would have {len(data["roster"])} players')
|
||||
errors.append(f'- {team_obj["abbrev"]} would have {len(data["roster"])} players')
|
||||
|
||||
logger.warning(f'Any errors? {errors}')
|
||||
if (data['wara'] > 32.001 or len(data['roster']) > 26) and not OFFSEASON_FLAG:
|
||||
if (exceeds_salary_cap(data['wara'], team_obj) or len(data['roster']) > 26) and not OFFSEASON_FLAG:
|
||||
roster_string = ''
|
||||
for x in data['roster']:
|
||||
roster_string += f'{x["wara"]: >5} - {x["name"]}\n'
|
||||
roster_string += f'{x["wara"]: >5.2f} - {x["name"]}\n'
|
||||
roster_errors.append(f'- This is the roster I have for {trade.teams[team]["team"]["abbrev"]}:\n'
|
||||
f'```\n{roster_string}```')
|
||||
|
||||
@ -967,7 +1310,7 @@ class Transactions(commands.Cog):
|
||||
for team in trade.teams:
|
||||
this_q.prompt = f'{trade.teams[team]["role"].mention}\nDo you accept this trade?'
|
||||
this_q.qtype = 'yesno'
|
||||
resp = await this_q.ask(trade.get_gms(self.bot, team))
|
||||
resp = await this_q.ask(await trade.get_gms(self.bot, team))
|
||||
|
||||
if not resp:
|
||||
await trade.send('RIP this move. Maybe next time.')
|
||||
@ -1044,7 +1387,7 @@ class Transactions(commands.Cog):
|
||||
# while True:
|
||||
# prompt = 'Are you adding a team to this deal? (Yes/No)'
|
||||
# this_q = Question(self.bot, trade.channel, prompt, 'yesno', 30)
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
#
|
||||
# if resp is None:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -1055,7 +1398,7 @@ class Transactions(commands.Cog):
|
||||
# else:
|
||||
# this_q.prompt = 'Please enter the team\'s abbreviation.'
|
||||
# this_q.qtype = 'text'
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
#
|
||||
# if not resp:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -1077,7 +1420,7 @@ class Transactions(commands.Cog):
|
||||
# while True:
|
||||
# prompt = f'Are you trading any draft picks?'
|
||||
# this_q = Question(self.bot, trade.channel, prompt, 'yesno', 300)
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
# effective_season = current.season if OFFSEASON_FLAG else current.season + 1
|
||||
# team_season = current.season
|
||||
#
|
||||
@ -1091,7 +1434,7 @@ class Transactions(commands.Cog):
|
||||
# # Get first pick
|
||||
# this_q.prompt = 'Enter the overall pick number being traded.'
|
||||
# this_q.qtype = 'int'
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
#
|
||||
# if not resp:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -1107,7 +1450,7 @@ class Transactions(commands.Cog):
|
||||
# await trade.send(f'Frick, I couldn\'t find pick #{resp}.')
|
||||
# else:
|
||||
# this_q.prompt = 'Now enter the return pick\'s overall pick number.'
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot))
|
||||
#
|
||||
# if not resp:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -1181,7 +1524,7 @@ class Transactions(commands.Cog):
|
||||
# for team in trade.teams:
|
||||
# this_q.prompt = f'{trade.teams[team]["role"].mention}\nDo you accept this trade?'
|
||||
# this_q.qtype = 'yesno'
|
||||
# resp = await this_q.ask(trade.get_gms(self.bot, team))
|
||||
# resp = await this_q.ask(await trade.get_gms(self.bot, team))
|
||||
#
|
||||
# if not resp:
|
||||
# await trade.send('RIP this move. Maybe next time.')
|
||||
@ -1254,7 +1597,7 @@ class Transactions(commands.Cog):
|
||||
while True and not OFFSEASON_FLAG:
|
||||
prompt = f'Are you adding someone to the Minor League roster?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1265,7 +1608,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you sending to the MiL?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1307,7 +1650,7 @@ class Transactions(commands.Cog):
|
||||
while True and not OFFSEASON_FLAG:
|
||||
prompt = f'Are you adding any players to the Major League roster?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1318,7 +1661,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you adding?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1367,7 +1710,7 @@ class Transactions(commands.Cog):
|
||||
while True:
|
||||
prompt = f'Are you dropping anyone to FA?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1378,7 +1721,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you dropping to FA?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1422,21 +1765,23 @@ class Transactions(commands.Cog):
|
||||
roster_errors = []
|
||||
for team in dropadd.teams:
|
||||
data = await dropadd.check_major_league_errors(team)
|
||||
team_obj = dropadd.teams[team]["team"]
|
||||
team_cap = get_team_salary_cap(team_obj)
|
||||
logger.warning(f'Done checking data - checking WARa now ({data["wara"]})')
|
||||
|
||||
if data['wara'] > 32.001 and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {data["wara"]:.2f} sWAR')
|
||||
if exceeds_salary_cap(data['wara'], team_obj) and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {team_obj["abbrev"]} would have {data["wara"]:.2f} sWAR (cap {team_cap:.1f})')
|
||||
|
||||
logger.warning(f'Now checking roster {len(data["roster"])}')
|
||||
if len(data['roster']) > 26 and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {len(data["roster"])} players')
|
||||
errors.append(f'- {team_obj["abbrev"]} would have {len(data["roster"])} players')
|
||||
|
||||
logger.warning(f'Any errors? {errors}')
|
||||
if (data['wara'] > 32.001 or len(data['roster']) > 26) and not OFFSEASON_FLAG:
|
||||
if (exceeds_salary_cap(data['wara'], team_obj) or len(data['roster']) > 26) and not OFFSEASON_FLAG:
|
||||
roster_string = ''
|
||||
for x in data['roster']:
|
||||
roster_string += f'{x["wara"]: >5} - {x["name"]}\n'
|
||||
errors.append(f'- This is the roster I have for {dropadd.teams[team]["team"]["abbrev"]}:\n'
|
||||
roster_string += f'{x["wara"]: >5.2f} - {x["name"]}\n'
|
||||
errors.append(f'- This is the roster I have for {team_obj["abbrev"]}:\n'
|
||||
f'```\n{roster_string}```')
|
||||
|
||||
mil_cap = 6 if current.week <= FA_LOCK_WEEK else 14
|
||||
@ -1458,7 +1803,7 @@ class Transactions(commands.Cog):
|
||||
for team in dropadd.teams:
|
||||
this_q.prompt = f'{dropadd.teams[team]["role"].mention}\nWould you like me to run this move?'
|
||||
this_q.qtype = 'yesno'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot, team))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot, team))
|
||||
|
||||
if not resp:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1484,7 +1829,7 @@ class Transactions(commands.Cog):
|
||||
q_string = player_name.replace(' ', '%20')
|
||||
dest_url = f'https://www.bing.com/images/search?q=rule%2034%20{q_string}&safesearch=off'
|
||||
else:
|
||||
dest_url = 'https://docs.google.com/spreadsheets/d/1kGNSQbwocG5NuXKw5NXFPJ1F2V7zM0_qsMv_1o7QjLs/edit?gid=0#gid=0'
|
||||
dest_url = 'https://docs.google.com/spreadsheets/d/1nNkStOm1St1U2aQ0Jrdhyti5Qe0b-sI8xTUGCTOLuXE/edit?usp=sharing'
|
||||
|
||||
await ctx.send(
|
||||
content=f'To play the 50/50, click the \'Trust Links\' button that pops up for [Bing](<https://www.bing.com/>) and for [Sheets](<https://docs.google.com/spreadsheets/u/0/>).\n\n[Rule 34 Draft](<{dest_url}>)'
|
||||
@ -1540,7 +1885,7 @@ class Transactions(commands.Cog):
|
||||
while True:
|
||||
prompt = f'Are you sending someone to the IL?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1551,7 +1896,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you sending to the IL?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1583,7 +1928,7 @@ class Transactions(commands.Cog):
|
||||
while True and not OFFSEASON_FLAG:
|
||||
prompt = f'Are you adding someone to the Major League team?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1594,7 +1939,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you adding?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1625,7 +1970,7 @@ class Transactions(commands.Cog):
|
||||
while True and not OFFSEASON_FLAG:
|
||||
prompt = f'Are you adding someone to the Minor League team?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1636,7 +1981,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you sending to the MiL?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1671,7 +2016,7 @@ class Transactions(commands.Cog):
|
||||
while True:
|
||||
prompt = f'Are you dropping anyone to FA?'
|
||||
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1682,7 +2027,7 @@ class Transactions(commands.Cog):
|
||||
else:
|
||||
this_q.prompt = 'Who are you dropping to FA?'
|
||||
this_q.qtype = 'text'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot))
|
||||
|
||||
if resp is None:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1724,18 +2069,20 @@ class Transactions(commands.Cog):
|
||||
roster_errors = []
|
||||
for team in dropadd.teams:
|
||||
data = await dropadd.check_major_league_errors(team)
|
||||
team_obj = dropadd.teams[team]["team"]
|
||||
team_cap = get_team_salary_cap(team_obj)
|
||||
|
||||
if data['wara'] > 32.001 and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {data["wara"]:.2f} WARa')
|
||||
if exceeds_salary_cap(data['wara'], team_obj) and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {team_obj["abbrev"]} would have {data["wara"]:.2f} WARa (cap {team_cap:.1f})')
|
||||
|
||||
if len(data['roster']) > 26 and not OFFSEASON_FLAG:
|
||||
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {len(data["roster"])} players')
|
||||
errors.append(f'- {team_obj["abbrev"]} would have {len(data["roster"])} players')
|
||||
|
||||
if (data['wara'] > 32.001 or len(data['roster']) > 26) and not OFFSEASON_FLAG:
|
||||
if (exceeds_salary_cap(data['wara'], team_obj) or len(data['roster']) > 26) and not OFFSEASON_FLAG:
|
||||
roster_string = ''
|
||||
for x in data['roster']:
|
||||
roster_string += f'{x["wara"]: >5} - {x["name"]}\n'
|
||||
errors.append(f'- This is the roster I have for {dropadd.teams[team]["team"]["abbrev"]}:\n'
|
||||
roster_string += f'{x["wara"]: >5.2f} - {x["name"]}\n'
|
||||
errors.append(f'- This is the roster I have for {team_obj["abbrev"]}:\n'
|
||||
f'```\n{roster_string}```')
|
||||
|
||||
if len(errors) + len(roster_errors) > 0:
|
||||
@ -1751,7 +2098,7 @@ class Transactions(commands.Cog):
|
||||
for team in dropadd.teams:
|
||||
this_q.prompt = f'{dropadd.teams[team]["role"].mention}\nWould you like me to run this move?'
|
||||
this_q.qtype = 'yesno'
|
||||
resp = await this_q.ask(dropadd.get_gms(self.bot, team))
|
||||
resp = await this_q.ask(await dropadd.get_gms(self.bot, team))
|
||||
|
||||
if not resp:
|
||||
await dropadd.send('RIP this move. Maybe next time.')
|
||||
@ -1816,7 +2163,7 @@ class Transactions(commands.Cog):
|
||||
guaranteed[x["moveid"]] = []
|
||||
|
||||
guaranteed[x["moveid"]].append(
|
||||
f'**{x["player"]["name"]}** ({x["player"]["wara"]}) from '
|
||||
f'**{x["player"]["name"]}** ({x["player"]["wara"]:.2f}) from '
|
||||
f'{x["oldteam"]["abbrev"]} to {x["newteam"]["abbrev"]}\n'
|
||||
)
|
||||
|
||||
@ -1825,7 +2172,7 @@ class Transactions(commands.Cog):
|
||||
frozen[x["moveid"]] = []
|
||||
|
||||
frozen[x["moveid"]].append(
|
||||
f'**{x["player"]["name"]}** ({x["player"]["wara"]}) from '
|
||||
f'**{x["player"]["name"]}** ({x["player"]["wara"]:.2f}) from '
|
||||
f'{x["oldteam"]["abbrev"]} to {x["newteam"]["abbrev"]}\n'
|
||||
)
|
||||
|
||||
@ -1866,6 +2213,7 @@ class Transactions(commands.Cog):
|
||||
embed.description = f'Week {current.week + 1}'
|
||||
|
||||
errors = []
|
||||
team_cap = get_team_salary_cap(this_team)
|
||||
|
||||
sil_wara = roster['shortil']['WARa']
|
||||
total_wara = roster['active']['WARa']
|
||||
@ -1874,8 +2222,8 @@ class Transactions(commands.Cog):
|
||||
wara_string += f' ({sil_wara:.2f} IL)'
|
||||
|
||||
embed.add_field(name='sWAR', value=wara_string)
|
||||
if total_wara > 32.001:
|
||||
errors.append(f'- sWAR currently {total_wara:.2f} (cap 32.0)')
|
||||
if exceeds_salary_cap(total_wara, this_team):
|
||||
errors.append(f'- sWAR currently {total_wara:.2f} (cap {team_cap:.1f})')
|
||||
|
||||
player_count = len(roster["active"]["players"])
|
||||
embed.add_field(name='Player Count', value=f'{player_count}')
|
||||
@ -1952,7 +2300,7 @@ class Transactions(commands.Cog):
|
||||
if player['team']['id'] != team['id']:
|
||||
await ctx.send(f'Omg stop trying to make {player["name"]} happen. It\'s not going to happen.')
|
||||
else:
|
||||
output_string += f'**{player["name"]}** ({player["wara"]}) to MiL\n'
|
||||
output_string += f'**{player["name"]}** ({player["wara"]:.2f}) to MiL\n'
|
||||
if player['team']['id'] == team['id']:
|
||||
moves.append({
|
||||
'week': 1,
|
||||
|
||||
@ -160,6 +160,12 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 3, timeout=3):
|
||||
raise ValueError(f'DB: {e}')
|
||||
|
||||
|
||||
# async def db_delete_transaction(move_id: str):
|
||||
# req_url = get_req_url('transactions', api_ver=3, object_id=move_id)
|
||||
# log_string = f'delete:\n{endpoint} {object_id}'
|
||||
# logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
|
||||
async def get_team_by_abbrev(team_abbrev: str, season: int):
|
||||
t_query = await db_get('teams', params=[('season', season), ('team_abbrev', team_abbrev)])
|
||||
if not t_query or t_query['count'] == 0:
|
||||
|
||||
85
helpers.py
85
helpers.py
@ -14,6 +14,7 @@ import json
|
||||
|
||||
import discord
|
||||
import requests
|
||||
import aiohttp
|
||||
|
||||
from discord.ext import commands
|
||||
from difflib import get_close_matches
|
||||
@ -25,6 +26,10 @@ PD_SEASON = 9
|
||||
FA_LOCK_WEEK = 14
|
||||
SBA_COLOR = 'a6ce39'
|
||||
|
||||
# Salary cap constants
|
||||
DEFAULT_SALARY_CAP = 32.0
|
||||
SALARY_CAP_TOLERANCE = 0.001 # Small tolerance for floating point comparisons
|
||||
|
||||
SBA_ROSTER_KEY = '1bt7LLJe6h7axkhDVlxJ4f319l8QmFB0zQH-pjM0c8a8'
|
||||
SBA_STATS_KEY = '1fnqx2uxC7DT5aTnx4EkXh83crwrL0W6eJefoC1d4KH4'
|
||||
SBA_STANDINGS_KEY = '1cXZcPY08RvqV_GeLvZ7PY5-0CyM-AijpJxsaFisZjBc'
|
||||
@ -37,7 +42,8 @@ SBA_SEASON7_DRAFT_KEY = '1BgySsUlQf9K21_uOjQOY7O0GrRfF6zt1BBaEFlvBokY'
|
||||
SBA_SEASON8_DRAFT_KEY = '1FG4cAs8OeTdrreRqu8D-APxibjB3RiEzn34KTTBLLDk'
|
||||
SBA_SEASON9_DRAFT_KEY = '1eyHqaVU9rtmhG1p0ZktOrz7FMDp3c_unCcFyMMYceLc'
|
||||
DRAFT_KEY = {
|
||||
11: '1Fz3GcTb7b9tLe8vkpyn59wRwC6P2QzxnLKtp7371sUc'
|
||||
11: '1Fz3GcTb7b9tLe8vkpyn59wRwC6P2QzxnLKtp7371sUc',
|
||||
12: '1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU'
|
||||
}
|
||||
SBA_STANDINGS_URL = f'{SBA_BASE_URL}/standings'
|
||||
SBA_SCHEDULE_URL = f'{SBA_BASE_URL}/schedule'
|
||||
@ -213,9 +219,12 @@ class Question:
|
||||
def text(mes):
|
||||
return mes.channel == self.channel and mes.author in responders
|
||||
|
||||
logger.info(f'Sending watched message')
|
||||
await self.channel.send(content=self.prompt, embed=self.embed)
|
||||
logger.info(f'message is sent')
|
||||
|
||||
try:
|
||||
logger.info(f'waiting for a response')
|
||||
resp = await self.bot.wait_for(
|
||||
'message',
|
||||
timeout=self.timeout,
|
||||
@ -864,7 +873,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
|
||||
embed.add_field(name='P Injury', value=f'{player["pitcher_injury"]} (6-{13 - player["pitcher_injury"]})')
|
||||
else:
|
||||
embed.add_field(name='P Injury', value=f'{player["pitcher_injury"]} (---)')
|
||||
if d_query['count'] > 0:
|
||||
if d_query and d_query.get('count') > 0:
|
||||
pick = d_query['picks'][0]
|
||||
num = pick["overall"] % 16
|
||||
if num == 0:
|
||||
@ -895,7 +904,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
|
||||
|
||||
b_query = await db_get('battingstats/totals', params=[
|
||||
('season', player['season']), ('player_id', player['id']), ('s_type', 'regular')])
|
||||
if b_query['count'] > 0:
|
||||
if b_query and b_query.get('count') > 0:
|
||||
b = b_query['stats'][0]
|
||||
|
||||
if b['ab'] > 0:
|
||||
@ -926,7 +935,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
|
||||
|
||||
p_query = await db_get('pitchingstats/totals', params=[
|
||||
('season', player['season']), ('player_id', player['id']), ('s_type', 'regular')])
|
||||
if p_query['count'] > 0:
|
||||
if p_query and p_query.get('count') > 0:
|
||||
p = p_query['stats'][0]
|
||||
|
||||
if p['ip'] > 0:
|
||||
@ -966,7 +975,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
|
||||
batter_priority = True
|
||||
b, p, batting_string, pitching_string = None, None, None, None
|
||||
|
||||
if b_query['count'] > 0:
|
||||
if b_query and b_query.get('count') > 0:
|
||||
b = b_query['stats'][0]
|
||||
batting_string = f'```\n' \
|
||||
f' AVG OBP SLG\n' \
|
||||
@ -976,7 +985,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
|
||||
f' PA H RBI 2B 3B HR SB\n' \
|
||||
f'{b["pa"]: >3} {b["hit"]: ^3} {b["rbi"]: ^3} {b["double"]: >2} {b["triple"]: >2} ' \
|
||||
f'{b["hr"]: >2} {b["sb"]: >2}```\n'
|
||||
if p_query['count'] > 0:
|
||||
if p_query and p_query.get('count') > 0:
|
||||
p = p_query['stats'][0]
|
||||
if b is None or p["tbf"] > b["pa"]:
|
||||
batter_priority = False
|
||||
@ -1182,3 +1191,67 @@ def random_from_list(data_list: list):
|
||||
|
||||
def get_team_url(this_team):
|
||||
return f'https://sba.manticorum.com/teams/{this_team["season"]}/{this_team["abbrev"]}'
|
||||
|
||||
|
||||
async def send_owner_notification(message: str):
|
||||
"""
|
||||
Send a notification message to the bot owner via Discord webhook.
|
||||
|
||||
Args:
|
||||
message (str): The message content to send
|
||||
"""
|
||||
logger = logging.getLogger('discord_app')
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
webhook_url = "https://discord.com/api/webhooks/1408811717424840876/7RXG_D5IqovA3Jwa9YOobUjVcVMuLc6cQyezABcWuXaHo5Fvz1en10M7J43o3OJ3bzGW"
|
||||
payload = {
|
||||
"content": message
|
||||
}
|
||||
await session.post(webhook_url, json=payload)
|
||||
except Exception as webhook_error:
|
||||
logger.error(f'Failed to send owner notification: {webhook_error}')
|
||||
|
||||
|
||||
def get_team_salary_cap(team) -> float:
|
||||
"""
|
||||
Get the salary cap for a team, falling back to the default if not set.
|
||||
|
||||
Args:
|
||||
team: Team data - can be a dict or Pydantic model with 'salary_cap' attribute.
|
||||
|
||||
Returns:
|
||||
float: The team's salary cap, or DEFAULT_SALARY_CAP (32.0) if not set.
|
||||
|
||||
Why: Teams may have custom salary caps (e.g., for expansion teams or penalties).
|
||||
This centralizes the fallback logic so all cap checks use the same source of truth.
|
||||
"""
|
||||
if team is None:
|
||||
return DEFAULT_SALARY_CAP
|
||||
|
||||
# Handle both dict and Pydantic model (or any object with salary_cap attribute)
|
||||
if isinstance(team, dict):
|
||||
salary_cap = team.get('salary_cap')
|
||||
else:
|
||||
salary_cap = getattr(team, 'salary_cap', None)
|
||||
|
||||
if salary_cap is not None:
|
||||
return salary_cap
|
||||
return DEFAULT_SALARY_CAP
|
||||
|
||||
|
||||
def exceeds_salary_cap(wara: float, team) -> bool:
|
||||
"""
|
||||
Check if a WAR total exceeds the team's salary cap.
|
||||
|
||||
Args:
|
||||
wara: The total WAR value to check
|
||||
team: Team data - can be a dict or Pydantic model
|
||||
|
||||
Returns:
|
||||
bool: True if wara exceeds the team's salary cap (with tolerance)
|
||||
|
||||
Why: Centralizes the salary cap comparison logic with proper floating point
|
||||
tolerance handling. All cap validation should use this function.
|
||||
"""
|
||||
cap = get_team_salary_cap(team)
|
||||
return wara > (cap + SALARY_CAP_TOLERANCE)
|
||||
|
||||
102
salary_cap_refactor_plan.json
Normal file
102
salary_cap_refactor_plan.json
Normal file
@ -0,0 +1,102 @@
|
||||
{
|
||||
"project": "Dynamic Salary Cap Refactor",
|
||||
"description": "Replace hardcoded salary cap values (32.0/32.001) with Team.salary_cap field",
|
||||
"created": "2025-12-09",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "SWAR-001",
|
||||
"name": "Add salary cap helper function",
|
||||
"description": "Create a helper function in helpers.py that retrieves the salary cap for a team, with fallback to default 32.0 for backwards compatibility",
|
||||
"files": ["helpers.py"],
|
||||
"lines": [1215, 1257],
|
||||
"priority": 1,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"notes": "Added get_team_salary_cap() and exceeds_salary_cap() functions with Pydantic model support"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-002",
|
||||
"name": "Update draft cap space check",
|
||||
"description": "Replace hardcoded 32.00001 cap check in draft.py with team.salary_cap from the Team model",
|
||||
"files": ["cogs/draft.py"],
|
||||
"lines": [442, 447],
|
||||
"priority": 2,
|
||||
"completed": true,
|
||||
"tested": false,
|
||||
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-003",
|
||||
"name": "Update trade validation sWAR check",
|
||||
"description": "Replace hardcoded 32.001 cap threshold in trade validation with team.salary_cap",
|
||||
"files": ["cogs/transactions.py"],
|
||||
"lines": [1281, 1293],
|
||||
"priority": 2,
|
||||
"completed": true,
|
||||
"tested": false,
|
||||
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-004",
|
||||
"name": "Update first drop/add validation",
|
||||
"description": "Replace hardcoded 32.001 cap threshold in first drop/add validation block with team.salary_cap",
|
||||
"files": ["cogs/transactions.py"],
|
||||
"lines": [1768, 1780],
|
||||
"priority": 2,
|
||||
"completed": true,
|
||||
"tested": false,
|
||||
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-005",
|
||||
"name": "Update second drop/add validation",
|
||||
"description": "Replace hardcoded 32.001 cap threshold in second drop/add validation block with team.salary_cap",
|
||||
"files": ["cogs/transactions.py"],
|
||||
"lines": [2072, 2081],
|
||||
"priority": 2,
|
||||
"completed": true,
|
||||
"tested": false,
|
||||
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-006",
|
||||
"name": "Update roster validation error message",
|
||||
"description": "Replace hardcoded '(cap 32.0)' in error message with actual team salary_cap value",
|
||||
"files": ["cogs/transactions.py"],
|
||||
"lines": [2216, 2226],
|
||||
"priority": 2,
|
||||
"completed": true,
|
||||
"tested": false,
|
||||
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-007",
|
||||
"name": "Add default salary cap constant",
|
||||
"description": "Define DEFAULT_SALARY_CAP = 32.0 constant in helpers.py for use as fallback when team.salary_cap is None",
|
||||
"files": ["helpers.py"],
|
||||
"lines": [30, 31],
|
||||
"priority": 1,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"notes": "Added DEFAULT_SALARY_CAP and SALARY_CAP_TOLERANCE constants"
|
||||
},
|
||||
{
|
||||
"id": "SWAR-008",
|
||||
"name": "Verify Team model salary_cap field",
|
||||
"description": "Confirm api_calls/team.py has salary_cap field properly defined and API returns this value",
|
||||
"files": ["api_calls/team.py"],
|
||||
"lines": [26],
|
||||
"priority": 1,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"notes": "Field exists: salary_cap: Optional[float] = None"
|
||||
}
|
||||
],
|
||||
"completion_checklist": [
|
||||
"All hardcoded 32.0/32.001 values replaced - DONE",
|
||||
"Helper function created with fallback logic - DONE",
|
||||
"All affected cogs tested manually - PENDING",
|
||||
"Unit tests added for salary cap validation - DONE (21 tests)",
|
||||
"Error messages display correct cap values - DONE"
|
||||
]
|
||||
}
|
||||
@ -61,7 +61,7 @@ async def test_patch_draftpick_success():
|
||||
round=1,
|
||||
origowner=Team(id=1, abbrev='AAA', sname='Alpha', lname='Alpha Squad', season=2025),
|
||||
owner=Team(id=2, abbrev='BBB', sname='Beta', lname='Beta Crew', season=2025),
|
||||
player=Player(id=99, name="Test Player", team=Team(id=2, abbrev='BBB', sname='Beta', lname='Beta Crew', season=2025))
|
||||
player=Player(id=99, name="Test Player", wara=2.5, image="test.png", season=2025, pos_1="SS", team=Team(id=2, abbrev='BBB', sname='Beta', lname='Beta Crew', season=2025))
|
||||
)
|
||||
|
||||
mock_response = {
|
||||
|
||||
421
tests/test_salary_cap.py
Normal file
421
tests/test_salary_cap.py
Normal file
@ -0,0 +1,421 @@
|
||||
"""
|
||||
Unit tests for salary cap helper functions in helpers.py.
|
||||
|
||||
These tests verify:
|
||||
1. get_team_salary_cap() returns correct cap values with fallback behavior
|
||||
2. exceeds_salary_cap() correctly identifies when WAR exceeds team cap
|
||||
3. Edge cases around None values and floating point tolerance
|
||||
|
||||
Why these tests matter:
|
||||
- Salary cap validation is critical for league integrity during trades/drafts
|
||||
- The helper functions centralize logic previously scattered across cogs
|
||||
- Proper fallback behavior ensures backwards compatibility
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from helpers import (
|
||||
DEFAULT_SALARY_CAP,
|
||||
SALARY_CAP_TOLERANCE,
|
||||
get_team_salary_cap,
|
||||
exceeds_salary_cap
|
||||
)
|
||||
|
||||
|
||||
class TestGetTeamSalaryCap:
|
||||
"""Tests for get_team_salary_cap() function."""
|
||||
|
||||
def test_returns_team_salary_cap_when_set(self):
|
||||
"""
|
||||
When a team has a custom salary_cap value set, return that value.
|
||||
|
||||
Why: Some teams may have different caps (expansion teams, penalties, etc.)
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 35.0}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == 35.0
|
||||
|
||||
def test_returns_default_when_salary_cap_is_none(self):
|
||||
"""
|
||||
When team.salary_cap is None, return the default cap (32.0).
|
||||
|
||||
Why: Most teams use the standard cap; None indicates no custom value.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': None}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
assert result == 32.0
|
||||
|
||||
def test_returns_default_when_salary_cap_key_missing(self):
|
||||
"""
|
||||
When the salary_cap key doesn't exist in team dict, return default.
|
||||
|
||||
Why: Backwards compatibility with older team data structures.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'sname': 'Test Team'}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
|
||||
def test_returns_default_when_team_is_none(self):
|
||||
"""
|
||||
When team is None, return the default cap.
|
||||
|
||||
Why: Defensive programming - callers may pass None in edge cases.
|
||||
"""
|
||||
result = get_team_salary_cap(None)
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
|
||||
def test_returns_default_when_team_is_empty_dict(self):
|
||||
"""
|
||||
When team is an empty dict, return the default cap.
|
||||
|
||||
Why: Edge case handling for malformed team data.
|
||||
"""
|
||||
result = get_team_salary_cap({})
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
|
||||
def test_respects_zero_salary_cap(self):
|
||||
"""
|
||||
When salary_cap is explicitly 0, return 0 (not default).
|
||||
|
||||
Why: Zero is a valid value (e.g., suspended team), distinct from None.
|
||||
"""
|
||||
team = {'abbrev': 'BANNED', 'salary_cap': 0.0}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == 0.0
|
||||
|
||||
def test_handles_integer_salary_cap(self):
|
||||
"""
|
||||
When salary_cap is an integer, return it as-is.
|
||||
|
||||
Why: API may return int instead of float; function should handle both.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 30}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == 30
|
||||
|
||||
|
||||
class TestExceedsSalaryCap:
|
||||
"""Tests for exceeds_salary_cap() function."""
|
||||
|
||||
def test_returns_false_when_under_cap(self):
|
||||
"""
|
||||
WAR of 30.0 should not exceed default cap of 32.0.
|
||||
|
||||
Why: Normal case - team is under cap and should pass validation.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
result = exceeds_salary_cap(30.0, team)
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_when_exactly_at_cap(self):
|
||||
"""
|
||||
WAR of exactly 32.0 should not exceed cap (within tolerance).
|
||||
|
||||
Why: Teams should be allowed to be exactly at cap limit.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
result = exceeds_salary_cap(32.0, team)
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_within_tolerance(self):
|
||||
"""
|
||||
WAR slightly above cap but within tolerance should not exceed.
|
||||
|
||||
Why: Floating point math may produce values like 32.0000001;
|
||||
tolerance prevents false positives from rounding errors.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
# 32.0005 is within 0.001 tolerance of 32.0
|
||||
result = exceeds_salary_cap(32.0005, team)
|
||||
assert result is False
|
||||
|
||||
def test_returns_true_when_over_cap(self):
|
||||
"""
|
||||
WAR of 33.0 clearly exceeds cap of 32.0.
|
||||
|
||||
Why: Core validation - must reject teams over cap.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
result = exceeds_salary_cap(33.0, team)
|
||||
assert result is True
|
||||
|
||||
def test_returns_true_just_over_tolerance(self):
|
||||
"""
|
||||
WAR just beyond tolerance should exceed cap.
|
||||
|
||||
Why: Tolerance has a boundary; values beyond it must fail.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
# 32.002 is beyond 0.001 tolerance
|
||||
result = exceeds_salary_cap(32.002, team)
|
||||
assert result is True
|
||||
|
||||
def test_uses_team_custom_cap(self):
|
||||
"""
|
||||
Should use team's custom cap, not default.
|
||||
|
||||
Why: Teams with higher/lower caps must be validated correctly.
|
||||
"""
|
||||
team = {'abbrev': 'EXPANSION', 'salary_cap': 28.0}
|
||||
# 30.0 is under default 32.0 but over custom 28.0
|
||||
result = exceeds_salary_cap(30.0, team)
|
||||
assert result is True
|
||||
|
||||
def test_uses_default_cap_when_team_cap_none(self):
|
||||
"""
|
||||
When team has no custom cap, use default for comparison.
|
||||
|
||||
Why: Backwards compatibility - existing teams without salary_cap field.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': None}
|
||||
result = exceeds_salary_cap(33.0, team)
|
||||
assert result is True
|
||||
|
||||
result = exceeds_salary_cap(31.0, team)
|
||||
assert result is False
|
||||
|
||||
def test_handles_none_team(self):
|
||||
"""
|
||||
When team is None, use default cap for comparison.
|
||||
|
||||
Why: Defensive programming for edge cases.
|
||||
"""
|
||||
result = exceeds_salary_cap(33.0, None)
|
||||
assert result is True
|
||||
|
||||
result = exceeds_salary_cap(31.0, None)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPydanticModelSupport:
|
||||
"""Tests for Pydantic model support in helper functions."""
|
||||
|
||||
def test_get_team_salary_cap_with_pydantic_model(self):
|
||||
"""
|
||||
Should work with Pydantic models that have salary_cap attribute.
|
||||
|
||||
Why: Team objects in the codebase are often Pydantic models,
|
||||
not just dicts. The helper must support both.
|
||||
"""
|
||||
class MockTeam:
|
||||
salary_cap = 35.0
|
||||
abbrev = 'TEST'
|
||||
|
||||
team = MockTeam()
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == 35.0
|
||||
|
||||
def test_get_team_salary_cap_with_pydantic_model_none_cap(self):
|
||||
"""
|
||||
Pydantic model with salary_cap=None should return default.
|
||||
|
||||
Why: Many existing Team objects have salary_cap=None.
|
||||
"""
|
||||
class MockTeam:
|
||||
salary_cap = None
|
||||
abbrev = 'TEST'
|
||||
|
||||
team = MockTeam()
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
|
||||
def test_get_team_salary_cap_with_object_missing_attribute(self):
|
||||
"""
|
||||
Object without salary_cap attribute should return default.
|
||||
|
||||
Why: Defensive handling for objects that don't have the attribute.
|
||||
"""
|
||||
class MockTeam:
|
||||
abbrev = 'TEST'
|
||||
|
||||
team = MockTeam()
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
|
||||
def test_exceeds_salary_cap_with_pydantic_model(self):
|
||||
"""
|
||||
exceeds_salary_cap should work with Pydantic-like objects.
|
||||
|
||||
Why: Draft and transaction code passes Team objects directly.
|
||||
"""
|
||||
class MockTeam:
|
||||
salary_cap = 28.0
|
||||
abbrev = 'EXPANSION'
|
||||
|
||||
team = MockTeam()
|
||||
# 30.0 exceeds custom cap of 28.0
|
||||
result = exceeds_salary_cap(30.0, team)
|
||||
assert result is True
|
||||
|
||||
# 27.0 does not exceed custom cap of 28.0
|
||||
result = exceeds_salary_cap(27.0, team)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_negative_salary_cap(self):
|
||||
"""
|
||||
Negative salary cap should be returned as-is (even if nonsensical).
|
||||
|
||||
Why: Function should not validate business logic - just return the value.
|
||||
If someone sets a negative cap, that's a data issue, not a helper issue.
|
||||
"""
|
||||
team = {'abbrev': 'BROKE', 'salary_cap': -5.0}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == -5.0
|
||||
|
||||
def test_negative_wara_under_cap(self):
|
||||
"""
|
||||
Negative WAR should not exceed any positive cap.
|
||||
|
||||
Why: Teams with negative WAR (all bad players) are clearly under cap.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
result = exceeds_salary_cap(-10.0, team)
|
||||
assert result is False
|
||||
|
||||
def test_negative_wara_with_negative_cap(self):
|
||||
"""
|
||||
Negative WAR vs negative cap - WAR higher than cap exceeds it.
|
||||
|
||||
Why: Edge case where both values are negative. -3.0 > -5.0 + 0.001
|
||||
"""
|
||||
team = {'abbrev': 'BROKE', 'salary_cap': -5.0}
|
||||
# -3.0 > -4.999 (which is -5.0 + 0.001), so it exceeds
|
||||
result = exceeds_salary_cap(-3.0, team)
|
||||
assert result is True
|
||||
|
||||
# -6.0 < -4.999, so it does not exceed
|
||||
result = exceeds_salary_cap(-6.0, team)
|
||||
assert result is False
|
||||
|
||||
def test_very_large_salary_cap(self):
|
||||
"""
|
||||
Very large salary cap values should work correctly.
|
||||
|
||||
Why: Ensure no overflow or precision issues with large numbers.
|
||||
"""
|
||||
team = {'abbrev': 'RICH', 'salary_cap': 1000000.0}
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == 1000000.0
|
||||
|
||||
result = exceeds_salary_cap(999999.0, team)
|
||||
assert result is False
|
||||
|
||||
result = exceeds_salary_cap(1000001.0, team)
|
||||
assert result is True
|
||||
|
||||
def test_very_small_salary_cap(self):
|
||||
"""
|
||||
Very small (but positive) salary cap should work.
|
||||
|
||||
Why: Some hypothetical penalty scenario with tiny cap.
|
||||
"""
|
||||
team = {'abbrev': 'TINY', 'salary_cap': 0.5}
|
||||
result = exceeds_salary_cap(0.4, team)
|
||||
assert result is False
|
||||
|
||||
result = exceeds_salary_cap(0.6, team)
|
||||
assert result is True
|
||||
|
||||
def test_float_precision_boundary(self):
|
||||
"""
|
||||
Test exact boundary of tolerance (cap + 0.001).
|
||||
|
||||
Why: Ensure the boundary condition is handled correctly.
|
||||
The check is wara > (cap + tolerance), so exactly at boundary should NOT exceed.
|
||||
"""
|
||||
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
|
||||
# Exactly at cap + tolerance = 32.001
|
||||
result = exceeds_salary_cap(32.001, team)
|
||||
assert result is False # Not greater than, equal to
|
||||
|
||||
# Just barely over
|
||||
result = exceeds_salary_cap(32.0011, team)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestRealTeamModel:
|
||||
"""Tests using the actual Team Pydantic model from api_calls."""
|
||||
|
||||
def test_with_real_team_model(self):
|
||||
"""
|
||||
Test with the actual Team Pydantic model used in production.
|
||||
|
||||
Why: Ensures the helper works with real Team objects, not just mocks.
|
||||
"""
|
||||
from api_calls.team import Team
|
||||
|
||||
team = Team(
|
||||
id=1,
|
||||
abbrev='TEST',
|
||||
sname='Test Team',
|
||||
lname='Test Team Long Name',
|
||||
season=12,
|
||||
salary_cap=28.5
|
||||
)
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == 28.5
|
||||
|
||||
def test_with_real_team_model_none_cap(self):
|
||||
"""
|
||||
Real Team model with salary_cap=None should use default.
|
||||
|
||||
Why: This is the most common case in production.
|
||||
"""
|
||||
from api_calls.team import Team
|
||||
|
||||
team = Team(
|
||||
id=2,
|
||||
abbrev='STD',
|
||||
sname='Standard Team',
|
||||
lname='Standard Team Long Name',
|
||||
season=12,
|
||||
salary_cap=None
|
||||
)
|
||||
result = get_team_salary_cap(team)
|
||||
assert result == DEFAULT_SALARY_CAP
|
||||
|
||||
def test_exceeds_with_real_team_model(self):
|
||||
"""
|
||||
exceeds_salary_cap with real Team model.
|
||||
|
||||
Why: End-to-end test with actual production model.
|
||||
"""
|
||||
from api_calls.team import Team
|
||||
|
||||
team = Team(
|
||||
id=3,
|
||||
abbrev='EXP',
|
||||
sname='Expansion',
|
||||
lname='Expansion Team',
|
||||
season=12,
|
||||
salary_cap=28.0
|
||||
)
|
||||
# 30.0 exceeds 28.0 cap
|
||||
assert exceeds_salary_cap(30.0, team) is True
|
||||
# 27.0 does not exceed 28.0 cap
|
||||
assert exceeds_salary_cap(27.0, team) is False
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for salary cap constants."""
|
||||
|
||||
def test_default_salary_cap_value(self):
|
||||
"""
|
||||
DEFAULT_SALARY_CAP should be 32.0 (league standard).
|
||||
|
||||
Why: Ensures constant wasn't accidentally changed.
|
||||
"""
|
||||
assert DEFAULT_SALARY_CAP == 32.0
|
||||
|
||||
def test_tolerance_value(self):
|
||||
"""
|
||||
SALARY_CAP_TOLERANCE should be 0.001.
|
||||
|
||||
Why: Tolerance must be small enough to catch real violations
|
||||
but large enough to handle floating point imprecision.
|
||||
"""
|
||||
assert SALARY_CAP_TOLERANCE == 0.001
|
||||
Loading…
Reference in New Issue
Block a user