From 2b4b84e1938e20a8c1c63809830678889c10d90a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 25 Jul 2025 10:00:25 -0500 Subject: [PATCH] Schedule fix, transaction priority fix --- cogs/players.py | 12 +- cogs/transactions.py | 310 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 314 insertions(+), 8 deletions(-) diff --git a/cogs/players.py b/cogs/players.py index 328fc99..39688e4 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -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) @@ -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', diff --git a/cogs/transactions.py b/cogs/transactions.py index d83cc6a..06f996b 100644 --- a/cogs/transactions.py +++ b/cogs/transactions.py @@ -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 = 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": { Strat Player Dict }, "to": { Strat Team Dict } } } @@ -342,16 +560,19 @@ class Transactions(commands.Cog): @tasks.loop(minutes=1) async def weekly_loop(self): + 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()}') + 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 + 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) @@ -372,6 +593,7 @@ class Transactions(commands.Cog): # 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}' @@ -383,6 +605,9 @@ class Transactions(commands.Cog): 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') @weekly_loop.before_loop async def before_notif_check(self): @@ -443,7 +668,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 +764,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( @@ -624,7 +928,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')