""" Polymorphic player models for league-agnostic game engine. Supports both SBA and PD leagues with different data complexity: - SBA: Simple player data (id, name, image, positions) - PD: Complex player data with scouting ratings for batting/pitching Author: Claude Date: 2025-10-28 """ from abc import ABC, abstractmethod from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field, field_validator # ==================== Base Player (Abstract) ==================== class BasePlayer(BaseModel, ABC): """ Abstract base class for all player types. Provides common interface for league-agnostic game engine. """ # Common fields across all leagues id: int = Field(..., description="Player ID (SBA) or Card ID (PD)") name: str = Field(..., description="Player display name") image: Optional[str] = Field(None, description="Primary card/player image URL") @abstractmethod def get_image_url(self) -> str: """Get player image URL (with fallback logic if needed).""" pass @abstractmethod def get_positions(self) -> List[str]: """Get list of positions player can play (e.g., ['2B', 'SS']).""" pass @abstractmethod def get_display_name(self) -> str: """Get formatted display name for UI.""" pass class Config: """Pydantic configuration.""" # Allow extra fields for future extensibility extra = "allow" # ==================== SBA Player Model ==================== class SbaPlayer(BasePlayer): """ SBA League player model. Simple model with minimal data needed for gameplay. Matches API response from: {{baseUrl}}/players/:player_id """ # SBA-specific fields wara: float = Field(default=0.0, description="Wins Above Replacement Average") image2: Optional[str] = Field(None, description="Secondary image URL") team_id: Optional[int] = Field(None, description="Current team ID") team_name: Optional[str] = Field(None, description="Current team name") season: Optional[int] = Field(None, description="Season number") # Positions (up to 8 possible positions) pos_1: Optional[str] = Field(None, description="Primary position") pos_2: Optional[str] = Field(None, description="Secondary position") pos_3: Optional[str] = Field(None, description="Tertiary position") pos_4: Optional[str] = Field(None, description="Fourth position") pos_5: Optional[str] = Field(None, description="Fifth position") pos_6: Optional[str] = Field(None, description="Sixth position") pos_7: Optional[str] = Field(None, description="Seventh position") pos_8: Optional[str] = Field(None, description="Eighth position") # Additional info headshot: Optional[str] = Field(None, description="Player headshot URL") vanity_card: Optional[str] = Field(None, description="Vanity card URL") strat_code: Optional[str] = Field(None, description="Strat-O-Matic code") bbref_id: Optional[str] = Field(None, description="Baseball Reference ID") injury_rating: Optional[str] = Field(None, description="Injury rating") def get_image_url(self) -> str: """Get player image with fallback logic.""" return self.image or self.image2 or self.headshot or "" def get_positions(self) -> List[str]: """Get list of all positions player can play.""" positions = [ self.pos_1, self.pos_2, self.pos_3, self.pos_4, self.pos_5, self.pos_6, self.pos_7, self.pos_8 ] return [pos for pos in positions if pos is not None] def get_display_name(self) -> str: """Get formatted display name.""" return self.name @classmethod def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer": """ Create SbaPlayer from API response. Args: data: API response dict from /players/:player_id Returns: SbaPlayer instance """ # Extract team info if present team_info = data.get("team", {}) team_id = team_info.get("id") if team_info else None team_name = team_info.get("lname") if team_info else None return cls( id=data["id"], name=data["name"], image=data.get("image"), image2=data.get("image2"), wara=data.get("wara", 0.0), team_id=team_id, team_name=team_name, season=data.get("season"), pos_1=data.get("pos_1"), pos_2=data.get("pos_2"), pos_3=data.get("pos_3"), pos_4=data.get("pos_4"), pos_5=data.get("pos_5"), pos_6=data.get("pos_6"), pos_7=data.get("pos_7"), pos_8=data.get("pos_8"), headshot=data.get("headshot"), vanity_card=data.get("vanity_card"), strat_code=data.get("strat_code"), bbref_id=data.get("bbref_id"), injury_rating=data.get("injury_rating"), ) # ==================== PD Player Model ==================== class PdCardset(BaseModel): """PD cardset information.""" id: int name: str description: str ranked_legal: bool = True class PdRarity(BaseModel): """PD card rarity information.""" id: int value: int name: str # MVP, Starter, Replacement, etc. color: str # Hex color class PdBattingRating(BaseModel): """ PD batting card ratings for one handedness matchup. Contains all probability data for dice roll outcomes. """ vs_hand: str = Field(..., description="Pitcher handedness: L or R") # Hit location rates pull_rate: float center_rate: float slap_rate: float # Outcome probabilities (sum to ~100.0) homerun: float = 0.0 bp_homerun: float = 0.0 triple: float = 0.0 double_three: float = 0.0 double_two: float = 0.0 double_pull: float = 0.0 single_two: float = 0.0 single_one: float = 0.0 single_center: float = 0.0 bp_single: float = 0.0 hbp: float = 0.0 walk: float = 0.0 strikeout: float = 0.0 lineout: float = 0.0 popout: float = 0.0 flyout_a: float = 0.0 flyout_bq: float = 0.0 flyout_lf_b: float = 0.0 flyout_rf_b: float = 0.0 groundout_a: float = 0.0 groundout_b: float = 0.0 groundout_c: float = 0.0 # Summary stats avg: float obp: float slg: float class PdPitchingRating(BaseModel): """ PD pitching card ratings for one handedness matchup. Contains all probability data for dice roll outcomes. """ vs_hand: str = Field(..., description="Batter handedness: L or R") # Outcome probabilities (sum to ~100.0) homerun: float = 0.0 bp_homerun: float = 0.0 triple: float = 0.0 double_three: float = 0.0 double_two: float = 0.0 double_cf: float = 0.0 single_two: float = 0.0 single_one: float = 0.0 single_center: float = 0.0 bp_single: float = 0.0 hbp: float = 0.0 walk: float = 0.0 strikeout: float = 0.0 flyout_lf_b: float = 0.0 flyout_cf_b: float = 0.0 flyout_rf_b: float = 0.0 groundout_a: float = 0.0 groundout_b: float = 0.0 # X-check probabilities (defensive plays) xcheck_p: float = 0.0 xcheck_c: float = 0.0 xcheck_1b: float = 0.0 xcheck_2b: float = 0.0 xcheck_3b: float = 0.0 xcheck_ss: float = 0.0 xcheck_lf: float = 0.0 xcheck_cf: float = 0.0 xcheck_rf: float = 0.0 # Summary stats avg: float obp: float slg: float class PdBattingCard(BaseModel): """PD batting card information (contains multiple ratings).""" steal_low: int steal_high: int steal_auto: bool steal_jump: float bunting: str # A, B, C, D rating hit_and_run: str # A, B, C, D rating running: int # Base running rating offense_col: int # Which offensive column (1 or 2) hand: str # L or R # Ratings for vs LHP and vs RHP ratings: Dict[str, PdBattingRating] = Field(default_factory=dict) class PdPitchingCard(BaseModel): """PD pitching card information (contains multiple ratings).""" balk: int wild_pitch: int hold: int # Hold runners rating starter_rating: Optional[int] = None relief_rating: Optional[int] = None closer_rating: Optional[int] = None batting: str # Pitcher's batting rating offense_col: int # Which offensive column when batting (1 or 2) hand: str # L or R # Ratings for vs LHB and vs RHB ratings: Dict[str, PdPitchingRating] = Field(default_factory=dict) class PdPlayer(BasePlayer): """ PD League player model. Complex model with detailed scouting data for simulation. Matches API response from: {{baseUrl}}/api/v2/players/:player_id """ # Override id field to use player_id (more explicit for PD) player_id: int = Field(..., description="PD player card ID", alias="id") cost: int = Field(..., description="Card cost/value") # Card metadata cardset: PdCardset set_num: int = Field(..., description="Card set number") rarity: PdRarity # Team info mlbclub: str = Field(..., description="MLB club name") franchise: str = Field(..., description="Franchise name") # Images image2: Optional[str] = Field(None, description="Secondary image URL") headshot: Optional[str] = Field(None, description="Player headshot URL") vanity_card: Optional[str] = Field(None, description="Vanity card URL") # Positions (up to 8 possible positions) pos_1: Optional[str] = Field(None, description="Primary position") pos_2: Optional[str] = Field(None, description="Secondary position") pos_3: Optional[str] = Field(None, description="Tertiary position") pos_4: Optional[str] = Field(None, description="Fourth position") pos_5: Optional[str] = Field(None, description="Fifth position") pos_6: Optional[str] = Field(None, description="Sixth position") pos_7: Optional[str] = Field(None, description="Seventh position") pos_8: Optional[str] = Field(None, description="Eighth position") # Reference IDs strat_code: Optional[str] = Field(None, description="Strat-O-Matic code") bbref_id: Optional[str] = Field(None, description="Baseball Reference ID") fangr_id: Optional[str] = Field(None, description="FanGraphs ID") # Card details description: str = Field(..., description="Card description (usually year)") quantity: int = Field(default=999, description="Card quantity available") # Scouting data (loaded separately if needed) batting_card: Optional[PdBattingCard] = Field(None, description="Batting card with ratings") pitching_card: Optional[PdPitchingCard] = Field(None, description="Pitching card with ratings") def get_image_url(self) -> str: """Get player image with fallback logic.""" return self.image or self.image2 or self.headshot or "" def get_positions(self) -> List[str]: """Get list of all positions player can play.""" positions = [ self.pos_1, self.pos_2, self.pos_3, self.pos_4, self.pos_5, self.pos_6, self.pos_7, self.pos_8 ] return [pos for pos in positions if pos is not None] def get_display_name(self) -> str: """Get formatted display name with description.""" return f"{self.name} ({self.description})" def get_batting_rating(self, vs_hand: str) -> Optional[PdBattingRating]: """ Get batting rating for specific pitcher handedness. Args: vs_hand: Pitcher handedness ('L' or 'R') Returns: Batting rating or None if not available """ if not self.batting_card or not self.batting_card.ratings: return None return self.batting_card.ratings.get(vs_hand) def get_pitching_rating(self, vs_hand: str) -> Optional[PdPitchingRating]: """ Get pitching rating for specific batter handedness. Args: vs_hand: Batter handedness ('L' or 'R') Returns: Pitching rating or None if not available """ if not self.pitching_card or not self.pitching_card.ratings: return None return self.pitching_card.ratings.get(vs_hand) @classmethod def from_api_response( cls, player_data: Dict[str, Any], batting_data: Optional[Dict[str, Any]] = None, pitching_data: Optional[Dict[str, Any]] = None ) -> "PdPlayer": """ Create PdPlayer from API responses. Args: player_data: API response from /api/v2/players/:player_id batting_data: Optional API response from /api/v2/battingcardratings/player/:player_id pitching_data: Optional API response from /api/v2/pitchingcardratings/player/:player_id Returns: PdPlayer instance with scouting data if provided """ # Parse batting card if provided batting_card = None if batting_data and "ratings" in batting_data: ratings_dict = {} for rating in batting_data["ratings"]: vs_hand = rating["vs_hand"] ratings_dict[vs_hand] = PdBattingRating(**rating) # Get card info from first rating (same for all matchups) card_info = batting_data["ratings"][0]["battingcard"] batting_card = PdBattingCard( steal_low=card_info["steal_low"], steal_high=card_info["steal_high"], steal_auto=card_info["steal_auto"], steal_jump=card_info["steal_jump"], bunting=card_info["bunting"], hit_and_run=card_info["hit_and_run"], running=card_info["running"], offense_col=card_info["offense_col"], hand=card_info["hand"], ratings=ratings_dict ) # Parse pitching card if provided pitching_card = None if pitching_data and "ratings" in pitching_data: ratings_dict = {} for rating in pitching_data["ratings"]: vs_hand = rating["vs_hand"] ratings_dict[vs_hand] = PdPitchingRating(**rating) # Get card info from first rating (same for all matchups) card_info = pitching_data["ratings"][0]["pitchingcard"] pitching_card = PdPitchingCard( balk=card_info["balk"], wild_pitch=card_info["wild_pitch"], hold=card_info["hold"], starter_rating=card_info.get("starter_rating"), relief_rating=card_info.get("relief_rating"), closer_rating=card_info.get("closer_rating"), batting=card_info["batting"], offense_col=card_info["offense_col"], hand=card_info["hand"], ratings=ratings_dict ) return cls( id=player_data["player_id"], name=player_data["p_name"], cost=player_data["cost"], image=player_data.get("image"), image2=player_data.get("image2"), cardset=PdCardset(**player_data["cardset"]), set_num=player_data["set_num"], rarity=PdRarity(**player_data["rarity"]), mlbclub=player_data["mlbclub"], franchise=player_data["franchise"], pos_1=player_data.get("pos_1"), pos_2=player_data.get("pos_2"), pos_3=player_data.get("pos_3"), pos_4=player_data.get("pos_4"), pos_5=player_data.get("pos_5"), pos_6=player_data.get("pos_6"), pos_7=player_data.get("pos_7"), pos_8=player_data.get("pos_8"), headshot=player_data.get("headshot"), vanity_card=player_data.get("vanity_card"), strat_code=player_data.get("strat_code"), bbref_id=player_data.get("bbref_id"), fangr_id=player_data.get("fangr_id"), description=player_data["description"], quantity=player_data.get("quantity", 999), batting_card=batting_card, pitching_card=pitching_card, )