Schedule fix, transaction priority fix

This commit is contained in:
Cal Corum 2025-07-25 10:00:25 -05:00
parent 9bb84ce287
commit 2b4b84e193
2 changed files with 314 additions and 8 deletions

View File

@ -998,12 +998,15 @@ class Players(commands.Cog):
param_list.append(('week', current.week)) param_list.append(('week', current.week))
g_query = await db_get('games', params=param_list) 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( await interaction.edit_original_response(
content=f'Hm. I don\'t see any games then.' content=f'Hm. I don\'t see any games then.'
) )
return return
# Sort games by game_id
g_query['games'] = sorted(g_query['games'], key=lambda game: game['id'])
if team_abbrev is not None: if team_abbrev is not None:
title = f'{this_team["lname"]} Schedule' title = f'{this_team["lname"]} Schedule'
embed = get_team_embed(title=title, team=this_team) embed = get_team_embed(title=title, team=this_team)
@ -2337,8 +2340,7 @@ class Players(commands.Cog):
@app_commands.command(name='charts') @app_commands.command(name='charts')
async def chart_command(self, interaction: discord.Interaction, chart_name: Literal[ async def chart_command(self, interaction: discord.Interaction, chart_name: Literal[
'block-plate', 'defense-matters', 'fly-b', 'g1', 'g2', 'g3', 'groundball', 'hit-and-run', 'rob-hr', 'defense', 'fly-b', 'g1', 'g2', 'g3', 'groundball','hit-and-run', 'rest', 'sac-bunt', 'squeeze-bunt']):
'rest', 'rob-hr', 'sac-bunt', 'squeeze-bunt']):
gb_url = 'https://sombaseball.ddns.net/static/images/season04/ground-ball-chart' gb_url = 'https://sombaseball.ddns.net/static/images/season04/ground-ball-chart'
all_charts = { all_charts = {
'rest': [f'{SBA_IMAGE_URL}/season05/charts/rest.png'], '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.png',
f'{SBA_IMAGE_URL}/season05/charts/squeeze-bunt-help.png' f'{SBA_IMAGE_URL}/season05/charts/squeeze-bunt-help.png'
], ],
'rob-hr': [f'{SBA_IMAGE_URL}/season05/charts/rob-hr.png'], 'rob-hr': [f'https://sba-cards-2024.s3.us-east-1.amazonaws.com/static-images/rob-hr.png'],
'defense-matters': [f'{SBA_IMAGE_URL}/season05/charts/defense-matters.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'], 'block-plate': [f'{SBA_IMAGE_URL}/season05/charts/block-plate.png'],
'hit-and-run': [ 'hit-and-run': [
f'{SBA_IMAGE_URL}/season05/charts/hit-and-run.png', f'{SBA_IMAGE_URL}/season05/charts/hit-and-run.png',

View File

@ -1,14 +1,232 @@
import re import re
import copy import copy
import random
import os
from dataclasses import dataclass
from typing import List, Tuple, Optional, Dict, Set
from helpers import * from helpers import *
from api_calls.current import get_current 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 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 from discord.ext import commands, tasks
OFFSEASON_FLAG = False OFFSEASON_FLAG = False
USE_NEW_TRANSACTION_LOGIC = os.getenv('USE_NEW_TRANSACTION_LOGIC', 'true').lower() == 'true'
logger = logging.getLogger('discord_app') 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: class SBaTransaction:
def __init__(self, channel, current, move_type, this_week=False, first_team=None, team_role=None): 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 } } } self.players = {} # Example: { <Player ID>: { "player": { Strat Player Dict }, "to": { Strat Team Dict } } }
@ -342,16 +560,19 @@ class Transactions(commands.Cog):
@tasks.loop(minutes=1) @tasks.loop(minutes=1)
async def weekly_loop(self): async def weekly_loop(self):
logger.info(f'Inside weekly_loop')
if OFFSEASON_FLAG: if OFFSEASON_FLAG:
logger.info(f'Exiting weekly_loop; OFFSEASON_FLAG: {OFFSEASON_FLAG}')
return return
current = await get_current() current = await get_current()
now = datetime.datetime.now() now = datetime.datetime.now()
logger.debug(f'Datetime: {now} / weekday: {now.weekday()}') logger.info(f'Datetime: {now} / weekday: {now.weekday()} / current: {current}')
# Begin Freeze # Begin Freeze
# if now.weekday() == 0 and now.hour == 5 and not current.freeze: # Spring/Summer # 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 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 current.week += 1
await db_patch('current', object_id=current.id, params=[('week', current.week), ('freeze', True)]) await db_patch('current', object_id=current.id, params=[('week', current.week), ('freeze', True)])
await self.run_transactions(current) await self.run_transactions(current)
@ -372,6 +593,7 @@ class Transactions(commands.Cog):
# End Freeze # End Freeze
# elif now.weekday() == 5 and now.hour == 5 and current.freeze: # Spring/Summer # 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 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)]) await db_patch('current', object_id=current.id, params=[('freeze', False)])
week_num = f'Week {current.week}' week_num = f'Week {current.week}'
@ -384,6 +606,9 @@ class Transactions(commands.Cog):
await send_to_channel(self.bot, 'transaction-log', freeze_message) await send_to_channel(self.bot, 'transaction-log', freeze_message)
self.trade_season = False self.trade_season = False
else:
logger.info(f'weekly_loop - No freeze actions being taken')
@weekly_loop.before_loop @weekly_loop.before_loop
async def before_notif_check(self): async def before_notif_check(self):
await self.bot.wait_until_ready() await self.bot.wait_until_ready()
@ -443,7 +668,8 @@ class Transactions(commands.Cog):
except Exception as e: except Exception as e:
logger.error(f'Could not notifiy GM2 of {team["abbrev"]} ({team["gmid2"]})of move cancellation: {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( # all_moves = await get_transactions(
# season=current.season, # season=current.season,
# week_start=current.week, # week_start=current.week,
@ -538,6 +764,84 @@ class Transactions(commands.Cog):
await db_patch('transactions', object_id=move_id, params=[('frozen', False)]) await db_patch('transactions', object_id=move_id, params=[('frozen', False)])
await self.post_move_to_transaction_log(move_id) 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): async def post_move_to_transaction_log(self, move_id):
current = await get_current() current = await get_current()
# all_moves = await get_transactions( # all_moves = await get_transactions(
@ -624,7 +928,7 @@ class Transactions(commands.Cog):
@commands.is_owner() @commands.is_owner()
async def process_freeze_helper_command(self, ctx): async def process_freeze_helper_command(self, ctx):
current = await get_current() current = await get_current()
await self.process_freeze_moves(current) await self.process_freeze_moves_new(current)
await ctx.send(random_conf_gif()) await ctx.send(random_conf_gif())
@commands.command(name='post-weekly') @commands.command(name='post-weekly')