strat-gameplay-webapp/backend/app/models/player_models.py
Cal Corum 5d5c13f2b8 CLAUDE: Implement Week 6 league configuration and play outcome systems
Week 6 Progress: 75% Complete

## Components Implemented

### 1. League Configuration System 
- Created BaseGameConfig abstract class for league-agnostic rules
- Implemented SbaConfig and PdConfig with league-specific settings
- Immutable configs (frozen=True) with singleton registry
- 28 unit tests, all passing

Files:
- backend/app/config/base_config.py
- backend/app/config/league_configs.py
- backend/tests/unit/config/test_league_configs.py

### 2. PlayOutcome Enum 
- Universal enum for all play outcomes (both SBA and PD)
- Helper methods: is_hit(), is_out(), is_uncapped(), is_interrupt()
- Supports standard hits, uncapped hits, interrupt plays, ballpark power
- 30 unit tests, all passing

Files:
- backend/app/config/result_charts.py
- backend/tests/unit/config/test_play_outcome.py

### 3. Player Model Refinements 
- Fixed PdPlayer.id field mapping (player_id → id)
- Improved field docstrings for image types
- Fixed position checking logic in SBA helper methods
- Added safety checks for missing image data

Files:
- backend/app/models/player_models.py (updated)

### 4. Documentation 
- Updated backend/CLAUDE.md with Week 6 section
- Documented card-based resolution mechanics
- Detailed config system and PlayOutcome usage

## Architecture Decisions

1. **Card-Based Resolution**: Both SBA and PD use same mechanics
   - 1d6 (column) + 2d6 (row) + 1d20 (split resolution)
   - PD: Digitized cards with auto-resolution
   - SBA: Manual entry from physical cards

2. **Immutable Configs**: Prevent accidental modification using Pydantic frozen

3. **Universal PlayOutcome**: Single enum for both leagues reduces duplication

## Testing
- Total: 58 tests, all passing
- Config tests: 28
- PlayOutcome tests: 30

## Remaining Work (25%)
- Update dice system (check_d20 → chaos_d20)
- Integrate PlayOutcome into PlayResolver
- Add Play.metadata support for uncapped hits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 22:46:12 -05:00

459 lines
16 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 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: str = Field(..., description="PRIMARY CARD: Main playing card image URL")
image2: Optional[str] = Field(None, description="ALT CARD: Secondary card for two-way players")
headshot: Optional[str] = Field(None, description="DEFAULT: League-provided headshot fallback")
vanity_card: Optional[str] = Field(None, description="CUSTOM: User-uploaded profile image")
# Positions (up to 8 possible positions)
pos_1: str = Field(..., 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")
@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
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: 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")
# Additional info
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_pitching_card_url(self) -> str:
"""Get pitching card image"""
if self.pos_1 in ['SP', 'RP']:
return self.image
elif 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
elif 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
@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["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
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: 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_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.description} {self.name}"
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['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,
)