major-domo-v2/tasks/transaction_freeze.py
2025-10-25 10:04:19 -05:00

686 lines
26 KiB
Python

"""
Transaction Freeze/Thaw Task for Discord Bot v2.0
Automated weekly system for freezing and processing transactions.
Runs on a schedule to increment weeks and process contested transactions.
"""
import random
from datetime import datetime, UTC
from typing import Dict, List, Tuple, Set
from dataclasses import dataclass
import discord
from discord.ext import commands, tasks
from services.league_service import league_service
from services.transaction_service import transaction_service
from services.standings_service import standings_service
from models.current import Current
from models.transaction import Transaction
from utils.logging import get_contextual_logger
from views.embeds import EmbedTemplate, EmbedColors
from config import get_config
@dataclass
class TransactionPriority:
"""
Data class for transaction priority calculation.
Used to resolve contested transactions (multiple teams wanting same player).
"""
transaction: Transaction
team_win_percentage: float
tiebreaker: float # win% + small random number for randomized tiebreak
def __lt__(self, other):
"""Allow sorting by tiebreaker value."""
return self.tiebreaker < other.tiebreaker
async def resolve_contested_transactions(
transactions: List[Transaction],
season: int
) -> Tuple[List[str], List[str]]:
"""
Resolve contested transactions where multiple teams want the same player.
This is extracted as a pure function for testability.
Args:
transactions: List of all frozen transactions for the week
season: Current season number
Returns:
Tuple of (winning_move_ids, losing_move_ids)
"""
logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions')
# Group transactions by player name
player_transactions: Dict[str, List[Transaction]] = {}
for transaction in transactions:
player_name = transaction.player.name.lower()
# Only consider transactions where a team is acquiring a player (not FA drops)
if transaction.newteam.abbrev.upper() != 'FA':
if player_name not in player_transactions:
player_transactions[player_name] = []
player_transactions[player_name].append(transaction)
# Identify contested players (multiple teams want same player)
contested_players: Dict[str, List[Transaction]] = {}
non_contested_moves: Set[str] = set()
for player_name, player_transactions_list in player_transactions.items():
if len(player_transactions_list) > 1:
contested_players[player_name] = player_transactions_list
logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)")
else:
# Non-contested, automatically wins
non_contested_moves.add(player_transactions_list[0].moveid)
# Resolve contests using team priority (win% + random tiebreaker)
winning_move_ids: Set[str] = set()
losing_move_ids: Set[str] = set()
for player_name, contested_transactions in contested_players.items():
priorities: List[TransactionPriority] = []
for transaction in contested_transactions:
# Get team for priority calculation
# If adding to MiL team, use the parent ML team for standings
if transaction.newteam.abbrev.endswith('MiL'):
team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix
else:
team_abbrev = transaction.newteam.abbrev
try:
# Get team standings to calculate win percentage
standings = await standings_service.get_team_standings(team_abbrev, season)
if standings and standings.wins is not None and standings.losses is not None:
total_games = standings.wins + standings.losses
win_pct = standings.wins / total_games if total_games > 0 else 0.0
else:
win_pct = 0.0
logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%")
# Add small random component for tiebreaking (5 decimal precision)
random_component = random.randint(10000, 99999) * 0.00000001
tiebreaker = win_pct + random_component
priorities.append(TransactionPriority(
transaction=transaction,
team_win_percentage=win_pct,
tiebreaker=tiebreaker
))
except Exception as e:
logger.error(f"Error calculating priority for {team_abbrev}: {e}")
# Give them 0.0 priority on error
priorities.append(TransactionPriority(
transaction=transaction,
team_win_percentage=0.0,
tiebreaker=random.randint(10000, 99999) * 0.00000001
))
# Sort by tiebreaker (lowest win% wins - worst teams get priority)
priorities.sort()
# First team wins, rest lose
if priorities:
winner = priorities[0]
winning_move_ids.add(winner.transaction.moveid)
logger.info(
f"Contest resolved for {player_name}: {winner.transaction.newteam.abbrev} wins "
f"(win%: {winner.team_win_percentage:.3f}, tiebreaker: {winner.tiebreaker:.8f})"
)
for loser in priorities[1:]:
losing_move_ids.add(loser.transaction.moveid)
logger.info(
f"Contest lost for {player_name}: {loser.transaction.newteam.abbrev} "
f"(win%: {loser.team_win_percentage:.3f}, tiebreaker: {loser.tiebreaker:.8f})"
)
# Add non-contested moves to winners
winning_move_ids.update(non_contested_moves)
return list(winning_move_ids), list(losing_move_ids)
class TransactionFreezeTask:
"""Automated weekly freeze/thaw system for transactions."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask')
self.weekly_warning_sent = False # Prevent duplicate error notifications
self.logger.info("Transaction freeze/thaw task initialized")
# Start the weekly loop
self.weekly_loop.start()
def cog_unload(self):
"""Stop the task when cog is unloaded."""
self.weekly_loop.cancel()
@tasks.loop(minutes=1)
async def weekly_loop(self):
"""
Main loop that checks time and triggers freeze/thaw operations.
Runs every minute and checks:
- Monday 00:00 -> Begin freeze (increment week, set freeze flag)
- Saturday 00:00 -> End freeze (process frozen transactions)
"""
try:
self.logger.info("Weekly loop check starting")
config = get_config()
# Skip if offseason mode is enabled
if config.offseason_flag:
self.logger.info("Skipping freeze/thaw operations - offseason mode enabled")
return
# Get current league state
current = await league_service.get_current_state()
if not current:
self.logger.warning("Could not get current league state")
return
now = datetime.now()
self.logger.info(
f"Weekly loop check",
datetime=now.isoformat(),
weekday=now.weekday(),
hour=now.hour,
current_week=current.week,
freeze_status=current.freeze
)
# BEGIN FREEZE: Monday at 00:00, not already frozen
if now.weekday() == 0 and now.hour == 0 and not current.freeze and self.weekly_warning_sent:
self.logger.info("Triggering freeze begin")
await self._begin_freeze(current)
self.weekly_warning_sent = False
# END FREEZE: Saturday at 00:00, currently frozen
elif now.weekday() == 5 and now.hour == 0 and current.freeze and not self.weekly_warning_sent:
self.logger.info("Triggering freeze end")
await self._end_freeze(current)
self.weekly_warning_sent = True
else:
self.logger.debug("No freeze/thaw action needed at this time")
except Exception as e:
self.logger.error(f"Unhandled exception in weekly_loop: {e}", exc_info=True)
error_message = (
f"⚠️ **Weekly Freeze Task Failed**\n"
f"```\n"
f"Error: {str(e)}\n"
f"Time: {datetime.now(UTC).isoformat()}\n"
f"Task: weekly_loop in transaction_freeze.py\n"
f"```"
)
try:
if not self.weekly_warning_sent:
await self._send_owner_notification(error_message)
self.weekly_warning_sent = True
except Exception as notify_error:
self.logger.error(f"Failed to send error notification: {notify_error}")
@weekly_loop.before_loop
async def before_weekly_loop(self):
"""Wait for bot to be ready before starting."""
await self.bot.wait_until_ready()
self.logger.info("Bot is ready, transaction freeze/thaw task starting")
async def _begin_freeze(self, current: Current):
"""
Begin weekly freeze period.
Actions:
1. Increment current week
2. Set freeze flag to True
3. Run regular transactions for current week
4. Send freeze announcement
5. Post weekly info (weeks 1-18 only)
"""
try:
self.logger.info(f"Beginning freeze for week {current.week}")
# Increment week and set freeze via service
new_week = current.week + 1
updated_current = await league_service.update_current_state(
week=new_week,
freeze=True
)
if not updated_current:
raise Exception("Failed to update current state during freeze begin")
self.logger.info(f"Week incremented to {new_week}, freeze set to True")
# Update local current object with returned data
current.week = updated_current.week
current.freeze = updated_current.freeze
# Run regular transactions for the new week
await self._run_transactions(current)
# Send freeze announcement
await self._send_freeze_announcement(current.week, is_beginning=True)
# Post weekly info for weeks 1-18
if 1 <= current.week <= 18:
await self._post_weekly_info(current)
self.logger.info(f"Freeze begin completed for week {current.week}")
except Exception as e:
self.logger.error(f"Error in _begin_freeze: {e}", exc_info=True)
raise
async def _end_freeze(self, current: Current):
"""
End weekly freeze period.
Actions:
1. Process frozen transactions with priority resolution
2. Set freeze flag to False
3. Send thaw announcement
"""
try:
self.logger.info(f"Ending freeze for week {current.week}")
# Process frozen transactions BEFORE unfreezing
await self._process_frozen_transactions(current)
# Set freeze to False via service
updated_current = await league_service.update_current_state(freeze=False)
if not updated_current:
raise Exception("Failed to update current state during freeze end")
self.logger.info(f"Freeze set to False for week {current.week}")
# Update local current object
current.freeze = updated_current.freeze
# Send thaw announcement
await self._send_freeze_announcement(current.week, is_beginning=False)
self.logger.info(f"Freeze end completed for week {current.week}")
except Exception as e:
self.logger.error(f"Error in _end_freeze: {e}", exc_info=True)
raise
async def _run_transactions(self, current: Current):
"""
Process regular (non-frozen) transactions for the current week.
These are transactions that take effect immediately.
"""
try:
# Get all non-frozen transactions for current week
client = await transaction_service.get_client()
params = [
('season', str(current.season)),
('week_start', str(current.week)),
('week_end', str(current.week))
]
response = await client.get('transactions', params=params)
if not response or response.get('count', 0) == 0:
self.logger.info(f"No regular transactions to process for week {current.week}")
return
transactions = response.get('transactions', [])
self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}")
# Note: The actual player updates would happen via the API here
# For now, we just log them - the API handles the actual roster updates
except Exception as e:
self.logger.error(f"Error running transactions: {e}", exc_info=True)
async def _process_frozen_transactions(self, current: Current):
"""
Process frozen transactions with priority resolution.
Uses the NEW transaction logic (no backup implementation).
Steps:
1. Get all frozen transactions for current week
2. Resolve contested transactions (multiple teams want same player)
3. Cancel losing transactions
4. Unfreeze and post winning transactions
"""
try:
# Get all frozen transactions for current week via service
transactions = await transaction_service.get_frozen_transactions_by_week(
season=current.season,
week_start=current.week,
week_end=current.week + 1
)
if not transactions:
self.logger.warning(f"No frozen transactions to process for week {current.week}")
return
self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}")
# Resolve contested transactions
winning_move_ids, losing_move_ids = await resolve_contested_transactions(
transactions,
current.season
)
# Cancel losing transactions via service
for losing_move_id in losing_move_ids:
try:
# Get all moves with this moveid (could be multiple players in one transaction)
losing_moves = [t for t in transactions if t.moveid == losing_move_id]
if losing_moves:
# Cancel the entire transaction (all moves with same moveid)
for move in losing_moves:
success = await transaction_service.cancel_transaction(move.moveid)
if not success:
self.logger.warning(f"Failed to cancel transaction {move.moveid}")
# Notify the GM(s) about cancellation
first_move = losing_moves[0]
# Determine which team to notify (the team that was trying to acquire)
team_for_notification = (first_move.newteam
if first_move.newteam.abbrev.upper() != 'FA'
else first_move.oldteam)
await self._notify_gm_of_cancellation(first_move, team_for_notification)
contested_players = [move.player.name for move in losing_moves]
self.logger.info(
f"Cancelled transaction {losing_move_id} due to contested players: "
f"{contested_players}"
)
except Exception as e:
self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}")
# Unfreeze winning transactions and post to log via service
for winning_move_id in winning_move_ids:
try:
# Get all moves with this moveid
winning_moves = [t for t in transactions if t.moveid == winning_move_id]
for move in winning_moves:
# Unfreeze the transaction via service
success = await transaction_service.unfreeze_transaction(move.moveid)
if not success:
self.logger.warning(f"Failed to unfreeze transaction {move.moveid}")
# Post to transaction log
await self._post_transaction_to_log(winning_move_id, transactions)
self.logger.info(f"Processed successful transaction {winning_move_id}")
except Exception as e:
self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}")
self.logger.info(
f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
f"{len(losing_move_ids)} cancelled transactions"
)
except Exception as e:
self.logger.error(f"Error during freeze processing: {e}", exc_info=True)
raise
async def _send_freeze_announcement(self, week: int, is_beginning: bool):
"""
Send freeze/thaw announcement to transaction log channel.
Args:
week: Current week number
is_beginning: True for freeze begin, False for freeze end
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
self.logger.warning("Could not find guild for freeze announcement")
return
channel = discord.utils.get(guild.text_channels, name='transaction-log')
if not channel:
self.logger.warning("Could not find transaction-log channel")
return
# Create announcement message (formatted like legacy bot)
week_num = f'Week {week}'
stars = '*' * 32
if is_beginning:
message = (
f'```\n'
f'{stars}\n'
f'{week_num:>9} Freeze Period Begins\n'
f'{stars}\n'
f'```'
)
else:
message = (
f'```\n'
f'{"*" * 30}\n'
f'{week_num:>9} Freeze Period Ends\n'
f'{"*" * 30}\n'
f'```'
)
await channel.send(message)
self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})")
except Exception as e:
self.logger.error(f"Error sending freeze announcement: {e}")
async def _post_weekly_info(self, current: Current):
"""
Post weekly schedule information to #weekly-info channel.
Args:
current: Current league state
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
info_channel = discord.utils.get(guild.text_channels, name='weekly-info')
if not info_channel:
self.logger.warning("Could not find weekly-info channel")
return
# Clear recent messages (last 25)
async for message in info_channel.history(limit=25):
try:
await message.delete()
except:
pass # Ignore deletion errors
# Determine season emoji
if current.week <= 5:
season_str = "🌼 **Spring**"
elif current.week > 14:
season_str = "🍂 **Fall**"
else:
season_str = "🏖️ **Summer**"
# Determine day/night schedule
night_str = "🌙 Night"
day_str = "🌞 Day"
is_div_week = current.week in [1, 3, 6, 14, 16, 18]
weekly_str = (
f'**Season**: {season_str}\n'
f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / '
f'{night_str} / {day_str}'
)
# Send info messages
await info_channel.send(
content=(
f'Each team has manage permissions in their home ballpark. '
f'They may pin messages and rename the channel.\n\n'
f'**Make sure your ballpark starts with your team abbreviation.**'
)
)
await info_channel.send(weekly_str)
self.logger.info(f"Weekly info posted for week {current.week}")
except Exception as e:
self.logger.error(f"Error posting weekly info: {e}")
async def _post_transaction_to_log(
self,
move_id: str,
all_transactions: List[Transaction]
):
"""
Post a transaction to the transaction log channel.
Args:
move_id: Transaction move ID
all_transactions: List of all transactions to find moves with this ID
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
channel = discord.utils.get(guild.text_channels, name='transaction-log')
if not channel:
return
# Get all moves with this moveid
moves = [t for t in all_transactions if t.moveid == move_id]
if not moves:
return
# Determine the team for the embed (team making the moves)
first_move = moves[0]
if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev:
this_team = first_move.newteam
elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev:
this_team = first_move.oldteam
else:
# Default to newteam if both are FA/IL
this_team = first_move.newteam
# Build move string
move_string = ""
week_num = first_move.week
for move in moves:
move_string += (
f'**{move.player.name}** ({move.player.wara:.1f}) '
f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n'
)
# Create embed
embed = EmbedTemplate.create_base_embed(
title=f'Week {week_num} Transaction',
description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname,
color=EmbedColors.INFO
)
# Set team color if available
if hasattr(this_team, 'color') and this_team.color:
try:
embed.color = discord.Color(int(this_team.color.replace('#', ''), 16))
except:
pass # Use default color on error
embed.add_field(name='Player Moves', value=move_string, inline=False)
await channel.send(embed=embed)
self.logger.info(f"Transaction posted to log: {move_id}")
except Exception as e:
self.logger.error(f"Error posting transaction to log: {e}")
async def _notify_gm_of_cancellation(
self,
transaction: Transaction,
team
):
"""
Send DM to GM(s) about cancelled transaction.
Args:
transaction: The cancelled transaction
team: Team whose GMs should be notified
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
cancel_text = (
f'Your transaction for **{transaction.player.name}** has been cancelled '
f'because another team successfully claimed them during the freeze period.'
)
# Notify GM1
if hasattr(team, 'gmid') and team.gmid:
try:
gm_one = guild.get_member(team.gmid)
if gm_one:
await gm_one.send(cancel_text)
self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}")
except Exception as e:
self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}")
# Notify GM2 if exists
if hasattr(team, 'gmid2') and team.gmid2:
try:
gm_two = guild.get_member(team.gmid2)
if gm_two:
await gm_two.send(cancel_text)
self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}")
except Exception as e:
self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}")
except Exception as e:
self.logger.error(f"Error notifying GM of cancellation: {e}")
async def _send_owner_notification(self, message: str):
"""
Send error notification to bot owner.
Args:
message: Error message to send
"""
try:
app_info = await self.bot.application_info()
if app_info.owner:
await app_info.owner.send(message)
self.logger.info("Owner notification sent")
except Exception as e:
self.logger.error(f"Could not send owner notification: {e}")
def setup_freeze_task(bot: commands.Bot) -> TransactionFreezeTask:
"""Set up the transaction freeze/thaw task."""
return TransactionFreezeTask(bot)