From 82abf3d9e62b113358e47167bd015c63cd00bd58 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 17 Oct 2025 23:26:08 -0500 Subject: [PATCH] CLAUDE: Add toggleable stats to /player command and injury system improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add interactive PlayerStatsView with toggle buttons to show/hide batting and pitching statistics independently in the /player command. Stats are hidden by default with clean, user-friendly buttons (💥 batting, ⚾ pitching) that update the embed in-place. Only the command caller can toggle stats, and buttons timeout after 5 minutes. Player Stats Toggle Feature: - Add views/players.py with PlayerStatsView class - Update /player command to use interactive view - Stats hidden by default, shown on button click - Independent batting/pitching toggles - User-restricted interactions with timeout handling Injury System Enhancements: - Add BatterInjuryModal and PitcherRestModal for injury logging - Add player_id extraction validator to Injury model - Fix injury creation to merge API request/response data - Add responders parameter to BaseView for multi-user interactions API Client Improvements: - Handle None values correctly in PATCH query parameters - Convert None to empty string for nullable fields in database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- api/client.py | 5 +- commands/players/info.py | 182 ++--------------- models/injury.py | 24 ++- services/injury_service.py | 21 +- views/base.py | 13 +- views/modals.py | 355 ++++++++++++++++++++++++++++++++- views/players.py | 395 +++++++++++++++++++++++++++++++++++++ 7 files changed, 811 insertions(+), 184 deletions(-) create mode 100644 views/players.py diff --git a/api/client.py b/api/client.py index 03698e9..df41640 100644 --- a/api/client.py +++ b/api/client.py @@ -339,7 +339,10 @@ class APIClient: # Add data as query parameters if requested if use_query_params and data: - params = [(k, str(v)) for k, v in data.items()] + # Handle None values by converting to empty string + # The database API's PATCH endpoint treats empty strings as NULL for nullable fields + # Example: {'il_return': None} → ?il_return= → Database sets il_return to NULL + params = [(k, '' if v is None else str(v)) for k, v in data.items()] url = self._add_params(url, params) await self._ensure_session() diff --git a/commands/players/info.py b/commands/players/info.py index 4036390..2001d91 100644 --- a/commands/players/info.py +++ b/commands/players/info.py @@ -14,8 +14,7 @@ from services.player_service import player_service from services.stats_service import stats_service from utils.logging import get_contextual_logger from utils.decorators import logged_command -from views.embeds import EmbedColors, EmbedTemplate -from models.team import RosterType +from views.players import PlayerStatsView async def player_name_autocomplete( @@ -155,176 +154,21 @@ class PlayerInfoCommands(commands.Cog): batting_stats=bool(batting_stats), pitching_stats=bool(pitching_stats)) - # Create comprehensive player embed with statistics - self.logger.debug("Creating Discord embed with statistics") - embed = await self._create_player_embed_with_stats( - player_with_team, - search_season, - batting_stats, - pitching_stats - ) - - await interaction.followup.send(embed=embed) - - async def _create_player_embed_with_stats( - self, - player, - season: int, - batting_stats=None, - pitching_stats=None - ) -> discord.Embed: - """Create a comprehensive player embed with statistics.""" - # Determine embed color based on team - embed_color = EmbedColors.PRIMARY - if hasattr(player, 'team') and player.team and hasattr(player.team, 'color'): - try: - # Convert hex color string to int - embed_color = int(player.team.color, 16) - except (ValueError, TypeError): - embed_color = EmbedColors.PRIMARY - - # Create base embed - embed = EmbedTemplate.create_base_embed( - title=f"🏟️ {player.name}", - color=embed_color - ) - - # Set team logo beside player name (as author icon) - if hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail: - embed.set_author( - name=player.name, - icon_url=player.team.thumbnail - ) - # Remove the emoji from title since we're using author - embed.title = None - - # Basic info section - embed.add_field( - name="Position", - value=player.primary_position, - inline=True - ) - - if hasattr(player, 'team') and player.team: - embed.add_field( - name="Team", - value=f"{player.team.abbrev} - {player.team.sname}", - inline=True - ) - - # Add Major League affiliate if this is a Minor League team - if player.team.roster_type() == RosterType.MINOR_LEAGUE: - major_affiliate = player.team.get_major_league_affiliate() - if major_affiliate: - embed.add_field( - name="Major Affiliate", - value=major_affiliate, - inline=True - ) - - embed.add_field( - name="sWAR", - value=f"{player.wara:.1f}", - inline=True + # Create interactive player view with toggleable statistics + self.logger.debug("Creating PlayerStatsView with toggleable statistics") + view = PlayerStatsView( + player=player_with_team, + season=search_season, + batting_stats=batting_stats, + pitching_stats=pitching_stats, + user_id=interaction.user.id ) - embed.add_field( - name="Player ID", - value=str(player.id), - inline=True - ) - - # All positions if multiple - if len(player.positions) > 1: - embed.add_field( - name="Positions", - value=", ".join(player.positions), - inline=True - ) - - embed.add_field( - name="Season", - value=str(season), - inline=True - ) + # Get initial embed with stats hidden + embed = await view.get_initial_embed() - # Add injury rating if available - if player.injury_rating: - embed.add_field( - name="Injury Rating", - value=player.injury_rating, - inline=True - ) - - # Add batting stats if available - if batting_stats: - self.logger.debug("Adding batting statistics to embed") - batting_value = ( - f"**AVG/OBP/SLG:** {batting_stats.avg:.3f}/{batting_stats.obp:.3f}/{batting_stats.slg:.3f}\n" - f"**OPS:** {batting_stats.ops:.3f} | **wOBA:** {batting_stats.woba:.3f}\n" - f"**HR:** {batting_stats.homerun} | **RBI:** {batting_stats.rbi} | **R:** {batting_stats.run}\n" - f"**AB:** {batting_stats.ab} | **H:** {batting_stats.hit} | **BB:** {batting_stats.bb} | **SO:** {batting_stats.so}" - ) - embed.add_field( - name="⚾ Batting Stats", - value=batting_value, - inline=False - ) - - # Add pitching stats if available - if pitching_stats: - self.logger.debug("Adding pitching statistics to embed") - ip = pitching_stats.innings_pitched - pitching_value = ( - f"**W-L:** {pitching_stats.win}-{pitching_stats.loss} | **ERA:** {pitching_stats.era:.2f}\n" - f"**WHIP:** {pitching_stats.whip:.2f} | **IP:** {ip:.1f}\n" - f"**SO:** {pitching_stats.so} | **BB:** {pitching_stats.bb} | **H:** {pitching_stats.hits}\n" - f"**GS:** {pitching_stats.gs} | **SV:** {pitching_stats.saves} | **HLD:** {pitching_stats.hold}" - ) - embed.add_field( - name="🥎 Pitching Stats", - value=pitching_value, - inline=False - ) - - # Add a note if no stats are available - if not batting_stats and not pitching_stats: - embed.add_field( - name="📊 Statistics", - value="No statistics available for this season.", - inline=False - ) - - # Set player card as main image - if player.image: - embed.set_image(url=player.image) - self.logger.debug("Player card image added to embed", image_url=player.image) - - # Set thumbnail with priority: fancycard → headshot → team logo - thumbnail_url = None - thumbnail_source = None - - if hasattr(player, 'vanity_card') and player.vanity_card: - thumbnail_url = player.vanity_card - thumbnail_source = "fancycard" - elif hasattr(player, 'headshot') and player.headshot: - thumbnail_url = player.headshot - thumbnail_source = "headshot" - elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail: - thumbnail_url = player.team.thumbnail - thumbnail_source = "team logo" - - if thumbnail_url: - embed.set_thumbnail(url=thumbnail_url) - self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url) - - # Footer with player ID and additional info - footer_text = f"Player ID: {player.id}" - if batting_stats and pitching_stats: - footer_text += " • Two-way player" - embed.set_footer(text=footer_text) - - return embed + # Send with interactive view + await interaction.followup.send(embed=embed, view=view) async def setup(bot: commands.Bot): diff --git a/models/injury.py b/models/injury.py index 29f18f8..53455e4 100644 --- a/models/injury.py +++ b/models/injury.py @@ -3,8 +3,8 @@ Injury model for tracking player injuries Represents an injury record with game timeline and status information. """ -from typing import Optional -from pydantic import Field +from typing import Optional, Any, Dict +from pydantic import Field, model_validator from models.base import SBABaseModel @@ -19,6 +19,26 @@ class Injury(SBABaseModel): player_id: int = Field(..., description="Player ID who is injured") total_games: int = Field(..., description="Total games player will be out") + @model_validator(mode='before') + @classmethod + def extract_player_id(cls, data: Any) -> Any: + """ + Extract player_id from nested player object if present. + + The API returns injuries with a nested 'player' object: + {'id': 123, 'player': {'id': 456, ...}, ...} + + This validator extracts the player ID before validation: + {'id': 123, 'player_id': 456, ...} + """ + if isinstance(data, dict): + # If player_id is missing but player object exists, extract it + if 'player_id' not in data and 'player' in data: + if isinstance(data['player'], dict) and 'id' in data['player']: + data['player_id'] = data['player']['id'] + + return data + # Injury timeline start_week: int = Field(..., description="Week injury started") start_game: int = Field(..., description="Game number injury started (1-4)") diff --git a/services/injury_service.py b/services/injury_service.py index 7a0e2e5..9f85d3d 100644 --- a/services/injury_service.py +++ b/services/injury_service.py @@ -155,13 +155,22 @@ class InjuryService(BaseService[Injury]): 'is_active': True } - injury = await self.create(injury_data) - if injury: - logger.info(f"Created injury for player {player_id}: {total_games} games") - return injury + # Call the API to create the injury + client = await self.get_client() + response = await client.post(self.endpoint, injury_data) - logger.error(f"Failed to create injury for player {player_id}") - return None + if not response: + logger.error(f"Failed to create injury for player {player_id}: No response from API") + return None + + # Merge the request data with the response to ensure all required fields are present + # (API may not return all fields that were sent) + merged_data = {**injury_data, **response} + + # Create Injury model from merged data + injury = Injury.from_api_data(merged_data) + logger.info(f"Created injury for player {player_id}: {total_games} games") + return injury except Exception as e: logger.error(f"Error creating injury for player {player_id}: {e}") diff --git a/views/base.py b/views/base.py index 08ba9c9..a39493b 100644 --- a/views/base.py +++ b/views/base.py @@ -4,7 +4,7 @@ Base View Classes for Discord Bot v2.0 Provides foundational view components with consistent styling and behavior. """ import logging -from typing import Optional, Any, Callable, Awaitable +from typing import List, Optional, Any, Callable, Awaitable, Union from datetime import datetime, timezone import discord @@ -21,20 +21,22 @@ class BaseView(discord.ui.View): *, timeout: float = 180.0, user_id: Optional[int] = None, + responders: Optional[List[int | None]] = None, logger_name: Optional[str] = None ): super().__init__(timeout=timeout) self.user_id = user_id + self.responders = responders self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView') self.interaction_count = 0 self.created_at = datetime.now(timezone.utc) async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check if user is authorized to interact with this view.""" - if self.user_id is None: + if self.user_id is None and self.responders is None: return True - if interaction.user.id != self.user_id: + if (self.user_id is not None and interaction.user.id != self.user_id) or (self.responders is not None and interaction.user.id not in self.responders): await interaction.response.send_message( "❌ You cannot interact with this menu.", ephemeral=True @@ -95,14 +97,15 @@ class ConfirmationView(BaseView): def __init__( self, *, - user_id: int, + user_id: Optional[int] = None, + responders: Optional[List[int | None]] = None, timeout: float = 60.0, confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None, cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None, confirm_label: str = "Confirm", cancel_label: str = "Cancel" ): - super().__init__(timeout=timeout, user_id=user_id, logger_name=f'{__name__}.ConfirmationView') + super().__init__(timeout=timeout, user_id=user_id, responders=responders, logger_name=f'{__name__}.ConfirmationView') self.confirm_callback = confirm_callback self.cancel_callback = cancel_callback self.result: Optional[bool] = None diff --git a/views/modals.py b/views/modals.py index 1f90fda..b6f83ea 100644 --- a/views/modals.py +++ b/views/modals.py @@ -485,4 +485,357 @@ def validate_season(season: str) -> bool: season_num = int(season) return 1 <= season_num <= 50 except ValueError: - return False \ No newline at end of file + return False + + +class BatterInjuryModal(BaseModal): + """Modal for collecting current week/game when logging batter injury.""" + + def __init__( + self, + player: 'Player', + injury_games: int, + season: int, + *, + timeout: Optional[float] = 300.0 + ): + """ + Initialize batter injury modal. + + Args: + player: Player object for the injured batter + injury_games: Injury games from roll + season: Current season number + timeout: Modal timeout in seconds + """ + super().__init__(title=f"Batter Injury - {player.name}", timeout=timeout) + + self.player = player + self.injury_games = injury_games + self.season = season + + # Current week input + self.current_week = discord.ui.TextInput( + label="Current Week", + placeholder="Enter current week number (e.g., 5)", + required=True, + max_length=2, + style=discord.TextStyle.short + ) + + # Current game input + self.current_game = discord.ui.TextInput( + label="Current Game", + placeholder="Enter current game number (1-4)", + required=True, + max_length=1, + style=discord.TextStyle.short + ) + + self.add_item(self.current_week) + self.add_item(self.current_game) + + async def on_submit(self, interaction: discord.Interaction): + """Handle batter injury input and log injury.""" + from services.player_service import player_service + from services.injury_service import injury_service + import math + + # Validate current week + try: + week = int(self.current_week.value) + if week < 1 or week > 18: + raise ValueError("Week must be between 1 and 18") + except ValueError: + embed = EmbedTemplate.error( + title="Invalid Week", + description="Current week must be a number between 1 and 18." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Validate current game + try: + game = int(self.current_game.value) + if game < 1 or game > 4: + raise ValueError("Game must be between 1 and 4") + except ValueError: + embed = EmbedTemplate.error( + title="Invalid Game", + description="Current game must be a number between 1 and 4." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Calculate injury dates + out_weeks = math.floor(self.injury_games / 4) + out_games = self.injury_games % 4 + + return_week = week + out_weeks + return_game = game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + # Adjust start date if injury starts after game 4 + start_week = week if game != 4 else week + 1 + start_game = game + 1 if game != 4 else 1 + + return_date = f'w{return_week:02d}g{return_game}' + + # Create injury record + try: + injury = await injury_service.create_injury( + season=self.season, + player_id=self.player.id, + total_games=self.injury_games, + start_week=start_week, + start_game=start_game, + end_week=return_week, + end_game=return_game + ) + + if not injury: + raise ValueError("Failed to create injury record") + + # Update player's il_return field + await player_service.update_player(self.player.id, {'il_return': return_date}) + + # Success response + embed = EmbedTemplate.success( + title="Injury Logged", + description=f"{self.player.name}'s injury has been logged." + ) + + embed.add_field( + name="Duration", + value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}", + inline=True + ) + + embed.add_field( + name="Return Date", + value=return_date, + inline=True + ) + + if self.player.team: + embed.add_field( + name="Team", + value=f"{self.player.team.lname} ({self.player.team.abbrev})", + inline=False + ) + + self.is_submitted = True + self.result = { + 'injury_id': injury.id, + 'total_games': self.injury_games, + 'return_date': return_date + } + + await interaction.response.send_message(embed=embed) + + except Exception as e: + self.logger.error("Failed to create batter injury", error=e, player_id=self.player.id) + embed = EmbedTemplate.error( + title="Error", + description="Failed to log the injury. Please try again or contact an administrator." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +class PitcherRestModal(BaseModal): + """Modal for collecting pitcher rest games when logging injury.""" + + def __init__( + self, + player: 'Player', + injury_games: int, + season: int, + *, + timeout: Optional[float] = 300.0 + ): + """ + Initialize pitcher rest modal. + + Args: + player: Player object for the injured pitcher + injury_games: Base injury games from roll + season: Current season number + timeout: Modal timeout in seconds + """ + super().__init__(title=f"Pitcher Rest - {player.name}", timeout=timeout) + + self.player = player + self.injury_games = injury_games + self.season = season + + # Current week input + self.current_week = discord.ui.TextInput( + label="Current Week", + placeholder="Enter current week number (e.g., 5)", + required=True, + max_length=2, + style=discord.TextStyle.short + ) + + # Current game input + self.current_game = discord.ui.TextInput( + label="Current Game", + placeholder="Enter current game number (1-4)", + required=True, + max_length=1, + style=discord.TextStyle.short + ) + + # Rest games input + self.rest_games = discord.ui.TextInput( + label="Pitcher Rest Games", + placeholder="Enter number of rest games (0 or more)", + required=True, + max_length=2, + style=discord.TextStyle.short + ) + + self.add_item(self.current_week) + self.add_item(self.current_game) + self.add_item(self.rest_games) + + async def on_submit(self, interaction: discord.Interaction): + """Handle pitcher rest input and log injury.""" + from services.player_service import player_service + from services.injury_service import injury_service + from models.injury import Injury + import math + + # Validate current week + try: + week = int(self.current_week.value) + if week < 1 or week > 18: + raise ValueError("Week must be between 1 and 18") + except ValueError: + embed = EmbedTemplate.error( + title="Invalid Week", + description="Current week must be a number between 1 and 18." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Validate current game + try: + game = int(self.current_game.value) + if game < 1 or game > 4: + raise ValueError("Game must be between 1 and 4") + except ValueError: + embed = EmbedTemplate.error( + title="Invalid Game", + description="Current game must be a number between 1 and 4." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Validate rest games + try: + rest = int(self.rest_games.value) + if rest < 0: + raise ValueError("Rest games cannot be negative") + except ValueError: + embed = EmbedTemplate.error( + title="Invalid Rest Games", + description="Rest games must be a non-negative number." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Calculate total injury + total_injury_games = self.injury_games + rest + + # Calculate injury dates + out_weeks = math.floor(total_injury_games / 4) + out_games = total_injury_games % 4 + + return_week = week + out_weeks + return_game = game + 1 + out_games + + if return_game > 4: + return_week += 1 + return_game -= 4 + + # Adjust start date if injury starts after game 4 + start_week = week if game != 4 else week + 1 + start_game = game + 1 if game != 4 else 1 + + return_date = f'w{return_week:02d}g{return_game}' + + # Create injury record + try: + injury = await injury_service.create_injury( + season=self.season, + player_id=self.player.id, + total_games=total_injury_games, + start_week=start_week, + start_game=start_game, + end_week=return_week, + end_game=return_game + ) + + if not injury: + raise ValueError("Failed to create injury record") + + # Update player's il_return field + await player_service.update_player(self.player.id, {'il_return': return_date}) + + # Success response + embed = EmbedTemplate.success( + title="Injury Logged", + description=f"{self.player.name}'s injury has been logged." + ) + + embed.add_field( + name="Base Injury", + value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}", + inline=True + ) + + embed.add_field( + name="Rest Requirement", + value=f"{rest} game{'s' if rest > 1 else ''}", + inline=True + ) + + embed.add_field( + name="Total Duration", + value=f"{total_injury_games} game{'s' if total_injury_games > 1 else ''}", + inline=True + ) + + embed.add_field( + name="Return Date", + value=return_date, + inline=True + ) + + if self.player.team: + embed.add_field( + name="Team", + value=f"{self.player.team.lname} ({self.player.team.abbrev})", + inline=False + ) + + self.is_submitted = True + self.result = { + 'injury_id': injury.id, + 'total_games': total_injury_games, + 'return_date': return_date + } + + await interaction.response.send_message(embed=embed) + + except Exception as e: + self.logger.error("Failed to create pitcher injury", error=e, player_id=self.player.id) + embed = EmbedTemplate.error( + title="Error", + description="Failed to log the injury. Please try again or contact an administrator." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) \ No newline at end of file diff --git a/views/players.py b/views/players.py new file mode 100644 index 0000000..2c64dd8 --- /dev/null +++ b/views/players.py @@ -0,0 +1,395 @@ +""" +Player View Components + +Interactive Discord UI components for player information display with toggleable statistics. +""" +from typing import Optional, TYPE_CHECKING + +import discord +from discord.ext import commands + +from utils.logging import get_contextual_logger +from views.base import BaseView +from views.embeds import EmbedTemplate, EmbedColors +from models.team import RosterType + +if TYPE_CHECKING: + from models.player import Player + from models.batting_stats import BattingStats + from models.pitching_stats import PitchingStats + + +class PlayerStatsView(BaseView): + """ + Interactive view for player information with toggleable batting and pitching statistics. + + Features: + - Basic player info always visible + - Batting stats hidden by default, toggled with button + - Pitching stats hidden by default, toggled with button + - Buttons only appear if corresponding stats exist + - User restriction - only command caller can toggle + - 5 minute timeout with graceful cleanup + """ + + def __init__( + self, + player: 'Player', + season: int, + batting_stats: Optional['BattingStats'] = None, + pitching_stats: Optional['PitchingStats'] = None, + user_id: Optional[int] = None + ): + """ + Initialize the player stats view. + + Args: + player: Player model with basic information + season: Season for statistics display + batting_stats: Batting statistics (if available) + pitching_stats: Pitching statistics (if available) + user_id: Discord user ID who can interact with this view + """ + super().__init__(timeout=300.0, user_id=user_id, logger_name=f'{__name__}.PlayerStatsView') + + self.player = player + self.season = season + self.batting_stats = batting_stats + self.pitching_stats = pitching_stats + self.show_batting = False + self.show_pitching = False + + # Only show batting button if stats are available + if not batting_stats: + self.remove_item(self.toggle_batting_button) + self.logger.debug("No batting stats available, batting button hidden") + + # Only show pitching button if stats are available + if not pitching_stats: + self.remove_item(self.toggle_pitching_button) + self.logger.debug("No pitching stats available, pitching button hidden") + + self.logger.info("PlayerStatsView initialized", + player_id=player.id, + player_name=player.name, + season=season, + has_batting=bool(batting_stats), + has_pitching=bool(pitching_stats), + user_id=user_id) + + @discord.ui.button( + label="Show Batting Stats", + style=discord.ButtonStyle.primary, + emoji="💥", + row=0 + ) + async def toggle_batting_button( + self, + interaction: discord.Interaction, + button: discord.ui.Button + ): + """Toggle batting statistics visibility.""" + self.increment_interaction_count() + self.show_batting = not self.show_batting + + # Update button label + button.label = "Hide Batting Stats" if self.show_batting else "Show Batting Stats" + + self.logger.info("Batting stats toggled", + player_id=self.player.id, + show_batting=self.show_batting, + user_id=interaction.user.id) + + # Rebuild and update embed + await self._update_embed(interaction) + + @discord.ui.button( + label="Show Pitching Stats", + style=discord.ButtonStyle.primary, + emoji="⚾", + row=0 + ) + async def toggle_pitching_button( + self, + interaction: discord.Interaction, + button: discord.ui.Button + ): + """Toggle pitching statistics visibility.""" + self.increment_interaction_count() + self.show_pitching = not self.show_pitching + + # Update button label + button.label = "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats" + + self.logger.info("Pitching stats toggled", + player_id=self.player.id, + show_pitching=self.show_pitching, + user_id=interaction.user.id) + + # Rebuild and update embed + await self._update_embed(interaction) + + async def _update_embed(self, interaction: discord.Interaction): + """ + Rebuild the player embed with current visibility settings and update the message. + + Args: + interaction: Discord interaction from button click + """ + try: + # Create embed with current visibility state + embed = await self._create_player_embed() + + # Update the message with new embed + await interaction.response.edit_message(embed=embed, view=self) + + self.logger.debug("Embed updated successfully", + show_batting=self.show_batting, + show_pitching=self.show_pitching) + + except Exception as e: + self.logger.error("Failed to update embed", error=str(e), exc_info=True) + + # Try to send error message + try: + error_embed = EmbedTemplate.error( + title="Update Failed", + description="Failed to update player statistics. Please try again." + ) + await interaction.response.send_message(embed=error_embed, ephemeral=True) + except Exception: + self.logger.error("Failed to send error message", exc_info=True) + + async def _create_player_embed(self) -> discord.Embed: + """ + Create player embed with current visibility settings. + + Returns: + Discord embed with player information and visible stats + """ + player = self.player + season = self.season + + # Determine embed color based on team + embed_color = EmbedColors.PRIMARY + if hasattr(player, 'team') and player.team and hasattr(player.team, 'color'): + try: + # Convert hex color string to int + embed_color = int(player.team.color, 16) + except (ValueError, TypeError): + embed_color = EmbedColors.PRIMARY + + # Create base embed with player name as title + # Add injury indicator emoji if player is injured + title = f"🤕 {player.name}" if player.il_return is not None else player.name + + embed = EmbedTemplate.create_base_embed( + title=title, + color=embed_color + ) + + # Basic info section (always visible) + embed.add_field( + name="Position", + value=player.primary_position, + inline=True + ) + + if hasattr(player, 'team') and player.team: + embed.add_field( + name="Team", + value=f"{player.team.abbrev} - {player.team.sname}", + inline=True + ) + + # Add Major League affiliate if this is a Minor League team + if player.team.roster_type() == RosterType.MINOR_LEAGUE: + major_affiliate = player.team.get_major_league_affiliate() + if major_affiliate: + embed.add_field( + name="Major Affiliate", + value=major_affiliate, + inline=True + ) + + embed.add_field( + name="sWAR", + value=f"{player.wara:.1f}", + inline=True + ) + + embed.add_field( + name="Player ID", + value=str(player.id), + inline=True + ) + + # All positions if multiple + if len(player.positions) > 1: + embed.add_field( + name="Positions", + value=", ".join(player.positions), + inline=True + ) + + embed.add_field( + name="Season", + value=str(season), + inline=True + ) + + # Always show injury rating + embed.add_field( + name="Injury Rating", + value=player.injury_rating or "N/A", + inline=True + ) + + # Show injury return date only if player is currently injured + if player.il_return: + embed.add_field( + name="Injury Return", + value=player.il_return, + inline=True + ) + + # Add batting stats if visible and available + if self.show_batting and self.batting_stats: + embed.add_field(name='', value='', inline=False) + + self.logger.debug("Adding batting statistics to embed") + batting_stats = self.batting_stats + + rate_stats = ( + "```\n" + "╭─────────────╮\n" + f"│ AVG {batting_stats.avg:.3f} │\n" + f"│ OBP {batting_stats.obp:.3f} │\n" + f"│ SLG {batting_stats.slg:.3f} │\n" + f"│ OPS {batting_stats.ops:.3f} │\n" + f"│ wOBA {batting_stats.woba:.3f} │\n" + "╰─────────────╯\n" + "```" + ) + embed.add_field( + name="Rate Stats", + value=rate_stats, + inline=True + ) + + count_stats = ( + "```\n" + "╭───────────╮\n" + f"│ HR {batting_stats.homerun:>3} │\n" + f"│ RBI {batting_stats.rbi:>3} │\n" + f"│ R {batting_stats.run:>3} │\n" + f"│ AB {batting_stats.ab:>4} │\n" + f"│ H {batting_stats.hit:>4} │\n" + f"│ BB {batting_stats.bb:>3} │\n" + f"│ SO {batting_stats.so:>3} │\n" + "╰───────────╯\n" + "```" + ) + embed.add_field( + name='Counting Stats', + value=count_stats, + inline=True + ) + + # Add pitching stats if visible and available + if self.show_pitching and self.pitching_stats: + embed.add_field(name='', value='', inline=False) + + self.logger.debug("Adding pitching statistics to embed") + pitching_stats = self.pitching_stats + ip = pitching_stats.innings_pitched + + record_stats = ( + "```\n" + "╭─────────────╮\n" + f"│ G-GS {pitching_stats.games:>2}-{pitching_stats.gs:<2} │\n" + f"│ W-L {pitching_stats.win:>2}-{pitching_stats.loss:<2} │\n" + f"│ H-SV {pitching_stats.hold:>2}-{pitching_stats.saves:<2} │\n" + f"│ ERA {pitching_stats.era:>5.2f} │\n" + f"│ WHIP {pitching_stats.whip:>5.2f} │\n" + "╰─────────────╯\n" + "```" + ) + embed.add_field( + name="Record Stats", + value=record_stats, + inline=True + ) + + strikeout_stats = ( + "```\n" + "╭──────────╮\n" + f"│ IP{ip:>6.1f} │\n" + f"│ SO {pitching_stats.so:>3} │\n" + f"│ BB {pitching_stats.bb:>3} │\n" + f"│ H {pitching_stats.hits:>3} │\n" + "╰──────────╯\n" + "```" + ) + embed.add_field( + name='Counting Stats', + value=strikeout_stats, + inline=True + ) + + # Add a note if no stats are visible + if not self.show_batting and not self.show_pitching: + if self.batting_stats or self.pitching_stats: + embed.add_field( + name="📊 Statistics", + value="Click the buttons below to show statistics.", + inline=False + ) + else: + embed.add_field( + name="📊 Statistics", + value="No statistics available for this season.", + inline=False + ) + + # Set player card as main image + if player.image: + embed.set_image(url=player.image) + self.logger.debug("Player card image added to embed", image_url=player.image) + + # Set thumbnail with priority: fancycard → headshot → team logo + thumbnail_url = None + thumbnail_source = None + + if hasattr(player, 'vanity_card') and player.vanity_card: + thumbnail_url = player.vanity_card + thumbnail_source = "fancycard" + elif hasattr(player, 'headshot') and player.headshot: + thumbnail_url = player.headshot + thumbnail_source = "headshot" + elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail: + thumbnail_url = player.team.thumbnail + thumbnail_source = "team logo" + + if thumbnail_url: + embed.set_thumbnail(url=thumbnail_url) + self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url) + + # Footer with player ID + footer_text = f"Player ID: {player.id}" + embed.set_footer(text=footer_text) + + return embed + + async def get_initial_embed(self) -> discord.Embed: + """ + Get the initial embed with stats hidden. + + Returns: + Discord embed with player information, stats hidden by default + """ + # Ensure stats are hidden for initial display + self.show_batting = False + self.show_pitching = False + + return await self._create_player_embed()