Migrated to ruff for faster, modern code formatting and linting: Configuration changes: - pyproject.toml: Added ruff 0.8.6, removed black/flake8 - Configured ruff with black-compatible formatting (88 chars) - Enabled comprehensive linting rules (pycodestyle, pyflakes, isort, pyupgrade, bugbear, comprehensions, simplify, return) - Updated CLAUDE.md: Changed code quality commands to use ruff Code improvements (490 auto-fixes): - Modernized type hints: List[T] → list[T], Dict[K,V] → dict[K,V], Optional[T] → T | None - Sorted all imports (isort integration) - Removed unused imports - Fixed whitespace issues - Reformatted 38 files for consistency Bug fixes: - app/core/play_resolver.py: Fixed type hint bug (any → Any) - tests/unit/core/test_runner_advancement.py: Removed obsolete random mock Testing: - All 739 unit tests passing (100%) - No regressions introduced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
574 lines
18 KiB
Python
574 lines
18 KiB
Python
"""
|
|
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 Any, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
# ==================== 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: str | None = Field(
|
|
None, description="PRIMARY CARD: Main playing card image URL"
|
|
)
|
|
image2: str | None = Field(
|
|
None, description="ALT CARD: Secondary card for two-way players"
|
|
)
|
|
headshot: str | None = Field(
|
|
None, description="DEFAULT: League-provided headshot fallback"
|
|
)
|
|
vanity_card: str | None = Field(
|
|
None, description="CUSTOM: User-uploaded profile image"
|
|
)
|
|
|
|
# Positions (up to 8 possible positions)
|
|
pos_1: str | None = Field(None, description="Primary position")
|
|
pos_2: str | None = Field(None, description="Secondary position")
|
|
pos_3: str | None = Field(None, description="Tertiary position")
|
|
pos_4: str | None = Field(None, description="Fourth position")
|
|
pos_5: str | None = Field(None, description="Fifth position")
|
|
pos_6: str | None = Field(None, description="Sixth position")
|
|
pos_7: str | None = Field(None, description="Seventh position")
|
|
pos_8: str | None = Field(None, description="Eighth position")
|
|
|
|
# Active position rating (loaded for current defensive position)
|
|
active_position_rating: Optional["PositionRating"] = Field(
|
|
default=None, description="Defensive rating for current position"
|
|
)
|
|
|
|
@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
|
|
|
|
@abstractmethod
|
|
def get_image_url(self) -> str:
|
|
"""Get card image URL with fallback logic (image -> image2 -> headshot -> empty)."""
|
|
pass
|
|
|
|
def get_player_image_url(self) -> str:
|
|
"""Get player profile image (prioritizes custom uploads over league defaults)."""
|
|
return self.vanity_card or self.headshot or ""
|
|
|
|
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")
|
|
team_id: int | None = Field(None, description="Current team ID")
|
|
team_name: str | None = Field(None, description="Current team name")
|
|
season: int | None = Field(None, description="Season number")
|
|
|
|
# Additional info
|
|
strat_code: str | None = Field(None, description="Strat-O-Matic code")
|
|
bbref_id: str | None = Field(None, description="Baseball Reference ID")
|
|
injury_rating: str | None = Field(None, description="Injury rating")
|
|
|
|
def get_pitching_card_url(self) -> str:
|
|
"""Get pitching card image"""
|
|
if self.pos_1 in ["SP", "RP"]:
|
|
return self.image
|
|
if self.image2 and (
|
|
"P" in str(self.pos_2) or "P" in str(self.pos_3) or "P" in str(self.pos_4)
|
|
):
|
|
return self.image2
|
|
raise ValueError(f"Pitching card not found for {self.get_display_name()}")
|
|
|
|
def get_batting_card_url(self) -> str:
|
|
"""Get batting card image"""
|
|
if "P" not in self.pos_1:
|
|
return self.image
|
|
if self.image2 and any(
|
|
"P" in str(pos)
|
|
for pos in [
|
|
self.pos_2,
|
|
self.pos_3,
|
|
self.pos_4,
|
|
self.pos_5,
|
|
self.pos_6,
|
|
self.pos_7,
|
|
self.pos_8,
|
|
]
|
|
if pos
|
|
):
|
|
return self.image2
|
|
raise ValueError(f"Batting card not found for {self.get_display_name()}")
|
|
|
|
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
|
|
|
|
def get_image_url(self) -> str:
|
|
"""Get card image URL with fallback logic."""
|
|
return self.image or self.image2 or self.headshot or ""
|
|
|
|
@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: int | None = None
|
|
relief_rating: int | None = None
|
|
closer_rating: int | None = 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 PositionRating(BaseModel):
|
|
"""
|
|
Defensive rating for a player at a specific position.
|
|
|
|
Used for X-Check play resolution. Ratings come from:
|
|
- PD: API endpoint /api/v2/cardpositions/player/:player_id
|
|
- SBA: Read from physical cards by players
|
|
"""
|
|
|
|
position: str = Field(..., description="Position code (SS, LF, CF, etc.)")
|
|
innings: int = Field(..., description="Innings played at position")
|
|
range: int = Field(..., ge=1, le=5, description="Defense range (1=best, 5=worst)")
|
|
error: int = Field(..., ge=0, le=88, description="Error rating (0=best, 88=worst)")
|
|
arm: int | None = Field(None, description="Throwing arm rating")
|
|
pb: int | None = Field(None, description="Passed balls (catchers only)")
|
|
overthrow: int | None = Field(None, description="Overthrow risk")
|
|
|
|
@classmethod
|
|
def from_api_response(cls, data: dict[str, Any]) -> "PositionRating":
|
|
"""
|
|
Create PositionRating from PD API response.
|
|
|
|
Args:
|
|
data: Single position dict from /api/v2/cardpositions response
|
|
|
|
Returns:
|
|
PositionRating instance
|
|
"""
|
|
return cls(
|
|
position=data["position"],
|
|
innings=data["innings"],
|
|
range=data["range"],
|
|
error=data["error"],
|
|
arm=data.get("arm"),
|
|
pb=data.get("pb"),
|
|
overthrow=data.get("overthrow"),
|
|
)
|
|
|
|
|
|
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
|
|
|
|
Note: PD API returns 'player_id' which is mapped to 'id' field in from_api_response().
|
|
"""
|
|
|
|
# PD-specific fields
|
|
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")
|
|
|
|
# Reference IDs
|
|
strat_code: str | None = Field(None, description="Strat-O-Matic code")
|
|
bbref_id: str | None = Field(None, description="Baseball Reference ID")
|
|
fangr_id: str | None = 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: PdBattingCard | None = Field(
|
|
None, description="Batting card with ratings"
|
|
)
|
|
pitching_card: PdPitchingCard | None = Field(
|
|
None, description="Pitching card with ratings"
|
|
)
|
|
|
|
@property
|
|
def player_id(self) -> int:
|
|
"""Alias for id (backward compatibility)."""
|
|
return self.id
|
|
|
|
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_image_url(self) -> str:
|
|
"""Get card image URL with fallback logic."""
|
|
return self.image or self.image2 or self.headshot or ""
|
|
|
|
def get_batting_rating(self, vs_hand: str) -> PdBattingRating | None:
|
|
"""
|
|
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) -> PdPitchingRating | None:
|
|
"""
|
|
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: dict[str, Any] | None = None,
|
|
pitching_data: dict[str, Any] | None = 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.get("player_id", player_data.get("id", 0)),
|
|
name=player_data.get("p_name", player_data.get("name", "")),
|
|
cost=player_data.get("cost", 0),
|
|
image=player_data.get("image", ""),
|
|
image2=player_data.get("image2"),
|
|
cardset=PdCardset(**player_data["cardset"])
|
|
if "cardset" in player_data
|
|
else PdCardset(id=0, name="", description=""),
|
|
set_num=player_data.get("set_num", 0),
|
|
rarity=PdRarity(**player_data["rarity"])
|
|
if "rarity" in player_data
|
|
else PdRarity(id=0, value=0, name="", color=""),
|
|
mlbclub=player_data.get("mlbclub", ""),
|
|
franchise=player_data.get("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,
|
|
)
|