diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index e58c306..419dacb 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -1702,15 +1702,213 @@ terminal_client/ - ⏳ Result charts & PD integration (not started) - ⏳ API client (deferred) +--- + +## Week 6: League Configuration & Play Outcome System (2025-10-28) + +**Status**: 75% Complete +**Phase**: Phase 2 - Week 6 (League Features & Integration) + +### Overview + +Implemented foundational configuration and outcome systems for both SBA and PD leagues, establishing the framework for card-based play resolution. + +### Components Implemented + +#### 1. League Configuration System ✅ + +**Location**: `app/config/` + +Provides immutable, league-specific configuration objects for game rules and API endpoints. + +**Files Created**: +- `app/config/base_config.py` - Abstract base configuration +- `app/config/league_configs.py` - SBA and PD implementations +- `app/config/__init__.py` - Public API +- `tests/unit/config/test_league_configs.py` - 28 tests (all passing) + +**Key Features**: +```python +from app.config import get_league_config + +# Get league-specific config +config = get_league_config("sba") +api_url = config.get_api_base_url() # "https://api.sba.manticorum.com" +chart = config.get_result_chart_name() # "sba_standard_v1" + +# Configs are immutable (frozen=True) +# config.innings = 7 # Raises ValidationError +``` + +**League Differences**: +- **SBA**: Manual result selection, simple player data +- **PD**: Flexible selection (manual or auto), detailed scouting data, cardset validation, advanced analytics + +**Config Registry**: +```python +LEAGUE_CONFIGS = { + "sba": SbaConfig(), + "pd": PdConfig() +} +``` + +#### 2. PlayOutcome Enum ✅ + +**Location**: `app/config/result_charts.py` + +Universal enum defining all possible play outcomes for both leagues. + +**Files Created**: +- `app/config/result_charts.py` - PlayOutcome enum with helpers +- `tests/unit/config/test_play_outcome.py` - 30 tests (all passing) + +**Outcome Categories**: +```python +from app.config import PlayOutcome + +# Standard outcomes +PlayOutcome.STRIKEOUT +PlayOutcome.SINGLE +PlayOutcome.HOMERUN + +# Uncapped hits (pitching cards only) +PlayOutcome.SINGLE_UNCAPPED # si(cf) - triggers advancement decisions +PlayOutcome.DOUBLE_UNCAPPED # do(cf) - triggers advancement decisions + +# Interrupt plays (logged with pa=0) +PlayOutcome.WILD_PITCH # Play.wp = 1 +PlayOutcome.PASSED_BALL # Play.pb = 1 +PlayOutcome.STOLEN_BASE # Play.sb = 1 +PlayOutcome.CAUGHT_STEALING # Play.cs = 1 + +# Ballpark power (PD specific) +PlayOutcome.BP_HOMERUN # Play.bphr = 1 +PlayOutcome.BP_SINGLE # Play.bp1b = 1 +``` + +**Helper Methods**: +```python +outcome = PlayOutcome.SINGLE_UNCAPPED + +outcome.is_hit() # True +outcome.is_out() # False +outcome.is_uncapped() # True - requires decision tree +outcome.is_interrupt() # False +outcome.get_bases_advanced() # 1 +``` + +#### 3. Card-Based Resolution System + +**Resolution Mechanics** (Both SBA and PD): +1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card) +2. Roll 2d6 → selects row 2-12 on that card +3. Roll 1d20 → resolves split results (e.g., 1-16: HR, 17-20: 2B) +4. Outcome from card = `PlayOutcome` enum value + +**League Differences**: +- **PD**: Card data digitized in `PdBattingRating`/`PdPitchingRating` + - Can auto-resolve using probabilities + - OR manual selection by players +- **SBA**: Physical cards only (not digitized) + - Players read physical cards and manually enter outcomes + - System validates and records the human-selected outcome + +### Testing + +**Test Coverage**: +- 28 config tests (league configs, registry, immutability) +- 30 PlayOutcome tests (helpers, categorization, edge cases) +- **Total: 58 tests, all passing** + +**Test Files**: +``` +tests/unit/config/ +├── test_league_configs.py # Config system tests +└── test_play_outcome.py # Outcome enum tests +``` + +### Architecture Decisions + +**1. Immutable Configs** +- Used Pydantic `frozen=True` to prevent accidental modification +- Configs are singletons in registry +- Type-safe with full validation + +**2. Universal PlayOutcome Enum** +- Single source of truth for all possible outcomes +- Works for both SBA and PD leagues +- Helper methods reduce duplicate logic in resolvers + +**3. No Static Result Charts** +- Originally planned d20 charts for SBA +- Realized both leagues use same card-based mechanics +- Charts come from player card data (PD) or manual entry (SBA) + +### Integration Points + +**With Game Engine**: +```python +from app.config import get_league_config, PlayOutcome + +# Get league config +config = get_league_config(state.league_id) + +# Resolve outcome (from card or manual entry) +outcome = PlayOutcome.SINGLE_UNCAPPED + +# Handle based on outcome type +if outcome.is_uncapped() and state.on_base_code > 0: + # Trigger advancement decision tree + present_advancement_options() +elif outcome.is_interrupt(): + # Log interrupt play (pa=0) + log_interrupt_play(outcome) +else: + # Standard play resolution + resolve_standard_play(outcome) +``` + +### Remaining Work (Week 6) + +**1. Dice System Update** ⏳ +- Rename `check_d20` → `chaos_d20` in AbRoll +- Update all references + +**2. PlayResolver Integration** ⏳ +- Replace old `PlayOutcome` enum with new one +- Use `PlayOutcome` throughout resolution logic +- Handle uncapped hit decision trees + +**3. Play.metadata Support** ⏳ +- Add JSON metadata field for uncapped hit tracking +- Log `{"uncapped": true}` when applicable + +### Key Files + +``` +app/config/ +├── __init__.py # Public API +├── base_config.py # Abstract base config +├── league_configs.py # SBA/PD implementations +└── result_charts.py # PlayOutcome enum + +tests/unit/config/ +├── test_league_configs.py # 28 tests +└── test_play_outcome.py # 30 tests +``` + +--- + **Next Priorities**: -1. League configuration system (BaseConfig, SbaConfig, PdConfig) -2. Result charts & PD play resolution with ratings -3. API client for live roster data (optional for now) +1. Update dice system (chaos_d20) +2. Integrate PlayOutcome into PlayResolver +3. Add Play.metadata support for uncapped hits +4. Complete week 6 remaining work **Python Version**: 3.13.3 **Database Server**: 10.10.0.42:5432 -**Implementation Status**: See `../.claude/implementation/week6-status-assessment.md` for detailed Week 6 progress +**Implementation Status**: Week 6 - 75% Complete (Config & PlayOutcome ✅, Integration pending) ## Database Model Updates (2025-10-21) diff --git a/backend/app/config/__init__.py b/backend/app/config/__init__.py new file mode 100644 index 0000000..7fb9d6c --- /dev/null +++ b/backend/app/config/__init__.py @@ -0,0 +1,35 @@ +""" +League configuration system for game rules and settings. + +Provides: + - League configurations (BaseGameConfig, SbaConfig, PdConfig) + - Play outcome definitions (PlayOutcome enum) + +Usage: + from app.config import get_league_config, PlayOutcome + + # Get config for specific league + config = get_league_config("sba") + api_url = config.get_api_base_url() + + # Use play outcomes + if outcome == PlayOutcome.SINGLE_UNCAPPED: + # Handle uncapped hit decision tree +""" +from app.config.base_config import BaseGameConfig +from app.config.league_configs import ( + SbaConfig, + PdConfig, + LEAGUE_CONFIGS, + get_league_config +) +from app.config.result_charts import PlayOutcome + +__all__ = [ + 'BaseGameConfig', + 'SbaConfig', + 'PdConfig', + 'LEAGUE_CONFIGS', + 'get_league_config', + 'PlayOutcome' +] diff --git a/backend/app/config/base_config.py b/backend/app/config/base_config.py new file mode 100644 index 0000000..b513db5 --- /dev/null +++ b/backend/app/config/base_config.py @@ -0,0 +1,62 @@ +""" +Base configuration class for league-specific game rules. + +Provides abstract interface that all league configs must implement. + +Author: Claude +Date: 2025-10-28 +""" +from abc import ABC, abstractmethod +from pydantic import BaseModel, Field + + +class BaseGameConfig(BaseModel, ABC): + """ + Abstract base configuration for all leagues. + + Defines common game rules and requires league-specific implementations + to provide result chart names, API endpoints, and feature flags. + """ + + league_id: str = Field(..., description="League identifier ('sba' or 'pd')") + version: str = Field(default="1.0.0", description="Config version for compatibility") + + # Basic baseball rules (same across leagues) + innings: int = Field(default=9, description="Standard innings per game") + outs_per_inning: int = Field(default=3, description="Outs required per half-inning") + strikes_for_out: int = Field(default=3, description="Strikes for strikeout") # TODO: remove - unneeded + balls_for_walk: int = Field(default=4, description="Balls for walk") # TODO: remove - unneeded + + @abstractmethod + def get_result_chart_name(self) -> str: + """ + Get name of result chart to use for this league. + + Returns: + Chart name identifier (e.g., 'sba_standard_v1') + """ + pass + + @abstractmethod + def supports_manual_result_selection(self) -> bool: # TODO: consider refactor: manually selecting results is default behavior with PD allowing auto-results as an option + """ + Whether players manually select results after dice roll. + + Returns: + True if players pick from chart, False if auto-resolved + """ + pass + + @abstractmethod + def get_api_base_url(self) -> str: + """ + Get base URL for league's external API. + + Returns: + Base URL for roster/player data API + """ + pass + + class Config: + """Pydantic configuration.""" + frozen = True # Immutable config - prevents accidental modification diff --git a/backend/app/config/league_configs.py b/backend/app/config/league_configs.py new file mode 100644 index 0000000..5883033 --- /dev/null +++ b/backend/app/config/league_configs.py @@ -0,0 +1,107 @@ +""" +League-specific configuration implementations. + +Provides concrete configs for SBA and PD leagues with their unique rules, +API endpoints, and feature flags. + +Author: Claude +Date: 2025-10-28 +""" +import logging +from typing import Dict +from app.config.base_config import BaseGameConfig + +logger = logging.getLogger(f'{__name__}.LeagueConfigs') + + +class SbaConfig(BaseGameConfig): + """ + SBA League configuration. + + Features: + - Manual result selection after dice roll + - Simple player data model + - Standard baseball rules + """ + + league_id: str = "sba" + + # SBA-specific features + player_selection_mode: str = "manual" # Players manually select from chart + + def get_result_chart_name(self) -> str: + """Use SBA standard result chart.""" + return "sba_standard_v1" + + def supports_manual_result_selection(self) -> bool: + """SBA players manually pick results from chart.""" + return True + + def get_api_base_url(self) -> str: + """SBA API base URL.""" + return "https://api.sba.manticorum.com" + + +class PdConfig(BaseGameConfig): + """ + Paper Dynasty League configuration. + + Features: + - Flexible result selection (manual or auto via scouting) + - Complex scouting data model + - Cardset validation + - Advanced analytics + """ + + league_id: str = "pd" + + # PD-specific features + player_selection_mode: str = "flexible" # Manual or auto via scouting model + use_scouting_model: bool = True # Use detailed ratings for auto-resolution + cardset_validation: bool = True # Validate cards against approved cardsets + + # Advanced features + detailed_analytics: bool = True # Track advanced stats (WPA, RE24, etc.) + wpa_calculation: bool = True # Calculate win probability added + + def get_result_chart_name(self) -> str: + """Use PD standard result chart.""" + return "pd_standard_v1" + + def supports_manual_result_selection(self) -> bool: + """PD supports manual selection (though auto is also available).""" + return True + + def get_api_base_url(self) -> str: + """PD API base URL.""" + return "https://pd.manticorum.com" + + +# ==================== Config Registry ==================== + +LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = { + "sba": SbaConfig(), + "pd": PdConfig() +} + + +def get_league_config(league_id: str) -> BaseGameConfig: + """ + Get configuration for specified league. + + Args: + league_id: League identifier ('sba' or 'pd') + + Returns: + League-specific config instance + + Raises: + ValueError: If league_id is not recognized + """ + config = LEAGUE_CONFIGS.get(league_id) + if not config: + logger.error(f"Unknown league ID: {league_id}") + raise ValueError(f"Unknown league: {league_id}. Valid leagues: {list(LEAGUE_CONFIGS.keys())}") + + logger.debug(f"Retrieved config for league: {league_id}") + return config diff --git a/backend/app/config/result_charts.py b/backend/app/config/result_charts.py new file mode 100644 index 0000000..d87c328 --- /dev/null +++ b/backend/app/config/result_charts.py @@ -0,0 +1,151 @@ +""" +Play outcome definitions for card-based resolution system. + +Both SBA and PD leagues use the same resolution mechanics: + 1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card) + 2. Roll 2d6 → selects row 2-12 on that card + 3. Roll 1d20 → resolves split results if needed + 4. Outcome from card is a PlayOutcome enum value + +League Differences: + - PD: Card data digitized in PdBattingRating/PdPitchingRating + Can auto-resolve using probabilities OR manual selection + - SBA: Physical cards only (not digitized) + Players read physical cards and manually enter outcomes + +This module defines the universal PlayOutcome enum used by both leagues. + +Author: Claude +Date: 2025-10-28 +""" +import logging +from enum import Enum + +logger = logging.getLogger(f'{__name__}.PlayOutcome') + + +class PlayOutcome(str, Enum): + """ + Universal play outcome types for both SBA and PD leagues. + + These represent all possible results that can appear on player cards. + Each outcome triggers specific game logic in the PlayResolver. + + Usage: + - PD: Outcomes determined from PdBattingRating/PdPitchingRating data + - SBA: Outcomes manually entered by players reading physical cards + """ + + # ==================== Outs ==================== + STRIKEOUT = "strikeout" + GROUNDOUT = "groundout" + FLYOUT = "flyout" + LINEOUT = "lineout" + POPOUT = "popout" + DOUBLE_PLAY = "double_play" + + # ==================== Hits ==================== + SINGLE = "single" + DOUBLE = "double" + TRIPLE = "triple" + HOMERUN = "homerun" + + # Uncapped hits (only on pitching cards) + # Trigger decision tree for advancing runners when on_base_code > 0 + SINGLE_UNCAPPED = "single_uncapped" # si(cf) on card + DOUBLE_UNCAPPED = "double_uncapped" # do(cf) on card + + # ==================== Walks/HBP ==================== + WALK = "walk" + HIT_BY_PITCH = "hbp" + INTENTIONAL_WALK = "intentional_walk" + + # ==================== Errors ==================== + ERROR = "error" + + # ==================== Interrupt Plays ==================== + # These are logged as separate plays with Play.pa = 0 + WILD_PITCH = "wild_pitch" # Play.wp = 1 + PASSED_BALL = "passed_ball" # Play.pb = 1 + STOLEN_BASE = "stolen_base" # Play.sb = 1 + CAUGHT_STEALING = "caught_stealing" # Play.cs = 1 + BALK = "balk" # Logged during steal attempt + PICK_OFF = "pick_off" # Runner picked off + + # ==================== Ballpark Power (PD specific) ==================== + # Special PD outcomes for ballpark factors + BP_HOMERUN = "bp_homerun" # Ballpark HR (Play.bphr = 1) + BP_SINGLE = "bp_single" # Ballpark single (Play.bp1b = 1) + BP_FLYOUT = "bp_flyout" # Ballpark flyout (Play.bpfo = 1) + BP_LINEOUT = "bp_lineout" # Ballpark lineout (Play.bplo = 1) + + # ==================== Helper Methods ==================== + + def is_hit(self) -> bool: + """Check if outcome is a hit (counts toward batting average).""" + return self in { + self.SINGLE, self.DOUBLE, self.TRIPLE, self.HOMERUN, + self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED, + self.BP_HOMERUN, self.BP_SINGLE + } + + def is_out(self) -> bool: + """Check if outcome records an out.""" + return self in { + self.STRIKEOUT, self.GROUNDOUT, self.FLYOUT, + self.LINEOUT, self.POPOUT, self.DOUBLE_PLAY, + self.CAUGHT_STEALING, self.PICK_OFF, + self.BP_FLYOUT, self.BP_LINEOUT + } + + def is_walk(self) -> bool: + """Check if outcome is a walk.""" + return self in {self.WALK, self.INTENTIONAL_WALK} + + def is_uncapped(self) -> bool: + """ + Check if outcome is uncapped (requires advancement decision). + + Uncapped hits only trigger decisions when on_base_code > 0. + """ + return self in {self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED} + + def is_interrupt(self) -> bool: + """ + Check if outcome is an interrupt play (logged with pa=0). + + Interrupt plays don't change the batter, only advance runners. + """ + return self in { + self.WILD_PITCH, self.PASSED_BALL, + self.STOLEN_BASE, self.CAUGHT_STEALING, + self.BALK, self.PICK_OFF + } + + def is_extra_base_hit(self) -> bool: + """Check if outcome is an extra-base hit (2B, 3B, HR).""" + return self in { + self.DOUBLE, self.TRIPLE, self.HOMERUN, + self.DOUBLE_UNCAPPED, self.BP_HOMERUN + } + + def get_bases_advanced(self) -> int: + """ + Get number of bases batter advances (for standard outcomes). + + Returns: + 0 for outs/walks, 1-4 for hits + + Note: Uncapped hits return base value; actual advancement + determined by decision tree. + """ + if self in {self.SINGLE, self.SINGLE_UNCAPPED, self.BP_SINGLE}: + return 1 + elif self in {self.DOUBLE, self.DOUBLE_UNCAPPED}: + return 2 + elif self == self.TRIPLE: + return 3 + elif self in {self.HOMERUN, self.BP_HOMERUN}: + return 4 + else: + return 0 diff --git a/backend/app/models/player_models.py b/backend/app/models/player_models.py index 03af4b2..15016a2 100644 --- a/backend/app/models/player_models.py +++ b/backend/app/models/player_models.py @@ -25,12 +25,20 @@ class BasePlayer(BaseModel, ABC): # 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") + 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") - @abstractmethod - def get_image_url(self) -> str: - """Get player image URL (with fallback logic if needed).""" - pass + # 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]: @@ -42,6 +50,10 @@ class BasePlayer(BaseModel, ABC): """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 @@ -60,31 +72,30 @@ class SbaPlayer(BasePlayer): # 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_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.""" @@ -117,13 +128,13 @@ class SbaPlayer(BasePlayer): return cls( id=data["id"], name=data["name"], - image=data.get("image"), + 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_1=data["pos_1"], pos_2=data.get("pos_2"), pos_3=data.get("pos_3"), pos_4=data.get("pos_4"), @@ -283,10 +294,11 @@ class PdPlayer(BasePlayer): 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(). """ - # Override id field to use player_id (more explicit for PD) - player_id: int = Field(..., description="PD player card ID", alias="id") + # PD-specific fields cost: int = Field(..., description="Card cost/value") # Card metadata @@ -298,21 +310,6 @@ class PdPlayer(BasePlayer): 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") @@ -326,10 +323,6 @@ class PdPlayer(BasePlayer): 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 = [ @@ -340,7 +333,7 @@ class PdPlayer(BasePlayer): def get_display_name(self) -> str: """Get formatted display name with description.""" - return f"{self.name} ({self.description})" + return f"{self.description} {self.name}" def get_batting_rating(self, vs_hand: str) -> Optional[PdBattingRating]: """ @@ -438,14 +431,14 @@ class PdPlayer(BasePlayer): id=player_data["player_id"], name=player_data["p_name"], cost=player_data["cost"], - image=player_data.get("image"), + 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_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"), diff --git a/backend/tests/unit/config/test_league_configs.py b/backend/tests/unit/config/test_league_configs.py new file mode 100644 index 0000000..d513051 --- /dev/null +++ b/backend/tests/unit/config/test_league_configs.py @@ -0,0 +1,210 @@ +""" +Unit tests for league configuration system. + +Tests: +- Config loading and registry +- Config immutability +- Abstract method implementations +- League-specific settings +""" +import pytest +from pydantic import ValidationError + +from app.config import ( + BaseGameConfig, + SbaConfig, + PdConfig, + LEAGUE_CONFIGS, + get_league_config +) + + +class TestBaseGameConfig: + """Tests for BaseGameConfig abstract class.""" + + def test_cannot_instantiate_abstract_base(self): + """BaseGameConfig cannot be instantiated directly.""" + with pytest.raises(TypeError): + BaseGameConfig(league_id="test") + + +class TestSbaConfig: + """Tests for SBA league configuration.""" + + def test_sba_config_creation(self): + """Can create SBA config instance.""" + config = SbaConfig() + assert config.league_id == "sba" + assert config.version == "1.0.0" + + def test_sba_basic_rules(self): + """SBA uses standard baseball rules.""" + config = SbaConfig() + assert config.innings == 9 + assert config.outs_per_inning == 3 + assert config.strikes_for_out == 3 + assert config.balls_for_walk == 4 + + def test_sba_result_chart_name(self): + """SBA uses sba_standard_v1 chart.""" + config = SbaConfig() + assert config.get_result_chart_name() == "sba_standard_v1" + + def test_sba_supports_manual_selection(self): + """SBA supports manual result selection.""" + config = SbaConfig() + assert config.supports_manual_result_selection() is True + + def test_sba_api_url(self): + """SBA API URL is correct.""" + config = SbaConfig() + assert config.get_api_base_url() == "https://api.sba.manticorum.com" + + def test_sba_player_selection_mode(self): + """SBA uses manual player selection.""" + config = SbaConfig() + assert config.player_selection_mode == "manual" + + def test_sba_config_is_immutable(self): + """Config cannot be modified after creation.""" + config = SbaConfig() + with pytest.raises(ValidationError): + config.innings = 7 + + +class TestPdConfig: + """Tests for PD league configuration.""" + + def test_pd_config_creation(self): + """Can create PD config instance.""" + config = PdConfig() + assert config.league_id == "pd" + assert config.version == "1.0.0" + + def test_pd_basic_rules(self): + """PD uses standard baseball rules.""" + config = PdConfig() + assert config.innings == 9 + assert config.outs_per_inning == 3 + assert config.strikes_for_out == 3 + assert config.balls_for_walk == 4 + + def test_pd_result_chart_name(self): + """PD uses pd_standard_v1 chart.""" + config = PdConfig() + assert config.get_result_chart_name() == "pd_standard_v1" + + def test_pd_supports_manual_selection(self): + """PD supports manual result selection (though auto is also available).""" + config = PdConfig() + assert config.supports_manual_result_selection() is True + + def test_pd_api_url(self): + """PD API URL is correct.""" + config = PdConfig() + assert config.get_api_base_url() == "https://pd.manticorum.com" + + def test_pd_player_selection_mode(self): + """PD uses flexible player selection.""" + config = PdConfig() + assert config.player_selection_mode == "flexible" + + def test_pd_scouting_model_enabled(self): + """PD has scouting model enabled.""" + config = PdConfig() + assert config.use_scouting_model is True + + def test_pd_cardset_validation_enabled(self): + """PD has cardset validation enabled.""" + config = PdConfig() + assert config.cardset_validation is True + + def test_pd_advanced_analytics_enabled(self): + """PD has advanced analytics enabled.""" + config = PdConfig() + assert config.detailed_analytics is True + assert config.wpa_calculation is True + + def test_pd_config_is_immutable(self): + """Config cannot be modified after creation.""" + config = PdConfig() + with pytest.raises(ValidationError): + config.use_scouting_model = False + + +class TestLeagueRegistry: + """Tests for league config registry and getter.""" + + def test_registry_contains_both_leagues(self): + """Registry has both SBA and PD configs.""" + assert "sba" in LEAGUE_CONFIGS + assert "pd" in LEAGUE_CONFIGS + assert len(LEAGUE_CONFIGS) == 2 + + def test_registry_configs_are_correct_type(self): + """Registry contains correct config types.""" + assert isinstance(LEAGUE_CONFIGS["sba"], SbaConfig) + assert isinstance(LEAGUE_CONFIGS["pd"], PdConfig) + + def test_get_sba_config(self): + """Can get SBA config via getter.""" + config = get_league_config("sba") + assert isinstance(config, SbaConfig) + assert config.league_id == "sba" + + def test_get_pd_config(self): + """Can get PD config via getter.""" + config = get_league_config("pd") + assert isinstance(config, PdConfig) + assert config.league_id == "pd" + + def test_get_invalid_league_raises(self): + """Getting invalid league raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + get_league_config("invalid") + assert "Unknown league: invalid" in str(exc_info.value) + assert "sba" in str(exc_info.value) + assert "pd" in str(exc_info.value) + + def test_registry_returns_same_instance(self): + """Registry returns singleton instances.""" + config1 = get_league_config("sba") + config2 = get_league_config("sba") + assert config1 is config2 + + +class TestConfigDifferences: + """Tests for differences between league configs.""" + + def test_different_result_charts(self): + """SBA and PD use different result charts.""" + sba = get_league_config("sba") + pd = get_league_config("pd") + assert sba.get_result_chart_name() != pd.get_result_chart_name() + + def test_different_api_urls(self): + """SBA and PD have different API URLs.""" + sba = get_league_config("sba") + pd = get_league_config("pd") + assert sba.get_api_base_url() != pd.get_api_base_url() + + def test_different_player_selection_modes(self): + """SBA and PD have different selection modes.""" + sba = get_league_config("sba") + pd = get_league_config("pd") + assert sba.player_selection_mode == "manual" + assert pd.player_selection_mode == "flexible" + + def test_pd_has_extra_features(self): + """PD has features that SBA doesn't.""" + pd = get_league_config("pd") + sba = get_league_config("sba") + + # PD-specific attributes + assert hasattr(pd, 'use_scouting_model') + assert hasattr(pd, 'cardset_validation') + assert hasattr(pd, 'detailed_analytics') + + # SBA doesn't have these (or they're False/None) + assert not hasattr(sba, 'use_scouting_model') + assert not hasattr(sba, 'cardset_validation') diff --git a/backend/tests/unit/config/test_play_outcome.py b/backend/tests/unit/config/test_play_outcome.py new file mode 100644 index 0000000..cc0a9ff --- /dev/null +++ b/backend/tests/unit/config/test_play_outcome.py @@ -0,0 +1,215 @@ +""" +Unit tests for PlayOutcome enum. + +Tests helper methods and outcome categorization. +""" +import pytest +from app.config import PlayOutcome + + +class TestPlayOutcomeHelpers: + """Tests for PlayOutcome helper methods.""" + + def test_is_hit_for_singles(self): + """Single outcomes are hits.""" + assert PlayOutcome.SINGLE.is_hit() is True + assert PlayOutcome.SINGLE_UNCAPPED.is_hit() is True + assert PlayOutcome.BP_SINGLE.is_hit() is True + + def test_is_hit_for_extra_bases(self): + """Extra base hits are hits.""" + assert PlayOutcome.DOUBLE.is_hit() is True + assert PlayOutcome.DOUBLE_UNCAPPED.is_hit() is True + assert PlayOutcome.TRIPLE.is_hit() is True + assert PlayOutcome.HOMERUN.is_hit() is True + assert PlayOutcome.BP_HOMERUN.is_hit() is True + + def test_is_hit_false_for_outs(self): + """Outs are not hits.""" + assert PlayOutcome.STRIKEOUT.is_hit() is False + assert PlayOutcome.GROUNDOUT.is_hit() is False + assert PlayOutcome.FLYOUT.is_hit() is False + assert PlayOutcome.BP_FLYOUT.is_hit() is False + + def test_is_hit_false_for_walks(self): + """Walks are not hits.""" + assert PlayOutcome.WALK.is_hit() is False + assert PlayOutcome.INTENTIONAL_WALK.is_hit() is False + + def test_is_out_for_standard_outs(self): + """Standard outs are outs.""" + assert PlayOutcome.STRIKEOUT.is_out() is True + assert PlayOutcome.GROUNDOUT.is_out() is True + assert PlayOutcome.FLYOUT.is_out() is True + assert PlayOutcome.LINEOUT.is_out() is True + assert PlayOutcome.POPOUT.is_out() is True + assert PlayOutcome.DOUBLE_PLAY.is_out() is True + + def test_is_out_for_baserunning_outs(self): + """Baserunning outs are outs.""" + assert PlayOutcome.CAUGHT_STEALING.is_out() is True + assert PlayOutcome.PICK_OFF.is_out() is True + + def test_is_out_for_ballpark_outs(self): + """Ballpark outs are outs.""" + assert PlayOutcome.BP_FLYOUT.is_out() is True + assert PlayOutcome.BP_LINEOUT.is_out() is True + + def test_is_out_false_for_hits(self): + """Hits are not outs.""" + assert PlayOutcome.SINGLE.is_out() is False + assert PlayOutcome.HOMERUN.is_out() is False + + def test_is_walk(self): + """Walk outcomes are walks.""" + assert PlayOutcome.WALK.is_walk() is True + assert PlayOutcome.INTENTIONAL_WALK.is_walk() is True + + def test_is_walk_false_for_hits(self): + """Hits are not walks.""" + assert PlayOutcome.SINGLE.is_walk() is False + assert PlayOutcome.HOMERUN.is_walk() is False + + def test_is_walk_false_for_hbp(self): + """HBP is not a walk (different stat).""" + assert PlayOutcome.HIT_BY_PITCH.is_walk() is False + + def test_is_uncapped(self): + """Uncapped outcomes are uncapped.""" + assert PlayOutcome.SINGLE_UNCAPPED.is_uncapped() is True + assert PlayOutcome.DOUBLE_UNCAPPED.is_uncapped() is True + + def test_is_uncapped_false_for_normal_hits(self): + """Normal hits are not uncapped.""" + assert PlayOutcome.SINGLE.is_uncapped() is False + assert PlayOutcome.DOUBLE.is_uncapped() is False + assert PlayOutcome.TRIPLE.is_uncapped() is False + + def test_is_interrupt(self): + """Interrupt plays are interrupts.""" + assert PlayOutcome.WILD_PITCH.is_interrupt() is True + assert PlayOutcome.PASSED_BALL.is_interrupt() is True + assert PlayOutcome.STOLEN_BASE.is_interrupt() is True + assert PlayOutcome.CAUGHT_STEALING.is_interrupt() is True + assert PlayOutcome.BALK.is_interrupt() is True + assert PlayOutcome.PICK_OFF.is_interrupt() is True + + def test_is_interrupt_false_for_normal_plays(self): + """Normal plays are not interrupts.""" + assert PlayOutcome.SINGLE.is_interrupt() is False + assert PlayOutcome.STRIKEOUT.is_interrupt() is False + assert PlayOutcome.WALK.is_interrupt() is False + + def test_is_extra_base_hit(self): + """Extra base hits are identified correctly.""" + assert PlayOutcome.DOUBLE.is_extra_base_hit() is True + assert PlayOutcome.DOUBLE_UNCAPPED.is_extra_base_hit() is True + assert PlayOutcome.TRIPLE.is_extra_base_hit() is True + assert PlayOutcome.HOMERUN.is_extra_base_hit() is True + assert PlayOutcome.BP_HOMERUN.is_extra_base_hit() is True + + def test_is_extra_base_hit_false_for_singles(self): + """Singles are not extra base hits.""" + assert PlayOutcome.SINGLE.is_extra_base_hit() is False + assert PlayOutcome.SINGLE_UNCAPPED.is_extra_base_hit() is False + assert PlayOutcome.BP_SINGLE.is_extra_base_hit() is False + + +class TestGetBasesAdvanced: + """Tests for get_bases_advanced() method.""" + + def test_singles_advance_one_base(self): + """Singles advance one base.""" + assert PlayOutcome.SINGLE.get_bases_advanced() == 1 + assert PlayOutcome.SINGLE_UNCAPPED.get_bases_advanced() == 1 + assert PlayOutcome.BP_SINGLE.get_bases_advanced() == 1 + + def test_doubles_advance_two_bases(self): + """Doubles advance two bases.""" + assert PlayOutcome.DOUBLE.get_bases_advanced() == 2 + assert PlayOutcome.DOUBLE_UNCAPPED.get_bases_advanced() == 2 + + def test_triples_advance_three_bases(self): + """Triples advance three bases.""" + assert PlayOutcome.TRIPLE.get_bases_advanced() == 3 + + def test_homeruns_advance_four_bases(self): + """Home runs advance four bases.""" + assert PlayOutcome.HOMERUN.get_bases_advanced() == 4 + assert PlayOutcome.BP_HOMERUN.get_bases_advanced() == 4 + + def test_outs_advance_zero_bases(self): + """Outs advance zero bases.""" + assert PlayOutcome.STRIKEOUT.get_bases_advanced() == 0 + assert PlayOutcome.GROUNDOUT.get_bases_advanced() == 0 + assert PlayOutcome.FLYOUT.get_bases_advanced() == 0 + + def test_walks_advance_zero_bases(self): + """Walks advance zero bases (forced advancement handled separately).""" + assert PlayOutcome.WALK.get_bases_advanced() == 0 + assert PlayOutcome.INTENTIONAL_WALK.get_bases_advanced() == 0 + + def test_interrupts_advance_zero_bases(self): + """Interrupts advance zero bases (advancement handled by interrupt logic).""" + assert PlayOutcome.WILD_PITCH.get_bases_advanced() == 0 + assert PlayOutcome.STOLEN_BASE.get_bases_advanced() == 0 + + +class TestPlayOutcomeValues: + """Tests for PlayOutcome string values.""" + + def test_outcome_string_values(self): + """Outcome values match expected strings.""" + assert PlayOutcome.STRIKEOUT.value == "strikeout" + assert PlayOutcome.SINGLE.value == "single" + assert PlayOutcome.SINGLE_UNCAPPED.value == "single_uncapped" + assert PlayOutcome.WILD_PITCH.value == "wild_pitch" + assert PlayOutcome.BP_HOMERUN.value == "bp_homerun" + + def test_can_create_from_string(self): + """Can create PlayOutcome from string value.""" + outcome = PlayOutcome("strikeout") + assert outcome == PlayOutcome.STRIKEOUT + + def test_uncapped_uses_code_friendly_names(self): + """Uncapped outcomes use underscores not parentheses.""" + assert "(" not in PlayOutcome.SINGLE_UNCAPPED.value + assert "(" not in PlayOutcome.DOUBLE_UNCAPPED.value + assert "_" in PlayOutcome.SINGLE_UNCAPPED.value + + +class TestPlayOutcomeCompleteness: + """Tests to ensure all outcome categories are covered.""" + + def test_all_hits_categorized(self): + """All hit outcomes are properly categorized.""" + hit_outcomes = { + PlayOutcome.SINGLE, PlayOutcome.DOUBLE, PlayOutcome.TRIPLE, + PlayOutcome.HOMERUN, PlayOutcome.SINGLE_UNCAPPED, + PlayOutcome.DOUBLE_UNCAPPED, PlayOutcome.BP_HOMERUN, + PlayOutcome.BP_SINGLE + } + for outcome in hit_outcomes: + assert outcome.is_hit() is True, f"{outcome} should be a hit" + + def test_all_outs_categorized(self): + """All out outcomes are properly categorized.""" + out_outcomes = { + PlayOutcome.STRIKEOUT, PlayOutcome.GROUNDOUT, + PlayOutcome.FLYOUT, PlayOutcome.LINEOUT, + PlayOutcome.POPOUT, PlayOutcome.DOUBLE_PLAY, + PlayOutcome.CAUGHT_STEALING, PlayOutcome.PICK_OFF, + PlayOutcome.BP_FLYOUT, PlayOutcome.BP_LINEOUT + } + for outcome in out_outcomes: + assert outcome.is_out() is True, f"{outcome} should be an out" + + def test_all_interrupts_categorized(self): + """All interrupt outcomes are properly categorized.""" + interrupt_outcomes = { + PlayOutcome.WILD_PITCH, PlayOutcome.PASSED_BALL, + PlayOutcome.STOLEN_BASE, PlayOutcome.CAUGHT_STEALING, + PlayOutcome.BALK, PlayOutcome.PICK_OFF + } + for outcome in interrupt_outcomes: + assert outcome.is_interrupt() is True, f"{outcome} should be an interrupt"