""" Transaction Builder Service Handles the complex logic for building multi-move transactions interactively. """ import logging from typing import Dict, List, Optional from dataclasses import dataclass from datetime import datetime, timezone from config import get_config from models.transaction import Transaction from models.team import Team from models.player import Player from models.roster import TeamRoster from services.roster_service import roster_service from services.transaction_service import transaction_service from services.league_service import league_service from models.team import RosterType logger = logging.getLogger(f"{__name__}.TransactionBuilder") # Removed MoveAction enum - using simple from/to roster locations instead @dataclass class TransactionMove: """Individual move within a transaction.""" player: Player from_roster: RosterType to_roster: RosterType from_team: Optional[Team] = None to_team: Optional[Team] = None @property def description(self) -> str: """Human readable move description.""" # Determine emoji and format based on from/to locations if ( self.from_roster == RosterType.FREE_AGENCY and self.to_roster != RosterType.FREE_AGENCY ): # Add from Free Agency emoji = "➕" return f"{emoji} {self.player.name}: FA → {self.to_team.abbrev} ({self.to_roster.value.upper()})" elif ( self.from_roster != RosterType.FREE_AGENCY and self.to_roster == RosterType.FREE_AGENCY ): # Drop to Free Agency emoji = "➖" return f"{emoji} {self.player.name}: {self.from_team.abbrev} ({self.from_roster.value.upper()}) → FA" elif ( self.from_roster == RosterType.MINOR_LEAGUE and self.to_roster == RosterType.MAJOR_LEAGUE ): # Recall from MiL to ML emoji = "⬆️" return f"{emoji} {self.player.name}: {self.from_team.abbrev} (MiL) → {self.to_team.abbrev} (ML)" elif ( self.from_roster == RosterType.MAJOR_LEAGUE and self.to_roster == RosterType.MINOR_LEAGUE ): # Demote from ML to MiL emoji = "⬇️" return f"{emoji} {self.player.name}: {self.from_team.abbrev} (ML) → {self.to_team.abbrev} (MiL)" elif self.to_roster == RosterType.INJURED_LIST: # Move to Injured List emoji = "🏥" from_desc = ( "FA" if self.from_roster == RosterType.FREE_AGENCY else f"{self.from_team.abbrev} ({self.from_roster.value.upper()})" ) return ( f"{emoji} {self.player.name}: {from_desc} → {self.to_team.abbrev} (IL)" ) elif self.from_roster == RosterType.INJURED_LIST: # Return from Injured List emoji = "💊" to_desc = ( "FA" if self.to_roster == RosterType.FREE_AGENCY else f"{self.to_team.abbrev} ({self.to_roster.value.upper()})" ) return ( f"{emoji} {self.player.name}: {self.from_team.abbrev} (IL) → {to_desc}" ) else: # Generic move emoji = "🔄" from_desc = ( "FA" if self.from_roster == RosterType.FREE_AGENCY else f"{self.from_team.abbrev} ({self.from_roster.value.upper()})" ) to_desc = ( "FA" if self.to_roster == RosterType.FREE_AGENCY else f"{self.to_team.abbrev} ({self.to_roster.value.upper()})" ) return f"{emoji} {self.player.name}: {from_desc} → {to_desc}" @dataclass class RosterValidationResult: """Results of roster validation.""" is_legal: bool major_league_count: int minor_league_count: int warnings: List[str] errors: List[str] suggestions: List[str] major_league_limit: int = 26 minor_league_limit: int = 6 major_league_swar: float = 0.0 minor_league_swar: float = 0.0 major_league_swar_cap: float = 32.0 # Team's sWAR cap limit pre_existing_ml_swar_change: float = 0.0 pre_existing_mil_swar_change: float = 0.0 pre_existing_transaction_count: int = 0 @property def major_league_status(self) -> str: """Status string for major league roster.""" if self.major_league_count > self.major_league_limit: return f"❌ Major League: {self.major_league_count}/{self.major_league_limit} (Over limit!)" else: return f"✅ Major League: {self.major_league_count}/{self.major_league_limit} (Legal)" @property def minor_league_status(self) -> str: """Status string for minor league roster.""" if self.minor_league_count > self.minor_league_limit: return f"❌ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Over limit!)" else: return f"✅ Minor League: {self.minor_league_count}/{self.minor_league_limit} (Legal)" @property def major_league_swar_status(self) -> str: """Status string for major league sWAR.""" if self.major_league_swar > self.major_league_swar_cap: return f"❌ Major League sWAR: {self.major_league_swar:.2f}/{self.major_league_swar_cap:.1f} (Over cap!)" else: return f"✅ Major League sWAR: {self.major_league_swar:.2f}/{self.major_league_swar_cap:.1f}" @property def minor_league_swar_status(self) -> str: """Status string for minor league sWAR.""" return f"📊 Minor League sWAR: {self.minor_league_swar:.2f}" @property def pre_existing_transactions_note(self) -> str: """Note about pre-existing transactions affecting calculations.""" if self.pre_existing_transaction_count == 0: return "" total_swar_change = ( self.pre_existing_ml_swar_change + self.pre_existing_mil_swar_change ) if total_swar_change == 0: return f"ℹ️ **Pre-existing Moves**: {self.pre_existing_transaction_count} scheduled moves (no sWAR impact)" elif total_swar_change > 0: return f"ℹ️ **Pre-existing Moves**: {self.pre_existing_transaction_count} scheduled moves (+{total_swar_change:.2f} sWAR)" else: return f"ℹ️ **Pre-existing Moves**: {self.pre_existing_transaction_count} scheduled moves ({total_swar_change:.2f} sWAR)" class TransactionBuilder: """Interactive transaction builder for complex multi-move transactions.""" def __init__(self, team: Team, user_id: int, season: int = get_config().sba_season): """ Initialize transaction builder. Args: team: Team making the transaction user_id: Discord user ID of the GM season: Season number """ self.team = team self.user_id = user_id self.season = season self.moves: List[TransactionMove] = [] self.created_at = datetime.now(timezone.utc) # Cache for roster data self._current_roster: Optional[TeamRoster] = None self._roster_loaded = False # Cache for pre-existing transactions self._existing_transactions: Optional[List[Transaction]] = None self._existing_transactions_loaded = False logger.info( f"TransactionBuilder initialized for {team.abbrev} by user {user_id}" ) async def load_roster_data(self) -> None: """Load current roster data for the team.""" if self._roster_loaded: return try: self._current_roster = await roster_service.get_current_roster(self.team.id) self._roster_loaded = True logger.debug(f"Loaded roster data for team {self.team.abbrev}") except Exception as e: logger.error(f"Failed to load roster data: {e}") self._current_roster = None self._roster_loaded = True async def load_existing_transactions(self, next_week: int) -> None: """ Load pre-existing transactions for next week. Queries for all organizational affiliates (ML, MiL, IL) to ensure trades involving affiliate teams are included in roster projections. """ if self._existing_transactions_loaded: return try: # Include all org affiliates so trades involving MiL/IL teams are captured base_abbrev = self.team._get_base_abbrev() org_abbrevs = [base_abbrev, f"{base_abbrev}MIL", f"{base_abbrev}IL"] self._existing_transactions = ( await transaction_service.get_team_transactions( team_abbrev=org_abbrevs, season=self.season, cancelled=False, week_start=next_week, ) ) self._existing_transactions_loaded = True logger.debug( f"Loaded {len(self._existing_transactions or [])} existing transactions for {self.team.abbrev} org ({org_abbrevs}) week {next_week}" ) except Exception as e: logger.error(f"Failed to load existing transactions: {e}") self._existing_transactions = [] self._existing_transactions_loaded = True async def add_move( self, move: TransactionMove, next_week: Optional[int] = None, check_pending_transactions: bool = True, ) -> tuple[bool, str]: """ Add a move to the transaction. Args: move: TransactionMove to add next_week: Week number for pending transaction check (optional, will be fetched if not provided) check_pending_transactions: Whether to check if player is in another team's pending transaction. Set to True for /dropadd (scheduled moves), False for /ilmove (immediate). Returns: Tuple of (success: bool, error_message: str). If success is True, error_message is empty. """ # Check if player is already in a move in this transaction builder existing_move = self.get_move_for_player(move.player.id) if existing_move: error_msg = ( f"Player {move.player.name} already has a move in this transaction" ) logger.warning(error_msg) return False, error_msg # Check if from_team and to_team are the same AND from_roster and to_roster are the same # (when both teams are not None) - this would be a meaningless move if ( move.from_team is not None and move.to_team is not None and move.from_team.id == move.to_team.id and move.from_roster == move.to_roster ): error_msg = f"Cannot move {move.player.name} from {move.from_team.abbrev} ({move.from_roster.value.upper()}) to {move.to_team.abbrev} ({move.to_roster.value.upper()}) - player is already in that location" logger.warning(error_msg) return False, error_msg # Check if player is already in another team's pending transaction for next week # This prevents duplicate claims that would need to be resolved at freeze time # Only applies to /dropadd (scheduled moves), not /ilmove (immediate moves) if check_pending_transactions: if next_week is None: try: current_state = await league_service.get_current_state() next_week = (current_state.week + 1) if current_state else 1 except Exception as e: logger.warning( f"Could not get current week for pending transaction check: {e}" ) next_week = 1 is_pending, claiming_team = ( await transaction_service.is_player_in_pending_transaction( player_id=move.player.id, week=next_week, season=self.season ) ) if is_pending: error_msg = f"{move.player.name} is already in a pending transaction for week {next_week} (claimed by {claiming_team})" logger.warning(error_msg) return False, error_msg self.moves.append(move) logger.info(f"Added move: {move.description}") return True, "" def remove_move(self, player_id: int) -> bool: """ Remove a move for a specific player. Args: player_id: ID of player to remove move for Returns: True if move was removed """ original_count = len(self.moves) self.moves = [move for move in self.moves if move.player.id != player_id] removed = len(self.moves) < original_count if removed: logger.info(f"Removed move for player {player_id}") return removed def get_move_for_player(self, player_id: int) -> Optional[TransactionMove]: """Get the move for a specific player if it exists.""" for move in self.moves: if move.player.id == player_id: return move return None async def validate_transaction( self, next_week: Optional[int] = None ) -> RosterValidationResult: """ Validate the current transaction and return detailed results. Args: next_week: Week to check for existing transactions (optional) Returns: RosterValidationResult with validation details """ await self.load_roster_data() # Load existing transactions if next_week is provided if next_week is not None: await self.load_existing_transactions(next_week) if not self._current_roster: return RosterValidationResult( is_legal=False, major_league_count=0, minor_league_count=0, warnings=[], errors=["Could not load current roster data"], suggestions=[], ) # Calculate roster changes from moves ml_changes = 0 mil_changes = 0 errors = [] warnings = [] suggestions = [] # Calculate current sWAR for each roster current_ml_swar = sum( player.wara for player in self._current_roster.active_players ) current_mil_swar = sum( player.wara for player in self._current_roster.minor_league_players ) # Track sWAR changes from moves ml_swar_changes = 0.0 mil_swar_changes = 0.0 # Track pre-existing transaction changes separately pre_existing_ml_swar_change = 0.0 pre_existing_mil_swar_change = 0.0 pre_existing_count = 0 # Process existing transactions first if self._existing_transactions: for transaction in self._existing_transactions: # Skip if this transaction was already processed or cancelled if transaction.cancelled: continue pre_existing_count += 1 # Determine roster changes from existing transaction # Use Team.is_same_organization() to check if transaction affects our organization # Leaving our organization (from any roster) if transaction.oldteam.is_same_organization(self.team): # Player leaving our organization - determine which roster they're leaving from from_roster_type = transaction.oldteam.roster_type() if from_roster_type == RosterType.MAJOR_LEAGUE: ml_changes -= 1 ml_swar_changes -= transaction.player.wara pre_existing_ml_swar_change -= transaction.player.wara elif from_roster_type == RosterType.MINOR_LEAGUE: mil_changes -= 1 mil_swar_changes -= transaction.player.wara pre_existing_mil_swar_change -= transaction.player.wara # Note: IL players don't count toward roster limits, so no changes needed # Joining our organization (to any roster) if transaction.newteam.is_same_organization(self.team): # Player joining our organization - determine which roster they're joining to_roster_type = transaction.newteam.roster_type() if to_roster_type == RosterType.MAJOR_LEAGUE: ml_changes += 1 ml_swar_changes += transaction.player.wara pre_existing_ml_swar_change += transaction.player.wara elif to_roster_type == RosterType.MINOR_LEAGUE: mil_changes += 1 mil_swar_changes += transaction.player.wara pre_existing_mil_swar_change += transaction.player.wara # Note: IL players don't count toward roster limits, so no changes needed for move in self.moves: # Log move being processed for diagnostics logger.debug( f"🔍 VALIDATION: Processing move - {move.player.name} (ID={move.player.id})" ) logger.debug( f"🔍 VALIDATION: from_roster={move.from_roster.value}, to_roster={move.to_roster.value}" ) # Calculate roster changes based on from/to locations if move.from_roster == RosterType.MAJOR_LEAGUE: ml_changes -= 1 ml_swar_changes -= move.player.wara logger.debug( f"🔍 VALIDATION: ML decrement - ml_changes now {ml_changes}" ) elif move.from_roster == RosterType.MINOR_LEAGUE: mil_changes -= 1 mil_swar_changes -= move.player.wara logger.debug( f"🔍 VALIDATION: MiL decrement - mil_changes now {mil_changes}" ) # Note: INJURED_LIST and FREE_AGENCY don't count toward ML roster limit if move.to_roster == RosterType.MAJOR_LEAGUE: ml_changes += 1 ml_swar_changes += move.player.wara logger.debug( f"🔍 VALIDATION: ML increment - ml_changes now {ml_changes}" ) elif move.to_roster == RosterType.MINOR_LEAGUE: mil_changes += 1 mil_swar_changes += move.player.wara logger.debug( f"🔍 VALIDATION: MiL increment - mil_changes now {mil_changes}" ) # Note: INJURED_LIST and FREE_AGENCY don't count toward ML roster limit # Calculate projected roster sizes and sWAR # Only Major League players count toward ML roster limit (IL and MiL are separate) current_ml_size = len(self._current_roster.active_players) current_mil_size = len(self._current_roster.minor_league_players) logger.debug( f"🔍 VALIDATION: Current roster - ML:{current_ml_size}, MiL:{current_mil_size}" ) logger.debug( f"🔍 VALIDATION: Changes calculated - ml_changes:{ml_changes}, mil_changes:{mil_changes}" ) projected_ml_size = current_ml_size + ml_changes projected_mil_size = current_mil_size + mil_changes projected_ml_swar = current_ml_swar + ml_swar_changes projected_mil_swar = current_mil_swar + mil_swar_changes logger.debug( f"🔍 VALIDATION: Projected roster - ML:{projected_ml_size}, MiL:{projected_mil_size}" ) logger.debug( f"🔍 VALIDATION: Projected sWAR - ML:{projected_ml_swar:.2f}, MiL:{projected_mil_swar:.2f}" ) # Get current week and config to determine roster limits config = get_config() try: current_state = await league_service.get_current_state() current_week = current_state.week if current_state else 1 except Exception as e: logger.warning(f"Could not get current week, using default limits: {e}") current_week = 1 # Determine roster limits based on week and offseason flag # During offseason, limits are relaxed to allow roster building if config.offseason_flag: ml_limit = config.ml_roster_limit_offseason mil_limit = config.mil_roster_limit_offseason logger.debug("🔍 VALIDATION: Offseason mode - using relaxed roster limits") elif current_week < config.expand_mil_week: ml_limit = config.ml_roster_limit_early mil_limit = config.mil_roster_limit_early else: ml_limit = config.ml_roster_limit_late mil_limit = config.mil_roster_limit_late # Validate roster limits is_legal = True if projected_ml_size > ml_limit: is_legal = False errors.append( f"Major League roster would have {projected_ml_size} players (limit: {ml_limit})" ) suggestions.append( f"Drop {projected_ml_size - ml_limit} ML player(s) to make roster legal" ) elif projected_ml_size < 0: is_legal = False errors.append("Cannot have negative players on Major League roster") if projected_mil_size > mil_limit: is_legal = False errors.append( f"Minor League roster would have {projected_mil_size} players (limit: {mil_limit})" ) suggestions.append( f"Drop {projected_mil_size - mil_limit} MiL player(s) to make roster legal" ) elif projected_mil_size < 0: is_legal = False errors.append("Cannot have negative players on Minor League roster") # Validate sWAR cap # Use team-specific cap if set, otherwise fall back to config default team_swar_cap = ( self.team.salary_cap if self.team.salary_cap is not None else config.swar_cap_limit ) if projected_ml_swar > team_swar_cap: is_legal = False errors.append( f"Major League sWAR would be {projected_ml_swar:.2f} (cap: {team_swar_cap:.1f})" ) over_cap = projected_ml_swar - team_swar_cap suggestions.append( f"Remove {over_cap:.2f} sWAR from ML roster to be under cap" ) # Add suggestions for empty transaction if not self.moves: suggestions.append("Add player moves to build your transaction") return RosterValidationResult( is_legal=is_legal, major_league_count=projected_ml_size, minor_league_count=projected_mil_size, warnings=warnings, errors=errors, suggestions=suggestions, major_league_limit=ml_limit, minor_league_limit=mil_limit, major_league_swar=projected_ml_swar, minor_league_swar=projected_mil_swar, major_league_swar_cap=team_swar_cap, pre_existing_ml_swar_change=pre_existing_ml_swar_change, pre_existing_mil_swar_change=pre_existing_mil_swar_change, pre_existing_transaction_count=pre_existing_count, ) async def submit_transaction( self, week: int, check_existing_transactions: bool = True ) -> List[Transaction]: """ Submit the transaction by creating individual Transaction models. Args: week: Week the transaction is effective for check_existing_transactions: Whether to include pre-existing transactions in validation. Set to True for /dropadd (scheduled moves - need to check against other scheduled moves). Set to False for /ilmove (immediate moves - already in database, don't double-count). Returns: List of created Transaction objects """ if not self.moves: raise ValueError("Cannot submit empty transaction") # For immediate moves (/ilmove), don't check pre-existing transactions if check_existing_transactions: validation = await self.validate_transaction(next_week=week) else: validation = await self.validate_transaction() if not validation.is_legal: raise ValueError( f"Cannot submit illegal transaction: {', '.join(validation.errors)}" ) transactions = [] move_id = f"Season-{self.season:03d}-Week-{week:02d}-{int(self.created_at.timestamp())}" # Create FA team for drops using config value config = get_config() fa_team = Team( id=config.free_agent_team_id, # Correct FA team ID from config (498) abbrev="FA", sname="Free Agents", lname="Free Agency", season=self.season, ) # type: ignore for move in self.moves: # Determine old and new teams based on roster locations # We need to map RosterType to the actual team (ML, MiL, or IL affiliate) if move.from_roster == RosterType.FREE_AGENCY: old_team = fa_team else: base_team = move.from_team or self.team # Get the appropriate affiliate based on roster type if move.from_roster == RosterType.MAJOR_LEAGUE: old_team = base_team # Already ML team elif move.from_roster == RosterType.MINOR_LEAGUE: old_team = await base_team.minor_league_affiliate() elif move.from_roster == RosterType.INJURED_LIST: old_team = await base_team.injured_list_affiliate() else: old_team = base_team if move.to_roster == RosterType.FREE_AGENCY: new_team = fa_team else: base_team = move.to_team or self.team # Get the appropriate affiliate based on roster type if move.to_roster == RosterType.MAJOR_LEAGUE: new_team = base_team # Already ML team elif move.to_roster == RosterType.MINOR_LEAGUE: new_team = await base_team.minor_league_affiliate() elif move.to_roster == RosterType.INJURED_LIST: new_team = await base_team.injured_list_affiliate() else: new_team = base_team # For cases where we don't have specific teams, fall back to defaults if not old_team: continue # Create transaction transaction = Transaction( id=0, # Will be set by API week=week, season=self.season, moveid=move_id, player=move.player, oldteam=old_team, newteam=new_team, cancelled=False, frozen=False, ) transactions.append(transaction) logger.info( f"Created {len(transactions)} transactions for submission with move_id {move_id}" ) return transactions def clear_moves(self) -> None: """Clear all moves from the transaction builder.""" self.moves.clear() logger.info("Cleared all moves from transaction builder") @property def is_empty(self) -> bool: """Check if transaction builder has no moves.""" return len(self.moves) == 0 @property def move_count(self) -> int: """Get total number of moves in transaction.""" return len(self.moves) # Global cache for active transaction builders _active_builders: Dict[int, TransactionBuilder] = {} def get_transaction_builder(user_id: int, team: Team) -> TransactionBuilder: """ Get or create a transaction builder for a user. Args: user_id: Discord user ID team: Team object Returns: TransactionBuilder instance """ if user_id not in _active_builders: _active_builders[user_id] = TransactionBuilder(team, user_id) return _active_builders[user_id] def clear_transaction_builder(user_id: int) -> None: """Clear transaction builder for a user.""" if user_id in _active_builders: del _active_builders[user_id] logger.info(f"Cleared transaction builder for user {user_id}")