diff --git a/models/play.py b/models/play.py new file mode 100644 index 0000000..047652a --- /dev/null +++ b/models/play.py @@ -0,0 +1,282 @@ +""" +Play-by-Play Data Model + +Represents a single play in a baseball game with complete statistics and game state information. +This model matches the database schema at /database/app/routers_v3/stratplay.py. + +NOTE: ID fields have corresponding optional model object fields for API-populated nested data. +Future enhancement could add validators to ensure consistency between ID and model fields. +""" +from typing import Optional, Literal +from pydantic import Field, field_validator +from models.base import SBABaseModel +from models.game import Game +from models.player import Player +from models.team import Team + + +class Play(SBABaseModel): + """ + Play-by-play data model for SBA games. + + Represents a single play in a baseball game with complete + statistics and game state information. + """ + + # Core fields (game_id/pitcher_id optional when nested objects provided) + game_id: Optional[int] = Field(None, description="Game ID this play belongs to") + game: Optional[Game] = Field(None, description="Game object (API-populated)") + play_num: int = Field(..., description="Sequential play number in game") + pitcher_id: Optional[int] = Field(None, description="Pitcher ID") + pitcher: Optional[Player] = Field(None, description="Pitcher object (API-populated)") + on_base_code: str = Field(..., description="Base runners code (e.g., '100', '011')") + inning_half: Literal['top', 'bot'] = Field(..., description="Inning half") + inning_num: int = Field(..., description="Inning number") + batting_order: int = Field(..., description="Batting order position") + starting_outs: int = Field(..., description="Outs at start of play") + away_score: int = Field(..., description="Away team score before play") + home_score: int = Field(..., description="Home team score before play") + + # Optional player IDs + batter_id: Optional[int] = Field(None, description="Batter ID") + batter: Optional[Player] = Field(None, description="Batter object (API-populated)") + batter_team_id: Optional[int] = Field(None, description="Batter's team ID") + batter_team: Optional[Team] = Field(None, description="Batter's team object (API-populated)") + pitcher_team_id: Optional[int] = Field(None, description="Pitcher's team ID") + pitcher_team: Optional[Team] = Field(None, description="Pitcher's team object (API-populated)") + batter_pos: Optional[str] = Field(None, description="Batter's position") + + # Base runner information + on_first_id: Optional[int] = Field(None, description="Runner on first ID") + on_first: Optional[Player] = Field(None, description="Runner on first object (API-populated)") + on_first_final: Optional[int] = Field(None, description="Runner on first final base") + on_second_id: Optional[int] = Field(None, description="Runner on second ID") + on_second: Optional[Player] = Field(None, description="Runner on second object (API-populated)") + on_second_final: Optional[int] = Field(None, description="Runner on second final base") + on_third_id: Optional[int] = Field(None, description="Runner on third ID") + on_third: Optional[Player] = Field(None, description="Runner on third object (API-populated)") + on_third_final: Optional[int] = Field(None, description="Runner on third final base") + batter_final: Optional[int] = Field(None, description="Batter's final base") + + # Statistical fields (all default to 0) + pa: int = Field(0, description="Plate appearance") + ab: int = Field(0, description="At bat") + run: int = Field(0, description="Runs scored") + e_run: int = Field(0, description="Earned runs") + hit: int = Field(0, description="Hits") + rbi: int = Field(0, description="RBIs") + double: int = Field(0, description="Doubles") + triple: int = Field(0, description="Triples") + homerun: int = Field(0, description="Home runs") + bb: int = Field(0, description="Walks") + so: int = Field(0, description="Strikeouts") + hbp: int = Field(0, description="Hit by pitch") + sac: int = Field(0, description="Sacrifice flies") + ibb: int = Field(0, description="Intentional walks") + gidp: int = Field(0, description="Grounded into double play") + bphr: int = Field(0, description="Ballpark home runs") + bpfo: int = Field(0, description="Ballpark flyouts") + bp1b: int = Field(0, description="Ballpark singles") + bplo: int = Field(0, description="Ballpark lineouts") + sb: int = Field(0, description="Stolen bases") + cs: int = Field(0, description="Caught stealing") + outs: int = Field(0, description="Outs recorded") + + # Pitching rest/workload fields + pitcher_rest_outs: Optional[int] = Field(None, description="Pitcher rest in outs") + inherited_runners: int = Field(0, description="Inherited runners") + inherited_scored: int = Field(0, description="Inherited runners scored") + on_hook_for_loss: int = Field(0, description="On hook for loss") + + # Advanced metrics + wpa: float = Field(0.0, description="Win probability added") + re24_primary: Optional[float] = Field(None, description="RE24 primary") + re24_running: Optional[float] = Field(None, description="RE24 running") + run_differential: Optional[int] = Field(None, description="Run differential") + + # Defensive players + catcher_id: Optional[int] = Field(None, description="Catcher ID") + catcher: Optional[Player] = Field(None, description="Catcher object (API-populated)") + catcher_team_id: Optional[int] = Field(None, description="Catcher's team ID") + catcher_team: Optional[Team] = Field(None, description="Catcher's team object (API-populated)") + defender_id: Optional[int] = Field(None, description="Defender ID") + defender: Optional[Player] = Field(None, description="Defender object (API-populated)") + defender_team_id: Optional[int] = Field(None, description="Defender's team ID") + defender_team: Optional[Team] = Field(None, description="Defender's team object (API-populated)") + runner_id: Optional[int] = Field(None, description="Runner ID") + runner: Optional[Player] = Field(None, description="Runner object (API-populated)") + runner_team_id: Optional[int] = Field(None, description="Runner's team ID") + runner_team: Optional[Team] = Field(None, description="Runner's team object (API-populated)") + + # Defensive plays + check_pos: Optional[str] = Field(None, description="Position checked") + error: int = Field(0, description="Errors") + wild_pitch: int = Field(0, description="Wild pitches") + passed_ball: int = Field(0, description="Passed balls") + pick_off: int = Field(0, description="Pick offs") + balk: int = Field(0, description="Balks") + + # Game situation + is_go_ahead: bool = Field(False, description="Go-ahead play") + is_tied: bool = Field(False, description="Tied game") + is_new_inning: bool = Field(False, description="New inning") + + # Player handedness + hand_batting: Optional[str] = Field(None, description="Batter handedness (L/R/S)") + hand_pitching: Optional[str] = Field(None, description="Pitcher handedness (L/R)") + + # Validators from database model + @field_validator('on_first_final') + @classmethod + def no_final_if_no_runner_one(cls, v, info): + """Validate on_first_final is None if no runner on first.""" + if info.data.get('on_first_id') is None: + return None + return v + + @field_validator('on_second_final') + @classmethod + def no_final_if_no_runner_two(cls, v, info): + """Validate on_second_final is None if no runner on second.""" + if info.data.get('on_second_id') is None: + return None + return v + + @field_validator('on_third_final') + @classmethod + def no_final_if_no_runner_three(cls, v, info): + """Validate on_third_final is None if no runner on third.""" + if info.data.get('on_third_id') is None: + return None + return v + + @field_validator('batter_final') + @classmethod + def no_final_if_no_batter(cls, v, info): + """Validate batter_final is None if no batter.""" + if info.data.get('batter_id') is None: + return None + return v + + def descriptive_text(self, away_team: Team, home_team: Team) -> str: + """ + Generate human-readable description of this play for key plays display. + + Args: + away_team: Away team object (for team abbreviations) + home_team: Home team object (for team abbreviations) + + Returns: + Formatted string like: "Top 3: Player Name (NYY) homers in 2 runs" + """ + # Determine inning text + inning_text = f"{'Top' if self.inning_half == 'top' else 'Bot'} {self.inning_num}" + + # Determine team abbreviation based on inning half + away_score = self.away_score + home_score = self.home_score + if self.inning_half == 'top': + away_score += self.rbi + else: + home_score += self.rbi + + score_text = f'tied at {home_score}' + if home_score > away_score: + score_text = f'{home_team.abbrev} up {home_score}-{away_score}' + else: + score_text = f'{away_team.abbrev} up {away_score}-{home_score}' + + # Build play description based on play type + description_parts = [] + which_player = 'batter' + + # Offensive plays + if self.homerun > 0: + if self.rbi == 1: + description_parts.append("homers") + else: + description_parts.append(f"homers in {self.rbi} runs") + elif self.triple > 0: + description_parts.append("triples") + if self.rbi > 0: + description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.double > 0: + description_parts.append("doubles") + if self.rbi > 0: + description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.hit > 0: + description_parts.append("singles") + if self.rbi > 0: + description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.bb > 0: + if self.ibb > 0: + description_parts.append("intentionally walked") + else: + description_parts.append("walks") + if self.rbi > 0: + description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.hbp > 0: + description_parts.append("hit by pitch") + if self.rbi > 0: + description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.sac > 0: + description_parts.append("sacrifice fly") + if self.rbi > 0: + description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.sb > 0: + description_parts.append("steals a base") + elif self.cs > 0: + which_player = 'catcher' + description_parts.append("guns down a baserunner") + elif self.gidp > 0: + description_parts.append("grounds into double play") + elif self.so > 0: + which_player = 'pitcher' + description_parts.append(f"gets a strikeout") + # Defensive plays + elif self.error > 0: + which_player = 'defender' + description_parts.append("commits an error") + if self.rbi > 0: + description_parts.append(f"allowing {self.rbi} run{'s' if self.rbi > 1 else ''}") + elif self.wild_pitch > 0: + which_player = 'pitcher' + description_parts.append("uncorks a wild pitch") + elif self.passed_ball > 0: + which_player = 'catcher' + description_parts.append("passed ball") + elif self.pick_off > 0: + which_player = 'runner' + description_parts.append("picked off") + elif self.balk > 0: + which_player = 'pitcher' + description_parts.append("balk") + else: + # Generic out + if self.outs > 0: + which_player = 'pitcher' + description_parts.append(f'records out number {self.starting_outs + self.outs}') + + # Combine parts + if description_parts: + play_desc = " ".join(description_parts) + else: + play_desc = "makes a play" + + player_dict = { + 'batter': self.batter, + 'pitcher': self.pitcher, + 'catcher': self.catcher, + 'runner': self.runner, + 'defender': self.defender + } + team_dict = { + 'batter': self.batter_team, + 'pitcher': self.pitcher_team, + 'catcher': self.catcher_team, + 'runner': self.runner_team, + 'defender': self.defender_team + } + + # Format: "Top 3: Derek Jeter (NYY) homers in 2 runs, NYY up 2-0" + return f"{inning_text}: {player_dict.get(which_player).name} ({team_dict.get(which_player).abbrev}) {play_desc}, {score_text}" diff --git a/utils/discord_helpers.py b/utils/discord_helpers.py new file mode 100644 index 0000000..793cf1d --- /dev/null +++ b/utils/discord_helpers.py @@ -0,0 +1,121 @@ +""" +Discord Helper Utilities + +Common Discord-related helper functions for channel lookups, +message sending, and formatting. +""" +from typing import Optional, List +import discord +from discord.ext import commands + +from models.play import Play +from models.team import Team +from utils.logging import get_contextual_logger + +logger = get_contextual_logger(__name__) + + +async def get_channel_by_name( + bot: commands.Bot, + channel_name: str +) -> Optional[discord.TextChannel]: + """ + Get a text channel by name from the configured guild. + + Args: + bot: Discord bot instance + channel_name: Name of the channel to find + + Returns: + TextChannel if found, None otherwise + """ + from config import get_config + + config = get_config() + guild_id = config.guild_id + + if not guild_id: + logger.error("GUILD_ID not configured") + return None + + guild = bot.get_guild(guild_id) + if not guild: + logger.error(f"Guild {guild_id} not found") + return None + + channel = discord.utils.get(guild.text_channels, name=channel_name) + + if not channel: + logger.warning(f"Channel '{channel_name}' not found in guild {guild_id}") + return None + + return channel + + +async def send_to_channel( + bot: commands.Bot, + channel_name: str, + content: Optional[str] = None, + embed: Optional[discord.Embed] = None +) -> bool: + """ + Send a message to a channel by name. + + Args: + bot: Discord bot instance + channel_name: Name of the channel + content: Text content to send + embed: Embed to send + + Returns: + True if message sent successfully, False otherwise + """ + channel = await get_channel_by_name(bot, channel_name) + + if not channel: + logger.error(f"Cannot send to channel '{channel_name}' - not found") + return False + + try: + # Build kwargs to avoid passing None for embed + kwargs = {} + if content is not None: + kwargs['content'] = content + if embed is not None: + kwargs['embed'] = embed + + await channel.send(**kwargs) + logger.info(f"Sent message to #{channel_name}") + return True + except Exception as e: + logger.error(f"Failed to send message to #{channel_name}: {e}") + return False + + +def format_key_plays( + plays: List[Play], + away_team: Team, + home_team: Team +) -> str: + """ + Format top plays into embed field text. + + Args: + plays: List of Play objects (should be sorted by WPA) + away_team: Away team object + home_team: Home team object + + Returns: + Formatted string for embed field, or empty string if no plays + """ + if not plays: + return "" + + key_plays_text = "" + + for play in plays: + # Use the Play.descriptive_text() method (already includes score) + play_description = play.descriptive_text(away_team, home_team) + key_plays_text += f"{play_description}\n" + + return key_plays_text