CLAUDE: Fix duplicate score display in key plays
Fixed issue where key plays were showing the score twice: - Before: "Top 3: Player (NYY) homers, NYY up 2-0, NYY up 2-0" - After: "Top 3: Player (NYY) homers, NYY up 2-0" Changes: - Simplified Play.descriptive_text() to use rbi for score calculation - Removed duplicate score logic in format_key_plays() helper - Now only shows score after the play is completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7aa454f619
commit
998e09d0c0
282
models/play.py
Normal file
282
models/play.py
Normal file
@ -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}"
|
||||||
121
utils/discord_helpers.py
Normal file
121
utils/discord_helpers.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user