From a4b99ee53e97d54958fdc6b9c50922d8c94ad4b1 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 20 Nov 2025 15:33:21 -0600 Subject: [PATCH] CLAUDE: Replace black and flake8 with ruff for formatting and linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/CLAUDE.md | 9 +- backend/app/api/routes/auth.py | 7 +- backend/app/api/routes/games.py | 7 +- backend/app/api/routes/health.py | 16 +- backend/app/config.py | 3 +- backend/app/config/__init__.py | 39 +- backend/app/config/base_config.py | 7 +- backend/app/config/common_x_check_tables.py | 731 +++++----- backend/app/config/league_configs.py | 51 +- backend/app/config/result_charts.py | 298 ++-- backend/app/config/test_game_data.py | 33 +- backend/app/core/ai_opponent.py | 30 +- backend/app/core/dice.py | 123 +- backend/app/core/game_engine.py | 344 +++-- backend/app/core/play_resolver.py | 521 ++++--- backend/app/core/roll_types.py | 159 ++- backend/app/core/runner_advancement.py | 1225 +++++++++-------- backend/app/core/state_manager.py | 235 ++-- backend/app/core/substitution_manager.py | 158 ++- backend/app/core/substitution_rules.py | 64 +- backend/app/core/validators.py | 75 +- .../app/core/x_check_advancement_tables.py | 681 ++++----- backend/app/database/operations.py | 308 ++--- backend/app/database/session.py | 10 +- backend/app/main.py | 30 +- backend/app/models/__init__.py | 36 +- backend/app/models/db_models.py | 136 +- backend/app/models/game_models.py | 267 ++-- backend/app/models/player_models.py | 185 ++- backend/app/models/roster_models.py | 7 +- backend/app/services/__init__.py | 19 +- backend/app/services/box_score_service.py | 128 +- backend/app/services/lineup_service.py | 62 +- backend/app/services/pd_api_client.py | 36 +- backend/app/services/play_stat_calculator.py | 101 +- .../app/services/position_rating_service.py | 38 +- backend/app/services/redis_client.py | 11 +- backend/app/services/sba_api_client.py | 13 +- backend/app/services/stat_view_refresher.py | 19 +- backend/app/utils/auth.py | 16 +- backend/app/utils/logging.py | 9 +- backend/app/websocket/connection_manager.py | 25 +- backend/app/websocket/handlers.py | 378 ++--- backend/pyproject.toml | 47 +- .../unit/core/test_runner_advancement.py | 3 +- backend/uv.lock | 110 +- 46 files changed, 3706 insertions(+), 3104 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 53ec432..2a570b1 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -44,9 +44,9 @@ uv run python -m terminal_client # Interactive REPL ### Code Quality ```bash -uv run mypy app/ # Type checking -uv run black app/ # Formatting -uv run flake8 app/ # Linting +uv run mypy app/ # Type checking +uv run ruff check app/ # Linting +uv run ruff format app/ # Formatting ``` ## Key Patterns @@ -97,7 +97,8 @@ uv run pytest tests/unit/ -q # Must show all passing ## Coding Standards -- **Formatting**: Black (88 chars) +- **Formatting**: Ruff (88 chars, black-compatible) +- **Linting**: Ruff (replaces flake8, includes isort, pyupgrade) - **Type Hints**: Required for public functions - **Logging**: `logger = logging.getLogger(f'{__name__}.ClassName')` - **Error Handling**: "Raise or Return" - no silent failures diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 3750c33..b89dda5 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,16 +1,18 @@ import logging + from fastapi import APIRouter, HTTPException from pydantic import BaseModel from app.utils.auth import create_token -logger = logging.getLogger(f'{__name__}.auth') +logger = logging.getLogger(f"{__name__}.auth") router = APIRouter() class TokenRequest(BaseModel): """Request model for token creation""" + user_id: str username: str discord_id: str @@ -18,6 +20,7 @@ class TokenRequest(BaseModel): class TokenResponse(BaseModel): """Response model for token creation""" + access_token: str token_type: str = "bearer" @@ -34,7 +37,7 @@ async def create_auth_token(request: TokenRequest): user_data = { "user_id": request.user_id, "username": request.username, - "discord_id": request.discord_id + "discord_id": request.discord_id, } token = create_token(user_data) diff --git a/backend/app/api/routes/games.py b/backend/app/api/routes/games.py index c087a1a..d998596 100644 --- a/backend/app/api/routes/games.py +++ b/backend/app/api/routes/games.py @@ -1,15 +1,16 @@ import logging + from fastapi import APIRouter -from typing import List from pydantic import BaseModel -logger = logging.getLogger(f'{__name__}.games') +logger = logging.getLogger(f"{__name__}.games") router = APIRouter() class GameListItem(BaseModel): """Game list item model""" + game_id: str league_id: str status: str @@ -17,7 +18,7 @@ class GameListItem(BaseModel): away_team_id: int -@router.get("/", response_model=List[GameListItem]) +@router.get("/", response_model=list[GameListItem]) async def list_games(): """ List all games diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py index da1690e..6e353ea 100644 --- a/backend/app/api/routes/health.py +++ b/backend/app/api/routes/health.py @@ -1,10 +1,11 @@ import logging -from fastapi import APIRouter + import pendulum +from fastapi import APIRouter from app.config import get_settings -logger = logging.getLogger(f'{__name__}.health') +logger = logging.getLogger(f"{__name__}.health") router = APIRouter() settings = get_settings() @@ -15,18 +16,19 @@ async def health_check(): """Health check endpoint""" return { "status": "healthy", - "timestamp": pendulum.now('UTC').to_iso8601_string(), + "timestamp": pendulum.now("UTC").to_iso8601_string(), "environment": settings.app_env, - "version": "1.0.0" + "version": "1.0.0", } @router.get("/health/db") async def database_health(): """Database health check""" - from app.database.session import engine from sqlalchemy import text + from app.database.session import engine + try: async with engine.connect() as conn: await conn.execute(text("SELECT 1")) @@ -34,7 +36,7 @@ async def database_health(): return { "status": "healthy", "database": "connected", - "timestamp": pendulum.now('UTC').to_iso8601_string() + "timestamp": pendulum.now("UTC").to_iso8601_string(), } except Exception as e: logger.error(f"Database health check failed: {e}") @@ -42,5 +44,5 @@ async def database_health(): "status": "unhealthy", "database": "disconnected", "error": str(e), - "timestamp": pendulum.now('UTC').to_iso8601_string() + "timestamp": pendulum.now("UTC").to_iso8601_string(), } diff --git a/backend/app/config.py b/backend/app/config.py index e88fea2..41be5b6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ from functools import lru_cache + from pydantic_settings import BaseSettings @@ -45,7 +46,7 @@ class Settings(BaseSettings): case_sensitive = False -@lru_cache() +@lru_cache def get_settings() -> Settings: """Get cached settings instance""" return Settings() diff --git a/backend/app/config/__init__.py b/backend/app/config/__init__.py index 39d34f7..10ca263 100644 --- a/backend/app/config/__init__.py +++ b/backend/app/config/__init__.py @@ -20,36 +20,39 @@ Usage: # Get application settings settings = get_settings() """ -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 # Import Settings and get_settings from sibling config.py for backward compatibility # This imports from /app/config.py (not /app/config/__init__.py) import sys from pathlib import Path +from app.config.base_config import BaseGameConfig +from app.config.league_configs import ( + LEAGUE_CONFIGS, + PdConfig, + SbaConfig, + get_league_config, +) +from app.config.result_charts import PlayOutcome + # Temporarily modify path to import the config.py file config_py_path = Path(__file__).parent.parent / "config.py" -spec = __import__('importlib.util').util.spec_from_file_location("_app_config", config_py_path) -_app_config = __import__('importlib.util').util.module_from_spec(spec) +spec = __import__("importlib.util").util.spec_from_file_location( + "_app_config", config_py_path +) +_app_config = __import__("importlib.util").util.module_from_spec(spec) spec.loader.exec_module(_app_config) Settings = _app_config.Settings get_settings = _app_config.get_settings __all__ = [ - 'BaseGameConfig', - 'SbaConfig', - 'PdConfig', - 'LEAGUE_CONFIGS', - 'get_league_config', - 'PlayOutcome', - 'Settings', - 'get_settings' + "BaseGameConfig", + "SbaConfig", + "PdConfig", + "LEAGUE_CONFIGS", + "get_league_config", + "PlayOutcome", + "Settings", + "get_settings", ] diff --git a/backend/app/config/base_config.py b/backend/app/config/base_config.py index a303f07..6e8fe71 100644 --- a/backend/app/config/base_config.py +++ b/backend/app/config/base_config.py @@ -6,7 +6,9 @@ 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 @@ -19,7 +21,9 @@ class BaseGameConfig(BaseModel, ABC): """ league_id: str = Field(..., description="League identifier ('sba' or 'pd')") - version: str = Field(default="1.0.0", description="Config version for compatibility") + 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") @@ -61,4 +65,5 @@ class BaseGameConfig(BaseModel, ABC): class Config: """Pydantic configuration.""" + frozen = True # Immutable config - prevents accidental modification diff --git a/backend/app/config/common_x_check_tables.py b/backend/app/config/common_x_check_tables.py index ef3888d..62c65a8 100644 --- a/backend/app/config/common_x_check_tables.py +++ b/backend/app/config/common_x_check_tables.py @@ -9,7 +9,6 @@ Tables include: Author: Claude Date: 2025-11-01 """ -from typing import List # ============================================================================ # DEFENSE RANGE TABLES (1d20 × Defense Range 1-5) @@ -18,79 +17,79 @@ from typing import List # Column index = defense range - 1 (0-indexed) # Values = base result code (G1, SI2, F2, etc.) -INFIELD_DEFENSE_TABLE: List[List[str]] = [ +INFIELD_DEFENSE_TABLE: list[list[str]] = [ # Range: 1 2 3 4 5 # Best Good Avg Poor Worst - ['G3#', 'SI1', 'SI2', 'SI2', 'SI2'], # d20 = 1 - ['G2#', 'SI1', 'SI2', 'SI2', 'SI2'], # d20 = 2 - ['G2#', 'G3#', 'SI1', 'SI2', 'SI2'], # d20 = 3 - ['G2#', 'G3#', 'SI1', 'SI2', 'SI2'], # d20 = 4 - ['G1', 'G3#', 'G3#', 'SI1', 'SI2'], # d20 = 5 - ['G1', 'G2#', 'G3#', 'SI1', 'SI2'], # d20 = 6 - ['G1', 'G2', 'G3#', 'G3#', 'SI1'], # d20 = 7 - ['G1', 'G2', 'G3#', 'G3#', 'SI1'], # d20 = 8 - ['G1', 'G2', 'G3', 'G3#', 'G3#'], # d20 = 9 - ['G1', 'G1', 'G2', 'G3#', 'G3#'], # d20 = 10 - ['G1', 'G1', 'G2', 'G3', 'G3#'], # d20 = 11 - ['G1', 'G1', 'G2', 'G3', 'G3#'], # d20 = 12 - ['G1', 'G1', 'G2', 'G3', 'G3'], # d20 = 13 - ['G1', 'G1', 'G2', 'G2', 'G3'], # d20 = 14 - ['G1', 'G1', 'G1', 'G2', 'G3'], # d20 = 15 - ['G1', 'G1', 'G1', 'G2', 'G3'], # d20 = 16 - ['G1', 'G1', 'G1', 'G1', 'G3'], # d20 = 17 - ['G1', 'G1', 'G1', 'G1', 'G2'], # d20 = 18 - ['G1', 'G1', 'G1', 'G1', 'G2'], # d20 = 19 - ['G1', 'G1', 'G1', 'G1', 'G1'], # d20 = 20 + ["G3#", "SI1", "SI2", "SI2", "SI2"], # d20 = 1 + ["G2#", "SI1", "SI2", "SI2", "SI2"], # d20 = 2 + ["G2#", "G3#", "SI1", "SI2", "SI2"], # d20 = 3 + ["G2#", "G3#", "SI1", "SI2", "SI2"], # d20 = 4 + ["G1", "G3#", "G3#", "SI1", "SI2"], # d20 = 5 + ["G1", "G2#", "G3#", "SI1", "SI2"], # d20 = 6 + ["G1", "G2", "G3#", "G3#", "SI1"], # d20 = 7 + ["G1", "G2", "G3#", "G3#", "SI1"], # d20 = 8 + ["G1", "G2", "G3", "G3#", "G3#"], # d20 = 9 + ["G1", "G1", "G2", "G3#", "G3#"], # d20 = 10 + ["G1", "G1", "G2", "G3", "G3#"], # d20 = 11 + ["G1", "G1", "G2", "G3", "G3#"], # d20 = 12 + ["G1", "G1", "G2", "G3", "G3"], # d20 = 13 + ["G1", "G1", "G2", "G2", "G3"], # d20 = 14 + ["G1", "G1", "G1", "G2", "G3"], # d20 = 15 + ["G1", "G1", "G1", "G2", "G3"], # d20 = 16 + ["G1", "G1", "G1", "G1", "G3"], # d20 = 17 + ["G1", "G1", "G1", "G1", "G2"], # d20 = 18 + ["G1", "G1", "G1", "G1", "G2"], # d20 = 19 + ["G1", "G1", "G1", "G1", "G1"], # d20 = 20 ] -OUTFIELD_DEFENSE_TABLE: List[List[str]] = [ +OUTFIELD_DEFENSE_TABLE: list[list[str]] = [ # Range: 1 2 3 4 5 # Best Good Avg Poor Worst - ['TR3', 'DO3', 'DO3', 'DO3', 'DO3'], # d20 = 1 - ['DO3', 'DO3', 'DO3', 'DO3', 'DO3'], # d20 = 2 - ['DO2', 'DO3', 'DO3', 'DO3', 'DO3'], # d20 = 3 - ['DO2', 'DO2', 'DO3', 'DO3', 'DO3'], # d20 = 4 - ['SI2', 'DO2', 'DO2', 'DO3', 'DO3'], # d20 = 5 - ['SI2', 'SI2', 'DO2', 'DO2', 'DO3'], # d20 = 6 - ['F1', 'SI2', 'SI2', 'DO2', 'DO2'], # d20 = 7 - ['F1', 'F1', 'SI2', 'SI2', 'DO2'], # d20 = 8 - ['F1', 'F1', 'F1', 'SI2', 'SI2'], # d20 = 9 - ['F1', 'F1', 'F1', 'SI2', 'SI2'], # d20 = 10 - ['F1', 'F1', 'F1', 'F1', 'SI2'], # d20 = 11 - ['F1', 'F1', 'F1', 'F1', 'SI2'], # d20 = 12 - ['F1', 'F1', 'F1', 'F1', 'F1'], # d20 = 13 - ['F2', 'F1', 'F1', 'F1', 'F1'], # d20 = 14 - ['F2', 'F2', 'F1', 'F1', 'F1'], # d20 = 15 - ['F2', 'F2', 'F2', 'F1', 'F1'], # d20 = 16 - ['F2', 'F2', 'F2', 'F2', 'F1'], # d20 = 17 - ['F3', 'F2', 'F2', 'F2', 'F2'], # d20 = 18 - ['F3', 'F3', 'F2', 'F2', 'F2'], # d20 = 19 - ['F3', 'F3', 'F3', 'F2', 'F2'], # d20 = 20 + ["TR3", "DO3", "DO3", "DO3", "DO3"], # d20 = 1 + ["DO3", "DO3", "DO3", "DO3", "DO3"], # d20 = 2 + ["DO2", "DO3", "DO3", "DO3", "DO3"], # d20 = 3 + ["DO2", "DO2", "DO3", "DO3", "DO3"], # d20 = 4 + ["SI2", "DO2", "DO2", "DO3", "DO3"], # d20 = 5 + ["SI2", "SI2", "DO2", "DO2", "DO3"], # d20 = 6 + ["F1", "SI2", "SI2", "DO2", "DO2"], # d20 = 7 + ["F1", "F1", "SI2", "SI2", "DO2"], # d20 = 8 + ["F1", "F1", "F1", "SI2", "SI2"], # d20 = 9 + ["F1", "F1", "F1", "SI2", "SI2"], # d20 = 10 + ["F1", "F1", "F1", "F1", "SI2"], # d20 = 11 + ["F1", "F1", "F1", "F1", "SI2"], # d20 = 12 + ["F1", "F1", "F1", "F1", "F1"], # d20 = 13 + ["F2", "F1", "F1", "F1", "F1"], # d20 = 14 + ["F2", "F2", "F1", "F1", "F1"], # d20 = 15 + ["F2", "F2", "F2", "F1", "F1"], # d20 = 16 + ["F2", "F2", "F2", "F2", "F1"], # d20 = 17 + ["F3", "F2", "F2", "F2", "F2"], # d20 = 18 + ["F3", "F3", "F2", "F2", "F2"], # d20 = 19 + ["F3", "F3", "F3", "F2", "F2"], # d20 = 20 ] -CATCHER_DEFENSE_TABLE: List[List[str]] = [ +CATCHER_DEFENSE_TABLE: list[list[str]] = [ # Range: 1 2 3 4 5 # Best Good Avg Poor Worst - ['G3', 'SI1', 'SI1', 'SI1', 'SI1'], # d20 = 1 - ['G2', 'G3', 'SI1', 'SI1', 'SI1'], # d20 = 2 - ['G2', 'G3', 'SI1', 'SI1', 'SI1'], # d20 = 3 - ['G1', 'G2', 'G3', 'SI1', 'SI1'], # d20 = 4 - ['G1', 'G2', 'G3', 'SI1', 'SI1'], # d20 = 5 - ['G1', 'G1', 'G2', 'G3', 'SI1'], # d20 = 6 - ['G1', 'G1', 'G2', 'G3', 'SI1'], # d20 = 7 - ['G1', 'G1', 'G1', 'G2', 'G3'], # d20 = 8 - ['G1', 'G1', 'G1', 'G2', 'G3'], # d20 = 9 - ['SPD', 'G1', 'G1', 'G1', 'G2'], # d20 = 10 - ['SPD', 'SPD', 'G1', 'G1', 'G1'], # d20 = 11 - ['SPD', 'SPD', 'SPD', 'G1', 'G1'], # d20 = 12 - ['FO', 'SPD', 'SPD', 'SPD', 'G1'], # d20 = 13 - ['FO', 'FO', 'SPD', 'SPD', 'SPD'], # d20 = 14 - ['FO', 'FO', 'FO', 'SPD', 'SPD'], # d20 = 15 - ['PO', 'FO', 'FO', 'FO', 'SPD'], # d20 = 16 - ['PO', 'PO', 'FO', 'FO', 'FO'], # d20 = 17 - ['PO', 'PO', 'PO', 'FO', 'FO'], # d20 = 18 - ['PO', 'PO', 'PO', 'PO', 'FO'], # d20 = 19 - ['PO', 'PO', 'PO', 'PO', 'PO'], # d20 = 20 + ["G3", "SI1", "SI1", "SI1", "SI1"], # d20 = 1 + ["G2", "G3", "SI1", "SI1", "SI1"], # d20 = 2 + ["G2", "G3", "SI1", "SI1", "SI1"], # d20 = 3 + ["G1", "G2", "G3", "SI1", "SI1"], # d20 = 4 + ["G1", "G2", "G3", "SI1", "SI1"], # d20 = 5 + ["G1", "G1", "G2", "G3", "SI1"], # d20 = 6 + ["G1", "G1", "G2", "G3", "SI1"], # d20 = 7 + ["G1", "G1", "G1", "G2", "G3"], # d20 = 8 + ["G1", "G1", "G1", "G2", "G3"], # d20 = 9 + ["SPD", "G1", "G1", "G1", "G2"], # d20 = 10 + ["SPD", "SPD", "G1", "G1", "G1"], # d20 = 11 + ["SPD", "SPD", "SPD", "G1", "G1"], # d20 = 12 + ["FO", "SPD", "SPD", "SPD", "G1"], # d20 = 13 + ["FO", "FO", "SPD", "SPD", "SPD"], # d20 = 14 + ["FO", "FO", "FO", "SPD", "SPD"], # d20 = 15 + ["PO", "FO", "FO", "FO", "SPD"], # d20 = 16 + ["PO", "PO", "FO", "FO", "FO"], # d20 = 17 + ["PO", "PO", "PO", "FO", "FO"], # d20 = 18 + ["PO", "PO", "PO", "PO", "FO"], # d20 = 19 + ["PO", "PO", "PO", "PO", "PO"], # d20 = 20 ] # ============================================================================ @@ -101,63 +100,63 @@ CATCHER_DEFENSE_TABLE: List[List[str]] = [ # Otherwise, error_result = 'NO' (no error) # Corner Outfield (LF, RF) Error Chart -LF_RF_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [3], 'E2': [], 'E3': [17]}, - 2: {'RP': [5], 'E1': [3, 18], 'E2': [], 'E3': [16]}, - 3: {'RP': [5], 'E1': [3, 18], 'E2': [], 'E3': [15]}, - 4: {'RP': [5], 'E1': [4], 'E2': [3, 15], 'E3': [18]}, - 5: {'RP': [5], 'E1': [4], 'E2': [14], 'E3': [18]}, - 6: {'RP': [5], 'E1': [3, 4], 'E2': [14, 17], 'E3': [18]}, - 7: {'RP': [5], 'E1': [16], 'E2': [6, 15], 'E3': [18]}, - 8: {'RP': [5], 'E1': [16], 'E2': [6, 15, 17], 'E3': [3, 18]}, - 9: {'RP': [5], 'E1': [16], 'E2': [11], 'E3': [3, 18]}, - 10: {'RP': [5], 'E1': [4, 16], 'E2': [14, 15, 17], 'E3': [3, 18]}, - 11: {'RP': [5], 'E1': [4, 16], 'E2': [13, 15], 'E3': [3, 18]}, - 12: {'RP': [5], 'E1': [4, 16], 'E2': [13, 14], 'E3': [3, 18]}, - 13: {'RP': [5], 'E1': [6], 'E2': [4, 12, 15], 'E3': [17]}, - 14: {'RP': [5], 'E1': [3, 6], 'E2': [12, 14], 'E3': [17]}, - 15: {'RP': [5], 'E1': [3, 6, 18], 'E2': [4, 12, 14], 'E3': [17]}, - 16: {'RP': [5], 'E1': [4, 6], 'E2': [12, 13], 'E3': [17]}, - 17: {'RP': [5], 'E1': [4, 6], 'E2': [9, 12], 'E3': [17]}, - 18: {'RP': [5], 'E1': [3, 4, 6], 'E2': [11, 12, 18], 'E3': [17]}, - 19: {'RP': [5], 'E1': [7], 'E2': [3, 10, 11], 'E3': [17, 18]}, - 20: {'RP': [5], 'E1': [3, 7], 'E2': [11, 13, 16], 'E3': [17, 18]}, - 21: {'RP': [5], 'E1': [3, 7], 'E2': [11, 12, 15], 'E3': [17, 18]}, - 22: {'RP': [5], 'E1': [3, 6, 16], 'E2': [9, 12, 14], 'E3': [17, 18]}, - 23: {'RP': [5], 'E1': [4, 7], 'E2': [11, 12, 14], 'E3': [17, 18]}, - 24: {'RP': [5], 'E1': [4, 6, 16], 'E2': [8, 11, 13], 'E3': [3, 17, 18]}, - 25: {'RP': [5], 'E1': [4, 6, 16], 'E2': [11, 12, 13], 'E3': [3, 17, 18]}, +LF_RF_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [3], "E2": [], "E3": [17]}, + 2: {"RP": [5], "E1": [3, 18], "E2": [], "E3": [16]}, + 3: {"RP": [5], "E1": [3, 18], "E2": [], "E3": [15]}, + 4: {"RP": [5], "E1": [4], "E2": [3, 15], "E3": [18]}, + 5: {"RP": [5], "E1": [4], "E2": [14], "E3": [18]}, + 6: {"RP": [5], "E1": [3, 4], "E2": [14, 17], "E3": [18]}, + 7: {"RP": [5], "E1": [16], "E2": [6, 15], "E3": [18]}, + 8: {"RP": [5], "E1": [16], "E2": [6, 15, 17], "E3": [3, 18]}, + 9: {"RP": [5], "E1": [16], "E2": [11], "E3": [3, 18]}, + 10: {"RP": [5], "E1": [4, 16], "E2": [14, 15, 17], "E3": [3, 18]}, + 11: {"RP": [5], "E1": [4, 16], "E2": [13, 15], "E3": [3, 18]}, + 12: {"RP": [5], "E1": [4, 16], "E2": [13, 14], "E3": [3, 18]}, + 13: {"RP": [5], "E1": [6], "E2": [4, 12, 15], "E3": [17]}, + 14: {"RP": [5], "E1": [3, 6], "E2": [12, 14], "E3": [17]}, + 15: {"RP": [5], "E1": [3, 6, 18], "E2": [4, 12, 14], "E3": [17]}, + 16: {"RP": [5], "E1": [4, 6], "E2": [12, 13], "E3": [17]}, + 17: {"RP": [5], "E1": [4, 6], "E2": [9, 12], "E3": [17]}, + 18: {"RP": [5], "E1": [3, 4, 6], "E2": [11, 12, 18], "E3": [17]}, + 19: {"RP": [5], "E1": [7], "E2": [3, 10, 11], "E3": [17, 18]}, + 20: {"RP": [5], "E1": [3, 7], "E2": [11, 13, 16], "E3": [17, 18]}, + 21: {"RP": [5], "E1": [3, 7], "E2": [11, 12, 15], "E3": [17, 18]}, + 22: {"RP": [5], "E1": [3, 6, 16], "E2": [9, 12, 14], "E3": [17, 18]}, + 23: {"RP": [5], "E1": [4, 7], "E2": [11, 12, 14], "E3": [17, 18]}, + 24: {"RP": [5], "E1": [4, 6, 16], "E2": [8, 11, 13], "E3": [3, 17, 18]}, + 25: {"RP": [5], "E1": [4, 6, 16], "E2": [11, 12, 13], "E3": [3, 17, 18]}, } # Center Field Error Chart -CF_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [3], 'E2': [], 'E3': [17]}, - 2: {'RP': [5], 'E1': [3, 18], 'E2': [], 'E3': [16]}, - 3: {'RP': [5], 'E1': [3, 18], 'E2': [], 'E3': [15]}, - 4: {'RP': [5], 'E1': [4], 'E2': [3, 15], 'E3': [18]}, - 5: {'RP': [5], 'E1': [4], 'E2': [14], 'E3': [18]}, - 6: {'RP': [5], 'E1': [3, 4], 'E2': [14, 17], 'E3': [18]}, - 7: {'RP': [5], 'E1': [16], 'E2': [6, 15], 'E3': [18]}, - 8: {'RP': [5], 'E1': [16], 'E2': [6, 15, 17], 'E3': [3, 18]}, - 9: {'RP': [5], 'E1': [16], 'E2': [11], 'E3': [3, 18]}, - 10: {'RP': [5], 'E1': [4, 16], 'E2': [14, 15, 17], 'E3': [3, 18]}, - 11: {'RP': [5], 'E1': [4, 16], 'E2': [13, 15], 'E3': [3, 18]}, - 12: {'RP': [5], 'E1': [4, 16], 'E2': [13, 14], 'E3': [3, 18]}, - 13: {'RP': [5], 'E1': [6], 'E2': [4, 12, 15], 'E3': [17]}, - 14: {'RP': [5], 'E1': [3, 6], 'E2': [12, 14], 'E3': [17]}, - 15: {'RP': [5], 'E1': [3, 6, 18], 'E2': [4, 12, 14], 'E3': [17]}, - 16: {'RP': [5], 'E1': [4, 6], 'E2': [12, 13], 'E3': [17]}, - 17: {'RP': [5], 'E1': [4, 6], 'E2': [9, 12], 'E3': [17]}, - 18: {'RP': [5], 'E1': [3, 4, 6], 'E2': [11, 12, 18], 'E3': [17]}, - 19: {'RP': [5], 'E1': [7], 'E2': [3, 10, 11], 'E3': [17, 18]}, - 20: {'RP': [5], 'E1': [3, 7], 'E2': [11, 13, 16], 'E3': [17, 18]}, - 21: {'RP': [5], 'E1': [3, 7], 'E2': [11, 12, 15], 'E3': [17, 18]}, - 22: {'RP': [5], 'E1': [3, 6, 16], 'E2': [9, 12, 14], 'E3': [17, 18]}, - 23: {'RP': [5], 'E1': [4, 7], 'E2': [11, 12, 14], 'E3': [17, 18]}, - 24: {'RP': [5], 'E1': [4, 6, 16], 'E2': [8, 11, 13], 'E3': [3, 17, 18]}, - 25: {'RP': [5], 'E1': [4, 6, 16], 'E2': [11, 12, 13], 'E3': [3, 17, 18]}, +CF_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [3], "E2": [], "E3": [17]}, + 2: {"RP": [5], "E1": [3, 18], "E2": [], "E3": [16]}, + 3: {"RP": [5], "E1": [3, 18], "E2": [], "E3": [15]}, + 4: {"RP": [5], "E1": [4], "E2": [3, 15], "E3": [18]}, + 5: {"RP": [5], "E1": [4], "E2": [14], "E3": [18]}, + 6: {"RP": [5], "E1": [3, 4], "E2": [14, 17], "E3": [18]}, + 7: {"RP": [5], "E1": [16], "E2": [6, 15], "E3": [18]}, + 8: {"RP": [5], "E1": [16], "E2": [6, 15, 17], "E3": [3, 18]}, + 9: {"RP": [5], "E1": [16], "E2": [11], "E3": [3, 18]}, + 10: {"RP": [5], "E1": [4, 16], "E2": [14, 15, 17], "E3": [3, 18]}, + 11: {"RP": [5], "E1": [4, 16], "E2": [13, 15], "E3": [3, 18]}, + 12: {"RP": [5], "E1": [4, 16], "E2": [13, 14], "E3": [3, 18]}, + 13: {"RP": [5], "E1": [6], "E2": [4, 12, 15], "E3": [17]}, + 14: {"RP": [5], "E1": [3, 6], "E2": [12, 14], "E3": [17]}, + 15: {"RP": [5], "E1": [3, 6, 18], "E2": [4, 12, 14], "E3": [17]}, + 16: {"RP": [5], "E1": [4, 6], "E2": [12, 13], "E3": [17]}, + 17: {"RP": [5], "E1": [4, 6], "E2": [9, 12], "E3": [17]}, + 18: {"RP": [5], "E1": [3, 4, 6], "E2": [11, 12, 18], "E3": [17]}, + 19: {"RP": [5], "E1": [7], "E2": [3, 10, 11], "E3": [17, 18]}, + 20: {"RP": [5], "E1": [3, 7], "E2": [11, 13, 16], "E3": [17, 18]}, + 21: {"RP": [5], "E1": [3, 7], "E2": [11, 12, 15], "E3": [17, 18]}, + 22: {"RP": [5], "E1": [3, 6, 16], "E2": [9, 12, 14], "E3": [17, 18]}, + 23: {"RP": [5], "E1": [4, 7], "E2": [11, 12, 14], "E3": [17, 18]}, + 24: {"RP": [5], "E1": [4, 6, 16], "E2": [8, 11, 13], "E3": [3, 17, 18]}, + 25: {"RP": [5], "E1": [4, 6, 16], "E2": [11, 12, 13], "E3": [3, 17, 18]}, } # Infield Error Charts @@ -165,251 +164,251 @@ CF_ERROR_CHART: dict[int, dict[str, List[int]]] = { # Structure: same as OF but E3 is always empty # Catcher Error Chart -CATCHER_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [17], 'E2': [], 'E3': []}, - 2: {'RP': [5], 'E1': [3, 17, 18], 'E2': [], 'E3': []}, - 3: {'RP': [5], 'E1': [3, 16, 10], 'E2': [], 'E3': []}, - 4: {'RP': [5], 'E1': [16, 17], 'E2': [18], 'E3': []}, - 5: {'RP': [5], 'E1': [4, 16, 17], 'E2': [18], 'E3': []}, - 6: {'RP': [5], 'E1': [14], 'E2': [18], 'E3': []}, - 7: {'RP': [5], 'E1': [3, 15, 16], 'E2': [18], 'E3': []}, - 8: {'RP': [5], 'E1': [6, 15], 'E2': [18], 'E3': []}, - 9: {'RP': [5], 'E1': [3, 13], 'E2': [18], 'E3': []}, - 10: {'RP': [5], 'E1': [12], 'E2': [18], 'E3': []}, - 11: {'RP': [5], 'E1': [3, 11], 'E2': [18], 'E3': []}, - 12: {'RP': [5], 'E1': [6, 15, 16, 17], 'E2': [3, 18], 'E3': []}, - 13: {'RP': [5], 'E1': [4, 6, 15, 16, 17], 'E2': [3, 18], 'E3': []}, - 14: {'RP': [5], 'E1': [12, 16, 17], 'E2': [3, 18], 'E3': []}, - 15: {'RP': [5], 'E1': [11, 15], 'E2': [3, 18], 'E3': []}, - 16: {'RP': [5], 'E1': [7, 14, 16, 17], 'E2': [3, 18], 'E3': []}, +CATCHER_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [17], "E2": [], "E3": []}, + 2: {"RP": [5], "E1": [3, 17, 18], "E2": [], "E3": []}, + 3: {"RP": [5], "E1": [3, 16, 10], "E2": [], "E3": []}, + 4: {"RP": [5], "E1": [16, 17], "E2": [18], "E3": []}, + 5: {"RP": [5], "E1": [4, 16, 17], "E2": [18], "E3": []}, + 6: {"RP": [5], "E1": [14], "E2": [18], "E3": []}, + 7: {"RP": [5], "E1": [3, 15, 16], "E2": [18], "E3": []}, + 8: {"RP": [5], "E1": [6, 15], "E2": [18], "E3": []}, + 9: {"RP": [5], "E1": [3, 13], "E2": [18], "E3": []}, + 10: {"RP": [5], "E1": [12], "E2": [18], "E3": []}, + 11: {"RP": [5], "E1": [3, 11], "E2": [18], "E3": []}, + 12: {"RP": [5], "E1": [6, 15, 16, 17], "E2": [3, 18], "E3": []}, + 13: {"RP": [5], "E1": [4, 6, 15, 16, 17], "E2": [3, 18], "E3": []}, + 14: {"RP": [5], "E1": [12, 16, 17], "E2": [3, 18], "E3": []}, + 15: {"RP": [5], "E1": [11, 15], "E2": [3, 18], "E3": []}, + 16: {"RP": [5], "E1": [7, 14, 16, 17], "E2": [3, 18], "E3": []}, } # First Base Error Chart -FIRST_BASE_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [17, 18], 'E2': [], 'E3': []}, - 2: {'RP': [5], 'E1': [3, 16, 18], 'E2': [], 'E3': []}, - 3: {'RP': [5], 'E1': [3, 15], 'E2': [18], 'E3': []}, - 4: {'RP': [5], 'E1': [14], 'E2': [18], 'E3': []}, - 5: {'RP': [5], 'E1': [14, 17], 'E2': [18], 'E3': []}, - 6: {'RP': [5], 'E1': [3, 13], 'E2': [18], 'E3': []}, - 7: {'RP': [5], 'E1': [3, 9], 'E2': [18], 'E3': []}, - 8: {'RP': [5], 'E1': [6, 15, 16, 17], 'E2': [3, 18], 'E3': []}, - 9: {'RP': [5], 'E1': [7, 14, 17], 'E2': [3, 18], 'E3': []}, - 10: {'RP': [5], 'E1': [11, 15], 'E2': [3, 18], 'E3': []}, - 11: {'RP': [5], 'E1': [6, 8, 15], 'E2': [3, 18], 'E3': []}, - 12: {'RP': [5], 'E1': [6, 9, 15], 'E2': [3, 18], 'E3': []}, - 13: {'RP': [5], 'E1': [11, 13], 'E2': [17], 'E3': []}, - 14: {'RP': [5], 'E1': [3, 9, 12], 'E2': [17], 'E3': []}, - 15: {'RP': [5], 'E1': [7, 12, 14], 'E2': [17], 'E3': []}, - 16: {'RP': [5], 'E1': [3, 11, 12, 16], 'E2': [17], 'E3': []}, - 17: {'RP': [5], 'E1': [3, 6, 11, 12], 'E2': [17], 'E3': []}, - 18: {'RP': [5], 'E1': [11, 12, 14], 'E2': [17], 'E3': []}, - 19: {'RP': [5], 'E1': [10, 11, 15, 16], 'E2': [17, 18], 'E3': []}, - 20: {'RP': [5], 'E1': [6, 10, 11, 15], 'E2': [17, 18], 'E3': []}, - 21: {'RP': [5], 'E1': [3, 9, 10, 12], 'E2': [17, 18], 'E3': []}, - 22: {'RP': [5], 'E1': [7, 11, 12, 14], 'E2': [17, 18], 'E3': []}, - 23: {'RP': [5], 'E1': [10, 11, 12, 16], 'E2': [17, 18], 'E3': []}, - 24: {'RP': [5], 'E1': [11, 12, 13, 14], 'E2': [3, 17, 18], 'E3': []}, - 25: {'RP': [5], 'E1': [9, 11, 12, 14], 'E2': [3, 17, 18], 'E3': []}, - 26: {'RP': [5], 'E1': [9, 12, 13, 14, 15], 'E2': [3, 17, 18], 'E3': []}, - 27: {'RP': [5], 'E1': [7, 8, 11, 13, 14], 'E2': [3, 17, 18], 'E3': []}, - 28: {'RP': [5], 'E1': [7, 11, 12, 13, 14], 'E2': [3, 17, 18], 'E3': []}, - 29: {'RP': [5], 'E1': [9, 10, 11, 12, 17], 'E2': [16], 'E3': []}, - 30: {'RP': [5], 'E1': [10, 11, 12, 13, 15, 18], 'E2': [16], 'E3': []}, +FIRST_BASE_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [17, 18], "E2": [], "E3": []}, + 2: {"RP": [5], "E1": [3, 16, 18], "E2": [], "E3": []}, + 3: {"RP": [5], "E1": [3, 15], "E2": [18], "E3": []}, + 4: {"RP": [5], "E1": [14], "E2": [18], "E3": []}, + 5: {"RP": [5], "E1": [14, 17], "E2": [18], "E3": []}, + 6: {"RP": [5], "E1": [3, 13], "E2": [18], "E3": []}, + 7: {"RP": [5], "E1": [3, 9], "E2": [18], "E3": []}, + 8: {"RP": [5], "E1": [6, 15, 16, 17], "E2": [3, 18], "E3": []}, + 9: {"RP": [5], "E1": [7, 14, 17], "E2": [3, 18], "E3": []}, + 10: {"RP": [5], "E1": [11, 15], "E2": [3, 18], "E3": []}, + 11: {"RP": [5], "E1": [6, 8, 15], "E2": [3, 18], "E3": []}, + 12: {"RP": [5], "E1": [6, 9, 15], "E2": [3, 18], "E3": []}, + 13: {"RP": [5], "E1": [11, 13], "E2": [17], "E3": []}, + 14: {"RP": [5], "E1": [3, 9, 12], "E2": [17], "E3": []}, + 15: {"RP": [5], "E1": [7, 12, 14], "E2": [17], "E3": []}, + 16: {"RP": [5], "E1": [3, 11, 12, 16], "E2": [17], "E3": []}, + 17: {"RP": [5], "E1": [3, 6, 11, 12], "E2": [17], "E3": []}, + 18: {"RP": [5], "E1": [11, 12, 14], "E2": [17], "E3": []}, + 19: {"RP": [5], "E1": [10, 11, 15, 16], "E2": [17, 18], "E3": []}, + 20: {"RP": [5], "E1": [6, 10, 11, 15], "E2": [17, 18], "E3": []}, + 21: {"RP": [5], "E1": [3, 9, 10, 12], "E2": [17, 18], "E3": []}, + 22: {"RP": [5], "E1": [7, 11, 12, 14], "E2": [17, 18], "E3": []}, + 23: {"RP": [5], "E1": [10, 11, 12, 16], "E2": [17, 18], "E3": []}, + 24: {"RP": [5], "E1": [11, 12, 13, 14], "E2": [3, 17, 18], "E3": []}, + 25: {"RP": [5], "E1": [9, 11, 12, 14], "E2": [3, 17, 18], "E3": []}, + 26: {"RP": [5], "E1": [9, 12, 13, 14, 15], "E2": [3, 17, 18], "E3": []}, + 27: {"RP": [5], "E1": [7, 8, 11, 13, 14], "E2": [3, 17, 18], "E3": []}, + 28: {"RP": [5], "E1": [7, 11, 12, 13, 14], "E2": [3, 17, 18], "E3": []}, + 29: {"RP": [5], "E1": [9, 10, 11, 12, 17], "E2": [16], "E3": []}, + 30: {"RP": [5], "E1": [10, 11, 12, 13, 15, 18], "E2": [16], "E3": []}, } # Second Base Error Chart -SECOND_BASE_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [18], 'E2': [], 'E3': []}, - 2: {'RP': [5], 'E1': [3, 18], 'E2': [], 'E3': []}, - 3: {'RP': [5], 'E1': [3, 17], 'E2': [], 'E3': []}, - 4: {'RP': [5], 'E1': [3, 17], 'E2': [18], 'E3': []}, - 5: {'RP': [5], 'E1': [16], 'E2': [18], 'E3': []}, - 6: {'RP': [5], 'E1': [3, 16], 'E2': [18], 'E3': []}, - 8: {'RP': [5], 'E1': [16, 17], 'E2': [18], 'E3': []}, - 10: {'RP': [5], 'E1': [4, 16, 17], 'E2': [18], 'E3': []}, - 11: {'RP': [5], 'E1': [15, 17], 'E2': [18], 'E3': []}, - 12: {'RP': [5], 'E1': [15, 17], 'E2': [3, 18], 'E3': []}, - 13: {'RP': [5], 'E1': [14], 'E2': [3, 18], 'E3': []}, - 14: {'RP': [5], 'E1': [15, 16], 'E2': [3, 18], 'E3': []}, - 15: {'RP': [5], 'E1': [14, 17], 'E2': [3, 18], 'E3': []}, - 16: {'RP': [5], 'E1': [15, 16, 17], 'E2': [3, 18], 'E3': []}, - 17: {'RP': [5], 'E1': [6, 15], 'E2': [3, 18], 'E3': []}, - 18: {'RP': [5], 'E1': [13], 'E2': [3, 18], 'E3': []}, - 19: {'RP': [5], 'E1': [6, 15, 17], 'E2': [3, 18], 'E3': []}, - 20: {'RP': [5], 'E1': [3, 13, 18], 'E2': [17], 'E3': []}, - 21: {'RP': [5], 'E1': [4, 13], 'E2': [17], 'E3': []}, - 22: {'RP': [5], 'E1': [12, 18], 'E2': [17], 'E3': []}, - 23: {'RP': [5], 'E1': [11], 'E2': [17], 'E3': []}, - 24: {'RP': [5], 'E1': [11, 18], 'E2': [17], 'E3': []}, - 25: {'RP': [5], 'E1': [3, 11, 18], 'E2': [17], 'E3': []}, - 26: {'RP': [5], 'E1': [13, 15], 'E2': [17], 'E3': []}, - 27: {'RP': [5], 'E1': [13, 15, 18], 'E2': [17], 'E3': []}, - 28: {'RP': [5], 'E1': [3, 13, 15], 'E2': [17, 18], 'E3': []}, - 29: {'RP': [5], 'E1': [3, 11, 16], 'E2': [17, 18], 'E3': []}, - 30: {'RP': [5], 'E1': [12, 15], 'E2': [17, 18], 'E3': []}, - 32: {'RP': [5], 'E1': [11, 15], 'E2': [17, 18], 'E3': []}, - 34: {'RP': [5], 'E1': [12, 14], 'E2': [17, 18], 'E3': []}, - 37: {'RP': [5], 'E1': [11, 15, 18], 'E2': [3, 17, 18], 'E3': []}, - 39: {'RP': [5], 'E1': [12, 13], 'E2': [3, 17, 18], 'E3': []}, - 41: {'RP': [5], 'E1': [11, 13], 'E2': [3, 17, 18], 'E3': []}, - 44: {'RP': [5], 'E1': [9, 12, 18], 'E2': [16], 'E3': []}, - 47: {'RP': [5], 'E1': [7, 12, 14], 'E2': [16], 'E3': []}, - 50: {'RP': [5], 'E1': [11, 13, 15, 18], 'E2': [16], 'E3': []}, - 53: {'RP': [5], 'E1': [11, 12, 15], 'E2': [16, 18], 'E3': []}, - 56: {'RP': [5], 'E1': [6, 12, 13, 15], 'E2': [16, 18], 'E3': []}, - 59: {'RP': [5], 'E1': [6, 11, 13, 15], 'E2': [3, 16, 18], 'E3': []}, - 62: {'RP': [5], 'E1': [6, 11, 12, 15], 'E2': [3, 16, 18], 'E3': []}, - 65: {'RP': [5], 'E1': [7, 12, 13, 14], 'E2': [3, 16, 18], 'E3': []}, - 68: {'RP': [5], 'E1': [10, 11, 12], 'E2': [16, 17], 'E3': []}, - 71: {'RP': [5], 'E1': [11, 12, 13, 15], 'E2': [16, 17], 'E3': []}, +SECOND_BASE_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [18], "E2": [], "E3": []}, + 2: {"RP": [5], "E1": [3, 18], "E2": [], "E3": []}, + 3: {"RP": [5], "E1": [3, 17], "E2": [], "E3": []}, + 4: {"RP": [5], "E1": [3, 17], "E2": [18], "E3": []}, + 5: {"RP": [5], "E1": [16], "E2": [18], "E3": []}, + 6: {"RP": [5], "E1": [3, 16], "E2": [18], "E3": []}, + 8: {"RP": [5], "E1": [16, 17], "E2": [18], "E3": []}, + 10: {"RP": [5], "E1": [4, 16, 17], "E2": [18], "E3": []}, + 11: {"RP": [5], "E1": [15, 17], "E2": [18], "E3": []}, + 12: {"RP": [5], "E1": [15, 17], "E2": [3, 18], "E3": []}, + 13: {"RP": [5], "E1": [14], "E2": [3, 18], "E3": []}, + 14: {"RP": [5], "E1": [15, 16], "E2": [3, 18], "E3": []}, + 15: {"RP": [5], "E1": [14, 17], "E2": [3, 18], "E3": []}, + 16: {"RP": [5], "E1": [15, 16, 17], "E2": [3, 18], "E3": []}, + 17: {"RP": [5], "E1": [6, 15], "E2": [3, 18], "E3": []}, + 18: {"RP": [5], "E1": [13], "E2": [3, 18], "E3": []}, + 19: {"RP": [5], "E1": [6, 15, 17], "E2": [3, 18], "E3": []}, + 20: {"RP": [5], "E1": [3, 13, 18], "E2": [17], "E3": []}, + 21: {"RP": [5], "E1": [4, 13], "E2": [17], "E3": []}, + 22: {"RP": [5], "E1": [12, 18], "E2": [17], "E3": []}, + 23: {"RP": [5], "E1": [11], "E2": [17], "E3": []}, + 24: {"RP": [5], "E1": [11, 18], "E2": [17], "E3": []}, + 25: {"RP": [5], "E1": [3, 11, 18], "E2": [17], "E3": []}, + 26: {"RP": [5], "E1": [13, 15], "E2": [17], "E3": []}, + 27: {"RP": [5], "E1": [13, 15, 18], "E2": [17], "E3": []}, + 28: {"RP": [5], "E1": [3, 13, 15], "E2": [17, 18], "E3": []}, + 29: {"RP": [5], "E1": [3, 11, 16], "E2": [17, 18], "E3": []}, + 30: {"RP": [5], "E1": [12, 15], "E2": [17, 18], "E3": []}, + 32: {"RP": [5], "E1": [11, 15], "E2": [17, 18], "E3": []}, + 34: {"RP": [5], "E1": [12, 14], "E2": [17, 18], "E3": []}, + 37: {"RP": [5], "E1": [11, 15, 18], "E2": [3, 17, 18], "E3": []}, + 39: {"RP": [5], "E1": [12, 13], "E2": [3, 17, 18], "E3": []}, + 41: {"RP": [5], "E1": [11, 13], "E2": [3, 17, 18], "E3": []}, + 44: {"RP": [5], "E1": [9, 12, 18], "E2": [16], "E3": []}, + 47: {"RP": [5], "E1": [7, 12, 14], "E2": [16], "E3": []}, + 50: {"RP": [5], "E1": [11, 13, 15, 18], "E2": [16], "E3": []}, + 53: {"RP": [5], "E1": [11, 12, 15], "E2": [16, 18], "E3": []}, + 56: {"RP": [5], "E1": [6, 12, 13, 15], "E2": [16, 18], "E3": []}, + 59: {"RP": [5], "E1": [6, 11, 13, 15], "E2": [3, 16, 18], "E3": []}, + 62: {"RP": [5], "E1": [6, 11, 12, 15], "E2": [3, 16, 18], "E3": []}, + 65: {"RP": [5], "E1": [7, 12, 13, 14], "E2": [3, 16, 18], "E3": []}, + 68: {"RP": [5], "E1": [10, 11, 12], "E2": [16, 17], "E3": []}, + 71: {"RP": [5], "E1": [11, 12, 13, 15], "E2": [16, 17], "E3": []}, } # Third Base Error Chart -THIRD_BASE_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [3], 'E2': [18], 'E3': []}, - 2: {'RP': [5], 'E1': [3, 4], 'E2': [18], 'E3': []}, - 3: {'RP': [5], 'E1': [3, 4], 'E2': [17], 'E3': []}, - 4: {'RP': [5], 'E1': [3, 16], 'E2': [17], 'E3': []}, - 5: {'RP': [5], 'E1': [15], 'E2': [17], 'E3': []}, - 6: {'RP': [5], 'E1': [4, 15], 'E2': [17], 'E3': []}, - 8: {'RP': [5], 'E1': [3, 15, 16], 'E2': [17, 18], 'E3': []}, - 10: {'RP': [5], 'E1': [13], 'E2': [3, 17, 18], 'E3': []}, - 11: {'RP': [5], 'E1': [6, 15, 17], 'E2': [16], 'E3': []}, - 12: {'RP': [5], 'E1': [12], 'E2': [16], 'E3': []}, - 13: {'RP': [5], 'E1': [11], 'E2': [16, 18], 'E3': []}, - 14: {'RP': [5], 'E1': [3, 4, 14, 15], 'E2': [16, 18], 'E3': []}, - 15: {'RP': [5], 'E1': [13, 15], 'E2': [3, 16, 18], 'E3': []}, - 16: {'RP': [5], 'E1': [4, 7, 14], 'E2': [3, 16, 18], 'E3': []}, - 17: {'RP': [5], 'E1': [12, 15], 'E2': [16, 17], 'E3': []}, - 18: {'RP': [5], 'E1': [3, 11, 15], 'E2': [16, 17], 'E3': []}, - 19: {'RP': [5], 'E1': [7, 14, 16, 17], 'E2': [15], 'E3': []}, - 20: {'RP': [5], 'E1': [11, 14], 'E2': [15], 'E3': []}, - 21: {'RP': [5], 'E1': [6, 11, 16], 'E2': [15, 18], 'E3': []}, - 22: {'RP': [5], 'E1': [12, 14, 16], 'E2': [15, 18], 'E3': []}, - 23: {'RP': [5], 'E1': [11, 13], 'E2': [3, 15, 18], 'E3': []}, - 24: {'RP': [5], 'E1': [9, 12], 'E2': [3, 15, 18], 'E3': []}, - 25: {'RP': [5], 'E1': [6, 8, 13], 'E2': [15, 17], 'E3': []}, - 26: {'RP': [5], 'E1': [10, 11], 'E2': [15, 17], 'E3': []}, - 27: {'RP': [5], 'E1': [9, 12, 16], 'E2': [15, 17, 18], 'E3': []}, - 28: {'RP': [5], 'E1': [11, 13, 15], 'E2': [14], 'E3': []}, - 29: {'RP': [5], 'E1': [9, 12, 15], 'E2': [14], 'E3': []}, - 30: {'RP': [5], 'E1': [6, 8, 13, 15], 'E2': [14, 18], 'E3': []}, - 31: {'RP': [5], 'E1': [10, 11, 15], 'E2': [14, 18], 'E3': []}, - 32: {'RP': [5], 'E1': [11, 13, 14, 17], 'E2': [15, 16, 18], 'E3': []}, - 33: {'RP': [5], 'E1': [8, 11, 13], 'E2': [15, 16, 18], 'E3': []}, - 34: {'RP': [5], 'E1': [6, 9, 12, 15], 'E2': [14, 17], 'E3': []}, - 35: {'RP': [5], 'E1': [11, 12, 13], 'E2': [14, 17], 'E3': []}, - 37: {'RP': [5], 'E1': [9, 11, 12], 'E2': [15, 16, 17], 'E3': []}, - 39: {'RP': [5], 'E1': [7, 9, 12, 14, 18], 'E2': [6, 15], 'E3': []}, - 41: {'RP': [5], 'E1': [10, 11, 12, 16], 'E2': [13], 'E3': []}, - 44: {'RP': [5], 'E1': [4, 11, 12, 13, 14], 'E2': [6, 15, 17], 'E3': []}, - 47: {'RP': [5], 'E1': [8, 9, 11, 12], 'E2': [13, 17], 'E3': []}, - 50: {'RP': [5], 'E1': [9, 10, 11, 12], 'E2': [14, 15, 18], 'E3': []}, - 53: {'RP': [5], 'E1': [6, 8, 9, 10, 11], 'E2': [13, 16], 'E3': []}, - 56: {'RP': [5], 'E1': [8, 9, 10, 15, 14, 17], 'E2': [3, 11, 18], 'E3': []}, - 59: {'RP': [5], 'E1': [4, 7, 9, 10, 11, 12], 'E2': [13, 15], 'E3': []}, - 62: {'RP': [5], 'E1': [7, 8, 9, 10, 11, 14], 'E2': [12, 16, 18], 'E3': []}, - 65: {'RP': [5], 'E1': [7, 8, 9, 10, 12, 13], 'E2': [11, 16, 18], 'E3': []}, +THIRD_BASE_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [3], "E2": [18], "E3": []}, + 2: {"RP": [5], "E1": [3, 4], "E2": [18], "E3": []}, + 3: {"RP": [5], "E1": [3, 4], "E2": [17], "E3": []}, + 4: {"RP": [5], "E1": [3, 16], "E2": [17], "E3": []}, + 5: {"RP": [5], "E1": [15], "E2": [17], "E3": []}, + 6: {"RP": [5], "E1": [4, 15], "E2": [17], "E3": []}, + 8: {"RP": [5], "E1": [3, 15, 16], "E2": [17, 18], "E3": []}, + 10: {"RP": [5], "E1": [13], "E2": [3, 17, 18], "E3": []}, + 11: {"RP": [5], "E1": [6, 15, 17], "E2": [16], "E3": []}, + 12: {"RP": [5], "E1": [12], "E2": [16], "E3": []}, + 13: {"RP": [5], "E1": [11], "E2": [16, 18], "E3": []}, + 14: {"RP": [5], "E1": [3, 4, 14, 15], "E2": [16, 18], "E3": []}, + 15: {"RP": [5], "E1": [13, 15], "E2": [3, 16, 18], "E3": []}, + 16: {"RP": [5], "E1": [4, 7, 14], "E2": [3, 16, 18], "E3": []}, + 17: {"RP": [5], "E1": [12, 15], "E2": [16, 17], "E3": []}, + 18: {"RP": [5], "E1": [3, 11, 15], "E2": [16, 17], "E3": []}, + 19: {"RP": [5], "E1": [7, 14, 16, 17], "E2": [15], "E3": []}, + 20: {"RP": [5], "E1": [11, 14], "E2": [15], "E3": []}, + 21: {"RP": [5], "E1": [6, 11, 16], "E2": [15, 18], "E3": []}, + 22: {"RP": [5], "E1": [12, 14, 16], "E2": [15, 18], "E3": []}, + 23: {"RP": [5], "E1": [11, 13], "E2": [3, 15, 18], "E3": []}, + 24: {"RP": [5], "E1": [9, 12], "E2": [3, 15, 18], "E3": []}, + 25: {"RP": [5], "E1": [6, 8, 13], "E2": [15, 17], "E3": []}, + 26: {"RP": [5], "E1": [10, 11], "E2": [15, 17], "E3": []}, + 27: {"RP": [5], "E1": [9, 12, 16], "E2": [15, 17, 18], "E3": []}, + 28: {"RP": [5], "E1": [11, 13, 15], "E2": [14], "E3": []}, + 29: {"RP": [5], "E1": [9, 12, 15], "E2": [14], "E3": []}, + 30: {"RP": [5], "E1": [6, 8, 13, 15], "E2": [14, 18], "E3": []}, + 31: {"RP": [5], "E1": [10, 11, 15], "E2": [14, 18], "E3": []}, + 32: {"RP": [5], "E1": [11, 13, 14, 17], "E2": [15, 16, 18], "E3": []}, + 33: {"RP": [5], "E1": [8, 11, 13], "E2": [15, 16, 18], "E3": []}, + 34: {"RP": [5], "E1": [6, 9, 12, 15], "E2": [14, 17], "E3": []}, + 35: {"RP": [5], "E1": [11, 12, 13], "E2": [14, 17], "E3": []}, + 37: {"RP": [5], "E1": [9, 11, 12], "E2": [15, 16, 17], "E3": []}, + 39: {"RP": [5], "E1": [7, 9, 12, 14, 18], "E2": [6, 15], "E3": []}, + 41: {"RP": [5], "E1": [10, 11, 12, 16], "E2": [13], "E3": []}, + 44: {"RP": [5], "E1": [4, 11, 12, 13, 14], "E2": [6, 15, 17], "E3": []}, + 47: {"RP": [5], "E1": [8, 9, 11, 12], "E2": [13, 17], "E3": []}, + 50: {"RP": [5], "E1": [9, 10, 11, 12], "E2": [14, 15, 18], "E3": []}, + 53: {"RP": [5], "E1": [6, 8, 9, 10, 11], "E2": [13, 16], "E3": []}, + 56: {"RP": [5], "E1": [8, 9, 10, 15, 14, 17], "E2": [3, 11, 18], "E3": []}, + 59: {"RP": [5], "E1": [4, 7, 9, 10, 11, 12], "E2": [13, 15], "E3": []}, + 62: {"RP": [5], "E1": [7, 8, 9, 10, 11, 14], "E2": [12, 16, 18], "E3": []}, + 65: {"RP": [5], "E1": [7, 8, 9, 10, 12, 13], "E2": [11, 16, 18], "E3": []}, } # Shortstop Error Chart -SHORTSTOP_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 1: {'RP': [5], 'E1': [18], 'E2': [], 'E3': []}, - 2: {'RP': [5], 'E1': [3, 18], 'E2': [], 'E3': []}, - 3: {'RP': [5], 'E1': [17], 'E2': [], 'E3': []}, - 4: {'RP': [5], 'E1': [17], 'E2': [18], 'E3': []}, - 5: {'RP': [5], 'E1': [3, 17], 'E2': [18], 'E3': []}, - 6: {'RP': [5], 'E1': [16], 'E2': [18], 'E3': []}, - 7: {'RP': [5], 'E1': [3, 16], 'E2': [18], 'E3': []}, - 8: {'RP': [5], 'E1': [16, 17], 'E2': [18], 'E3': []}, - 10: {'RP': [5], 'E1': [6, 17], 'E2': [3, 18], 'E3': []}, - 12: {'RP': [5], 'E1': [15], 'E2': [3, 18], 'E3': []}, - 14: {'RP': [5], 'E1': [4, 15], 'E2': [17], 'E3': []}, - 16: {'RP': [5], 'E1': [14], 'E2': [17], 'E3': []}, - 17: {'RP': [5], 'E1': [15, 16], 'E2': [17], 'E3': []}, - 18: {'RP': [5], 'E1': [15, 16, 18], 'E2': [17], 'E3': []}, - 19: {'RP': [5], 'E1': [4, 14], 'E2': [17], 'E3': []}, - 20: {'RP': [5], 'E1': [4, 15, 16], 'E2': [17], 'E3': []}, - 21: {'RP': [5], 'E1': [6, 15], 'E2': [17], 'E3': []}, - 22: {'RP': [5], 'E1': [6, 15], 'E2': [17, 18], 'E3': []}, - 23: {'RP': [5], 'E1': [3, 13], 'E2': [17, 18], 'E3': []}, - 24: {'RP': [5], 'E1': [4, 6, 15], 'E2': [17, 18], 'E3': []}, - 25: {'RP': [5], 'E1': [4, 13], 'E2': [17, 18], 'E3': []}, - 26: {'RP': [5], 'E1': [12], 'E2': [17, 18], 'E3': []}, - 27: {'RP': [5], 'E1': [3, 12], 'E2': [17, 18], 'E3': []}, - 28: {'RP': [5], 'E1': [6, 15, 16], 'E2': [3, 17, 18], 'E3': []}, - 29: {'RP': [5], 'E1': [11], 'E2': [3, 17, 18], 'E3': []}, - 30: {'RP': [5], 'E1': [4, 12], 'E2': [3, 17, 18], 'E3': []}, - 31: {'RP': [5], 'E1': [4, 6, 15, 16], 'E2': [3, 17, 18], 'E3': []}, - 32: {'RP': [5], 'E1': [13, 15], 'E2': [3, 17, 18], 'E3': []}, - 33: {'RP': [5], 'E1': [13, 15], 'E2': [16], 'E3': []}, - 34: {'RP': [5], 'E1': [13, 15, 18], 'E2': [16], 'E3': []}, - 36: {'RP': [5], 'E1': [13, 15, 17], 'E2': [16], 'E3': []}, - 38: {'RP': [5], 'E1': [13, 14], 'E2': [16], 'E3': []}, - 40: {'RP': [5], 'E1': [11, 15], 'E2': [16, 18], 'E3': []}, - 42: {'RP': [5], 'E1': [12, 14], 'E2': [16, 18], 'E3': []}, - 44: {'RP': [5], 'E1': [8, 13], 'E2': [16, 18], 'E3': []}, - 48: {'RP': [5], 'E1': [6, 12, 15], 'E2': [3, 18, 18], 'E3': []}, - 52: {'RP': [5], 'E1': [11, 13, 18], 'E2': [16, 17], 'E3': []}, - 56: {'RP': [5], 'E1': [11, 12, 18], 'E2': [16, 17], 'E3': []}, - 60: {'RP': [5], 'E1': [7, 11, 14], 'E2': [15], 'E3': []}, - 64: {'RP': [5], 'E1': [6, 9, 12], 'E2': [15, 18], 'E3': []}, - 68: {'RP': [5], 'E1': [9, 12, 14], 'E2': [15, 18], 'E3': []}, - 72: {'RP': [5], 'E1': [6, 11, 13, 15], 'E2': [4, 16, 17], 'E3': []}, - 76: {'RP': [5], 'E1': [9, 12, 13], 'E2': [15, 17], 'E3': []}, - 80: {'RP': [5], 'E1': [4, 11, 12, 13], 'E2': [15, 17], 'E3': []}, - 84: {'RP': [5], 'E1': [10, 11, 12], 'E2': [3, 15, 17], 'E3': []}, - 88: {'RP': [5], 'E1': [9, 11, 12, 16], 'E2': [14], 'E3': []}, +SHORTSTOP_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 1: {"RP": [5], "E1": [18], "E2": [], "E3": []}, + 2: {"RP": [5], "E1": [3, 18], "E2": [], "E3": []}, + 3: {"RP": [5], "E1": [17], "E2": [], "E3": []}, + 4: {"RP": [5], "E1": [17], "E2": [18], "E3": []}, + 5: {"RP": [5], "E1": [3, 17], "E2": [18], "E3": []}, + 6: {"RP": [5], "E1": [16], "E2": [18], "E3": []}, + 7: {"RP": [5], "E1": [3, 16], "E2": [18], "E3": []}, + 8: {"RP": [5], "E1": [16, 17], "E2": [18], "E3": []}, + 10: {"RP": [5], "E1": [6, 17], "E2": [3, 18], "E3": []}, + 12: {"RP": [5], "E1": [15], "E2": [3, 18], "E3": []}, + 14: {"RP": [5], "E1": [4, 15], "E2": [17], "E3": []}, + 16: {"RP": [5], "E1": [14], "E2": [17], "E3": []}, + 17: {"RP": [5], "E1": [15, 16], "E2": [17], "E3": []}, + 18: {"RP": [5], "E1": [15, 16, 18], "E2": [17], "E3": []}, + 19: {"RP": [5], "E1": [4, 14], "E2": [17], "E3": []}, + 20: {"RP": [5], "E1": [4, 15, 16], "E2": [17], "E3": []}, + 21: {"RP": [5], "E1": [6, 15], "E2": [17], "E3": []}, + 22: {"RP": [5], "E1": [6, 15], "E2": [17, 18], "E3": []}, + 23: {"RP": [5], "E1": [3, 13], "E2": [17, 18], "E3": []}, + 24: {"RP": [5], "E1": [4, 6, 15], "E2": [17, 18], "E3": []}, + 25: {"RP": [5], "E1": [4, 13], "E2": [17, 18], "E3": []}, + 26: {"RP": [5], "E1": [12], "E2": [17, 18], "E3": []}, + 27: {"RP": [5], "E1": [3, 12], "E2": [17, 18], "E3": []}, + 28: {"RP": [5], "E1": [6, 15, 16], "E2": [3, 17, 18], "E3": []}, + 29: {"RP": [5], "E1": [11], "E2": [3, 17, 18], "E3": []}, + 30: {"RP": [5], "E1": [4, 12], "E2": [3, 17, 18], "E3": []}, + 31: {"RP": [5], "E1": [4, 6, 15, 16], "E2": [3, 17, 18], "E3": []}, + 32: {"RP": [5], "E1": [13, 15], "E2": [3, 17, 18], "E3": []}, + 33: {"RP": [5], "E1": [13, 15], "E2": [16], "E3": []}, + 34: {"RP": [5], "E1": [13, 15, 18], "E2": [16], "E3": []}, + 36: {"RP": [5], "E1": [13, 15, 17], "E2": [16], "E3": []}, + 38: {"RP": [5], "E1": [13, 14], "E2": [16], "E3": []}, + 40: {"RP": [5], "E1": [11, 15], "E2": [16, 18], "E3": []}, + 42: {"RP": [5], "E1": [12, 14], "E2": [16, 18], "E3": []}, + 44: {"RP": [5], "E1": [8, 13], "E2": [16, 18], "E3": []}, + 48: {"RP": [5], "E1": [6, 12, 15], "E2": [3, 18, 18], "E3": []}, + 52: {"RP": [5], "E1": [11, 13, 18], "E2": [16, 17], "E3": []}, + 56: {"RP": [5], "E1": [11, 12, 18], "E2": [16, 17], "E3": []}, + 60: {"RP": [5], "E1": [7, 11, 14], "E2": [15], "E3": []}, + 64: {"RP": [5], "E1": [6, 9, 12], "E2": [15, 18], "E3": []}, + 68: {"RP": [5], "E1": [9, 12, 14], "E2": [15, 18], "E3": []}, + 72: {"RP": [5], "E1": [6, 11, 13, 15], "E2": [4, 16, 17], "E3": []}, + 76: {"RP": [5], "E1": [9, 12, 13], "E2": [15, 17], "E3": []}, + 80: {"RP": [5], "E1": [4, 11, 12, 13], "E2": [15, 17], "E3": []}, + 84: {"RP": [5], "E1": [10, 11, 12], "E2": [3, 15, 17], "E3": []}, + 88: {"RP": [5], "E1": [9, 11, 12, 16], "E2": [14], "E3": []}, } # Pitcher Error Chart -PITCHER_ERROR_CHART: dict[int, dict[str, List[int]]] = { - 0: {'RP': [5], 'E1': [], 'E2': [], 'E3': []}, - 4: {'RP': [5], 'E1': [14], 'E2': [18], 'E3': []}, - 6: {'RP': [5], 'E1': [3, 13], 'E2': [18], 'E3': []}, - 7: {'RP': [5], 'E1': [3, 12], 'E2': [18], 'E3': []}, - 8: {'RP': [5], 'E1': [6, 15, 16, 17], 'E2': [3, 18], 'E3': []}, - 10: {'RP': [5], 'E1': [11, 15], 'E2': [3, 18], 'E3': []}, - 11: {'RP': [5], 'E1': [12, 15, 16], 'E2': [3, 18], 'E3': []}, - 12: {'RP': [5], 'E1': [6, 12, 15], 'E2': [3, 18], 'E3': []}, - 13: {'RP': [5], 'E1': [11, 13], 'E2': [17], 'E3': []}, - 14: {'RP': [5], 'E1': [7, 13, 14], 'E2': [17], 'E3': []}, - 15: {'RP': [5], 'E1': [4, 11, 12], 'E2': [17], 'E3': []}, - 16: {'RP': [5], 'E1': [4, 9, 12, 16], 'E2': [17], 'E3': []}, - 17: {'RP': [5], 'E1': [3, 6, 11, 12], 'E2': [17], 'E3': []}, - 18: {'RP': [5], 'E1': [11, 12, 14], 'E2': [17], 'E3': []}, - 19: {'RP': [5], 'E1': [6, 9, 12, 15], 'E2': [17, 18], 'E3': []}, - 20: {'RP': [5], 'E1': [6, 10, 11, 15], 'E2': [17, 18], 'E3': []}, - 21: {'RP': [5], 'E1': [7, 11, 13, 14], 'E2': [17, 18], 'E3': []}, - 22: {'RP': [5], 'E1': [8, 12, 13, 14], 'E2': [17, 18], 'E3': []}, - 23: {'RP': [5], 'E1': [10, 11, 12, 16], 'E2': [17, 18], 'E3': []}, - 24: {'RP': [5], 'E1': [10, 11, 12, 15], 'E2': [17, 18], 'E3': []}, - 26: {'RP': [5], 'E1': [9, 12, 13, 14, 15], 'E2': [3, 17, 18], 'E3': []}, - 27: {'RP': [5], 'E1': [10, 11, 12, 13], 'E2': [3, 17, 18], 'E3': []}, - 28: {'RP': [5], 'E1': [9, 10, 11, 12], 'E2': [3, 17, 18], 'E3': []}, - 30: {'RP': [5], 'E1': [3, 10, 11, 12, 13, 15], 'E2': [16], 'E3': []}, - 31: {'RP': [5], 'E1': [10, 11, 12, 13, 14], 'E2': [16], 'E3': []}, - 33: {'RP': [5], 'E1': [3, 8, 10, 11, 12, 13], 'E2': [16], 'E3': []}, - 34: {'RP': [5], 'E1': [9, 10, 11, 12, 13], 'E2': [16, 18], 'E3': []}, - 35: {'RP': [5], 'E1': [9, 10, 11, 12, 14, 15], 'E2': [16, 18], 'E3': []}, - 36: {'RP': [5], 'E1': [3, 4, 6, 7, 9, 10, 11, 12], 'E2': [16, 18], 'E3': []}, - 38: {'RP': [5], 'E1': [6, 8, 10, 11, 12, 13, 15], 'E2': [16, 18], 'E3': []}, - 39: {'RP': [5], 'E1': [6, 7, 8, 9, 10, 12, 13], 'E2': [3, 16, 18], 'E3': []}, - 40: {'RP': [5], 'E1': [4, 6, 9, 10, 11, 12, 13, 15], 'E2': [3, 16, 18], 'E3': []}, - 42: {'RP': [5], 'E1': [7, 9, 10, 11, 12, 13, 14], 'E2': [3, 16, 18], 'E3': []}, - 43: {'RP': [5], 'E1': [6, 7, 8, 9, 10, 12, 13, 14], 'E2': [3, 16, 18], 'E3': []}, - 44: {'RP': [5], 'E1': [3, 7, 8, 9, 10, 11, 12, 13], 'E2': [16, 17], 'E3': []}, - 46: {'RP': [5], 'E1': [6, 8, 9, 10, 11, 12, 13, 15], 'E2': [16, 17, 18], 'E3': []}, - 47: {'RP': [5], 'E1': [7, 8, 9, 10, 11, 12, 13, 15], 'E2': [16, 17, 18], 'E3': []}, - 48: {'RP': [5], 'E1': [7, 8, 9, 10, 11, 12, 13, 14], 'E2': [16, 17, 18], 'E3': []}, - 50: {'RP': [5], 'E1': [7, 8, 9, 10, 11, 12, 13, 14], 'E2': [15, 17], 'E3': []}, - 51: {'RP': [5], 'E1': [7, 8, 9, 10, 11, 12, 13, 14], 'E2': [15, 16], 'E3': []}, +PITCHER_ERROR_CHART: dict[int, dict[str, list[int]]] = { + 0: {"RP": [5], "E1": [], "E2": [], "E3": []}, + 4: {"RP": [5], "E1": [14], "E2": [18], "E3": []}, + 6: {"RP": [5], "E1": [3, 13], "E2": [18], "E3": []}, + 7: {"RP": [5], "E1": [3, 12], "E2": [18], "E3": []}, + 8: {"RP": [5], "E1": [6, 15, 16, 17], "E2": [3, 18], "E3": []}, + 10: {"RP": [5], "E1": [11, 15], "E2": [3, 18], "E3": []}, + 11: {"RP": [5], "E1": [12, 15, 16], "E2": [3, 18], "E3": []}, + 12: {"RP": [5], "E1": [6, 12, 15], "E2": [3, 18], "E3": []}, + 13: {"RP": [5], "E1": [11, 13], "E2": [17], "E3": []}, + 14: {"RP": [5], "E1": [7, 13, 14], "E2": [17], "E3": []}, + 15: {"RP": [5], "E1": [4, 11, 12], "E2": [17], "E3": []}, + 16: {"RP": [5], "E1": [4, 9, 12, 16], "E2": [17], "E3": []}, + 17: {"RP": [5], "E1": [3, 6, 11, 12], "E2": [17], "E3": []}, + 18: {"RP": [5], "E1": [11, 12, 14], "E2": [17], "E3": []}, + 19: {"RP": [5], "E1": [6, 9, 12, 15], "E2": [17, 18], "E3": []}, + 20: {"RP": [5], "E1": [6, 10, 11, 15], "E2": [17, 18], "E3": []}, + 21: {"RP": [5], "E1": [7, 11, 13, 14], "E2": [17, 18], "E3": []}, + 22: {"RP": [5], "E1": [8, 12, 13, 14], "E2": [17, 18], "E3": []}, + 23: {"RP": [5], "E1": [10, 11, 12, 16], "E2": [17, 18], "E3": []}, + 24: {"RP": [5], "E1": [10, 11, 12, 15], "E2": [17, 18], "E3": []}, + 26: {"RP": [5], "E1": [9, 12, 13, 14, 15], "E2": [3, 17, 18], "E3": []}, + 27: {"RP": [5], "E1": [10, 11, 12, 13], "E2": [3, 17, 18], "E3": []}, + 28: {"RP": [5], "E1": [9, 10, 11, 12], "E2": [3, 17, 18], "E3": []}, + 30: {"RP": [5], "E1": [3, 10, 11, 12, 13, 15], "E2": [16], "E3": []}, + 31: {"RP": [5], "E1": [10, 11, 12, 13, 14], "E2": [16], "E3": []}, + 33: {"RP": [5], "E1": [3, 8, 10, 11, 12, 13], "E2": [16], "E3": []}, + 34: {"RP": [5], "E1": [9, 10, 11, 12, 13], "E2": [16, 18], "E3": []}, + 35: {"RP": [5], "E1": [9, 10, 11, 12, 14, 15], "E2": [16, 18], "E3": []}, + 36: {"RP": [5], "E1": [3, 4, 6, 7, 9, 10, 11, 12], "E2": [16, 18], "E3": []}, + 38: {"RP": [5], "E1": [6, 8, 10, 11, 12, 13, 15], "E2": [16, 18], "E3": []}, + 39: {"RP": [5], "E1": [6, 7, 8, 9, 10, 12, 13], "E2": [3, 16, 18], "E3": []}, + 40: {"RP": [5], "E1": [4, 6, 9, 10, 11, 12, 13, 15], "E2": [3, 16, 18], "E3": []}, + 42: {"RP": [5], "E1": [7, 9, 10, 11, 12, 13, 14], "E2": [3, 16, 18], "E3": []}, + 43: {"RP": [5], "E1": [6, 7, 8, 9, 10, 12, 13, 14], "E2": [3, 16, 18], "E3": []}, + 44: {"RP": [5], "E1": [3, 7, 8, 9, 10, 11, 12, 13], "E2": [16, 17], "E3": []}, + 46: {"RP": [5], "E1": [6, 8, 9, 10, 11, 12, 13, 15], "E2": [16, 17, 18], "E3": []}, + 47: {"RP": [5], "E1": [7, 8, 9, 10, 11, 12, 13, 15], "E2": [16, 17, 18], "E3": []}, + 48: {"RP": [5], "E1": [7, 8, 9, 10, 11, 12, 13, 14], "E2": [16, 17, 18], "E3": []}, + 50: {"RP": [5], "E1": [7, 8, 9, 10, 11, 12, 13, 14], "E2": [15, 17], "E3": []}, + 51: {"RP": [5], "E1": [7, 8, 9, 10, 11, 12, 13, 14], "E2": [15, 16], "E3": []}, } # ============================================================================ @@ -418,9 +417,8 @@ PITCHER_ERROR_CHART: dict[int, dict[str, List[int]]] = { def get_fielders_holding_runners( - runner_bases: List[int], - batter_handedness: str -) -> List[str]: + runner_bases: list[int], batter_handedness: str +) -> list[str]: """ Determine which fielders are responsible for holding runners. @@ -437,18 +435,17 @@ def get_fielders_holding_runners( return [] holding_positions = [] - mif_vs_batter = '2B' if batter_handedness.lower() == 'r' else 'SS' + mif_vs_batter = "2B" if batter_handedness.lower() == "r" else "SS" if 1 in runner_bases: - holding_positions.append('1B') + holding_positions.append("1B") holding_positions.append(mif_vs_batter) - + if 2 in runner_bases and mif_vs_batter not in holding_positions: holding_positions.append(mif_vs_batter) - + if 3 in runner_bases: - holding_positions.append('3B') - + holding_positions.append("3B") return holding_positions @@ -458,7 +455,7 @@ def get_fielders_holding_runners( # ============================================================================ -def get_error_chart_for_position(position: str) -> dict[int, dict[str, List[int]]]: +def get_error_chart_for_position(position: str) -> dict[int, dict[str, list[int]]]: """ Get error chart for a specific position. @@ -472,15 +469,15 @@ def get_error_chart_for_position(position: str) -> dict[int, dict[str, List[int] ValueError: If position not recognized """ charts = { - 'P': PITCHER_ERROR_CHART, - 'C': CATCHER_ERROR_CHART, - '1B': FIRST_BASE_ERROR_CHART, - '2B': SECOND_BASE_ERROR_CHART, - '3B': THIRD_BASE_ERROR_CHART, - 'SS': SHORTSTOP_ERROR_CHART, - 'LF': LF_RF_ERROR_CHART, - 'RF': LF_RF_ERROR_CHART, - 'CF': CF_ERROR_CHART, + "P": PITCHER_ERROR_CHART, + "C": CATCHER_ERROR_CHART, + "1B": FIRST_BASE_ERROR_CHART, + "2B": SECOND_BASE_ERROR_CHART, + "3B": THIRD_BASE_ERROR_CHART, + "SS": SHORTSTOP_ERROR_CHART, + "LF": LF_RF_ERROR_CHART, + "RF": LF_RF_ERROR_CHART, + "CF": CF_ERROR_CHART, } if position not in charts: diff --git a/backend/app/config/league_configs.py b/backend/app/config/league_configs.py index efe7eea..0a55686 100644 --- a/backend/app/config/league_configs.py +++ b/backend/app/config/league_configs.py @@ -7,18 +7,20 @@ API endpoints, and feature flags. Author: Claude Date: 2025-10-28 """ + import logging -from typing import Dict, List, Callable +from collections.abc import Callable + from app.config.base_config import BaseGameConfig from app.config.common_x_check_tables import ( + CATCHER_DEFENSE_TABLE, INFIELD_DEFENSE_TABLE, OUTFIELD_DEFENSE_TABLE, - CATCHER_DEFENSE_TABLE, - get_fielders_holding_runners, get_error_chart_for_position, + get_fielders_holding_runners, ) -logger = logging.getLogger(f'{__name__}.LeagueConfigs') +logger = logging.getLogger(f"{__name__}.LeagueConfigs") class SbaConfig(BaseGameConfig): @@ -37,17 +39,21 @@ class SbaConfig(BaseGameConfig): player_selection_mode: str = "manual" # Players manually select from chart # X-Check defense tables (shared common tables) - x_check_defense_tables: Dict[str, List[List[str]]] = { - 'infield': INFIELD_DEFENSE_TABLE, - 'outfield': OUTFIELD_DEFENSE_TABLE, - 'catcher': CATCHER_DEFENSE_TABLE, + x_check_defense_tables: dict[str, list[list[str]]] = { + "infield": INFIELD_DEFENSE_TABLE, + "outfield": OUTFIELD_DEFENSE_TABLE, + "catcher": CATCHER_DEFENSE_TABLE, } # X-Check error chart lookup function - x_check_error_charts: Callable[[str], dict[int, dict[str, List[int]]]] = get_error_chart_for_position + x_check_error_charts: Callable[[str], dict[int, dict[str, list[int]]]] = ( + get_error_chart_for_position + ) # Holding runners function - x_check_holding_runners: Callable[[List[int], str], List[str]] = get_fielders_holding_runners + x_check_holding_runners: Callable[[list[int], str], list[str]] = ( + get_fielders_holding_runners + ) def get_result_chart_name(self) -> str: """Use SBA standard result chart.""" @@ -100,17 +106,21 @@ class PdConfig(BaseGameConfig): wpa_calculation: bool = True # Calculate win probability added # X-Check defense tables (shared common tables) - x_check_defense_tables: Dict[str, List[List[str]]] = { - 'infield': INFIELD_DEFENSE_TABLE, - 'outfield': OUTFIELD_DEFENSE_TABLE, - 'catcher': CATCHER_DEFENSE_TABLE, + x_check_defense_tables: dict[str, list[list[str]]] = { + "infield": INFIELD_DEFENSE_TABLE, + "outfield": OUTFIELD_DEFENSE_TABLE, + "catcher": CATCHER_DEFENSE_TABLE, } # X-Check error chart lookup function - x_check_error_charts: Callable[[str], dict[int, dict[str, List[int]]]] = get_error_chart_for_position + x_check_error_charts: Callable[[str], dict[int, dict[str, list[int]]]] = ( + get_error_chart_for_position + ) # Holding runners function - x_check_holding_runners: Callable[[List[int], str], List[str]] = get_fielders_holding_runners + x_check_holding_runners: Callable[[list[int], str], list[str]] = ( + get_fielders_holding_runners + ) def get_result_chart_name(self) -> str: """Use PD standard result chart.""" @@ -142,10 +152,7 @@ class PdConfig(BaseGameConfig): # ==================== Config Registry ==================== -LEAGUE_CONFIGS: Dict[str, BaseGameConfig] = { - "sba": SbaConfig(), - "pd": PdConfig() -} +LEAGUE_CONFIGS: dict[str, BaseGameConfig] = {"sba": SbaConfig(), "pd": PdConfig()} def get_league_config(league_id: str) -> BaseGameConfig: @@ -164,7 +171,9 @@ def get_league_config(league_id: str) -> BaseGameConfig: 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())}") + 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 index a8458d5..c53d72e 100644 --- a/backend/app/config/result_charts.py +++ b/backend/app/config/result_charts.py @@ -21,18 +21,19 @@ This module defines: Author: Claude Date: 2025-10-28, Updated 2025-10-30 """ + import logging import random from abc import ABC, abstractmethod from enum import Enum -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: + from app.core.dice import AbRoll from app.models.game_models import GameState from app.models.player_models import BasePlayer - from app.core.dice import AbRoll -logger = logging.getLogger(f'{__name__}') +logger = logging.getLogger(f"{__name__}") class PlayOutcome(str, Enum): @@ -56,23 +57,23 @@ class PlayOutcome(str, Enum): GROUNDBALL_C = "groundball_c" # Standard groundout # Flyouts - 4 variants for different trajectories/depths - FLYOUT_A = "flyout_a" # Deep - all runners advance - FLYOUT_B = "flyout_b" # Medium - R3 scores, R2 DECIDE, R1 holds + FLYOUT_A = "flyout_a" # Deep - all runners advance + FLYOUT_B = "flyout_b" # Medium - R3 scores, R2 DECIDE, R1 holds FLYOUT_BQ = "flyout_bq" # Medium-shallow (fly(b)?) - R3 DECIDE, R2 holds, R1 holds - FLYOUT_C = "flyout_c" # Shallow - all runners hold + FLYOUT_C = "flyout_c" # Shallow - all runners hold LINEOUT = "lineout" POPOUT = "popout" # ==================== Hits ==================== # Singles - variants for different advancement rules - SINGLE_1 = "single_1" # Single with standard advancement - SINGLE_2 = "single_2" # Single with enhanced advancement + SINGLE_1 = "single_1" # Single with standard advancement + SINGLE_2 = "single_2" # Single with enhanced advancement SINGLE_UNCAPPED = "single_uncapped" # si(cf) - pitching card, decision tree # Doubles - variants for batter advancement - DOUBLE_2 = "double_2" # Double to 2nd base - DOUBLE_3 = "double_3" # Double to 3rd base (extra advancement) + DOUBLE_2 = "double_2" # Double to 2nd base + DOUBLE_3 = "double_3" # Double to 3rd base (extra advancement) DOUBLE_UNCAPPED = "double_uncapped" # do(cf) - pitching card, decision tree TRIPLE = "triple" @@ -93,39 +94,53 @@ class PlayOutcome(str, Enum): # ==================== 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 + 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" # Play.balk = 1 / Logged during steal attempt - PICK_OFF = "pick_off" # Play.pick_off = 1 / Runner picked off + BALK = "balk" # Play.balk = 1 / Logged during steal attempt + PICK_OFF = "pick_off" # Play.pick_off = 1 / Runner picked off # ==================== Ballpark Power ==================== - 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) + 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_1, self.SINGLE_2, self.SINGLE_UNCAPPED, - self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED, - self.TRIPLE, self.HOMERUN, - self.BP_HOMERUN, self.BP_SINGLE + self.SINGLE_1, + self.SINGLE_2, + self.SINGLE_UNCAPPED, + self.DOUBLE_2, + self.DOUBLE_3, + self.DOUBLE_UNCAPPED, + self.TRIPLE, + self.HOMERUN, + self.BP_HOMERUN, + self.BP_SINGLE, } def is_out(self) -> bool: """Check if outcome records an out.""" return self in { self.STRIKEOUT, - self.GROUNDBALL_A, self.GROUNDBALL_B, self.GROUNDBALL_C, - self.FLYOUT_A, self.FLYOUT_B, self.FLYOUT_BQ, self.FLYOUT_C, - self.LINEOUT, self.POPOUT, - self.CAUGHT_STEALING, self.PICK_OFF, - self.BP_FLYOUT, self.BP_LINEOUT + self.GROUNDBALL_A, + self.GROUNDBALL_B, + self.GROUNDBALL_C, + self.FLYOUT_A, + self.FLYOUT_B, + self.FLYOUT_BQ, + self.FLYOUT_C, + self.LINEOUT, + self.POPOUT, + self.CAUGHT_STEALING, + self.PICK_OFF, + self.BP_FLYOUT, + self.BP_LINEOUT, } def is_walk(self) -> bool: @@ -147,16 +162,23 @@ class PlayOutcome(str, Enum): 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 + 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_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED, - self.TRIPLE, self.HOMERUN, self.BP_HOMERUN + self.DOUBLE_2, + self.DOUBLE_3, + self.DOUBLE_UNCAPPED, + self.TRIPLE, + self.HOMERUN, + self.BP_HOMERUN, } def is_x_check(self) -> bool: @@ -175,14 +197,13 @@ class PlayOutcome(str, Enum): """ if self in {self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED, self.BP_SINGLE}: return 1 - elif self in {self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED}: + if self in {self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED}: return 2 - elif self == self.TRIPLE: + if self == self.TRIPLE: return 3 - elif self in {self.HOMERUN, self.BP_HOMERUN}: + if self in {self.HOMERUN, self.BP_HOMERUN}: return 4 - else: - return 0 + return 0 def requires_hit_location(self) -> bool: """ @@ -206,7 +227,7 @@ class PlayOutcome(str, Enum): self.FLYOUT_C, # Uncapped hits - location determines defender used in interactive play self.SINGLE_UNCAPPED, - self.DOUBLE_UNCAPPED + self.DOUBLE_UNCAPPED, } @@ -214,10 +235,11 @@ class PlayOutcome(str, Enum): # HIT LOCATION HELPER # ============================================================================ + def calculate_hit_location( outcome: PlayOutcome, batter_handedness: str, -) -> Optional[str]: +) -> str | None: """ Calculate hit location based on outcome and batter handedness. @@ -256,46 +278,46 @@ def calculate_hit_location( if is_groundball: # Infield locations: 1B, 2B, SS, 3B, P, C if roll < 0.45: # Pull side - if batter_handedness == 'R': + if batter_handedness == "R": # RHB pulls left (3B, SS) - return random.choice(['3B', 'SS']) - else: # LHB - # LHB pulls right (1B, 2B) - return random.choice(['1B', '2B']) - elif roll < 0.80: # Center (45% + 35% = 80%) + return random.choice(["3B", "SS"]) + # LHB + # LHB pulls right (1B, 2B) + return random.choice(["1B", "2B"]) + if roll < 0.80: # Center (45% + 35% = 80%) # Up the middle (2B, SS, P, C) - return random.choice(['2B', 'SS', 'P', 'C']) - else: # Opposite field (20%) - if batter_handedness == 'R': - # RHB opposite is right (1B, 2B) - return random.choice(['1B', '2B']) - else: # LHB - # LHB opposite is left (3B, SS) - return random.choice(['3B', 'SS']) - else: - # Fly ball locations: LF, CF, RF - if roll < 0.45: # Pull side - if batter_handedness == 'R': - # RHB pulls left - return 'LF' - else: # LHB - # LHB pulls right - return 'RF' - elif roll < 0.80: # Center - return 'CF' - else: # Opposite field - if batter_handedness == 'R': - # RHB opposite is right - return 'RF' - else: # LHB - # LHB opposite is left - return 'LF' + return random.choice(["2B", "SS", "P", "C"]) + # Opposite field (20%) + if batter_handedness == "R": + # RHB opposite is right (1B, 2B) + return random.choice(["1B", "2B"]) + # LHB + # LHB opposite is left (3B, SS) + return random.choice(["3B", "SS"]) + # Fly ball locations: LF, CF, RF + if roll < 0.45: # Pull side + if batter_handedness == "R": + # RHB pulls left + return "LF" + # LHB + # LHB pulls right + return "RF" + if roll < 0.80: # Center + return "CF" + # Opposite field + if batter_handedness == "R": + # RHB opposite is right + return "RF" + # LHB + # LHB opposite is left + return "LF" # ============================================================================ # RESULT CHART ABSTRACTION # ============================================================================ + class ResultChart(ABC): """ Abstract base class for result chart implementations. @@ -319,11 +341,11 @@ class ResultChart(ABC): @abstractmethod def get_outcome( self, - roll: 'AbRoll', - state: 'GameState', - batter: 'BasePlayer', - pitcher: 'BasePlayer' - ) -> tuple[PlayOutcome, Optional[str]]: + roll: "AbRoll", + state: "GameState", + batter: "BasePlayer", + pitcher: "BasePlayer", + ) -> tuple[PlayOutcome, str | None]: """ Determine play outcome and hit location. @@ -361,11 +383,11 @@ class ManualResultChart(ResultChart): def get_outcome( self, - roll: 'AbRoll', - state: 'GameState', - batter: 'BasePlayer', - pitcher: 'BasePlayer' - ) -> tuple[PlayOutcome, Optional[str]]: + roll: "AbRoll", + state: "GameState", + batter: "BasePlayer", + pitcher: "BasePlayer", + ) -> tuple[PlayOutcome, str | None]: """ Not implemented for manual mode. @@ -399,10 +421,8 @@ class PdAutoResultChart(ResultChart): """ def _select_card( - self, - batter: 'BasePlayer', - pitcher: 'BasePlayer' - ) -> tuple[Optional[dict], bool]: + self, batter: "BasePlayer", pitcher: "BasePlayer" + ) -> tuple[dict | None, bool]: """ Flip coin to choose batting or pitching card. @@ -417,35 +437,39 @@ class PdAutoResultChart(ResultChart): """ # Flip coin coin_flip = random.randint(1, 2) # 1 or 2 (50/50) - use_batting_card = (coin_flip == 1) + use_batting_card = coin_flip == 1 if use_batting_card: # Use batting card - need pitcher handedness - batting_card = getattr(batter, 'batting_card', None) + batting_card = getattr(batter, "batting_card", None) if not batting_card or not batting_card.ratings: - logger.warning(f"Batter {batter.name} missing batting card, falling back to pitching") + logger.warning( + f"Batter {batter.name} missing batting card, falling back to pitching" + ) use_batting_card = False else: - pitcher_hand = getattr(getattr(pitcher, 'pitching_card', None), 'hand', 'R') - rating = batting_card.ratings.get(pitcher_hand, batting_card.ratings.get('R')) + pitcher_hand = getattr( + getattr(pitcher, "pitching_card", None), "hand", "R" + ) + rating = batting_card.ratings.get( + pitcher_hand, batting_card.ratings.get("R") + ) logger.debug(f"Selected batting card vs {pitcher_hand}HP") return (rating, True) # Use pitching card - need batter handedness - pitching_card = getattr(pitcher, 'pitching_card', None) + pitching_card = getattr(pitcher, "pitching_card", None) if not pitching_card or not pitching_card.ratings: logger.warning(f"Pitcher {pitcher.name} missing pitching card") return (None, False) - batter_hand = getattr(getattr(batter, 'batting_card', None), 'hand', 'R') - rating = pitching_card.ratings.get(batter_hand, pitching_card.ratings.get('R')) + batter_hand = getattr(getattr(batter, "batting_card", None), "hand", "R") + rating = pitching_card.ratings.get(batter_hand, pitching_card.ratings.get("R")) logger.debug(f"Selected pitching card vs {batter_hand}HB") return (rating, False) def _build_distribution( - self, - rating: dict, - is_batting_card: bool + self, rating: dict, is_batting_card: bool ) -> list[tuple[float, PlayOutcome]]: """ Build cumulative distribution from rating percentages. @@ -462,17 +486,17 @@ class PdAutoResultChart(ResultChart): # Common outcomes for both cards common_outcomes = [ - ('homerun', PlayOutcome.HOMERUN), - ('bp_homerun', PlayOutcome.BP_HOMERUN), - ('triple', PlayOutcome.TRIPLE), - ('double_three', PlayOutcome.DOUBLE_3), - ('double_two', PlayOutcome.DOUBLE_2), - ('single_two', PlayOutcome.SINGLE_2), - ('single_one', PlayOutcome.SINGLE_1), - ('bp_single', PlayOutcome.BP_SINGLE), - ('hbp', PlayOutcome.HIT_BY_PITCH), - ('walk', PlayOutcome.WALK), - ('strikeout', PlayOutcome.STRIKEOUT), + ("homerun", PlayOutcome.HOMERUN), + ("bp_homerun", PlayOutcome.BP_HOMERUN), + ("triple", PlayOutcome.TRIPLE), + ("double_three", PlayOutcome.DOUBLE_3), + ("double_two", PlayOutcome.DOUBLE_2), + ("single_two", PlayOutcome.SINGLE_2), + ("single_one", PlayOutcome.SINGLE_1), + ("bp_single", PlayOutcome.BP_SINGLE), + ("hbp", PlayOutcome.HIT_BY_PITCH), + ("walk", PlayOutcome.WALK), + ("strikeout", PlayOutcome.STRIKEOUT), ] # Add common outcomes @@ -486,17 +510,17 @@ class PdAutoResultChart(ResultChart): if is_batting_card: # Batting card specific batting_specific = [ - ('double_pull', PlayOutcome.DOUBLE_2), # Map pull to double_2 - ('single_center', PlayOutcome.SINGLE_1), # Map center to single_1 - ('lineout', PlayOutcome.LINEOUT), - ('popout', PlayOutcome.POPOUT), - ('flyout_a', PlayOutcome.FLYOUT_A), - ('flyout_bq', PlayOutcome.FLYOUT_B), - ('flyout_lf_b', PlayOutcome.FLYOUT_B), - ('flyout_rf_b', PlayOutcome.FLYOUT_B), - ('groundout_a', PlayOutcome.GROUNDBALL_A), - ('groundout_b', PlayOutcome.GROUNDBALL_B), - ('groundout_c', PlayOutcome.GROUNDBALL_C), + ("double_pull", PlayOutcome.DOUBLE_2), # Map pull to double_2 + ("single_center", PlayOutcome.SINGLE_1), # Map center to single_1 + ("lineout", PlayOutcome.LINEOUT), + ("popout", PlayOutcome.POPOUT), + ("flyout_a", PlayOutcome.FLYOUT_A), + ("flyout_bq", PlayOutcome.FLYOUT_B), + ("flyout_lf_b", PlayOutcome.FLYOUT_B), + ("flyout_rf_b", PlayOutcome.FLYOUT_B), + ("groundout_a", PlayOutcome.GROUNDBALL_A), + ("groundout_b", PlayOutcome.GROUNDBALL_B), + ("groundout_c", PlayOutcome.GROUNDBALL_C), ] for field, outcome in batting_specific: percentage = getattr(rating, field, 0.0) @@ -506,13 +530,19 @@ class PdAutoResultChart(ResultChart): else: # Pitching card specific pitching_specific = [ - ('double_cf', PlayOutcome.DOUBLE_UNCAPPED), # Pitching has uncapped double - ('single_center', PlayOutcome.SINGLE_UNCAPPED), # Pitching has uncapped single - ('flyout_lf_b', PlayOutcome.FLYOUT_B), - ('flyout_cf_b', PlayOutcome.FLYOUT_B), - ('flyout_rf_b', PlayOutcome.FLYOUT_B), - ('groundout_a', PlayOutcome.GROUNDBALL_A), - ('groundout_b', PlayOutcome.GROUNDBALL_B), + ( + "double_cf", + PlayOutcome.DOUBLE_UNCAPPED, + ), # Pitching has uncapped double + ( + "single_center", + PlayOutcome.SINGLE_UNCAPPED, + ), # Pitching has uncapped single + ("flyout_lf_b", PlayOutcome.FLYOUT_B), + ("flyout_cf_b", PlayOutcome.FLYOUT_B), + ("flyout_rf_b", PlayOutcome.FLYOUT_B), + ("groundout_a", PlayOutcome.GROUNDBALL_A), + ("groundout_b", PlayOutcome.GROUNDBALL_B), ] for field, outcome in pitching_specific: percentage = getattr(rating, field, 0.0) @@ -526,7 +556,9 @@ class PdAutoResultChart(ResultChart): logger.debug(f"Built distribution with cumulative total: {cumulative:.1f}%") return distribution - def _select_outcome(self, distribution: list[tuple[float, PlayOutcome]]) -> PlayOutcome: + def _select_outcome( + self, distribution: list[tuple[float, PlayOutcome]] + ) -> PlayOutcome: """ Roll 1d100 and select outcome from cumulative distribution. @@ -555,11 +587,11 @@ class PdAutoResultChart(ResultChart): def get_outcome( self, - roll: 'AbRoll', - state: 'GameState', - batter: 'BasePlayer', - pitcher: 'BasePlayer' - ) -> tuple[PlayOutcome, Optional[str]]: + roll: "AbRoll", + state: "GameState", + batter: "BasePlayer", + pitcher: "BasePlayer", + ) -> tuple[PlayOutcome, str | None]: """ Auto-generate outcome from PD card ratings. @@ -590,7 +622,7 @@ class PdAutoResultChart(ResultChart): outcome = self._select_outcome(distribution) # Calculate hit location if needed - batter_hand = getattr(getattr(batter, 'batting_card', None), 'hand', 'R') + batter_hand = getattr(getattr(batter, "batting_card", None), "hand", "R") location = calculate_hit_location(outcome, batter_hand) logger.info( diff --git a/backend/app/config/test_game_data.py b/backend/app/config/test_game_data.py index 5207bd2..1a6626a 100644 --- a/backend/app/config/test_game_data.py +++ b/backend/app/config/test_game_data.py @@ -32,13 +32,23 @@ HOME_TEAM = { # Pitcher: Zac Gallen AWAY_LINEUP = [ # Batters (batting_order 1-9) - {"player_id": 12288, "name": "Ronald Acuna Jr", "position": "RF", "batting_order": 1}, + { + "player_id": 12288, + "name": "Ronald Acuna Jr", + "position": "RF", + "batting_order": 1, + }, {"player_id": 12395, "name": "Trea Turner", "position": "SS", "batting_order": 2}, {"player_id": 11483, "name": "Alec Burleson", "position": "1B", "batting_order": 3}, {"player_id": 11487, "name": "Alex Bregman", "position": "3B", "batting_order": 4}, {"player_id": 12262, "name": "Ramon Urias", "position": "2B", "batting_order": 5}, {"player_id": 12356, "name": "Steven Kwan", "position": "LF", "batting_order": 6}, - {"player_id": 12339, "name": "Shea Langeliers", "position": "C", "batting_order": 7}, + { + "player_id": 12339, + "name": "Shea Langeliers", + "position": "C", + "batting_order": 7, + }, {"player_id": 11706, "name": "David Fry", "position": "DH", "batting_order": 8}, {"player_id": 11545, "name": "Blake Perkins", "position": "CF", "batting_order": 9}, # Starting Pitcher @@ -54,12 +64,22 @@ HOME_LINEUP = [ {"player_id": 12110, "name": "Luke Raley", "position": "LF", "batting_order": 3}, {"player_id": 12135, "name": "Matt Chapman", "position": "3B", "batting_order": 4}, {"player_id": 11768, "name": "Eric Haase", "position": "C", "batting_order": 5}, - {"player_id": 11523, "name": "Anthony Santander", "position": "RF", "batting_order": 6}, + { + "player_id": 11523, + "name": "Anthony Santander", + "position": "RF", + "batting_order": 6, + }, {"player_id": 11611, "name": "Cal Raleigh", "position": "DH", "batting_order": 7}, {"player_id": 12250, "name": "Pete Alonso", "position": "1B", "batting_order": 8}, {"player_id": 11927, "name": "JJ Bleday", "position": "CF", "batting_order": 9}, # Starting Pitcher - {"player_id": 12366, "name": "Tanner Houck", "position": "P", "batting_order": None}, + { + "player_id": 12366, + "name": "Tanner Houck", + "position": "P", + "batting_order": None, + }, ] @@ -94,7 +114,6 @@ def get_lineup_for_team(team_id: int) -> list[dict]: """ if team_id == 499: return AWAY_LINEUP - elif team_id == 544: + if team_id == 544: return HOME_LINEUP - else: - raise ValueError(f"Unknown team_id: {team_id}. Use 499 (WV) or 544 (CLS)") + raise ValueError(f"Unknown team_id: {team_id}. Use 499 (WV) or 544 (CLS)") diff --git a/backend/app/core/ai_opponent.py b/backend/app/core/ai_opponent.py index 6b95ce3..3cd977e 100644 --- a/backend/app/core/ai_opponent.py +++ b/backend/app/core/ai_opponent.py @@ -12,11 +12,10 @@ Date: 2025-10-29 """ import logging -from typing import Optional -from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision +from app.models.game_models import DefensiveDecision, GameState, OffensiveDecision -logger = logging.getLogger(f'{__name__}.AIOpponent') +logger = logging.getLogger(f"{__name__}.AIOpponent") class AIOpponent: @@ -41,10 +40,7 @@ class AIOpponent: self.difficulty = difficulty logger.info(f"AIOpponent initialized with difficulty: {difficulty}") - async def generate_defensive_decision( - self, - state: GameState - ) -> DefensiveDecision: + async def generate_defensive_decision(self, state: GameState) -> DefensiveDecision: """ Generate defensive decision for AI-controlled fielding team. @@ -70,7 +66,7 @@ class AIOpponent: alignment="normal", infield_depth="normal", outfield_depth="normal", - hold_runners=[] + hold_runners=[], ) # TODO Week 9: Add actual AI logic @@ -79,13 +75,12 @@ class AIOpponent: # if state.is_runner_on_third() and state.outs < 2: # decision.infield_depth = "in" - logger.info(f"AI defensive decision: IF: {decision.infield_depth}, OF: {decision.outfield_depth}") + logger.info( + f"AI defensive decision: IF: {decision.infield_depth}, OF: {decision.outfield_depth}" + ) return decision - async def generate_offensive_decision( - self, - state: GameState - ) -> OffensiveDecision: + async def generate_offensive_decision(self, state: GameState) -> OffensiveDecision: """ Generate offensive decision for AI-controlled batting team. @@ -108,10 +103,7 @@ class AIOpponent: # Week 7 stub: Simple default decision decision = OffensiveDecision( - approach="normal", - steal_attempts=[], - hit_and_run=False, - bunt_attempt=False + approach="normal", steal_attempts=[], hit_and_run=False, bunt_attempt=False ) # TODO Week 9: Add actual AI logic @@ -120,7 +112,9 @@ class AIOpponent: # if self._should_attempt_steal(state): # decision.steal_attempts = [2] - logger.info(f"AI offensive decision: steal={decision.steal_attempts}, hr={decision.hit_and_run}") + logger.info( + f"AI offensive decision: steal={decision.steal_attempts}, hr={decision.hit_and_run}" + ) return decision def _should_attempt_steal(self, state: GameState) -> bool: diff --git a/backend/app/core/dice.py b/backend/app/core/dice.py index b2001b2..1352f11 100644 --- a/backend/app/core/dice.py +++ b/backend/app/core/dice.py @@ -4,17 +4,23 @@ Cryptographically Secure Dice Rolling System Implements secure random number generation for baseball gameplay with support for all roll types: at-bat, jump, fielding, and generic d20. """ + import logging import secrets -from typing import List, Optional, Dict from uuid import UUID + import pendulum from app.core.roll_types import ( - RollType, DiceRoll, AbRoll, JumpRoll, FieldingRoll, D20Roll + AbRoll, + D20Roll, + DiceRoll, + FieldingRoll, + JumpRoll, + RollType, ) -logger = logging.getLogger(f'{__name__}.DiceSystem') +logger = logging.getLogger(f"{__name__}.DiceSystem") class DiceSystem: @@ -26,7 +32,7 @@ class DiceSystem: """ def __init__(self): - self._roll_history: List[DiceRoll] = [] + self._roll_history: list[DiceRoll] = [] def _generate_roll_id(self) -> str: """Generate unique cryptographic roll ID""" @@ -47,9 +53,9 @@ class DiceSystem: def roll_ab( self, league_id: str, - game_id: Optional[UUID] = None, - team_id: Optional[int] = None, - player_id: Optional[int] = None + game_id: UUID | None = None, + team_id: int | None = None, + player_id: int | None = None, ) -> AbRoll: """ Roll at-bat dice: 1d6 + 2d6 + 2d20 @@ -78,7 +84,7 @@ class DiceSystem: roll_id=self._generate_roll_id(), roll_type=RollType.AB, league_id=league_id, - timestamp=pendulum.now('UTC'), + timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, @@ -89,20 +95,26 @@ class DiceSystem: resolution_d20=resolution_d20, d6_two_total=0, # Calculated in __post_init__ check_wild_pitch=False, - check_passed_ball=False + check_passed_ball=False, ) self._roll_history.append(roll) - logger.info(f"AB roll: {roll}", extra={"roll_id": roll.roll_id, "game_id": str(game_id) if game_id else None}) + logger.info( + f"AB roll: {roll}", + extra={ + "roll_id": roll.roll_id, + "game_id": str(game_id) if game_id else None, + }, + ) return roll def roll_jump( self, league_id: str, - game_id: Optional[UUID] = None, - team_id: Optional[int] = None, - player_id: Optional[int] = None + game_id: UUID | None = None, + team_id: int | None = None, + player_id: int | None = None, ) -> JumpRoll: """ Roll jump dice for stolen base attempt @@ -130,7 +142,9 @@ class DiceSystem: if check_roll == 1 or check_roll == 2: # Pickoff or balk - roll resolution die resolution_roll = self._roll_d20() - logger.debug(f"Jump check roll {check_roll}: {'pickoff' if check_roll == 1 else 'balk'}") + logger.debug( + f"Jump check roll {check_roll}: {'pickoff' if check_roll == 1 else 'balk'}" + ) else: # Normal jump - roll 2d6 jump_dice_a = self._roll_d6() @@ -141,18 +155,24 @@ class DiceSystem: roll_id=self._generate_roll_id(), roll_type=RollType.JUMP, league_id=league_id, - timestamp=pendulum.now('UTC'), + timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, check_roll=check_roll, jump_dice_a=jump_dice_a, jump_dice_b=jump_dice_b, - resolution_roll=resolution_roll + resolution_roll=resolution_roll, ) self._roll_history.append(roll) - logger.info(f"Jump roll: {roll}", extra={"roll_id": roll.roll_id, "game_id": str(game_id) if game_id else None}) + logger.info( + f"Jump roll: {roll}", + extra={ + "roll_id": roll.roll_id, + "game_id": str(game_id) if game_id else None, + }, + ) return roll @@ -160,9 +180,9 @@ class DiceSystem: self, position: str, league_id: str, - game_id: Optional[UUID] = None, - team_id: Optional[int] = None, - player_id: Optional[int] = None + game_id: UUID | None = None, + team_id: int | None = None, + player_id: int | None = None, ) -> FieldingRoll: """ Roll fielding check: 1d20 (range) + 3d6 (error) + 1d100 (rare play) @@ -180,9 +200,11 @@ class DiceSystem: Raises: ValueError: If position is invalid """ - VALID_POSITIONS = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] + VALID_POSITIONS = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"] if position not in VALID_POSITIONS: - raise ValueError(f"Invalid position: {position}. Must be one of {VALID_POSITIONS}") + raise ValueError( + f"Invalid position: {position}. Must be one of {VALID_POSITIONS}" + ) d20 = self._roll_d20() d6_one = self._roll_d6() @@ -194,7 +216,7 @@ class DiceSystem: roll_id=self._generate_roll_id(), roll_type=RollType.FIELDING, league_id=league_id, - timestamp=pendulum.now('UTC'), + timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, @@ -205,7 +227,7 @@ class DiceSystem: d6_three=d6_three, d100=d100, error_total=0, # Calculated in __post_init__ - _is_rare_play=False + _is_rare_play=False, ) self._roll_history.append(roll) @@ -215,8 +237,8 @@ class DiceSystem: "roll_id": roll.roll_id, "position": position, "is_rare": roll.is_rare_play, - "game_id": str(game_id) if game_id else None - } + "game_id": str(game_id) if game_id else None, + }, ) return roll @@ -224,9 +246,9 @@ class DiceSystem: def roll_d20( self, league_id: str, - game_id: Optional[UUID] = None, - team_id: Optional[int] = None, - player_id: Optional[int] = None + game_id: UUID | None = None, + team_id: int | None = None, + player_id: int | None = None, ) -> D20Roll: """ Roll single d20 (modifiers applied to target, not roll) @@ -246,24 +268,30 @@ class DiceSystem: roll_id=self._generate_roll_id(), roll_type=RollType.D20, league_id=league_id, - timestamp=pendulum.now('UTC'), + timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, - roll=base_roll + roll=base_roll, ) self._roll_history.append(roll) - logger.info(f"D20 roll: {roll}", extra={"roll_id": roll.roll_id, "game_id": str(game_id) if game_id else None}) + logger.info( + f"D20 roll: {roll}", + extra={ + "roll_id": roll.roll_id, + "game_id": str(game_id) if game_id else None, + }, + ) return roll def get_roll_history( self, - roll_type: Optional[RollType] = None, - game_id: Optional[UUID] = None, - limit: int = 100 - ) -> List[DiceRoll]: + roll_type: RollType | None = None, + game_id: UUID | None = None, + limit: int = 100, + ) -> list[DiceRoll]: """ Get roll history with optional filtering @@ -286,10 +314,8 @@ class DiceSystem: return filtered[-limit:] def get_rolls_since( - self, - game_id: UUID, - since_timestamp: pendulum.DateTime - ) -> List[DiceRoll]: + self, game_id: UUID, since_timestamp: pendulum.DateTime + ) -> list[DiceRoll]: """ Get all rolls for a game since a specific timestamp @@ -303,7 +329,8 @@ class DiceSystem: List of DiceRoll objects for game since timestamp """ return [ - roll for roll in self._roll_history + roll + for roll in self._roll_history if roll.game_id == game_id and roll.timestamp >= since_timestamp ] @@ -319,10 +346,7 @@ class DiceSystem: """ return any(r.roll_id == roll_id for r in self._roll_history) - def get_distribution_stats( - self, - roll_type: Optional[RollType] = None - ) -> Dict: + def get_distribution_stats(self, roll_type: RollType | None = None) -> dict: """ Get distribution statistics for testing @@ -340,10 +364,7 @@ class DiceSystem: if not rolls_to_analyze: return {} - stats = { - "total_rolls": len(rolls_to_analyze), - "by_type": {} - } + stats = {"total_rolls": len(rolls_to_analyze), "by_type": {}} # Count by type for roll in rolls_to_analyze: @@ -363,7 +384,9 @@ class DiceSystem: """Get dice system statistics""" return { "total_rolls": len(self._roll_history), - "by_type": self.get_distribution_stats()["by_type"] if self._roll_history else {} + "by_type": self.get_distribution_stats()["by_type"] + if self._roll_history + else {}, } diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index 5b6f22d..6687186 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -9,29 +9,28 @@ Phase 3: Enhanced with async decision workflow and AI opponent integration. Author: Claude Date: 2025-10-24 """ + import asyncio import logging from uuid import UUID -from typing import Optional, List -import pendulum -from app.core.state_manager import state_manager -from app.core.play_resolver import PlayResolver, PlayResult +import pendulum +from sqlalchemy.ext.asyncio import AsyncSession + from app.config import PlayOutcome, get_league_config -from app.core.validators import game_validator, ValidationError -from app.core.dice import dice_system from app.core.ai_opponent import ai_opponent +from app.core.dice import dice_system +from app.core.play_resolver import PlayResolver, PlayResult +from app.core.state_manager import state_manager +from app.core.validators import ValidationError, game_validator from app.database.operations import DatabaseOperations from app.database.session import AsyncSessionLocal -from sqlalchemy.ext.asyncio import AsyncSession -from app.models.game_models import ( - GameState, DefensiveDecision, OffensiveDecision -) -from app.services.position_rating_service import position_rating_service -from app.services.lineup_service import lineup_service +from app.models.game_models import DefensiveDecision, GameState, OffensiveDecision from app.services import PlayStatCalculator +from app.services.lineup_service import lineup_service +from app.services.position_rating_service import position_rating_service -logger = logging.getLogger(f'{__name__}.GameEngine') +logger = logging.getLogger(f"{__name__}.GameEngine") class GameEngine: @@ -43,7 +42,7 @@ class GameEngine: def __init__(self): self.db_ops = DatabaseOperations() # Track rolls per inning for batch saving - self._rolls_this_inning: dict[UUID, List] = {} + self._rolls_this_inning: dict[UUID, list] = {} # Locks for concurrent decision submission (prevents race conditions) self._game_locks: dict[UUID, asyncio.Lock] = {} @@ -54,10 +53,7 @@ class GameEngine: return self._game_locks[game_id] async def _load_position_ratings_for_lineup( - self, - game_id: UUID, - team_id: int, - league_id: str + self, game_id: UUID, team_id: int, league_id: str ) -> None: """ Load position ratings for all players in a team's lineup. @@ -75,7 +71,9 @@ class GameEngine: # Check if league supports ratings league_config = get_league_config(league_id) if not league_config.supports_position_ratings(): - logger.debug(f"League {league_id} doesn't support position ratings, skipping") + logger.debug( + f"League {league_id} doesn't support position ratings, skipping" + ) return # Get lineup from cache @@ -84,7 +82,9 @@ class GameEngine: logger.warning(f"No lineup found for team {team_id} in game {game_id}") return - logger.info(f"Loading position ratings for team {team_id} lineup ({len(lineup.players)} players)") + logger.info( + f"Loading position ratings for team {team_id} lineup ({len(lineup.players)} players)" + ) # Load ratings for each player loaded_count = 0 @@ -94,7 +94,7 @@ class GameEngine: rating = await position_rating_service.get_rating_for_position( card_id=player.card_id, position=player.position, - league_id=league_id + league_id=league_id, ) if rating: @@ -114,7 +114,9 @@ class GameEngine: f"Failed to load rating for card {player.card_id} at {player.position}: {e}" ) - logger.info(f"Loaded {loaded_count}/{len(lineup.players)} position ratings for team {team_id}") + logger.info( + f"Loaded {loaded_count}/{len(lineup.players)} position ratings for team {team_id}" + ) async def start_game(self, game_id: UUID) -> GameState: """ @@ -136,8 +138,12 @@ class GameEngine: # HARD REQUIREMENT: Validate both lineups are complete # At game start, we validate BOTH teams (exception to the "defensive only" rule) - home_lineup = await self.db_ops.get_active_lineup(state.game_id, state.home_team_id) - away_lineup = await self.db_ops.get_active_lineup(state.game_id, state.away_team_id) + home_lineup = await self.db_ops.get_active_lineup( + state.game_id, state.home_team_id + ) + away_lineup = await self.db_ops.get_active_lineup( + state.game_id, state.away_team_id + ) # Check minimum 9 players per team if not home_lineup or len(home_lineup) < 9: @@ -165,14 +171,10 @@ class GameEngine: # Phase 3E-Main: Load position ratings for both teams (PD league only) await self._load_position_ratings_for_lineup( - game_id=game_id, - team_id=state.home_team_id, - league_id=state.league_id + game_id=game_id, team_id=state.home_team_id, league_id=state.league_id ) await self._load_position_ratings_for_lineup( - game_id=game_id, - team_id=state.away_team_id, - league_id=state.league_id + game_id=game_id, team_id=state.away_team_id, league_id=state.league_id ) # Mark as active @@ -197,7 +199,7 @@ class GameEngine: half="top", home_score=0, away_score=0, - status="active" + status="active", ) logger.info( @@ -206,9 +208,7 @@ class GameEngine: return state async def submit_defensive_decision( - self, - game_id: UUID, - decision: DefensiveDecision + self, game_id: UUID, decision: DefensiveDecision ) -> GameState: """ Submit defensive team decision. @@ -225,7 +225,7 @@ class GameEngine: game_validator.validate_defensive_decision(decision, state) # Store decision in state (for backward compatibility) - state.decisions_this_play['defensive'] = decision.model_dump() + state.decisions_this_play["defensive"] = decision.model_dump() state.pending_decision = "offensive" state.pending_defensive_decision = decision @@ -233,7 +233,9 @@ class GameEngine: fielding_team_id = state.get_fielding_team_id() try: state_manager.submit_decision(game_id, fielding_team_id, decision) - logger.info(f"Resolved pending defensive decision future for game {game_id}") + logger.info( + f"Resolved pending defensive decision future for game {game_id}" + ) except ValueError: # No pending future - that's okay (direct submission without await) logger.debug(f"No pending defensive decision for game {game_id}") @@ -244,9 +246,7 @@ class GameEngine: return state async def submit_offensive_decision( - self, - game_id: UUID, - decision: OffensiveDecision + self, game_id: UUID, decision: OffensiveDecision ) -> GameState: """ Submit offensive team decision. @@ -263,7 +263,7 @@ class GameEngine: game_validator.validate_offensive_decision(decision, state) # Store decision in state (for backward compatibility) - state.decisions_this_play['offensive'] = decision.model_dump() + state.decisions_this_play["offensive"] = decision.model_dump() state.pending_decision = "resolution" state.pending_offensive_decision = decision @@ -271,7 +271,9 @@ class GameEngine: batting_team_id = state.get_batting_team_id() try: state_manager.submit_decision(game_id, batting_team_id, decision) - logger.info(f"Resolved pending offensive decision future for game {game_id}") + logger.info( + f"Resolved pending offensive decision future for game {game_id}" + ) except ValueError: # No pending future - that's okay (direct submission without await) logger.debug(f"No pending offensive decision for game {game_id}") @@ -286,9 +288,7 @@ class GameEngine: # ============================================================================ async def await_defensive_decision( - self, - state: GameState, - timeout: int = None + self, state: GameState, timeout: int = None ) -> DefensiveDecision: """ Wait for defensive team to submit decision. @@ -317,38 +317,42 @@ class GameEngine: return await ai_opponent.generate_defensive_decision(state) # Human team: wait for decision via WebSocket - logger.info(f"Awaiting human defensive decision for game {state.game_id}, team {fielding_team_id}") + logger.info( + f"Awaiting human defensive decision for game {state.game_id}, team {fielding_team_id}" + ) # Set pending decision in state manager state_manager.set_pending_decision( - game_id=state.game_id, - team_id=fielding_team_id, - decision_type="defensive" + game_id=state.game_id, team_id=fielding_team_id, decision_type="defensive" ) # Update state with decision phase state.decision_phase = "awaiting_defensive" - state.decision_deadline = pendulum.now('UTC').add(seconds=timeout).to_iso8601_string() + state.decision_deadline = ( + pendulum.now("UTC").add(seconds=timeout).to_iso8601_string() + ) state_manager.update_state(state.game_id, state) try: # Wait for decision with timeout decision = await asyncio.wait_for( - state_manager.await_decision(state.game_id, fielding_team_id, "defensive"), - timeout=timeout + state_manager.await_decision( + state.game_id, fielding_team_id, "defensive" + ), + timeout=timeout, ) logger.info(f"Received defensive decision for game {state.game_id}") return decision - except asyncio.TimeoutError: + except TimeoutError: # Use default decision on timeout - logger.warning(f"Defensive decision timeout for game {state.game_id}, using default") + logger.warning( + f"Defensive decision timeout for game {state.game_id}, using default" + ) return DefensiveDecision() # All defaults async def await_offensive_decision( - self, - state: GameState, - timeout: int = None + self, state: GameState, timeout: int = None ) -> OffensiveDecision: """ Wait for offensive team to submit decision. @@ -376,40 +380,42 @@ class GameEngine: return await ai_opponent.generate_offensive_decision(state) # Human team: wait for decision via WebSocket - logger.info(f"Awaiting human offensive decision for game {state.game_id}, team {batting_team_id}") + logger.info( + f"Awaiting human offensive decision for game {state.game_id}, team {batting_team_id}" + ) # Set pending decision in state manager state_manager.set_pending_decision( - game_id=state.game_id, - team_id=batting_team_id, - decision_type="offensive" + game_id=state.game_id, team_id=batting_team_id, decision_type="offensive" ) # Update state with decision phase state.decision_phase = "awaiting_offensive" - state.decision_deadline = pendulum.now('UTC').add(seconds=timeout).to_iso8601_string() + state.decision_deadline = ( + pendulum.now("UTC").add(seconds=timeout).to_iso8601_string() + ) state_manager.update_state(state.game_id, state) try: # Wait for decision with timeout decision = await asyncio.wait_for( - state_manager.await_decision(state.game_id, batting_team_id, "offensive"), - timeout=timeout + state_manager.await_decision( + state.game_id, batting_team_id, "offensive" + ), + timeout=timeout, ) logger.info(f"Received offensive decision for game {state.game_id}") return decision - except asyncio.TimeoutError: + except TimeoutError: # Use default decision on timeout - logger.warning(f"Offensive decision timeout for game {state.game_id}, using default") + logger.warning( + f"Offensive decision timeout for game {state.game_id}, using default" + ) return OffensiveDecision() # All defaults async def _finalize_play( - self, - state: GameState, - result: PlayResult, - ab_roll, - log_suffix: str = "" + self, state: GameState, result: PlayResult, ab_roll, log_suffix: str = "" ) -> None: """ Common finalization logic for both resolve_play and resolve_manual_play. @@ -437,11 +443,11 @@ class GameEngine: # Capture state before applying result state_before = { - 'inning': state.inning, - 'half': state.half, - 'home_score': state.home_score, - 'away_score': state.away_score, - 'status': state.status + "inning": state.inning, + "half": state.half, + "home_score": state.home_score, + "away_score": state.away_score, + "status": state.status, } # Apply result to state (outs, score, runners) - before transaction @@ -454,12 +460,13 @@ class GameEngine: await self._save_play_to_db(state, result, session=session) # Update game state in DB only if something changed - if (state.inning != state_before['inning'] or - state.half != state_before['half'] or - state.home_score != state_before['home_score'] or - state.away_score != state_before['away_score'] or - state.status != state_before['status']): - + if ( + state.inning != state_before["inning"] + or state.half != state_before["half"] + or state.home_score != state_before["home_score"] + or state.away_score != state_before["away_score"] + or state.status != state_before["status"] + ): await self.db_ops.update_game_state( game_id=state.game_id, inning=state.inning, @@ -467,9 +474,11 @@ class GameEngine: home_score=state.home_score, away_score=state.away_score, status=state.status, - session=session + session=session, + ) + logger.info( + "Updated game state in DB - score/inning/status changed" ) - logger.info("Updated game state in DB - score/inning/status changed") else: logger.debug("Skipped game state update - no changes to persist") @@ -484,7 +493,7 @@ class GameEngine: home_score=state.home_score, away_score=state.away_score, status=state.status, - session=session + session=session, ) # Commit entire transaction @@ -514,15 +523,17 @@ class GameEngine: # Update in-memory state state_manager.update_state(game_id, state) - logger.info(f"Resolved play {state.play_count} for game {game_id}: {result.description}{log_suffix}") + logger.info( + f"Resolved play {state.play_count} for game {game_id}: {result.description}{log_suffix}" + ) async def resolve_play( self, game_id: UUID, - forced_outcome: Optional[PlayOutcome] = None, - xcheck_position: Optional[str] = None, - xcheck_result: Optional[str] = None, - xcheck_error: Optional[str] = None + forced_outcome: PlayOutcome | None = None, + xcheck_position: str | None = None, + xcheck_result: str | None = None, + xcheck_error: str | None = None, ) -> PlayResult: """ Resolve the current play with dice roll (testing/forced outcome method). @@ -544,11 +555,19 @@ class GameEngine: game_validator.validate_game_active(state) # Get decisions - defensive_decision = DefensiveDecision(**state.decisions_this_play.get('defensive', {})) - offensive_decision = OffensiveDecision(**state.decisions_this_play.get('offensive', {})) + defensive_decision = DefensiveDecision( + **state.decisions_this_play.get("defensive", {}) + ) + offensive_decision = OffensiveDecision( + **state.decisions_this_play.get("offensive", {}) + ) # Create resolver for this game's league and mode - resolver = PlayResolver(league_id=state.league_id, auto_mode=state.auto_mode, state_manager=state_manager) + resolver = PlayResolver( + league_id=state.league_id, + auto_mode=state.auto_mode, + state_manager=state_manager, + ) # Roll dice ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id) @@ -561,7 +580,9 @@ class GameEngine: ) # For X_CHECK, use xcheck_position as the hit_location parameter - hit_location = xcheck_position if forced_outcome == PlayOutcome.X_CHECK else None + hit_location = ( + xcheck_position if forced_outcome == PlayOutcome.X_CHECK else None + ) result = resolver.resolve_outcome( outcome=forced_outcome, @@ -571,7 +592,7 @@ class GameEngine: offensive_decision=offensive_decision, ab_roll=ab_roll, forced_xcheck_result=xcheck_result, - forced_xcheck_error=xcheck_error + forced_xcheck_error=xcheck_error, ) # Finalize the play (common logic) @@ -582,9 +603,9 @@ class GameEngine: async def resolve_manual_play( self, game_id: UUID, - ab_roll: 'AbRoll', + ab_roll: "AbRoll", outcome: PlayOutcome, - hit_location: Optional[str] = None + hit_location: str | None = None, ) -> PlayResult: """ Resolve play with manually-submitted outcome (manual mode). @@ -621,11 +642,19 @@ class GameEngine: ) # Get decisions - defensive_decision = DefensiveDecision(**state.decisions_this_play.get('defensive', {})) - offensive_decision = OffensiveDecision(**state.decisions_this_play.get('offensive', {})) + defensive_decision = DefensiveDecision( + **state.decisions_this_play.get("defensive", {}) + ) + offensive_decision = OffensiveDecision( + **state.decisions_this_play.get("offensive", {}) + ) # Create resolver for this game's league and mode - resolver = PlayResolver(league_id=state.league_id, auto_mode=state.auto_mode, state_manager=state_manager) + resolver = PlayResolver( + league_id=state.league_id, + auto_mode=state.auto_mode, + state_manager=state_manager, + ) # Call core resolution with manual outcome result = resolver.resolve_outcome( @@ -634,7 +663,7 @@ class GameEngine: state=state, defensive_decision=defensive_decision, offensive_decision=offensive_decision, - ab_roll=ab_roll + ab_roll=ab_roll, ) # Finalize the play (common logic) @@ -653,7 +682,9 @@ class GameEngine: state.outs += result.outs_recorded # Build advancement lookup - advancement_map = {from_base: to_base for from_base, to_base in result.runners_advanced} + advancement_map = { + from_base: to_base for from_base, to_base in result.runners_advanced + } # Create temporary storage for new runner positions new_first = None @@ -708,7 +739,9 @@ class GameEngine: state.play_count += 1 state.last_play_result = result.description - runner_count = len([r for r in [state.on_first, state.on_second, state.on_third] if r]) + runner_count = len( + [r for r in [state.on_first, state.on_second, state.on_third] if r] + ) logger.debug( f"Applied play result: outs={state.outs}, " f"score={state.away_score}-{state.home_score}, " @@ -737,11 +770,17 @@ class GameEngine: # Validate defensive team lineup positions # Top of inning: home team is defending # Bottom of inning: away team is defending - defensive_team = state.home_team_id if state.half == "top" else state.away_team_id - defensive_lineup = await self.db_ops.get_active_lineup(state.game_id, defensive_team) + defensive_team = ( + state.home_team_id if state.half == "top" else state.away_team_id + ) + defensive_lineup = await self.db_ops.get_active_lineup( + state.game_id, defensive_team + ) if not defensive_lineup: - raise ValidationError(f"No lineup found for defensive team {defensive_team}") + raise ValidationError( + f"No lineup found for defensive team {defensive_team}" + ) game_validator.validate_defensive_lineup_positions(defensive_lineup) @@ -750,7 +789,9 @@ class GameEngine: # Check if game is over if game_validator.is_game_over(state): state.status = "completed" - logger.info(f"Game {state.game_id} completed - Final: Away {state.away_score}, Home {state.home_score}") + logger.info( + f"Game {state.game_id} completed - Final: Away {state.away_score}, Home {state.home_score}" + ) async def _prepare_next_play(self, state: GameState) -> None: """ @@ -780,63 +821,75 @@ class GameEngine: fielding_team = state.away_team_id # Try to get lineups from cache first, only fetch from DB if not cached - from app.models.game_models import TeamLineupState, LineupPlayerState + from app.models.game_models import LineupPlayerState batting_lineup_state = state_manager.get_lineup(state.game_id, batting_team) fielding_lineup_state = state_manager.get_lineup(state.game_id, fielding_team) # Fetch from database only if not in cache if not batting_lineup_state: - batting_lineup_state = await lineup_service.load_team_lineup_with_player_data( - game_id=state.game_id, - team_id=batting_team, - league_id=state.league_id + batting_lineup_state = ( + await lineup_service.load_team_lineup_with_player_data( + game_id=state.game_id, + team_id=batting_team, + league_id=state.league_id, + ) ) if batting_lineup_state: - state_manager.set_lineup(state.game_id, batting_team, batting_lineup_state) + state_manager.set_lineup( + state.game_id, batting_team, batting_lineup_state + ) if not fielding_lineup_state: - fielding_lineup_state = await lineup_service.load_team_lineup_with_player_data( - game_id=state.game_id, - team_id=fielding_team, - league_id=state.league_id + fielding_lineup_state = ( + await lineup_service.load_team_lineup_with_player_data( + game_id=state.game_id, + team_id=fielding_team, + league_id=state.league_id, + ) ) if fielding_lineup_state: - state_manager.set_lineup(state.game_id, fielding_team, fielding_lineup_state) + state_manager.set_lineup( + state.game_id, fielding_team, fielding_lineup_state + ) # Set current player snapshot using cached lineup data # Batter: use the batting order index to find the player if batting_lineup_state and current_idx < len(batting_lineup_state.players): # Get batting order sorted list batting_order = sorted( - [p for p in batting_lineup_state.players if p.batting_order is not None], - key=lambda x: x.batting_order or 0 + [ + p + for p in batting_lineup_state.players + if p.batting_order is not None + ], + key=lambda x: x.batting_order or 0, ) if current_idx < len(batting_order): state.current_batter = batting_order[current_idx] else: # Create placeholder - this shouldn't happen in normal gameplay state.current_batter = LineupPlayerState( - lineup_id=0, - card_id=0, - position="DH", - batting_order=None + lineup_id=0, card_id=0, position="DH", batting_order=None + ) + logger.warning( + f"Batter index {current_idx} out of range for batting order" ) - logger.warning(f"Batter index {current_idx} out of range for batting order") else: # Create placeholder - this shouldn't happen in normal gameplay state.current_batter = LineupPlayerState( - lineup_id=0, - card_id=0, - position="DH", - batting_order=None + lineup_id=0, card_id=0, position="DH", batting_order=None ) logger.warning(f"No batting lineup found for team {batting_team}") # Pitcher and catcher: find by position from cached lineup if fielding_lineup_state: - state.current_pitcher = next((p for p in fielding_lineup_state.players if p.position == "P"), None) - state.current_catcher = next((p for p in fielding_lineup_state.players if p.position == "C"), None) + state.current_pitcher = next( + (p for p in fielding_lineup_state.players if p.position == "P"), None + ) + state.current_catcher = next( + (p for p in fielding_lineup_state.players if p.position == "C"), None + ) else: state.current_pitcher = None state.current_catcher = None @@ -887,10 +940,7 @@ class GameEngine: raise async def _save_play_to_db( - self, - state: GameState, - result: PlayResult, - session: Optional[AsyncSession] = None + self, state: GameState, result: PlayResult, session: AsyncSession | None = None ) -> None: """ Save play to database using snapshot from GameState. @@ -937,9 +987,9 @@ class GameEngine: # Runners AFTER play (from result.runners_advanced) # Build dict of from_base -> to_base for quick lookup finals = {from_base: to_base for from_base, to_base in result.runners_advanced} - on_first_final = finals.get(1) # None if out/scored, 1-4 if advanced - on_second_final = finals.get(2) # None if out/scored, 1-4 if advanced - on_third_final = finals.get(3) # None if out/scored, 1-4 if advanced + on_first_final = finals.get(1) # None if out/scored, 1-4 if advanced + on_second_final = finals.get(2) # None if out/scored, 1-4 if advanced + on_third_final = finals.get(3) # None if out/scored, 1-4 if advanced # Batter result (None=out, 1-4=base reached) batter_final = result.batter_result @@ -974,8 +1024,8 @@ class GameEngine: "home_score": state.home_score, "complete": True, # Strategic decisions - "defensive_choices": state.decisions_this_play.get('defensive', {}), - "offensive_choices": state.decisions_this_play.get('offensive', {}) + "defensive_choices": state.decisions_this_play.get("defensive", {}), + "offensive_choices": state.decisions_this_play.get("offensive", {}), } # Add metadata for uncapped hits (Phase 3: will include runner advancement decisions) @@ -990,7 +1040,7 @@ class GameEngine: # Create state_after by cloning state and applying result state_after = state.model_copy(deep=True) state_after.outs += result.outs_recorded - if state.half == 'top': + if state.half == "top": state_after.away_score += result.runs_scored else: state_after.home_score += result.runs_scored @@ -1000,16 +1050,18 @@ class GameEngine: outcome=result.outcome, result=result, state_before=state, - state_after=state_after + state_after=state_after, ) # Add stat fields to play_data play_data.update(stats) await self.db_ops.save_play(play_data, session=session) - logger.debug(f"Saved play {state.play_count}: batter={batter_id}, on_base={on_base_code}") + logger.debug( + f"Saved play {state.play_count}: batter={batter_id}, on_base={on_base_code}" + ) - async def get_game_state(self, game_id: UUID) -> Optional[GameState]: + async def get_game_state(self, game_id: UUID) -> GameState | None: """Get current game state""" return state_manager.get_state(game_id) @@ -1059,7 +1111,9 @@ class GameEngine: logger.info(f"Deleted {deleted_plays} plays") # 4. Delete substitutions that occurred after target play - deleted_subs = await self.db_ops.delete_substitutions_after(game_id, target_play) + deleted_subs = await self.db_ops.delete_substitutions_after( + game_id, target_play + ) logger.info(f"Deleted {deleted_subs} substitutions") # Note: We don't delete dice rolls from the rolls table - they're kept for auditing @@ -1102,7 +1156,7 @@ class GameEngine: half=state.half, home_score=state.home_score, away_score=state.away_score, - status="completed" + status="completed", ) # Clean up per-game resources to prevent memory leaks diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index deab7b9..874b256 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -11,42 +11,53 @@ Date: 2025-10-24 Updated: 2025-10-31 - Week 7 Task 6: Integrated RunnerAdvancement and outcome-first architecture Updated: 2025-11-02 - Phase 3C: Added X-Check resolution logic """ + import logging from dataclasses import dataclass -from typing import Optional, List, Tuple, TYPE_CHECKING -import pendulum +from typing import TYPE_CHECKING, Any -from app.core.dice import dice_system -from app.core.roll_types import AbRoll, RollType -from app.core.runner_advancement import AdvancementResult, RunnerAdvancement -from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision, ManualOutcomeSubmission, XCheckResult from app.config import PlayOutcome, get_league_config -from app.config.result_charts import calculate_hit_location, PdAutoResultChart, ManualResultChart from app.config.common_x_check_tables import ( + CATCHER_DEFENSE_TABLE, INFIELD_DEFENSE_TABLE, OUTFIELD_DEFENSE_TABLE, - CATCHER_DEFENSE_TABLE, get_error_chart_for_position, get_fielders_holding_runners, ) +from app.config.result_charts import ( + PdAutoResultChart, +) +from app.core.dice import dice_system +from app.core.roll_types import AbRoll +from app.core.runner_advancement import AdvancementResult, RunnerAdvancement +from app.models.game_models import ( + DefensiveDecision, + GameState, + ManualOutcomeSubmission, + OffensiveDecision, + XCheckResult, +) if TYPE_CHECKING: from app.models.player_models import PdPlayer -logger = logging.getLogger(f'{__name__}.PlayResolver') +logger = logging.getLogger(f"{__name__}.PlayResolver") @dataclass class PlayResult: """Result of a resolved play""" + outcome: PlayOutcome outs_recorded: int runs_scored: int - batter_result: Optional[int] # None = out, 1-4 = base reached - runners_advanced: List[tuple[int, int]] # [(from_base, to_base), ...] + batter_result: int | None # None = out, 1-4 = base reached + runners_advanced: list[tuple[int, int]] # [(from_base, to_base), ...] description: str ab_roll: AbRoll # Full at-bat roll for audit trail - hit_location: Optional[str] = None # '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C' + hit_location: str | None = ( + None # '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'P', 'C' + ) # Statistics is_hit: bool = False @@ -54,7 +65,7 @@ class PlayResult: is_walk: bool = False # X-Check details (Phase 3C) - x_check_details: Optional[XCheckResult] = None + x_check_details: XCheckResult | None = None class PlayResolver: @@ -74,7 +85,9 @@ class PlayResolver: ValueError: If auto_mode requested for league that doesn't support it """ - def __init__(self, league_id: str, auto_mode: bool = False, state_manager: Optional[any] = None): + def __init__( + self, league_id: str, auto_mode: bool = False, state_manager: Any | None = None + ): self.league_id = league_id self.auto_mode = auto_mode self.runner_advancement = RunnerAdvancement() @@ -108,7 +121,7 @@ class PlayResolver: state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision, - ab_roll: AbRoll + ab_roll: AbRoll, ) -> PlayResult: """ Resolve a manually submitted play (SBA + PD manual mode). @@ -126,7 +139,9 @@ class PlayResolver: Returns: PlayResult with complete outcome """ - logger.info(f"Resolving manual play - {submission.outcome} at {submission.hit_location}") + logger.info( + f"Resolving manual play - {submission.outcome} at {submission.hit_location}" + ) # Convert string to PlayOutcome enum outcome = PlayOutcome(submission.outcome) @@ -138,16 +153,16 @@ class PlayResolver: state=state, defensive_decision=defensive_decision, offensive_decision=offensive_decision, - ab_roll=ab_roll + ab_roll=ab_roll, ) def resolve_auto_play( self, state: GameState, - batter: 'PdPlayer', - pitcher: 'PdPlayer', + batter: "PdPlayer", + pitcher: "PdPlayer", defensive_decision: DefensiveDecision, - offensive_decision: OffensiveDecision + offensive_decision: OffensiveDecision, ) -> PlayResult: """ Resolve an auto-generated play (PD auto mode only). @@ -177,11 +192,8 @@ class PlayResolver: ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=state.game_id) # Generate outcome from ratings - outcome, hit_location = self.result_chart.get_outcome( #type: ignore - roll=ab_roll, - state=state, - batter=batter, - pitcher=pitcher + outcome, hit_location = self.result_chart.get_outcome( # type: ignore + roll=ab_roll, state=state, batter=batter, pitcher=pitcher ) # Delegate to core resolution @@ -191,19 +203,19 @@ class PlayResolver: state=state, defensive_decision=defensive_decision, offensive_decision=offensive_decision, - ab_roll=ab_roll + ab_roll=ab_roll, ) def resolve_outcome( self, outcome: PlayOutcome, - hit_location: Optional[str], + hit_location: str | None, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision, ab_roll: AbRoll, - forced_xcheck_result: Optional[str] = None, - forced_xcheck_error: Optional[str] = None + forced_xcheck_result: str | None = None, + forced_xcheck_error: str | None = None, ) -> PlayResult: """ CORE resolution method - all play resolution logic lives here. @@ -224,7 +236,9 @@ class PlayResolver: Returns: PlayResult with complete outcome, runner movements, and statistics """ - logger.info(f"Resolving {outcome.value} - Inning {state.inning} {state.half}, {state.outs} outs") + logger.info( + f"Resolving {outcome.value} - Inning {state.inning} {state.half}, {state.outs} outs" + ) # ==================== Strikeout ==================== if outcome == PlayOutcome.STRIKEOUT: @@ -237,32 +251,41 @@ class PlayResolver: description="Strikeout looking", ab_roll=ab_roll, hit_location=None, - is_out=True + is_out=True, ) # ==================== Groundballs ==================== - elif outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]: + if outcome in [ + PlayOutcome.GROUNDBALL_A, + PlayOutcome.GROUNDBALL_B, + PlayOutcome.GROUNDBALL_C, + ]: # Delegate to RunnerAdvancement for all groundball outcomes advancement_result = self.runner_advancement.advance_runners( outcome=outcome, - hit_location=hit_location or 'SS', # Default to SS if location not specified + hit_location=hit_location + or "SS", # Default to SS if location not specified state=state, - defensive_decision=defensive_decision + defensive_decision=defensive_decision, ) # Convert RunnerMovement list to tuple format for PlayResult runners_advanced = [ (movement.from_base, movement.to_base) for movement in advancement_result.movements - if not movement.is_out and movement.from_base > 0 # Exclude batter, include only runners + if not movement.is_out + and movement.from_base > 0 # Exclude batter, include only runners ] # Extract batter result from movements batter_movement = next( - (m for m in advancement_result.movements if m.from_base == 0), - None + (m for m in advancement_result.movements if m.from_base == 0), None + ) + batter_result = ( + batter_movement.to_base + if batter_movement and not batter_movement.is_out + else None ) - batter_result = batter_movement.to_base if batter_movement and not batter_movement.is_out else None return PlayResult( outcome=outcome, @@ -273,32 +296,42 @@ class PlayResolver: description=advancement_result.description, ab_roll=ab_roll, hit_location=hit_location, - is_out=(advancement_result.outs_recorded > 0) + is_out=(advancement_result.outs_recorded > 0), ) # ==================== Flyouts ==================== - elif outcome in [PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, PlayOutcome.FLYOUT_C]: + if outcome in [ + PlayOutcome.FLYOUT_A, + PlayOutcome.FLYOUT_B, + PlayOutcome.FLYOUT_BQ, + PlayOutcome.FLYOUT_C, + ]: # Delegate to RunnerAdvancement for all flyball outcomes advancement_result = self.runner_advancement.advance_runners( outcome=outcome, - hit_location=hit_location or 'CF', # Default to CF if location not specified + hit_location=hit_location + or "CF", # Default to CF if location not specified state=state, - defensive_decision=defensive_decision + defensive_decision=defensive_decision, ) # Convert RunnerMovement list to tuple format for PlayResult runners_advanced = [ (movement.from_base, movement.to_base) for movement in advancement_result.movements - if not movement.is_out and movement.from_base > 0 # Exclude batter, include only runners + if not movement.is_out + and movement.from_base > 0 # Exclude batter, include only runners ] # Extract batter result from movements (always out for flyouts) batter_movement = next( - (m for m in advancement_result.movements if m.from_base == 0), - None + (m for m in advancement_result.movements if m.from_base == 0), None + ) + batter_result = ( + batter_movement.to_base + if batter_movement and not batter_movement.is_out + else None ) - batter_result = batter_movement.to_base if batter_movement and not batter_movement.is_out else None return PlayResult( outcome=outcome, @@ -309,11 +342,11 @@ class PlayResolver: description=advancement_result.description, ab_roll=ab_roll, hit_location=hit_location, - is_out=(advancement_result.outs_recorded > 0) + is_out=(advancement_result.outs_recorded > 0), ) # ==================== Lineout ==================== - elif outcome == PlayOutcome.LINEOUT: + if outcome == PlayOutcome.LINEOUT: return PlayResult( outcome=outcome, outs_recorded=1, @@ -322,13 +355,15 @@ class PlayResolver: runners_advanced=[], description="Lineout", ab_roll=ab_roll, - is_out=True + is_out=True, ) - elif outcome == PlayOutcome.WALK: + if outcome == PlayOutcome.WALK: # Walk - batter to first, runners advance if forced runners_advanced = self._advance_on_walk(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -338,14 +373,16 @@ class PlayResolver: runners_advanced=runners_advanced, description="Walk", ab_roll=ab_roll, - is_walk=True + is_walk=True, ) # ==================== Singles ==================== - elif outcome == PlayOutcome.SINGLE_1: + if outcome == PlayOutcome.SINGLE_1: # Single with standard advancement runners_advanced = self._advance_on_single_1(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -355,13 +392,15 @@ class PlayResolver: runners_advanced=runners_advanced, description="Single to left field", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.SINGLE_2: + if outcome == PlayOutcome.SINGLE_2: # Single with enhanced advancement (more aggressive) runners_advanced = self._advance_on_single_2(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -371,14 +410,16 @@ class PlayResolver: runners_advanced=runners_advanced, description="Single to right field", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.SINGLE_UNCAPPED: + if outcome == PlayOutcome.SINGLE_UNCAPPED: # TODO Phase 3: Implement uncapped hit decision tree # For now, treat as SINGLE_1 runners_advanced = self._advance_on_single_1(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -388,14 +429,16 @@ class PlayResolver: runners_advanced=runners_advanced, description="Single to center (uncapped)", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) # ==================== Doubles ==================== - elif outcome == PlayOutcome.DOUBLE_2: + if outcome == PlayOutcome.DOUBLE_2: # Double to 2nd base runners_advanced = self._advance_on_double_2(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -405,13 +448,15 @@ class PlayResolver: runners_advanced=runners_advanced, description="Double to left-center", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.DOUBLE_3: + if outcome == PlayOutcome.DOUBLE_3: # Double with extra runner advancement (runners advance 3 bases) runners_advanced = self._advance_on_double_3(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -421,14 +466,16 @@ class PlayResolver: runners_advanced=runners_advanced, description="Double to right-center gap (runners advance 3 bases)", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.DOUBLE_UNCAPPED: + if outcome == PlayOutcome.DOUBLE_UNCAPPED: # TODO Phase 3: Implement uncapped hit decision tree # For now, treat as DOUBLE_2 runners_advanced = self._advance_on_double_2(state) - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -438,10 +485,10 @@ class PlayResolver: runners_advanced=runners_advanced, description="Double (uncapped)", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.TRIPLE: + if outcome == PlayOutcome.TRIPLE: # All runners score runners_advanced = [(base, 4) for base, _ in state.get_all_runners()] runs_scored = len(runners_advanced) @@ -454,10 +501,10 @@ class PlayResolver: runners_advanced=runners_advanced, description="Triple to right-center gap", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.HOMERUN: + if outcome == PlayOutcome.HOMERUN: # Everyone scores runners_advanced = [(base, 4) for base, _ in state.get_all_runners()] runs_scored = len(runners_advanced) + 1 @@ -470,13 +517,15 @@ class PlayResolver: runners_advanced=runners_advanced, description="Home run to left field", ab_roll=ab_roll, - is_hit=True + is_hit=True, ) - elif outcome == PlayOutcome.WILD_PITCH: + if outcome == PlayOutcome.WILD_PITCH: # Runners advance one base runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()] - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -485,13 +534,15 @@ class PlayResolver: batter_result=None, # Batter stays at plate runners_advanced=runners_advanced, description="Wild pitch - runners advance", - ab_roll=ab_roll + ab_roll=ab_roll, ) - elif outcome == PlayOutcome.PASSED_BALL: + if outcome == PlayOutcome.PASSED_BALL: # Runners advance one base runners_advanced = [(base, base + 1) for base, _ in state.get_all_runners()] - runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4) + runs_scored = sum( + 1 for (from_base, to_base) in runners_advanced if to_base == 4 + ) return PlayResult( outcome=outcome, @@ -500,11 +551,11 @@ class PlayResolver: batter_result=None, # Batter stays at plate runners_advanced=runners_advanced, description="Passed ball - runners advance", - ab_roll=ab_roll + ab_roll=ab_roll, ) # ==================== X-Check ==================== - elif outcome == PlayOutcome.X_CHECK: + if outcome == PlayOutcome.X_CHECK: # X-Check requires position in hit_location if not hit_location: raise ValueError("X-Check outcome requires hit_location (position)") @@ -516,13 +567,12 @@ class PlayResolver: defensive_decision=defensive_decision, ab_roll=ab_roll, forced_result=forced_xcheck_result, - forced_error=forced_xcheck_error + forced_error=forced_xcheck_error, ) - else: - raise ValueError(f"Unhandled outcome: {outcome}") + raise ValueError(f"Unhandled outcome: {outcome}") - def _advance_on_walk(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_walk(self, state: GameState) -> list[tuple[int, int]]: """Calculate runner advancement on walk""" advances = [] @@ -539,7 +589,7 @@ class PlayResolver: return advances - def _advance_on_single_1(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_single_1(self, state: GameState) -> list[tuple[int, int]]: """Calculate runner advancement on single (simplified)""" advances = [] @@ -553,7 +603,7 @@ class PlayResolver: return advances - def _advance_on_single_2(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_single_2(self, state: GameState) -> list[tuple[int, int]]: """Calculate runner advancement on single (simplified)""" advances = [] @@ -569,7 +619,7 @@ class PlayResolver: return advances - def _advance_on_double_2(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_double_2(self, state: GameState) -> list[tuple[int, int]]: """Calculate runner advancement on double""" advances = [] @@ -579,7 +629,7 @@ class PlayResolver: return advances - def _advance_on_double_3(self, state: GameState) -> List[tuple[int, int]]: + def _advance_on_double_3(self, state: GameState) -> list[tuple[int, int]]: """Calculate runner advancement on double""" advances = [] @@ -599,8 +649,8 @@ class PlayResolver: state: GameState, defensive_decision: DefensiveDecision, ab_roll: AbRoll, - forced_result: Optional[str] = None, - forced_error: Optional[str] = None + forced_result: str | None = None, + forced_error: str | None = None, ) -> PlayResult: """ Resolve X-Check play with defense range and error tables. @@ -633,7 +683,9 @@ class PlayResolver: """ logger.info(f"Resolving X-Check to {position}") if forced_result: - logger.info(f"🎯 Forcing X-Check result: {forced_result} + {forced_error or 'NO'}") + logger.info( + f"🎯 Forcing X-Check result: {forced_result} + {forced_error or 'NO'}" + ) # Check league config league_config = get_league_config(state.league_id) @@ -671,20 +723,20 @@ class PlayResolver: # Step 2: Roll dice using proper fielding roll (includes audit trail) fielding_roll = dice_system.roll_fielding( - position=position, - league_id=state.league_id, - game_id=state.game_id + position=position, league_id=state.league_id, game_id=state.game_id ) d20_roll = fielding_roll.d20 d6_roll = fielding_roll.error_total - logger.debug(f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll} (roll_id={fielding_roll.roll_id})") + logger.debug( + f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll} (roll_id={fielding_roll.roll_id})" + ) # Step 3: Adjust range if playing in adjusted_range = self._adjust_range_for_defensive_position( base_range=defender_range, position=position, - defensive_decision=defensive_decision + defensive_decision=defensive_decision, ) # Initialize SPD test variables (used in both forced and normal paths) @@ -701,9 +753,7 @@ class PlayResolver: else: # Normal flow: look up from defense table base_result = self._lookup_defense_table( - position=position, - d20_roll=d20_roll, - defense_range=adjusted_range + position=position, d20_roll=d20_roll, defense_range=adjusted_range ) logger.debug(f"Base result from defense table: {base_result}") @@ -711,20 +761,20 @@ class PlayResolver: # Step 5: Apply SPD test if needed converted_result = base_result - if base_result == 'SPD': + if base_result == "SPD": # TODO: Need batter for SPD test - placeholder for now - converted_result = 'G3' # Default to G3 if SPD test fails + converted_result = "G3" # Default to G3 if SPD test fails logger.debug(f"SPD test defaulted to fail → {converted_result}") # Step 6: Apply G2#/G3# conversion if applicable - if converted_result in ['G2#', 'G3#']: + if converted_result in ["G2#", "G3#"]: converted_result = self._apply_hash_conversion( result=converted_result, position=position, adjusted_range=adjusted_range, base_range=defender_range, state=state, - batter_hand='R' # Placeholder + batter_hand="R", # Placeholder ) # Step 7: Look up error result (or use forced) @@ -735,17 +785,14 @@ class PlayResolver: else: # Normal flow: look up from error chart error_result = self._lookup_error_chart( - position=position, - error_rating=defender_error_rating, - d6_roll=d6_roll + position=position, error_rating=defender_error_rating, d6_roll=d6_roll ) logger.debug(f"Error result: {error_result}") # Step 8: Determine final outcome final_outcome, hit_type = self._determine_final_x_check_outcome( - converted_result=converted_result, - error_result=error_result + converted_result=converted_result, error_result=error_result ) # Step 9: Create XCheckResult @@ -767,7 +814,7 @@ class PlayResolver: ) # Step 10: Get runner advancement - defender_in = (adjusted_range > defender_range) + defender_in = adjusted_range > defender_range # Call appropriate x_check function based on converted_result advancement = self._get_x_check_advancement( @@ -776,22 +823,26 @@ class PlayResolver: state=state, defender_in=defender_in, hit_location=position, - defensive_decision=defensive_decision + defensive_decision=defensive_decision, ) # Convert AdvancementResult to PlayResult format runners_advanced = [ (movement.from_base, movement.to_base) for movement in advancement.movements - if not movement.is_out and movement.from_base > 0 # Exclude batter, include only runners + if not movement.is_out + and movement.from_base > 0 # Exclude batter, include only runners ] # Extract batter result from movements batter_movement = next( - (m for m in advancement.movements if m.from_base == 0), - None + (m for m in advancement.movements if m.from_base == 0), None + ) + batter_result = ( + batter_movement.to_base + if batter_movement and not batter_movement.is_out + else None ) - batter_result = batter_movement.to_base if batter_movement and not batter_movement.is_out else None runs_scored = advancement.runs_scored outs_recorded = advancement.outs_recorded @@ -808,14 +859,11 @@ class PlayResolver: hit_location=position, is_hit=final_outcome.is_hit(), is_out=final_outcome.is_out(), - x_check_details=x_check_details + x_check_details=x_check_details, ) def _adjust_range_for_defensive_position( - self, - base_range: int, - position: str, - defensive_decision: DefensiveDecision + self, base_range: int, position: str, defensive_decision: DefensiveDecision ) -> int: """ Adjust defense range for defensive positioning. @@ -832,9 +880,12 @@ class PlayResolver: """ playing_in = False - if defensive_decision.infield_depth == 'corners_in' and position in ['1B', '3B', 'P', 'C']: - playing_in = True - elif defensive_decision.infield_depth == 'infield_in' and position in ['1B', '2B', '3B', 'SS', 'P', 'C']: + if ( + defensive_decision.infield_depth == "corners_in" + and position in ["1B", "3B", "P", "C"] + or defensive_decision.infield_depth == "infield_in" + and position in ["1B", "2B", "3B", "SS", "P", "C"] + ): playing_in = True if playing_in: @@ -845,10 +896,7 @@ class PlayResolver: return base_range def _lookup_defense_table( - self, - position: str, - d20_roll: int, - defense_range: int + self, position: str, d20_roll: int, defense_range: int ) -> str: """ Look up base result from defense table. @@ -862,8 +910,8 @@ class PlayResolver: Base result code (G1, F2, SI2, SPD, etc.) """ # Determine which table to use - if position in ['P', 'C', '1B', '2B', '3B', 'SS']: - if position == 'C': + if position in ["P", "C", "1B", "2B", "3B", "SS"]: + if position == "C": table = CATCHER_DEFENSE_TABLE else: table = INFIELD_DEFENSE_TABLE @@ -886,7 +934,7 @@ class PlayResolver: adjusted_range: int, base_range: int, state: GameState, - batter_hand: str + batter_hand: str, ) -> str: """ Convert G2# or G3# to SI2 if conditions are met. @@ -909,7 +957,7 @@ class PlayResolver: # Check condition (a): playing in if adjusted_range > base_range: logger.debug(f"{result} → SI2 (defender playing in)") - return 'SI2' + return "SI2" # Check condition (b): holding runner runner_bases = [base for base, _ in state.get_all_runners()] @@ -918,18 +966,15 @@ class PlayResolver: if position in holding_positions: logger.debug(f"{result} → SI2 (defender holding runner)") - return 'SI2' + return "SI2" # No conversion - remove # suffix - base_result = result.replace('#', '') + base_result = result.replace("#", "") logger.debug(f"{result} → {base_result} (no conversion)") return base_result def _lookup_error_chart( - self, - position: str, - error_rating: int, - d6_roll: int + self, position: str, error_rating: int, d6_roll: int ) -> str: """ Look up error result from error chart. @@ -952,24 +997,24 @@ class PlayResolver: rating_row = error_chart[error_rating] # Check each error type in priority order - for error_type in ['RP', 'E3', 'E2', 'E1']: + for error_type in ["RP", "E3", "E2", "E1"]: if d6_roll in rating_row[error_type]: logger.debug(f"Error chart: 3d6={d6_roll} → {error_type}") return error_type # No error logger.debug(f"Error chart: 3d6={d6_roll} → NO") - return 'NO' + return "NO" def _get_x_check_advancement( self, converted_result: str, error_result: str, - state: 'GameState', + state: "GameState", defender_in: bool, hit_location: str, - defensive_decision: 'DefensiveDecision' - ) -> 'AdvancementResult': + defensive_decision: "DefensiveDecision", + ) -> "AdvancementResult": """ Get runner advancement for X-Check result. @@ -994,46 +1039,68 @@ class PlayResolver: ValueError: If result type is not recognized """ from app.core.runner_advancement import ( - x_check_g1, x_check_g2, x_check_g3, - x_check_f1, x_check_f2, x_check_f3, - AdvancementResult, RunnerMovement + x_check_f1, + x_check_f2, + x_check_f3, + x_check_g1, + x_check_g2, + x_check_g3, ) on_base_code = state.current_on_base_code # Groundball results - if converted_result == 'G1': - return x_check_g1(on_base_code, defender_in, error_result, state, hit_location, defensive_decision) - elif converted_result == 'G2': - return x_check_g2(on_base_code, defender_in, error_result, state, hit_location, defensive_decision) - elif converted_result == 'G3': - return x_check_g3(on_base_code, defender_in, error_result, state, hit_location, defensive_decision) + if converted_result == "G1": + return x_check_g1( + on_base_code, + defender_in, + error_result, + state, + hit_location, + defensive_decision, + ) + if converted_result == "G2": + return x_check_g2( + on_base_code, + defender_in, + error_result, + state, + hit_location, + defensive_decision, + ) + if converted_result == "G3": + return x_check_g3( + on_base_code, + defender_in, + error_result, + state, + hit_location, + defensive_decision, + ) # Flyball results - elif converted_result == 'F1': + if converted_result == "F1": return x_check_f1(on_base_code, error_result, state, hit_location) - elif converted_result == 'F2': + if converted_result == "F2": return x_check_f2(on_base_code, error_result, state, hit_location) - elif converted_result == 'F3': + if converted_result == "F3": return x_check_f3(on_base_code, error_result, state, hit_location) # Hit results - use existing advancement methods + error bonuses - elif converted_result in ['SI1', 'SI2', 'DO2', 'DO3', 'TR3']: - return self._get_hit_advancement_with_error(converted_result, error_result, state) + if converted_result in ["SI1", "SI2", "DO2", "DO3", "TR3"]: + return self._get_hit_advancement_with_error( + converted_result, error_result, state + ) # Out results - error overrides out, so just error advancement - elif converted_result in ['FO', 'PO']: + if converted_result in ["FO", "PO"]: return self._get_out_advancement_with_error(error_result, state) - else: - raise ValueError(f"Unknown X-Check result type: {converted_result}") + raise ValueError(f"Unknown X-Check result type: {converted_result}") def _get_hit_advancement_with_error( - self, - hit_type: str, - error_result: str, - state: 'GameState' - ) -> 'AdvancementResult': + self, hit_type: str, error_result: str, state: "GameState" + ) -> "AdvancementResult": """ Get runner advancement for X-Check hit with error. @@ -1056,26 +1123,28 @@ class PlayResolver: # Get base advancement (without error) - if hit_type == 'SI1': + if hit_type == "SI1": base_advances = self._advance_on_single_1(state) batter_reaches = 1 - elif hit_type == 'SI2': + elif hit_type == "SI2": base_advances = self._advance_on_single_2(state) batter_reaches = 1 - elif hit_type == 'DO2': + elif hit_type == "DO2": base_advances = self._advance_on_double_2(state) batter_reaches = 2 - elif hit_type == 'DO3': + elif hit_type == "DO3": base_advances = self._advance_on_double_3(state) - batter_reaches = 2 # DO = double (batter to 2B), 3 = runners advance 3 bases - elif hit_type == 'TR3': + batter_reaches = ( + 2 # DO = double (batter to 2B), 3 = runners advance 3 bases + ) + elif hit_type == "TR3": base_advances = self._advance_on_triple(state) batter_reaches = 3 else: raise ValueError(f"Unknown hit type: {hit_type}") # Apply error bonus - error_bonus = {'NO': 0, 'E1': 1, 'E2': 2, 'E3': 3, 'RP': 3}.get(error_result, 0) + error_bonus = {"NO": 0, "E1": 1, "E2": 2, "E3": 3, "RP": 3}.get(error_result, 0) movements = [] runs_scored = 0 @@ -1084,38 +1153,40 @@ class PlayResolver: batter_final = min(batter_reaches + error_bonus, 4) if batter_final == 4: runs_scored += 1 - movements.append(RunnerMovement( - lineup_id=0, # Placeholder - will be set by game engine - from_base=0, - to_base=batter_final, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=0, # Placeholder - will be set by game engine + from_base=0, + to_base=batter_final, + is_out=False, + ) + ) # Add runner movements (with error bonus) for from_base, to_base in base_advances: final_base = min(to_base + error_bonus, 4) if final_base == 4: runs_scored += 1 - movements.append(RunnerMovement( - lineup_id=0, # Placeholder - from_base=from_base, - to_base=final_base, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=0, # Placeholder + from_base=from_base, + to_base=final_base, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=0, runs_scored=runs_scored, result_type=None, - description=f"X-Check {hit_type} + {error_result}" + description=f"X-Check {hit_type} + {error_result}", ) def _get_out_advancement_with_error( - self, - error_result: str, - state: 'GameState' - ) -> 'AdvancementResult': + self, error_result: str, state: "GameState" + ) -> "AdvancementResult": """ Get runner advancement for X-Check out with error. @@ -1135,18 +1206,20 @@ class PlayResolver: """ from app.core.runner_advancement import AdvancementResult, RunnerMovement - if error_result == 'NO': + if error_result == "NO": # No error on out - just record out return AdvancementResult( - movements=[RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True)], + movements=[ + RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True) + ], outs_recorded=1, runs_scored=0, result_type=None, - description="X-Check out (no error)" + description="X-Check out (no error)", ) # Error prevents out - batter and runners advance - error_bonus = {'E1': 1, 'E2': 2, 'E3': 3, 'RP': 3}[error_result] + error_bonus = {"E1": 1, "E2": 2, "E3": 3, "RP": 3}[error_result] movements = [] runs_scored = 0 @@ -1154,42 +1227,36 @@ class PlayResolver: batter_final = min(error_bonus, 4) if batter_final == 4: runs_scored += 1 - movements.append(RunnerMovement( - lineup_id=0, - from_base=0, - to_base=batter_final, - is_out=False - )) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=batter_final, is_out=False) + ) # All runners advance by error bonus for base, _ in state.get_all_runners(): final_base = min(base + error_bonus, 4) if final_base == 4: runs_scored += 1 - movements.append(RunnerMovement( - lineup_id=0, - from_base=base, - to_base=final_base, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=0, from_base=base, to_base=final_base, is_out=False + ) + ) return AdvancementResult( movements=movements, outs_recorded=0, runs_scored=runs_scored, result_type=None, - description=f"X-Check out + {error_result} (error overrides out)" + description=f"X-Check out + {error_result} (error overrides out)", ) - def _advance_on_triple(self, state: 'GameState') -> List[tuple[int, int]]: + def _advance_on_triple(self, state: "GameState") -> list[tuple[int, int]]: """Calculate runner advancement on triple (all runners score).""" return [(base, 4) for base, _ in state.get_all_runners()] def _determine_final_x_check_outcome( - self, - converted_result: str, - error_result: str - ) -> Tuple[PlayOutcome, str]: + self, converted_result: str, error_result: str + ) -> tuple[PlayOutcome, str]: """ Determine final outcome and hit_type from converted result + error. @@ -1208,19 +1275,19 @@ class PlayResolver: """ # Map result codes to PlayOutcome result_map = { - 'SI1': PlayOutcome.SINGLE_1, - 'SI2': PlayOutcome.SINGLE_2, - 'DO2': PlayOutcome.DOUBLE_2, - 'DO3': PlayOutcome.DOUBLE_3, - 'TR3': PlayOutcome.TRIPLE, - 'G1': PlayOutcome.GROUNDBALL_B, - 'G2': PlayOutcome.GROUNDBALL_B, - 'G3': PlayOutcome.GROUNDBALL_C, - 'F1': PlayOutcome.FLYOUT_A, - 'F2': PlayOutcome.FLYOUT_B, - 'F3': PlayOutcome.FLYOUT_C, - 'FO': PlayOutcome.LINEOUT, - 'PO': PlayOutcome.POPOUT, + "SI1": PlayOutcome.SINGLE_1, + "SI2": PlayOutcome.SINGLE_2, + "DO2": PlayOutcome.DOUBLE_2, + "DO3": PlayOutcome.DOUBLE_3, + "TR3": PlayOutcome.TRIPLE, + "G1": PlayOutcome.GROUNDBALL_B, + "G2": PlayOutcome.GROUNDBALL_B, + "G3": PlayOutcome.GROUNDBALL_C, + "F1": PlayOutcome.FLYOUT_A, + "F2": PlayOutcome.FLYOUT_B, + "F3": PlayOutcome.FLYOUT_C, + "FO": PlayOutcome.LINEOUT, + "PO": PlayOutcome.POPOUT, } base_outcome = result_map.get(converted_result) @@ -1230,12 +1297,12 @@ class PlayResolver: # Build hit_type string result_lower = converted_result.lower() - if error_result == 'NO': + if error_result == "NO": # No error hit_type = f"{result_lower}_no_error" final_outcome = base_outcome - elif error_result == 'RP': + elif error_result == "RP": # Rare play hit_type = f"{result_lower}_rare_play" # Rare plays are treated like errors for stats @@ -1253,6 +1320,8 @@ class PlayResolver: # Hit + error: keep hit outcome final_outcome = base_outcome - logger.info(f"Final: {converted_result} + {error_result} → {final_outcome.value} ({hit_type})") + logger.info( + f"Final: {converted_result} + {error_result} → {final_outcome.value} ({hit_type})" + ) return final_outcome, hit_type diff --git a/backend/app/core/roll_types.py b/backend/app/core/roll_types.py index 5d332a1..349e986 100644 --- a/backend/app/core/roll_types.py +++ b/backend/app/core/roll_types.py @@ -4,19 +4,21 @@ Dice Roll Type Definitions Defines all baseball dice roll types with their structures and validation. Supports both SBA and PD leagues with league-specific logic. """ + from dataclasses import dataclass, field from enum import Enum -from typing import Optional, Dict from uuid import UUID + import pendulum class RollType(str, Enum): """Types of dice rolls in baseball gameplay""" - AB = "ab" # At-bat roll - JUMP = "jump" # Baserunning jump + + AB = "ab" # At-bat roll + JUMP = "jump" # Baserunning jump FIELDING = "fielding" # Defensive fielding check - D20 = "d20" # Generic d20 roll + D20 = "d20" # Generic d20 roll @dataclass @@ -26,16 +28,19 @@ class DiceRoll: Includes auditing fields for analytics and game recovery. """ + roll_id: str roll_type: RollType league_id: str # 'sba' or 'pd' timestamp: pendulum.DateTime - game_id: Optional[UUID] = field(default=None) + game_id: UUID | None = field(default=None) # Auditing fields for analytics - team_id: Optional[int] = field(default=None) # Team making the roll - player_id: Optional[int] = field(default=None) # Polymorphic: Lineup.player_id (SBA) or Lineup.card_id (PD) - context: Optional[Dict] = field(default=None) # Additional metadata (JSONB storage) + team_id: int | None = field(default=None) # Team making the roll + player_id: int | None = field( + default=None + ) # Polymorphic: Lineup.player_id (SBA) or Lineup.card_id (PD) + context: dict | None = field(default=None) # Additional metadata (JSONB storage) def to_dict(self) -> dict: """Convert to dictionary for serialization""" @@ -47,7 +52,7 @@ class DiceRoll: "game_id": str(self.game_id) if self.game_id else None, "team_id": self.team_id, "player_id": self.player_id, - "context": self.context + "context": self.context, } @@ -62,43 +67,50 @@ class AbRoll(DiceRoll): 3. If chaos_d20 == 2: 5% chance - check passed ball using resolution_d20 4. If chaos_d20 >= 3: Use chaos_d20 for at-bat result, resolution_d20 for split results """ + # Required fields (no defaults) - d6_one: int # First d6 (1-6) - d6_two_a: int # First die of 2d6 pair - d6_two_b: int # Second die of 2d6 pair - chaos_d20: int # First d20 - chaos die (1=WP check, 2=PB check, 3+=normal) - resolution_d20: int # Second d20 - for WP/PB resolution or split results + d6_one: int # First d6 (1-6) + d6_two_a: int # First die of 2d6 pair + d6_two_b: int # Second die of 2d6 pair + chaos_d20: int # First d20 - chaos die (1=WP check, 2=PB check, 3+=normal) + resolution_d20: int # Second d20 - for WP/PB resolution or split results # Derived values with defaults (calculated in __post_init__) - d6_two_total: int = field(default=0) # Sum of 2d6 - check_wild_pitch: bool = field(default=False) # chaos_d20 == 1 (still needs resolution_d20 to confirm) - check_passed_ball: bool = field(default=False) # chaos_d20 == 2 (still needs resolution_d20 to confirm) + d6_two_total: int = field(default=0) # Sum of 2d6 + check_wild_pitch: bool = field( + default=False + ) # chaos_d20 == 1 (still needs resolution_d20 to confirm) + check_passed_ball: bool = field( + default=False + ) # chaos_d20 == 2 (still needs resolution_d20 to confirm) def __post_init__(self): """Calculate derived values""" self.d6_two_total = self.d6_two_a + self.d6_two_b - self.check_wild_pitch = (self.chaos_d20 == 1) - self.check_passed_ball = (self.chaos_d20 == 2) + self.check_wild_pitch = self.chaos_d20 == 1 + self.check_passed_ball = self.chaos_d20 == 2 def to_dict(self) -> dict: base = super().to_dict() - base.update({ - "d6_one": self.d6_one, - "d6_two_a": self.d6_two_a, - "d6_two_b": self.d6_two_b, - "d6_two_total": self.d6_two_total, - "chaos_d20": self.chaos_d20, - "resolution_d20": self.resolution_d20, - "check_wild_pitch": self.check_wild_pitch, - "check_passed_ball": self.check_passed_ball - }) + base.update( + { + "d6_one": self.d6_one, + "d6_two_a": self.d6_two_a, + "d6_two_b": self.d6_two_b, + "d6_two_total": self.d6_two_total, + "chaos_d20": self.chaos_d20, + "resolution_d20": self.resolution_d20, + "check_wild_pitch": self.check_wild_pitch, + "check_passed_ball": self.check_passed_ball, + } + ) return base def __str__(self) -> str: """String representation (max 50 chars for DB VARCHAR)""" if self.check_wild_pitch: return f"WP {self.resolution_d20}" - elif self.check_passed_ball: + if self.check_passed_ball: return f"PB {self.resolution_d20}" return f"AB {self.d6_one},{self.d6_two_total}({self.d6_two_a}+{self.d6_two_b}) d20={self.resolution_d20}" @@ -114,44 +126,47 @@ class JumpRoll(DiceRoll): 3. If check_roll == 2: Balk check (roll resolution_roll d20) 4. If check_roll >= 3: Normal jump (roll 2d6 for jump rating) """ + # Required field - check_roll: int # Initial d20 (1=pickoff, 2=balk, else normal) + check_roll: int # Initial d20 (1=pickoff, 2=balk, else normal) # Optional fields with defaults - jump_dice_a: Optional[int] = field(default=None) # First d6 of jump (if normal) - jump_dice_b: Optional[int] = field(default=None) # Second d6 of jump (if normal) - resolution_roll: Optional[int] = field(default=None) # d20 for pickoff/balk resolution + jump_dice_a: int | None = field(default=None) # First d6 of jump (if normal) + jump_dice_b: int | None = field(default=None) # Second d6 of jump (if normal) + resolution_roll: int | None = field(default=None) # d20 for pickoff/balk resolution # Derived values with defaults (calculated in __post_init__) - jump_total: Optional[int] = field(default=None) + jump_total: int | None = field(default=None) is_pickoff_check: bool = field(default=False) is_balk_check: bool = field(default=False) def __post_init__(self): """Calculate derived values""" - self.is_pickoff_check = (self.check_roll == 1) - self.is_balk_check = (self.check_roll == 2) + self.is_pickoff_check = self.check_roll == 1 + self.is_balk_check = self.check_roll == 2 if self.jump_dice_a is not None and self.jump_dice_b is not None: self.jump_total = self.jump_dice_a + self.jump_dice_b def to_dict(self) -> dict: base = super().to_dict() - base.update({ - "check_roll": self.check_roll, - "jump_dice_a": self.jump_dice_a, - "jump_dice_b": self.jump_dice_b, - "jump_total": self.jump_total, - "resolution_roll": self.resolution_roll, - "is_pickoff_check": self.is_pickoff_check, - "is_balk_check": self.is_balk_check - }) + base.update( + { + "check_roll": self.check_roll, + "jump_dice_a": self.jump_dice_a, + "jump_dice_b": self.jump_dice_b, + "jump_total": self.jump_total, + "resolution_roll": self.resolution_roll, + "is_pickoff_check": self.is_pickoff_check, + "is_balk_check": self.is_balk_check, + } + ) return base def __str__(self) -> str: if self.is_pickoff_check: return f"Jump Roll: Pickoff Check (resolution={self.resolution_roll})" - elif self.is_balk_check: + if self.is_balk_check: return f"Jump Roll: Balk Check (resolution={self.resolution_roll})" return f"Jump Roll: {self.jump_total} ({self.jump_dice_a}+{self.jump_dice_b})" @@ -165,16 +180,17 @@ class FieldingRoll(DiceRoll): - SBA: d100 == 1 (1% chance) - PD: error_total == 5 (3d6 sum of 5) """ + # Required fields - position: str # P, C, 1B, 2B, 3B, SS, LF, CF, RF - d20: int # Range roll - d6_one: int # Error die 1 - d6_two: int # Error die 2 - d6_three: int # Error die 3 - d100: int # Rare play check (SBA only) + position: str # P, C, 1B, 2B, 3B, SS, LF, CF, RF + d20: int # Range roll + d6_one: int # Error die 1 + d6_two: int # Error die 2 + d6_three: int # Error die 3 + d100: int # Rare play check (SBA only) # Derived values with defaults (calculated in __post_init__) - error_total: int = field(default=0) # Sum of 3d6 for error chart lookup + error_total: int = field(default=0) # Sum of 3d6 for error chart lookup _is_rare_play: bool = field(default=False) # Private, use property def __post_init__(self): @@ -183,11 +199,13 @@ class FieldingRoll(DiceRoll): # League-specific rare play detection if self.league_id == "sba": - self._is_rare_play = (self.d100 == 1) + self._is_rare_play = self.d100 == 1 elif self.league_id == "pd": - self._is_rare_play = (self.error_total == 5) + self._is_rare_play = self.error_total == 5 else: - raise ValueError(f"Unknown league_id: {self.league_id}. Must be 'sba' or 'pd'") + raise ValueError( + f"Unknown league_id: {self.league_id}. Must be 'sba' or 'pd'" + ) @property def is_rare_play(self) -> bool: @@ -196,16 +214,18 @@ class FieldingRoll(DiceRoll): def to_dict(self) -> dict: base = super().to_dict() - base.update({ - "position": self.position, - "d20": self.d20, - "d6_one": self.d6_one, - "d6_two": self.d6_two, - "d6_three": self.d6_three, - "d100": self.d100, - "error_total": self.error_total, - "is_rare_play": self.is_rare_play # Uses property - }) + base.update( + { + "position": self.position, + "d20": self.d20, + "d6_one": self.d6_one, + "d6_two": self.d6_two, + "d6_three": self.d6_three, + "d100": self.d100, + "error_total": self.error_total, + "is_rare_play": self.is_rare_play, # Uses property + } + ) return base def __str__(self) -> str: @@ -220,13 +240,12 @@ class D20Roll(DiceRoll): Note: Modifiers in this game are applied to target numbers, not rolls. """ + roll: int def to_dict(self) -> dict: base = super().to_dict() - base.update({ - "roll": self.roll - }) + base.update({"roll": self.roll}) return base def __str__(self) -> str: diff --git a/backend/app/core/runner_advancement.py b/backend/app/core/runner_advancement.py index 0c9a8d3..e0b2e5c 100644 --- a/backend/app/core/runner_advancement.py +++ b/backend/app/core/runner_advancement.py @@ -18,16 +18,13 @@ FLYBALLS (FLYOUT_A, FLYOUT_B, FLYOUT_BQ, FLYOUT_C): """ import logging -import random from dataclasses import dataclass from enum import IntEnum -from typing import Optional, List, Dict -from uuid import UUID -from app.models.game_models import GameState, DefensiveDecision from app.config.result_charts import PlayOutcome +from app.models.game_models import DefensiveDecision, GameState -logger = logging.getLogger(f'{__name__}.RunnerAdvancement') +logger = logging.getLogger(f"{__name__}.RunnerAdvancement") # pyright: reportOptionalMemberAccess=false @@ -100,6 +97,7 @@ class RunnerMovement: to_base: Ending base (0=out, 1-3=bases, 4=scored) is_out: Whether the runner was thrown out """ + lineup_id: int from_base: int to_base: int @@ -108,10 +106,9 @@ class RunnerMovement: def __repr__(self) -> str: if self.is_out: return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→OUT)" - elif self.to_base == 4: + if self.to_base == 4: return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→HOME)" - else: - return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→{self.to_base})" + return f"RunnerMovement(#{self.lineup_id}: {self.from_base}→{self.to_base})" @dataclass @@ -126,10 +123,11 @@ class AdvancementResult: result_type: The groundball result type (1-13), or None for flyballs description: Human-readable description of the result """ - movements: List[RunnerMovement] + + movements: list[RunnerMovement] outs_recorded: int runs_scored: int - result_type: Optional[GroundballResultType] + result_type: GroundballResultType | None description: str @@ -152,14 +150,14 @@ class RunnerAdvancement: """ def __init__(self): - self.logger = logging.getLogger(f'{__name__}.RunnerAdvancement') + self.logger = logging.getLogger(f"{__name__}.RunnerAdvancement") def advance_runners( self, outcome: PlayOutcome, hit_location: str, state: GameState, - defensive_decision: DefensiveDecision + defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Calculate runner advancement for groundball or flyball outcome. @@ -180,28 +178,33 @@ class RunnerAdvancement: if outcome in [ PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, - PlayOutcome.GROUNDBALL_C + PlayOutcome.GROUNDBALL_C, ]: - return self._advance_runners_groundball(outcome, hit_location, state, defensive_decision) + return self._advance_runners_groundball( + outcome, hit_location, state, defensive_decision + ) # Check if flyball - elif outcome in [ + if outcome in [ PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_BQ, - PlayOutcome.FLYOUT_C + PlayOutcome.FLYOUT_C, ]: - return self._advance_runners_flyball(outcome, hit_location, state, defensive_decision) + return self._advance_runners_flyball( + outcome, hit_location, state, defensive_decision + ) - else: - raise ValueError(f"advance_runners only handles groundballs and flyballs, got {outcome}") + raise ValueError( + f"advance_runners only handles groundballs and flyballs, got {outcome}" + ) def _advance_runners_groundball( self, outcome: PlayOutcome, hit_location: str, state: GameState, - defensive_decision: DefensiveDecision + defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Calculate runner advancement for groundball outcome. @@ -222,7 +225,7 @@ class RunnerAdvancement: on_base_code=state.current_on_base_code, starting_outs=state.outs, defensive_decision=defensive_decision, - hit_location=hit_location + hit_location=hit_location, ) self.logger.info( @@ -235,7 +238,7 @@ class RunnerAdvancement: result_type=result_type, state=state, hit_location=hit_location, - defensive_decision=defensive_decision + defensive_decision=defensive_decision, ) def _determine_groundball_result( @@ -244,7 +247,7 @@ class RunnerAdvancement: on_base_code: int, starting_outs: int, defensive_decision: DefensiveDecision, - hit_location: str + hit_location: str, ) -> GroundballResultType: """ Determine which groundball result applies based on situation. @@ -270,12 +273,12 @@ class RunnerAdvancement: corners_in = defensive_decision.infield_depth == "corners_in" # Map hit location to position groups - hit_to_mif = hit_location in ['2B', 'SS'] # Middle infield - hit_to_cif = hit_location in ['1B', '3B', 'P', 'C'] # Corner infield - hit_to_right = hit_location in ['1B', '2B'] # Right side + hit_to_mif = hit_location in ["2B", "SS"] # Middle infield + hit_to_cif = hit_location in ["1B", "3B", "P", "C"] # Corner infield + hit_to_right = hit_location in ["1B", "2B"] # Right side # Convert outcome to letter - gb_letter = outcome.name.split('_')[1] # 'GROUNDBALL_A' → 'A' + gb_letter = outcome.name.split("_")[1] # 'GROUNDBALL_A' → 'A' # ======================================== # INFIELD IN CHART (Runner on 3rd scenarios) @@ -286,7 +289,7 @@ class RunnerAdvancement: on_base_code=on_base_code, hit_location=hit_location, hit_to_mif=hit_to_mif, - hit_to_cif=hit_to_cif + hit_to_cif=hit_to_cif, ) # ======================================== @@ -299,7 +302,7 @@ class RunnerAdvancement: on_base_code=on_base_code, hit_location=hit_location, hit_to_mif=hit_to_mif, - hit_to_cif=hit_to_cif + hit_to_cif=hit_to_cif, ) # ======================================== @@ -310,7 +313,7 @@ class RunnerAdvancement: on_base_code=on_base_code, hit_location=hit_location, hit_to_mif=hit_to_mif, - hit_to_right=hit_to_right + hit_to_right=hit_to_right, ) def _apply_infield_in_chart( @@ -319,7 +322,7 @@ class RunnerAdvancement: on_base_code: int, hit_location: str, hit_to_mif: bool, - hit_to_cif: bool + hit_to_cif: bool, ) -> GroundballResultType: """ Apply Infield In chart logic. @@ -332,50 +335,51 @@ class RunnerAdvancement: """ # Bases loaded (on_base_code == 7) if on_base_code == 7: - if gb_letter == 'A': + if gb_letter == "A": return GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST # 10 - else: # B or C - return GroundballResultType.BATTER_SAFE_LEAD_OUT # 11 + # B or C + return GroundballResultType.BATTER_SAFE_LEAD_OUT # 11 # 1st & 3rd (on_base_code == 5) if on_base_code == 5: - if gb_letter == 'A': + if gb_letter == "A": return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7 - elif gb_letter == 'B': + if gb_letter == "B": return GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES # 9 - else: # C - # Check for DECIDE opportunity - if hit_location in ['SS', 'P', 'C']: - return GroundballResultType.DECIDE_OPPORTUNITY # 12 - else: - return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8 + # C + # Check for DECIDE opportunity + if hit_location in ["SS", "P", "C"]: + return GroundballResultType.DECIDE_OPPORTUNITY # 12 + return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8 # 2nd & 3rd (on_base_code == 6) if on_base_code == 6: - if gb_letter == 'A': + if gb_letter == "A": return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7 - elif gb_letter == 'B': + if gb_letter == "B": return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 - else: # C - return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8 + # C + return GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT # 8 # 3rd only (on_base_code == 3) if on_base_code == 3: - if gb_letter == 'A': + if gb_letter == "A": return GroundballResultType.BATTER_OUT_FORCED_ONLY # 7 - elif gb_letter == 'B': + if gb_letter == "B": return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 - else: # C - # Check for DECIDE on certain hit locations - if hit_location in ['1B', '2B']: - return GroundballResultType.DECIDE_OPPORTUNITY # 12 - elif hit_location == '3B': - return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 - else: # SS, P, C - return GroundballResultType.DECIDE_OPPORTUNITY # 12 + # C + # Check for DECIDE on certain hit locations + if hit_location in ["1B", "2B"]: + return GroundballResultType.DECIDE_OPPORTUNITY # 12 + if hit_location == "3B": + return GroundballResultType.BATTER_OUT_RUNNERS_HOLD # 1 + # SS, P, C + return GroundballResultType.DECIDE_OPPORTUNITY # 12 # Fallback (shouldn't reach here) - self.logger.warning(f"Unexpected Infield In scenario: bases={on_base_code}, letter={gb_letter}") + self.logger.warning( + f"Unexpected Infield In scenario: bases={on_base_code}, letter={gb_letter}" + ) return GroundballResultType.BATTER_OUT_RUNNERS_HOLD def _apply_infield_back_chart( @@ -384,7 +388,7 @@ class RunnerAdvancement: on_base_code: int, hit_location: str, hit_to_mif: bool, - hit_to_right: bool + hit_to_right: bool, ) -> GroundballResultType: """ Apply Infield Back chart logic (default positioning). @@ -405,38 +409,39 @@ class RunnerAdvancement: # Runner on 1st (includes 1, 4, 5, 7 - any scenario with runner on 1st) if on_base_code in [1, 4, 5, 7]: - if gb_letter == 'A': + if gb_letter == "A": return GroundballResultType.DOUBLE_PLAY_AT_SECOND # 2 - elif gb_letter == 'B': + if gb_letter == "B": return GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND # 4 - else: # C - return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 + # C + return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 # Runner on 2nd only (on_base_code == 2) if on_base_code == 2: - if gb_letter in ['A', 'B']: + if gb_letter in ["A", "B"]: return GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE # 6 - else: # C - return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 + # C + return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 # Runner on 3rd (includes 3, 6 - scenarios with runner on 3rd but not 1st) if on_base_code in [3, 6]: - if gb_letter in ['A', 'B']: + if gb_letter in ["A", "B"]: return GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD # 5 - else: # C - return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 + # C + return GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE # 3 # Fallback - self.logger.warning(f"Unexpected Infield Back scenario: bases={on_base_code}, letter={gb_letter}") + self.logger.warning( + f"Unexpected Infield Back scenario: bases={on_base_code}, letter={gb_letter}" + ) return GroundballResultType.BATTER_OUT_RUNNERS_HOLD - def _execute_result( self, result_type: GroundballResultType, state: GameState, hit_location: str, - defensive_decision: Optional[DefensiveDecision] = None + defensive_decision: DefensiveDecision | None = None, ) -> AdvancementResult: """ Execute a specific groundball result and return movements. @@ -453,33 +458,32 @@ class RunnerAdvancement: # Dispatch to appropriate handler if result_type == GroundballResultType.BATTER_OUT_RUNNERS_HOLD: return self._gb_result_1(state) - elif result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND: + if result_type == GroundballResultType.DOUBLE_PLAY_AT_SECOND: return self._gb_result_2(state, defensive_decision, hit_location) - elif result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE: + if result_type == GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE: return self._gb_result_3(state) - elif result_type == GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND: + if result_type == GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND: return self._gb_result_4(state) - elif result_type == GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD: + if result_type == GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD: return self._gb_result_5(state, hit_location) - elif result_type == GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE: + if result_type == GroundballResultType.CONDITIONAL_ON_RIGHT_SIDE: return self._gb_result_6(state, hit_location) - elif result_type in [ + if result_type in [ GroundballResultType.BATTER_OUT_FORCED_ONLY, - GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT + GroundballResultType.BATTER_OUT_FORCED_ONLY_ALT, ]: return self._gb_result_7(state) - elif result_type == GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES: + if result_type == GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES: return self._gb_result_9(state) - elif result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST: + if result_type == GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST: return self._gb_result_10(state, defensive_decision, hit_location) - elif result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT: + if result_type == GroundballResultType.BATTER_SAFE_LEAD_OUT: return self._gb_result_11(state) - elif result_type == GroundballResultType.DECIDE_OPPORTUNITY: + if result_type == GroundballResultType.DECIDE_OPPORTUNITY: return self._gb_result_12(state, hit_location) - elif result_type == GroundballResultType.CONDITIONAL_DOUBLE_PLAY: + if result_type == GroundballResultType.CONDITIONAL_DOUBLE_PLAY: return self._gb_result_13(state, defensive_decision, hit_location) - else: - raise ValueError(f"Unknown result type: {result_type}") + raise ValueError(f"Unknown result type: {result_type}") # ======================================== # Result Handlers (1-13) @@ -492,51 +496,59 @@ class RunnerAdvancement: movements = [] # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # All runners stay put if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=1, + is_out=False, + ) + ) if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=2, + is_out=False, + ) + ) if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=3, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=GroundballResultType.BATTER_OUT_RUNNERS_HOLD, - description="Batter out, runners hold" + description="Batter out, runners hold", ) def _gb_result_2( self, state: GameState, - defensive_decision: Optional[DefensiveDecision], - hit_location: str + defensive_decision: DefensiveDecision | None, + hit_location: str, ) -> AdvancementResult: """ Result 2: Double play at 2nd and 1st (when possible). @@ -554,32 +566,38 @@ class RunnerAdvancement: if can_turn_dp: # Runner on first out at second - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=0, + is_out=True, + ) + ) outs += 1 # Batter out at first - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) outs += 1 description = "Double play: Runner out at 2nd, batter out at 1st" else: # Can't turn DP, just batter out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) outs += 1 description = "Batter out" @@ -587,22 +605,26 @@ class RunnerAdvancement: if state.outs + outs < 3: if state.is_runner_on_second(): # Runner scores from second - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=4, + is_out=False, + ) + ) runs += 1 if state.is_runner_on_third(): # Runner scores from third - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=4, + is_out=False, + ) + ) runs += 1 return AdvancementResult( @@ -610,7 +632,7 @@ class RunnerAdvancement: outs_recorded=outs, runs_scored=runs, result_type=GroundballResultType.DOUBLE_PLAY_AT_SECOND, - description=description + description=description, ) def _gb_result_3(self, state: GameState) -> AdvancementResult: @@ -621,38 +643,46 @@ class RunnerAdvancement: runs = 0 # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # All runners advance 1 base (if less than 2 outs after play) if state.outs < 2: # Play doesn't end inning if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=2, + is_out=False, + ) + ) if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, # type: ignore - from_base=2, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, # type: ignore + from_base=2, + to_base=3, + is_out=False, + ) + ) if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=4, + is_out=False, + ) + ) runs += 1 return AdvancementResult( @@ -660,7 +690,7 @@ class RunnerAdvancement: outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, - description="Batter out, runners advance 1 base" + description="Batter out, runners advance 1 base", ) def _gb_result_4(self, state: GameState) -> AdvancementResult: @@ -673,38 +703,46 @@ class RunnerAdvancement: # Runner on first forced out at second if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=0, + is_out=True, + ) + ) # Batter safe at first - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=1, + is_out=False, + ) + ) # Other runners advance if play doesn't end inning if state.outs < 2: if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=3, + is_out=False, + ) + ) if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=4, + is_out=False, + ) + ) runs += 1 return AdvancementResult( @@ -712,7 +750,7 @@ class RunnerAdvancement: outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, - description="Batter safe, force out at 2nd, other runners advance" + description="Batter safe, force out at 2nd, other runners advance", ) def _gb_result_5(self, state: GameState, hit_location: str) -> AdvancementResult: @@ -722,12 +760,11 @@ class RunnerAdvancement: - Hit to 2B/SS: Batter out, runners advance 1 base (Result 3) - Hit anywhere else: Batter out, runners hold (Result 1) """ - hit_to_mif = hit_location in ['2B', 'SS'] + hit_to_mif = hit_location in ["2B", "SS"] if hit_to_mif: return self._gb_result_3(state) - else: - return self._gb_result_1(state) + return self._gb_result_1(state) def _gb_result_6(self, state: GameState, hit_location: str) -> AdvancementResult: """ @@ -736,12 +773,11 @@ class RunnerAdvancement: - Hit to 1B/2B: Batter out, runners advance 1 base (Result 3) - Hit anywhere else: Batter out, runners hold (Result 1) """ - hit_to_right = hit_location in ['1B', '2B'] + hit_to_right = hit_location in ["1B", "2B"] if hit_to_right: return self._gb_result_3(state) - else: - return self._gb_result_1(state) + return self._gb_result_1(state) def _gb_result_7(self, state: GameState) -> AdvancementResult: """ @@ -751,12 +787,14 @@ class RunnerAdvancement: runs = 0 # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # Check forced runners (only if play doesn't end inning) if state.outs < 2: @@ -764,56 +802,66 @@ class RunnerAdvancement: if state.is_runner_on_third(): forced = state.is_runner_on_first() and state.is_runner_on_second() if forced: - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=4, + is_out=False, + ) + ) runs += 1 else: # Holds - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=3, + is_out=False, + ) + ) # Runner on 2nd advances only if forced (1st and 2nd occupied) if state.is_runner_on_second(): forced = state.is_runner_on_first() if forced: - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=3, + is_out=False, + ) + ) else: # Holds - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=2, + is_out=False, + ) + ) # Runner on 1st always forced if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=2, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_OUT_FORCED_ONLY, - description="Batter out, forced runners advance" + description="Batter out, forced runners advance", ) def _gb_result_9(self, state: GameState) -> AdvancementResult: @@ -825,44 +873,50 @@ class RunnerAdvancement: movements = [] # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # Runner on 3rd holds if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=3, + is_out=False, + ) + ) # Runner on 1st advances to 2nd if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=2, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=GroundballResultType.LEAD_HOLDS_TRAIL_ADVANCES, - description="Batter out, R3 holds, R1 to 2nd" + description="Batter out, R3 holds, R1 to 2nd", ) def _gb_result_10( self, state: GameState, - defensive_decision: Optional[DefensiveDecision], - hit_location: str + defensive_decision: DefensiveDecision | None, + hit_location: str, ) -> AdvancementResult: """ Result 10: Double play at home and 1st (bases loaded). @@ -881,59 +935,69 @@ class RunnerAdvancement: if can_turn_dp: # Runner on third out at home if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=0, + is_out=True, + ) + ) outs += 1 # Batter out at first - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) outs += 1 description = "Double play: Runner out at home, batter out at 1st" else: # Can't turn DP, just batter out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) outs += 1 description = "Batter out" # Other runners advance if play doesn't end inning if state.outs + outs < 3: if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=3, + is_out=False, + ) + ) if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=2, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=outs, runs_scored=runs, result_type=GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST, - description=description + description=description, ) def _gb_result_11(self, state: GameState) -> AdvancementResult: @@ -949,63 +1013,77 @@ class RunnerAdvancement: # Lead runner is out (highest base) if state.is_runner_on_third(): # Runner on 3rd is lead runner - out at home or 3rd - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=0, + is_out=True, + ) + ) elif state.is_runner_on_second(): # Runner on 2nd is lead runner - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=0, + is_out=True, + ) + ) elif state.is_runner_on_first(): # Runner on 1st is lead runner - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=0, + is_out=True, + ) + ) # Batter safe at first - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=1, + is_out=False, + ) + ) # Other runners advance if play doesn't end inning if state.outs < 2: # If runner on 2nd exists and wasn't the lead runner if state.is_runner_on_second() and state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=3, + is_out=False, + ) + ) # If runner on 1st exists and wasn't the lead runner - if state.is_runner_on_first() and (state.is_runner_on_second() or state.is_runner_on_third()): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=2, - is_out=False - )) + if state.is_runner_on_first() and ( + state.is_runner_on_second() or state.is_runner_on_third() + ): + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=2, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=GroundballResultType.BATTER_SAFE_LEAD_OUT, - description="Batter safe, lead runner out, others advance" + description="Batter safe, lead runner out, others advance", ) def _gb_result_12(self, state: GameState, hit_location: str) -> AdvancementResult: @@ -1025,62 +1103,70 @@ class RunnerAdvancement: movements = [] # Hit to 1B/2B: Simple advancement - if hit_location in ['1B', '2B']: + if hit_location in ["1B", "2B"]: return self._gb_result_3(state) # Hit to 3B: Runners hold - if hit_location == '3B': + if hit_location == "3B": return self._gb_result_1(state) # Hit to SS/P/C: DECIDE opportunity # For now, default to conservative play (batter out, runners hold) # TODO: This needs to be interactive in the game engine - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # Hold all runners by default if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=1, + is_out=False, + ) + ) if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=2, + is_out=False, + ) + ) if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=3, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=GroundballResultType.DECIDE_OPPORTUNITY, - description="DECIDE opportunity (conservative: batter out, runners hold)" + description="DECIDE opportunity (conservative: batter out, runners hold)", ) def _gb_result_13( self, state: GameState, - defensive_decision: Optional[DefensiveDecision], - hit_location: str + defensive_decision: DefensiveDecision | None, + hit_location: str, ) -> AdvancementResult: """ Result 13: Conditional double play. @@ -1088,7 +1174,7 @@ class RunnerAdvancement: - Hit to C/3B: Double play at 3rd and 2nd base, batter safe - Hit anywhere else: Same as Result 2 (double play at 2nd and 1st) """ - hit_to_c_or_3b = hit_location in ['C', '3B'] + hit_to_c_or_3b = hit_location in ["C", "3B"] if hit_to_c_or_3b: movements = [] @@ -1100,41 +1186,49 @@ class RunnerAdvancement: if can_turn_dp: # Runner on 3rd out if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=0, + is_out=True, + ) + ) outs += 1 # Runner on 2nd out if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=0, + is_out=True, + ) + ) outs += 1 # Batter safe at first - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=1, + is_out=False, + ) + ) description = "Double play at 3rd and 2nd, batter safe" else: # Can't turn DP - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) outs += 1 description = "Batter out" @@ -1143,11 +1237,10 @@ class RunnerAdvancement: outs_recorded=outs, runs_scored=0, result_type=GroundballResultType.CONDITIONAL_DOUBLE_PLAY, - description=description + description=description, ) - else: - # Same as Result 2 - return self._gb_result_2(state, defensive_decision, hit_location) + # Same as Result 2 + return self._gb_result_2(state, defensive_decision, hit_location) # ======================================== # FLYBALL METHODS @@ -1158,7 +1251,7 @@ class RunnerAdvancement: outcome: PlayOutcome, hit_location: str, state: GameState, - defensive_decision: DefensiveDecision + defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Calculate runner advancement for flyball outcome. @@ -1186,14 +1279,13 @@ class RunnerAdvancement: # Dispatch directly based on outcome if outcome == PlayOutcome.FLYOUT_A: return self._fb_result_deep(state) - elif outcome == PlayOutcome.FLYOUT_B: + if outcome == PlayOutcome.FLYOUT_B: return self._fb_result_medium(state, hit_location) - elif outcome == PlayOutcome.FLYOUT_BQ: + if outcome == PlayOutcome.FLYOUT_BQ: return self._fb_result_bq(state, hit_location) - elif outcome == PlayOutcome.FLYOUT_C: + if outcome == PlayOutcome.FLYOUT_C: return self._fb_result_shallow(state) - else: - raise ValueError(f"Unknown flyball outcome: {outcome}") + raise ValueError(f"Unknown flyball outcome: {outcome}") # ======================================== # Flyball Result Handlers @@ -1212,52 +1304,62 @@ class RunnerAdvancement: runs = 0 # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # All runners tag up and advance one base (if less than 3 outs) if state.outs < 2: # Play doesn't end inning if state.is_runner_on_third(): # Runner scores (sacrifice fly) - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=4, + is_out=False, + ) + ) runs += 1 if state.is_runner_on_second(): # Runner advances to third - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=3, + is_out=False, + ) + ) if state.is_runner_on_first(): # Runner advances to second - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=2, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=None, # Flyballs don't use result types - description="Deep flyball - all runners tag up and advance" + description="Deep flyball - all runners tag up and advance", ) - def _fb_result_medium(self, state: GameState, hit_location: str) -> AdvancementResult: + def _fb_result_medium( + self, state: GameState, hit_location: str + ) -> AdvancementResult: """ FLYOUT_B: Medium flyball - interactive tag-up situation. @@ -1273,51 +1375,59 @@ class RunnerAdvancement: runs = 0 # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # Runner advancement (if less than 3 outs) if state.outs < 2: if state.is_runner_on_third(): # Runner on 3rd always scores - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=4, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=4, + is_out=False, + ) + ) runs += 1 if state.is_runner_on_second(): # DECIDE opportunity - R2 may attempt to tag to 3rd # TODO: Interactive decision-making via game engine # For now: conservative default (holds) - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=2, # Holds by default - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=2, # Holds by default + is_out=False, + ) + ) if state.is_runner_on_first(): # Runner on 1st always holds (too risky) - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=1, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=None, # Flyballs don't use result types - description=f"Medium flyball to {hit_location} - R3 scores, R2 DECIDE (held), R1 holds" + description=f"Medium flyball to {hit_location} - R3 scores, R2 DECIDE (held), R1 holds", ) def _fb_result_bq(self, state: GameState, hit_location: str) -> AdvancementResult: @@ -1336,12 +1446,14 @@ class RunnerAdvancement: runs = 0 # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # Runner advancement (if less than 3 outs) if state.outs < 2: @@ -1349,37 +1461,43 @@ class RunnerAdvancement: # DECIDE opportunity - R3 may attempt to score # TODO: Interactive decision-making via game engine # For now: conservative default (holds) - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=3, # Holds by default - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=3, # Holds by default + is_out=False, + ) + ) if state.is_runner_on_second(): # Runner on 2nd holds (too risky) - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=2, + is_out=False, + ) + ) if state.is_runner_on_first(): # Runner on 1st holds (too risky) - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=1, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=runs, result_type=None, # Flyballs don't use result types - description=f"Medium-shallow flyball to {hit_location} - R3 DECIDE (held), all others hold" + description=f"Medium-shallow flyball to {hit_location} - R3 DECIDE (held), all others hold", ) def _fb_result_shallow(self, state: GameState) -> AdvancementResult: @@ -1392,44 +1510,52 @@ class RunnerAdvancement: movements = [] # Batter is out - movements.append(RunnerMovement( - lineup_id=state.current_batter.lineup_id, - from_base=0, - to_base=0, - is_out=True - )) + movements.append( + RunnerMovement( + lineup_id=state.current_batter.lineup_id, + from_base=0, + to_base=0, + is_out=True, + ) + ) # All runners hold if state.is_runner_on_first(): - movements.append(RunnerMovement( - lineup_id=state.on_first.lineup_id, - from_base=1, - to_base=1, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_first.lineup_id, + from_base=1, + to_base=1, + is_out=False, + ) + ) if state.is_runner_on_second(): - movements.append(RunnerMovement( - lineup_id=state.on_second.lineup_id, - from_base=2, - to_base=2, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_second.lineup_id, + from_base=2, + to_base=2, + is_out=False, + ) + ) if state.is_runner_on_third(): - movements.append(RunnerMovement( - lineup_id=state.on_third.lineup_id, - from_base=3, - to_base=3, - is_out=False - )) + movements.append( + RunnerMovement( + lineup_id=state.on_third.lineup_id, + from_base=3, + to_base=3, + is_out=False, + ) + ) return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=None, # Flyballs don't use result types - description="Shallow flyball - all runners hold" + description="Shallow flyball - all runners hold", ) @@ -1444,7 +1570,7 @@ def x_check_g1( error_result: str, state: GameState, hit_location: str, - defensive_decision: DefensiveDecision + defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Runner advancement for X-Check G1 result. @@ -1464,26 +1590,25 @@ def x_check_g1( AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import ( + build_advancement_from_code, get_groundball_advancement, - build_advancement_from_code ) # Lookup groundball result type from table - gb_type = get_groundball_advancement('G1', on_base_code, defender_in, error_result) + gb_type = get_groundball_advancement("G1", on_base_code, defender_in, error_result) # If error result: use simple error advancement (doesn't need GameState details) - if error_result in ['E1', 'E2', 'E3', 'RP']: + if error_result in ["E1", "E2", "E3", "RP"]: return build_advancement_from_code(on_base_code, gb_type, result_name="G1") # If no error: delegate to existing result handler (needs full GameState) - else: - runner_adv = RunnerAdvancement() - return runner_adv._execute_result( - result_type=gb_type, - state=state, - hit_location=hit_location, - defensive_decision=defensive_decision - ) + runner_adv = RunnerAdvancement() + return runner_adv._execute_result( + result_type=gb_type, + state=state, + hit_location=hit_location, + defensive_decision=defensive_decision, + ) def x_check_g2( @@ -1492,7 +1617,7 @@ def x_check_g2( error_result: str, state: GameState, hit_location: str, - defensive_decision: DefensiveDecision + defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Runner advancement for X-Check G2 result. @@ -1512,25 +1637,24 @@ def x_check_g2( AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import ( + build_advancement_from_code, get_groundball_advancement, - build_advancement_from_code ) - gb_type = get_groundball_advancement('G2', on_base_code, defender_in, error_result) + gb_type = get_groundball_advancement("G2", on_base_code, defender_in, error_result) # If error result: use simple error advancement (doesn't need GameState details) - if error_result in ['E1', 'E2', 'E3', 'RP']: + if error_result in ["E1", "E2", "E3", "RP"]: return build_advancement_from_code(on_base_code, gb_type, result_name="G2") # If no error: delegate to existing result handler (needs full GameState) - else: - runner_adv = RunnerAdvancement() - return runner_adv._execute_result( - result_type=gb_type, - state=state, - hit_location=hit_location, - defensive_decision=defensive_decision - ) + runner_adv = RunnerAdvancement() + return runner_adv._execute_result( + result_type=gb_type, + state=state, + hit_location=hit_location, + defensive_decision=defensive_decision, + ) def x_check_g3( @@ -1539,7 +1663,7 @@ def x_check_g3( error_result: str, state: GameState, hit_location: str, - defensive_decision: DefensiveDecision + defensive_decision: DefensiveDecision, ) -> AdvancementResult: """ Runner advancement for X-Check G3 result. @@ -1559,32 +1683,28 @@ def x_check_g3( AdvancementResult with runner movements """ from app.core.x_check_advancement_tables import ( + build_advancement_from_code, get_groundball_advancement, - build_advancement_from_code ) - gb_type = get_groundball_advancement('G3', on_base_code, defender_in, error_result) + gb_type = get_groundball_advancement("G3", on_base_code, defender_in, error_result) # If error result: use simple error advancement (doesn't need GameState details) - if error_result in ['E1', 'E2', 'E3', 'RP']: + if error_result in ["E1", "E2", "E3", "RP"]: return build_advancement_from_code(on_base_code, gb_type, result_name="G3") # If no error: delegate to existing result handler (needs full GameState) - else: - runner_adv = RunnerAdvancement() - return runner_adv._execute_result( - result_type=gb_type, - state=state, - hit_location=hit_location, - defensive_decision=defensive_decision - ) + runner_adv = RunnerAdvancement() + return runner_adv._execute_result( + result_type=gb_type, + state=state, + hit_location=hit_location, + defensive_decision=defensive_decision, + ) def x_check_f1( - on_base_code: int, - error_result: str, - state: GameState, - hit_location: str + on_base_code: int, error_result: str, state: GameState, hit_location: str ) -> AdvancementResult: """ Runner advancement for X-Check F1 (deep flyball) result. @@ -1605,20 +1725,18 @@ def x_check_f1( from app.core.x_check_advancement_tables import build_flyball_advancement_with_error # If error result: use simple error advancement (doesn't need GameState details) - if error_result != 'NO': - return build_flyball_advancement_with_error(on_base_code, error_result, flyball_type="F1") + if error_result != "NO": + return build_flyball_advancement_with_error( + on_base_code, error_result, flyball_type="F1" + ) # If no error: delegate to existing FLYOUT_A logic - else: - runner_adv = RunnerAdvancement() - return runner_adv._fb_result_deep(state) + runner_adv = RunnerAdvancement() + return runner_adv._fb_result_deep(state) def x_check_f2( - on_base_code: int, - error_result: str, - state: GameState, - hit_location: str + on_base_code: int, error_result: str, state: GameState, hit_location: str ) -> AdvancementResult: """ Runner advancement for X-Check F2 (medium flyball) result. @@ -1639,20 +1757,18 @@ def x_check_f2( from app.core.x_check_advancement_tables import build_flyball_advancement_with_error # If error result: use simple error advancement (doesn't need GameState details) - if error_result != 'NO': - return build_flyball_advancement_with_error(on_base_code, error_result, flyball_type="F2") + if error_result != "NO": + return build_flyball_advancement_with_error( + on_base_code, error_result, flyball_type="F2" + ) # If no error: delegate to existing FLYOUT_B logic - else: - runner_adv = RunnerAdvancement() - return runner_adv._fb_result_medium(state, hit_location) + runner_adv = RunnerAdvancement() + return runner_adv._fb_result_medium(state, hit_location) def x_check_f3( - on_base_code: int, - error_result: str, - state: GameState, - hit_location: str + on_base_code: int, error_result: str, state: GameState, hit_location: str ) -> AdvancementResult: """ Runner advancement for X-Check F3 (shallow flyball) result. @@ -1673,10 +1789,11 @@ def x_check_f3( from app.core.x_check_advancement_tables import build_flyball_advancement_with_error # If error result: use simple error advancement (doesn't need GameState details) - if error_result != 'NO': - return build_flyball_advancement_with_error(on_base_code, error_result, flyball_type="F3") + if error_result != "NO": + return build_flyball_advancement_with_error( + on_base_code, error_result, flyball_type="F3" + ) # If no error: delegate to existing FLYOUT_C logic - else: - runner_adv = RunnerAdvancement() - return runner_adv._fb_result_shallow(state) + runner_adv = RunnerAdvancement() + return runner_adv._fb_result_shallow(state) diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 58964de..4935bc5 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -12,14 +12,20 @@ Date: 2025-10-22 import asyncio import logging -from typing import Dict, Optional, Union from uuid import UUID + import pendulum -from app.models.game_models import GameState, TeamLineupState, LineupPlayerState, DefensiveDecision, OffensiveDecision from app.database.operations import DatabaseOperations +from app.models.game_models import ( + DefensiveDecision, + GameState, + LineupPlayerState, + OffensiveDecision, + TeamLineupState, +) -logger = logging.getLogger(f'{__name__}.StateManager') +logger = logging.getLogger(f"{__name__}.StateManager") class StateManager: @@ -37,13 +43,15 @@ class StateManager: def __init__(self): """Initialize the state manager with empty storage""" - self._states: Dict[UUID, GameState] = {} - self._lineups: Dict[UUID, Dict[int, TeamLineupState]] = {} # game_id -> {team_id: lineup} - self._last_access: Dict[UUID, pendulum.DateTime] = {} + self._states: dict[UUID, GameState] = {} + self._lineups: dict[ + UUID, dict[int, TeamLineupState] + ] = {} # game_id -> {team_id: lineup} + self._last_access: dict[UUID, pendulum.DateTime] = {} # Phase 3: Decision queue for async decision awaiting # Key: (game_id, team_id, decision_type) - self._pending_decisions: Dict[tuple[UUID, int, str], asyncio.Future] = {} + self._pending_decisions: dict[tuple[UUID, int, str], asyncio.Future] = {} self.db_ops = DatabaseOperations() @@ -57,7 +65,7 @@ class StateManager: away_team_id: int, home_team_is_ai: bool = False, away_team_is_ai: bool = False, - auto_mode: bool = False + auto_mode: bool = False, ) -> GameState: """ Create a new game state in memory. @@ -80,15 +88,15 @@ class StateManager: if game_id in self._states: raise ValueError(f"Game {game_id} already exists in state manager") - logger.info(f"Creating game state for {game_id} ({league_id} league, auto_mode={auto_mode})") + logger.info( + f"Creating game state for {game_id} ({league_id} league, auto_mode={auto_mode})" + ) # Create placeholder batter (will be set by _prepare_next_play() when game starts) from app.models.game_models import LineupPlayerState + placeholder_batter = LineupPlayerState( - lineup_id=0, - card_id=0, - position="DH", - batting_order=None + lineup_id=0, card_id=0, position="DH", batting_order=None ) state = GameState( @@ -99,17 +107,17 @@ class StateManager: home_team_is_ai=home_team_is_ai, away_team_is_ai=away_team_is_ai, auto_mode=auto_mode, - current_batter=placeholder_batter # Will be replaced by _prepare_next_play() when game starts + current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts ) self._states[game_id] = state self._lineups[game_id] = {} - self._last_access[game_id] = pendulum.now('UTC') + self._last_access[game_id] = pendulum.now("UTC") logger.debug(f"Game {game_id} created in memory") return state - def get_state(self, game_id: UUID) -> Optional[GameState]: + def get_state(self, game_id: UUID) -> GameState | None: """ Get game state by ID. @@ -122,7 +130,7 @@ class StateManager: GameState if found, None otherwise """ if game_id in self._states: - self._last_access[game_id] = pendulum.now('UTC') + self._last_access[game_id] = pendulum.now("UTC") return self._states[game_id] return None @@ -141,8 +149,10 @@ class StateManager: raise ValueError(f"Game {game_id} not found in state manager") self._states[game_id] = state - self._last_access[game_id] = pendulum.now('UTC') - logger.debug(f"Updated state for game {game_id} (inning {state.inning}, {state.half})") + self._last_access[game_id] = pendulum.now("UTC") + logger.debug( + f"Updated state for game {game_id} (inning {state.inning}, {state.half})" + ) def set_lineup(self, game_id: UUID, team_id: int, lineup: TeamLineupState) -> None: """ @@ -163,9 +173,11 @@ class StateManager: self._lineups[game_id] = {} self._lineups[game_id][team_id] = lineup - logger.info(f"Set lineup for team {team_id} in game {game_id} ({len(lineup.players)} players)") + logger.info( + f"Set lineup for team {team_id} in game {game_id} ({len(lineup.players)} players)" + ) - def get_lineup(self, game_id: UUID, team_id: int) -> Optional[TeamLineupState]: + def get_lineup(self, game_id: UUID, team_id: int) -> TeamLineupState | None: """ Get team lineup for a game. @@ -202,11 +214,15 @@ class StateManager: removed_parts.append("access") if removed_parts: - logger.info(f"Removed game {game_id} from memory ({', '.join(removed_parts)})") + logger.info( + f"Removed game {game_id} from memory ({', '.join(removed_parts)})" + ) else: - logger.warning(f"Attempted to remove game {game_id} but it was not in memory") + logger.warning( + f"Attempted to remove game {game_id} but it was not in memory" + ) - async def recover_game(self, game_id: UUID) -> Optional[GameState]: + async def recover_game(self, game_id: UUID) -> GameState | None: """ Recover game state from database. @@ -234,7 +250,7 @@ class StateManager: # Cache in memory self._states[game_id] = state - self._last_access[game_id] = pendulum.now('UTC') + self._last_access[game_id] = pendulum.now("UTC") logger.info(f"Recovered game {game_id} - inning {state.inning}, {state.half}") return state @@ -253,31 +269,31 @@ class StateManager: Returns: Reconstructed GameState """ - game = game_data['game'] - lineups = game_data.get('lineups', []) + game = game_data["game"] + lineups = game_data.get("lineups", []) # Build lineup lookup dict for quick access - lineup_dict = {l['id']: l for l in lineups} + lineup_dict = {l["id"]: l for l in lineups} # Helper function to create LineupPlayerState from lineup_id - def get_lineup_player(lineup_id: int) -> Optional[LineupPlayerState]: + def get_lineup_player(lineup_id: int) -> LineupPlayerState | None: if not lineup_id or lineup_id not in lineup_dict: return None lineup = lineup_dict[lineup_id] return LineupPlayerState( - lineup_id=lineup['id'], - card_id=lineup['card_id'] or 0, # Handle nullable - position=lineup['position'], - batting_order=lineup.get('batting_order'), - is_active=lineup.get('is_active', True) + lineup_id=lineup["id"], + card_id=lineup["card_id"] or 0, # Handle nullable + position=lineup["position"], + batting_order=lineup.get("batting_order"), + is_active=lineup.get("is_active", True), ) # Determine fielding team based on current half - current_half = game.get('current_half', 'top') - home_team_id = game['home_team_id'] - away_team_id = game['away_team_id'] + current_half = game.get("current_half", "top") + home_team_id = game["home_team_id"] + away_team_id = game["away_team_id"] - if current_half == 'top': + if current_half == "top": # Top of inning: away team batting, home team fielding batting_team_id = away_team_id fielding_team_id = home_team_id @@ -289,109 +305,113 @@ class StateManager: # Get current batter from batting team (player with batting_order 1 as placeholder) current_batter_placeholder = None for lineup in lineups: - if (lineup.get('team_id') == batting_team_id and - lineup.get('batting_order') == 1 and - lineup.get('is_active')): - current_batter_placeholder = get_lineup_player(lineup['id']) + if ( + lineup.get("team_id") == batting_team_id + and lineup.get("batting_order") == 1 + and lineup.get("is_active") + ): + current_batter_placeholder = get_lineup_player(lineup["id"]) break # If no batter found, use first available lineup from batting team if not current_batter_placeholder: for lineup in lineups: - if lineup.get('team_id') == batting_team_id and lineup.get('is_active'): - current_batter_placeholder = get_lineup_player(lineup['id']) + if lineup.get("team_id") == batting_team_id and lineup.get("is_active"): + current_batter_placeholder = get_lineup_player(lineup["id"]) break # If still no batter (no lineups at all), raise error - game is in invalid state if not current_batter_placeholder: - raise ValueError(f"Cannot recover game {game['id']}: No lineups found for batting team") + raise ValueError( + f"Cannot recover game {game['id']}: No lineups found for batting team" + ) # Get current pitcher and catcher from fielding team current_pitcher = None current_catcher = None for lineup in lineups: - if lineup.get('team_id') == fielding_team_id and lineup.get('is_active'): - if lineup.get('position') == 'P' and not current_pitcher: - current_pitcher = get_lineup_player(lineup['id']) - elif lineup.get('position') == 'C' and not current_catcher: - current_catcher = get_lineup_player(lineup['id']) + if lineup.get("team_id") == fielding_team_id and lineup.get("is_active"): + if lineup.get("position") == "P" and not current_pitcher: + current_pitcher = get_lineup_player(lineup["id"]) + elif lineup.get("position") == "C" and not current_catcher: + current_catcher = get_lineup_player(lineup["id"]) # Stop if we found both if current_pitcher and current_catcher: break state = GameState( - game_id=game['id'], - league_id=game['league_id'], + game_id=game["id"], + league_id=game["league_id"], home_team_id=home_team_id, away_team_id=away_team_id, - home_team_is_ai=game.get('home_team_is_ai', False), - away_team_is_ai=game.get('away_team_is_ai', False), - status=game['status'], - inning=game.get('current_inning', 1), + home_team_is_ai=game.get("home_team_is_ai", False), + away_team_is_ai=game.get("away_team_is_ai", False), + status=game["status"], + inning=game.get("current_inning", 1), half=current_half, - home_score=game.get('home_score', 0), - away_score=game.get('away_score', 0), - play_count=len(game_data.get('plays', [])), + home_score=game.get("home_score", 0), + away_score=game.get("away_score", 0), + play_count=len(game_data.get("plays", [])), current_batter=current_batter_placeholder, current_pitcher=current_pitcher, - current_catcher=current_catcher + current_catcher=current_catcher, ) # Get last completed play to recover runner state and batter indices - plays = game_data.get('plays', []) + plays = game_data.get("plays", []) if plays: # Sort by play_number desc and get last completed play - completed_plays = [p for p in plays if p.get('complete', False)] + completed_plays = [p for p in plays if p.get("complete", False)] if completed_plays: - last_play = max(completed_plays, key=lambda p: p['play_number']) + last_play = max(completed_plays, key=lambda p: p["play_number"]) # Recover runners from *_final fields (where they ended up after last play) # Check each base - if a runner ended on that base, place them there runner_count = 0 # Check if on_first_id runner ended on first (on_first_final == 1) - if last_play.get('on_first_final') == 1: - state.on_first = get_lineup_player(last_play.get('on_first_id')) + if last_play.get("on_first_final") == 1: + state.on_first = get_lineup_player(last_play.get("on_first_id")) if state.on_first: runner_count += 1 # Check if on_second_id runner ended on second OR if on_first_id runner advanced to second - if last_play.get('on_second_final') == 2: - state.on_second = get_lineup_player(last_play.get('on_second_id')) + if last_play.get("on_second_final") == 2: + state.on_second = get_lineup_player(last_play.get("on_second_id")) if state.on_second: runner_count += 1 - elif last_play.get('on_first_final') == 2: - state.on_second = get_lineup_player(last_play.get('on_first_id')) + elif last_play.get("on_first_final") == 2: + state.on_second = get_lineup_player(last_play.get("on_first_id")) if state.on_second: runner_count += 1 # Check if any runner ended on third - if last_play.get('on_third_final') == 3: - state.on_third = get_lineup_player(last_play.get('on_third_id')) + if last_play.get("on_third_final") == 3: + state.on_third = get_lineup_player(last_play.get("on_third_id")) if state.on_third: runner_count += 1 - elif last_play.get('on_second_final') == 3: - state.on_third = get_lineup_player(last_play.get('on_second_id')) + elif last_play.get("on_second_final") == 3: + state.on_third = get_lineup_player(last_play.get("on_second_id")) if state.on_third: runner_count += 1 - elif last_play.get('on_first_final') == 3: - state.on_third = get_lineup_player(last_play.get('on_first_id')) + elif last_play.get("on_first_final") == 3: + state.on_third = get_lineup_player(last_play.get("on_first_id")) if state.on_third: runner_count += 1 # Check if batter reached base (and didn't score) - batter_final = last_play.get('batter_final') + batter_final = last_play.get("batter_final") if batter_final == 1: - state.on_first = get_lineup_player(last_play.get('batter_id')) + state.on_first = get_lineup_player(last_play.get("batter_id")) if state.on_first: runner_count += 1 elif batter_final == 2: - state.on_second = get_lineup_player(last_play.get('batter_id')) + state.on_second = get_lineup_player(last_play.get("batter_id")) if state.on_second: runner_count += 1 elif batter_final == 3: - state.on_third = get_lineup_player(last_play.get('batter_id')) + state.on_third = get_lineup_player(last_play.get("batter_id")) if state.on_third: runner_count += 1 @@ -411,7 +431,9 @@ class StateManager: # Count runners on base runners_on_base = len(state.get_all_runners()) - logger.info(f"Rebuilt state for game {state.game_id}: {state.play_count} plays, {runners_on_base} runners") + logger.info( + f"Rebuilt state for game {state.game_id}: {state.play_count} plays, {runners_on_base} runners" + ) return state def evict_idle_games(self, idle_minutes: int = 60) -> int: @@ -427,9 +449,10 @@ class StateManager: Returns: Number of games evicted """ - cutoff = pendulum.now('UTC').subtract(minutes=idle_minutes) + cutoff = pendulum.now("UTC").subtract(minutes=idle_minutes) to_evict = [ - game_id for game_id, last_access in self._last_access.items() + game_id + for game_id, last_access in self._last_access.items() if last_access < cutoff ] @@ -462,12 +485,16 @@ class StateManager: # Count by league for state in self._states.values(): league = state.league_id - stats["games_by_league"][league] = stats["games_by_league"].get(league, 0) + 1 + stats["games_by_league"][league] = ( + stats["games_by_league"].get(league, 0) + 1 + ) # Count by status for state in self._states.values(): status = state.status - stats["games_by_status"][status] = stats["games_by_status"].get(status, 0) + 1 + stats["games_by_status"][status] = ( + stats["games_by_status"].get(status, 0) + 1 + ) return stats @@ -497,10 +524,7 @@ class StateManager: # ============================================================================ def set_pending_decision( - self, - game_id: UUID, - team_id: int, - decision_type: str + self, game_id: UUID, team_id: int, decision_type: str ) -> None: """ Mark that a decision is required and create a future for it. @@ -515,14 +539,13 @@ class StateManager: # Create a new future for this decision self._pending_decisions[key] = asyncio.Future() - logger.debug(f"Set pending {decision_type} decision for game {game_id}, team {team_id}") + logger.debug( + f"Set pending {decision_type} decision for game {game_id}, team {team_id}" + ) async def await_decision( - self, - game_id: UUID, - team_id: int, - decision_type: str - ) -> Union[DefensiveDecision, OffensiveDecision]: + self, game_id: UUID, team_id: int, decision_type: str + ) -> DefensiveDecision | OffensiveDecision: """ Wait for a decision to be submitted. @@ -551,14 +574,16 @@ class StateManager: # Await the future (will be resolved by submit_decision) decision = await self._pending_decisions[key] - logger.debug(f"Received {decision_type} decision for game {game_id}, team {team_id}") + logger.debug( + f"Received {decision_type} decision for game {game_id}, team {team_id}" + ) return decision def submit_decision( self, game_id: UUID, team_id: int, - decision: Union[DefensiveDecision, OffensiveDecision] + decision: DefensiveDecision | OffensiveDecision, ) -> None: """ Submit a decision (called by WebSocket handler or AI opponent). @@ -575,7 +600,10 @@ class StateManager: """ # Determine decision type from the decision object from app.models.game_models import DefensiveDecision - decision_type = "defensive" if isinstance(decision, DefensiveDecision) else "offensive" + + decision_type = ( + "defensive" if isinstance(decision, DefensiveDecision) else "offensive" + ) key = (game_id, team_id, decision_type) @@ -597,13 +625,12 @@ class StateManager: # Clean up the future del self._pending_decisions[key] - logger.info(f"Submitted {decision_type} decision for game {game_id}, team {team_id}") + logger.info( + f"Submitted {decision_type} decision for game {game_id}, team {team_id}" + ) def cancel_pending_decision( - self, - game_id: UUID, - team_id: int, - decision_type: str + self, game_id: UUID, team_id: int, decision_type: str ) -> None: """ Cancel a pending decision (e.g., on timeout or game abort). @@ -622,7 +649,9 @@ class StateManager: future.cancel() del self._pending_decisions[key] - logger.debug(f"Cancelled pending {decision_type} decision for game {game_id}, team {team_id}") + logger.debug( + f"Cancelled pending {decision_type} decision for game {game_id}, team {team_id}" + ) # Singleton instance for global access diff --git a/backend/app/core/substitution_manager.py b/backend/app/core/substitution_manager.py index e959e4f..c48d348 100644 --- a/backend/app/core/substitution_manager.py +++ b/backend/app/core/substitution_manager.py @@ -10,34 +10,36 @@ Follows the established pattern: Author: Claude Date: 2025-11-03 """ + import logging from dataclasses import dataclass -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from uuid import UUID -from app.core.substitution_rules import SubstitutionRules, ValidationResult from app.core.state_manager import state_manager +from app.core.substitution_rules import SubstitutionRules from app.models.game_models import LineupPlayerState, TeamLineupState from app.services.lineup_service import lineup_service if TYPE_CHECKING: from app.database.operations import DatabaseOperations -logger = logging.getLogger(f'{__name__}.SubstitutionManager') +logger = logging.getLogger(f"{__name__}.SubstitutionManager") @dataclass class SubstitutionResult: """Result of a substitution operation""" + success: bool - new_lineup_id: Optional[int] = None - player_out_lineup_id: Optional[int] = None - player_in_card_id: Optional[int] = None - new_position: Optional[str] = None - new_batting_order: Optional[int] = None - updated_lineup: Optional[TeamLineupState] = None - error_message: Optional[str] = None - error_code: Optional[str] = None + new_lineup_id: int | None = None + player_out_lineup_id: int | None = None + player_in_card_id: int | None = None + new_position: str | None = None + new_batting_order: int | None = None + updated_lineup: TeamLineupState | None = None + error_message: str | None = None + error_code: str | None = None class SubstitutionManager: @@ -51,7 +53,7 @@ class SubstitutionManager: 4. Return result (WebSocket broadcast happens in handler) """ - def __init__(self, db_ops: 'DatabaseOperations'): + def __init__(self, db_ops: "DatabaseOperations"): """ Initialize substitution manager. @@ -66,7 +68,7 @@ class SubstitutionManager: game_id: UUID, player_out_lineup_id: int, player_in_card_id: int, - team_id: int + team_id: int, ) -> SubstitutionResult: """ Execute pinch hitter substitution. @@ -98,7 +100,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Game {game_id} not found", - error_code="GAME_NOT_FOUND" + error_code="GAME_NOT_FOUND", ) roster = state_manager.get_lineup(game_id, team_id) @@ -106,7 +108,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Roster not found for team {team_id}", - error_code="ROSTER_NOT_FOUND" + error_code="ROSTER_NOT_FOUND", ) player_out = roster.get_player_by_lineup_id(player_out_lineup_id) @@ -114,7 +116,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Player with lineup_id {player_out_lineup_id} not found", - error_code="PLAYER_NOT_FOUND" + error_code="PLAYER_NOT_FOUND", ) # STEP 2: Validate substitution @@ -122,14 +124,14 @@ class SubstitutionManager: state=state, player_out=player_out, player_in_card_id=player_in_card_id, - roster=roster + roster=roster, ) if not validation.is_valid: return SubstitutionResult( success=False, error_message=validation.error_message, - error_code=validation.error_code + error_code=validation.error_code, ) # STEP 3: Update DATABASE FIRST @@ -142,14 +144,16 @@ class SubstitutionManager: position=player_out.position, # Pinch hitter takes same defensive position batting_order=player_out.batting_order, # Takes same spot in order inning=state.inning, - play_number=state.play_count + play_number=state.play_count, ) except Exception as e: - logger.error(f"Database error during pinch hit substitution: {e}", exc_info=True) + logger.error( + f"Database error during pinch hit substitution: {e}", exc_info=True + ) return SubstitutionResult( success=False, error_message=f"Database error: {str(e)}", - error_code="DB_ERROR" + error_code="DB_ERROR", ) # STEP 4: Update IN-MEMORY STATE SECOND @@ -160,8 +164,10 @@ class SubstitutionManager: # Fetch player data for SBA league player_name = f"Player #{player_in_card_id}" player_image = "" - if state.league_id == 'sba': - player_name, player_image = await lineup_service.get_sba_player_data(player_in_card_id) + if state.league_id == "sba": + player_name, player_image = await lineup_service.get_sba_player_data( + player_in_card_id + ) # Create new player entry new_player = LineupPlayerState( @@ -172,7 +178,7 @@ class SubstitutionManager: is_active=True, is_starter=False, player_name=player_name, - player_image=player_image + player_image=player_image, ) # Add to roster @@ -182,7 +188,10 @@ class SubstitutionManager: state_manager.set_lineup(game_id, team_id, roster) # Update current_batter if this is the current batter - if state.current_batter and state.current_batter.lineup_id == player_out_lineup_id: + if ( + state.current_batter + and state.current_batter.lineup_id == player_out_lineup_id + ): state.current_batter = new_player # Update object reference state_manager.update_state(game_id, state) @@ -198,13 +207,12 @@ class SubstitutionManager: player_in_card_id=player_in_card_id, new_position=player_out.position, new_batting_order=player_out.batting_order, - updated_lineup=roster + updated_lineup=roster, ) except Exception as e: logger.error( - f"State update error during pinch hit substitution: {e}", - exc_info=True + f"State update error during pinch hit substitution: {e}", exc_info=True ) # Database is already updated - this is a state sync issue # Log error but return partial success (DB is source of truth) @@ -212,7 +220,7 @@ class SubstitutionManager: success=False, new_lineup_id=new_lineup_id, error_message=f"State sync error: {str(e)}", - error_code="STATE_SYNC_ERROR" + error_code="STATE_SYNC_ERROR", ) async def defensive_replace( @@ -222,8 +230,8 @@ class SubstitutionManager: player_in_card_id: int, new_position: str, team_id: int, - new_batting_order: Optional[int] = None, - allow_mid_inning: bool = False + new_batting_order: int | None = None, + allow_mid_inning: bool = False, ) -> SubstitutionResult: """ Execute defensive replacement. @@ -254,7 +262,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Game {game_id} not found", - error_code="GAME_NOT_FOUND" + error_code="GAME_NOT_FOUND", ) roster = state_manager.get_lineup(game_id, team_id) @@ -262,7 +270,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Roster not found for team {team_id}", - error_code="ROSTER_NOT_FOUND" + error_code="ROSTER_NOT_FOUND", ) player_out = roster.get_player_by_lineup_id(player_out_lineup_id) @@ -270,7 +278,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Player with lineup_id {player_out_lineup_id} not found", - error_code="PLAYER_NOT_FOUND" + error_code="PLAYER_NOT_FOUND", ) # STEP 2: Validate substitution @@ -280,18 +288,22 @@ class SubstitutionManager: player_in_card_id=player_in_card_id, new_position=new_position, roster=roster, - allow_mid_inning=allow_mid_inning + allow_mid_inning=allow_mid_inning, ) if not validation.is_valid: return SubstitutionResult( success=False, error_message=validation.error_message, - error_code=validation.error_code + error_code=validation.error_code, ) # Determine batting order (keep old if not specified) - batting_order = new_batting_order if new_batting_order is not None else player_out.batting_order + batting_order = ( + new_batting_order + if new_batting_order is not None + else player_out.batting_order + ) # STEP 3: Update DATABASE FIRST try: @@ -303,14 +315,16 @@ class SubstitutionManager: position=new_position, batting_order=batting_order, inning=state.inning, - play_number=state.play_count + play_number=state.play_count, ) except Exception as e: - logger.error(f"Database error during defensive replacement: {e}", exc_info=True) + logger.error( + f"Database error during defensive replacement: {e}", exc_info=True + ) return SubstitutionResult( success=False, error_message=f"Database error: {str(e)}", - error_code="DB_ERROR" + error_code="DB_ERROR", ) # STEP 4: Update IN-MEMORY STATE SECOND @@ -321,8 +335,10 @@ class SubstitutionManager: # Fetch player data for SBA league player_name = f"Player #{player_in_card_id}" player_image = "" - if state.league_id == 'sba': - player_name, player_image = await lineup_service.get_sba_player_data(player_in_card_id) + if state.league_id == "sba": + player_name, player_image = await lineup_service.get_sba_player_data( + player_in_card_id + ) # Create new player entry new_player = LineupPlayerState( @@ -333,7 +349,7 @@ class SubstitutionManager: is_active=True, is_starter=False, player_name=player_name, - player_image=player_image + player_image=player_image, ) # Add to roster @@ -343,10 +359,18 @@ class SubstitutionManager: state_manager.set_lineup(game_id, team_id, roster) # Update current pitcher/catcher if this affects them - if player_out.position == 'P' and state.current_pitcher and state.current_pitcher.lineup_id == player_out_lineup_id: + if ( + player_out.position == "P" + and state.current_pitcher + and state.current_pitcher.lineup_id == player_out_lineup_id + ): state.current_pitcher = new_player state_manager.update_state(game_id, state) - elif player_out.position == 'C' and state.current_catcher and state.current_catcher.lineup_id == player_out_lineup_id: + elif ( + player_out.position == "C" + and state.current_catcher + and state.current_catcher.lineup_id == player_out_lineup_id + ): state.current_catcher = new_player state_manager.update_state(game_id, state) @@ -362,19 +386,18 @@ class SubstitutionManager: player_in_card_id=player_in_card_id, new_position=new_position, new_batting_order=batting_order, - updated_lineup=roster + updated_lineup=roster, ) except Exception as e: logger.error( - f"State update error during defensive replacement: {e}", - exc_info=True + f"State update error during defensive replacement: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"State sync error: {str(e)}", - error_code="STATE_SYNC_ERROR" + error_code="STATE_SYNC_ERROR", ) async def change_pitcher( @@ -383,7 +406,7 @@ class SubstitutionManager: pitcher_out_lineup_id: int, pitcher_in_card_id: int, team_id: int, - force_change: bool = False + force_change: bool = False, ) -> SubstitutionResult: """ Execute pitching change. @@ -414,7 +437,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Game {game_id} not found", - error_code="GAME_NOT_FOUND" + error_code="GAME_NOT_FOUND", ) roster = state_manager.get_lineup(game_id, team_id) @@ -422,7 +445,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Roster not found for team {team_id}", - error_code="ROSTER_NOT_FOUND" + error_code="ROSTER_NOT_FOUND", ) pitcher_out = roster.get_player_by_lineup_id(pitcher_out_lineup_id) @@ -430,7 +453,7 @@ class SubstitutionManager: return SubstitutionResult( success=False, error_message=f"Player with lineup_id {pitcher_out_lineup_id} not found", - error_code="PLAYER_NOT_FOUND" + error_code="PLAYER_NOT_FOUND", ) # STEP 2: Validate substitution @@ -439,14 +462,14 @@ class SubstitutionManager: pitcher_out=pitcher_out, pitcher_in_card_id=pitcher_in_card_id, roster=roster, - force_change=force_change + force_change=force_change, ) if not validation.is_valid: return SubstitutionResult( success=False, error_message=validation.error_message, - error_code=validation.error_code + error_code=validation.error_code, ) # STEP 3: Update DATABASE FIRST @@ -456,17 +479,17 @@ class SubstitutionManager: team_id=team_id, player_out_lineup_id=pitcher_out_lineup_id, player_in_card_id=pitcher_in_card_id, - position='P', # Always pitcher + position="P", # Always pitcher batting_order=pitcher_out.batting_order, # Maintains batting order inning=state.inning, - play_number=state.play_count + play_number=state.play_count, ) except Exception as e: logger.error(f"Database error during pitching change: {e}", exc_info=True) return SubstitutionResult( success=False, error_message=f"Database error: {str(e)}", - error_code="DB_ERROR" + error_code="DB_ERROR", ) # STEP 4: Update IN-MEMORY STATE SECOND @@ -477,19 +500,21 @@ class SubstitutionManager: # Fetch player data for SBA league player_name = f"Player #{pitcher_in_card_id}" player_image = "" - if state.league_id == 'sba': - player_name, player_image = await lineup_service.get_sba_player_data(pitcher_in_card_id) + if state.league_id == "sba": + player_name, player_image = await lineup_service.get_sba_player_data( + pitcher_in_card_id + ) # Create new pitcher entry new_pitcher = LineupPlayerState( lineup_id=new_lineup_id, card_id=pitcher_in_card_id, - position='P', + position="P", batting_order=pitcher_out.batting_order, is_active=True, is_starter=False, player_name=player_name, - player_image=player_image + player_image=player_image, ) # Add to roster @@ -512,19 +537,18 @@ class SubstitutionManager: new_lineup_id=new_lineup_id, player_out_lineup_id=pitcher_out_lineup_id, player_in_card_id=pitcher_in_card_id, - new_position='P', + new_position="P", new_batting_order=pitcher_out.batting_order, - updated_lineup=roster + updated_lineup=roster, ) except Exception as e: logger.error( - f"State update error during pitching change: {e}", - exc_info=True + f"State update error during pitching change: {e}", exc_info=True ) return SubstitutionResult( success=False, new_lineup_id=new_lineup_id, error_message=f"State sync error: {str(e)}", - error_code="STATE_SYNC_ERROR" + error_code="STATE_SYNC_ERROR", ) diff --git a/backend/app/core/substitution_rules.py b/backend/app/core/substitution_rules.py index 134149a..e9229df 100644 --- a/backend/app/core/substitution_rules.py +++ b/backend/app/core/substitution_rules.py @@ -10,20 +10,22 @@ Enforces official baseball substitution rules: Author: Claude Date: 2025-11-03 """ + import logging from dataclasses import dataclass -from typing import Optional, List + from app.models.game_models import GameState, LineupPlayerState, TeamLineupState -logger = logging.getLogger(f'{__name__}.SubstitutionRules') +logger = logging.getLogger(f"{__name__}.SubstitutionRules") @dataclass class ValidationResult: """Result of a substitution validation check""" + is_valid: bool - error_message: Optional[str] = None - error_code: Optional[str] = None + error_message: str | None = None + error_code: str | None = None class SubstitutionRules: @@ -42,7 +44,7 @@ class SubstitutionRules: state: GameState, player_out: LineupPlayerState, player_in_card_id: int, - roster: TeamLineupState + roster: TeamLineupState, ) -> ValidationResult: """ Validate pinch hitter substitution. @@ -67,7 +69,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Can only pinch hit for current batter. Current batter lineup_id: {state.current_batter.lineup_id}", - error_code="NOT_CURRENT_BATTER" + error_code="NOT_CURRENT_BATTER", ) # Check player_out is active @@ -75,7 +77,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message="Cannot substitute for player who is already out of game", - error_code="PLAYER_ALREADY_OUT" + error_code="PLAYER_ALREADY_OUT", ) # Check substitute is in roster @@ -84,7 +86,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Player with card_id {player_in_card_id} not found in roster", - error_code="NOT_IN_ROSTER" + error_code="NOT_IN_ROSTER", ) # Check substitute is not already active @@ -92,7 +94,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Player {player_in.card_id} is already in the game", - error_code="ALREADY_ACTIVE" + error_code="ALREADY_ACTIVE", ) # All checks passed @@ -110,7 +112,7 @@ class SubstitutionRules: player_in_card_id: int, new_position: str, roster: TeamLineupState, - allow_mid_inning: bool = False + allow_mid_inning: bool = False, ) -> ValidationResult: """ Validate defensive replacement. @@ -138,7 +140,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message="Cannot substitute for player who is already out of game", - error_code="PLAYER_ALREADY_OUT" + error_code="PLAYER_ALREADY_OUT", ) # Check timing (can substitute mid-play if injury, otherwise must wait for half-inning) @@ -152,12 +154,12 @@ class SubstitutionRules: ) # Validate new position - valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'] + valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] if new_position not in valid_positions: return ValidationResult( is_valid=False, error_message=f"Invalid position: {new_position}. Must be one of {valid_positions}", - error_code="INVALID_POSITION" + error_code="INVALID_POSITION", ) # Check substitute is in roster @@ -166,7 +168,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Player with card_id {player_in_card_id} not found in roster", - error_code="NOT_IN_ROSTER" + error_code="NOT_IN_ROSTER", ) # Check substitute is not already active @@ -174,7 +176,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Player {player_in.card_id} is already in the game", - error_code="ALREADY_ACTIVE" + error_code="ALREADY_ACTIVE", ) # All checks passed @@ -191,7 +193,7 @@ class SubstitutionRules: pitcher_out: LineupPlayerState, pitcher_in_card_id: int, roster: TeamLineupState, - force_change: bool = False + force_change: bool = False, ) -> ValidationResult: """ Validate pitching change. @@ -218,15 +220,15 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message="Cannot substitute for pitcher who is already out of game", - error_code="PLAYER_ALREADY_OUT" + error_code="PLAYER_ALREADY_OUT", ) # Check pitcher_out is actually a pitcher - if pitcher_out.position != 'P': + if pitcher_out.position != "P": return ValidationResult( is_valid=False, error_message=f"Player being replaced is not a pitcher (position: {pitcher_out.position})", - error_code="NOT_A_PITCHER" + error_code="NOT_A_PITCHER", ) # Check minimum batters faced (unless force_change) @@ -234,7 +236,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message="Pitcher must face at least 1 batter before being replaced", - error_code="MIN_BATTERS_NOT_MET" + error_code="MIN_BATTERS_NOT_MET", ) # Check substitute is in roster @@ -243,7 +245,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Player with card_id {pitcher_in_card_id} not found in roster", - error_code="NOT_IN_ROSTER" + error_code="NOT_IN_ROSTER", ) # Check substitute is not already active @@ -251,7 +253,7 @@ class SubstitutionRules: return ValidationResult( is_valid=False, error_message=f"Player {pitcher_in.card_id} is already in the game", - error_code="ALREADY_ACTIVE" + error_code="ALREADY_ACTIVE", ) # Check new pitcher can play pitcher position (MVP: skip position eligibility check) @@ -276,7 +278,7 @@ class SubstitutionRules: player_in_2_card_id: int, new_position_2: str, new_batting_order_2: int, - roster: TeamLineupState + roster: TeamLineupState, ) -> ValidationResult: """ Validate double switch (two simultaneous substitutions with batting order swap). @@ -313,13 +315,13 @@ class SubstitutionRules: player_in_card_id=player_in_1_card_id, new_position=new_position_1, roster=roster, - allow_mid_inning=False + allow_mid_inning=False, ) if not result_1.is_valid: return ValidationResult( is_valid=False, error_message=f"First substitution invalid: {result_1.error_message}", - error_code=f"FIRST_SUB_{result_1.error_code}" + error_code=f"FIRST_SUB_{result_1.error_code}", ) # Validate second substitution @@ -329,28 +331,30 @@ class SubstitutionRules: player_in_card_id=player_in_2_card_id, new_position=new_position_2, roster=roster, - allow_mid_inning=False + allow_mid_inning=False, ) if not result_2.is_valid: return ValidationResult( is_valid=False, error_message=f"Second substitution invalid: {result_2.error_message}", - error_code=f"SECOND_SUB_{result_2.error_code}" + error_code=f"SECOND_SUB_{result_2.error_code}", ) # Validate batting orders - if new_batting_order_1 not in range(1, 10) or new_batting_order_2 not in range(1, 10): + if new_batting_order_1 not in range(1, 10) or new_batting_order_2 not in range( + 1, 10 + ): return ValidationResult( is_valid=False, error_message="Batting orders must be between 1 and 9", - error_code="INVALID_BATTING_ORDER" + error_code="INVALID_BATTING_ORDER", ) if new_batting_order_1 == new_batting_order_2: return ValidationResult( is_valid=False, error_message="Both players cannot have same batting order", - error_code="DUPLICATE_BATTING_ORDER" + error_code="DUPLICATE_BATTING_ORDER", ) # All checks passed diff --git a/backend/app/core/validators.py b/backend/app/core/validators.py index 795292c..a41cab9 100644 --- a/backend/app/core/validators.py +++ b/backend/app/core/validators.py @@ -6,16 +6,17 @@ Ensures all game actions follow baseball rules and state is valid. Author: Claude Date: 2025-10-24 """ + import logging -from uuid import UUID -from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision +from app.models.game_models import DefensiveDecision, GameState, OffensiveDecision -logger = logging.getLogger(f'{__name__}.GameValidator') +logger = logging.getLogger(f"{__name__}.GameValidator") class ValidationError(Exception): """Raised when validation fails""" + pass @@ -43,7 +44,9 @@ class GameValidator: raise ValidationError(f"Invalid half: {half}") @staticmethod - def validate_defensive_decision(decision: DefensiveDecision, state: GameState) -> None: + def validate_defensive_decision( + decision: DefensiveDecision, state: GameState + ) -> None: """ Validate defensive team decision against current game state. @@ -72,33 +75,41 @@ class GameValidator: occupied_bases = state.bases_occupied() for base in decision.hold_runners: if base not in [1, 2, 3]: - raise ValidationError(f"Invalid hold runner base: {base} (must be 1, 2, or 3)") + raise ValidationError( + f"Invalid hold runner base: {base} (must be 1, 2, or 3)" + ) if base not in occupied_bases: - raise ValidationError(f"Cannot hold runner on base {base} - no runner present") + raise ValidationError( + f"Cannot hold runner on base {base} - no runner present" + ) # Validate corners_in/infield_in depth requirements (requires runner on third) - if decision.infield_depth in ['corners_in', 'infield_in']: + if decision.infield_depth in ["corners_in", "infield_in"]: if not state.is_runner_on_third(): - raise ValidationError(f"Cannot play {decision.infield_depth} without a runner on third") + raise ValidationError( + f"Cannot play {decision.infield_depth} without a runner on third" + ) # Validate shallow outfield requires walk-off scenario - if decision.outfield_depth == 'shallow': + if decision.outfield_depth == "shallow": # Walk-off conditions: # 1. Home team batting (bottom of inning) # 2. Bottom of final inning or later # 3. Tied or trailing # 4. Runner on base - is_home_batting = (state.half == 'bottom') - is_late_inning = (state.inning >= state.regulation_innings) + is_home_batting = state.half == "bottom" + is_late_inning = state.inning >= state.regulation_innings if is_home_batting: - is_close_game = (state.home_score <= state.away_score) + is_close_game = state.home_score <= state.away_score else: - is_close_game = (state.away_score <= state.home_score) + is_close_game = state.away_score <= state.home_score has_runners = len(occupied_bases) > 0 - if not (is_home_batting and is_late_inning and is_close_game and has_runners): + if not ( + is_home_batting and is_late_inning and is_close_game and has_runners + ): raise ValidationError( f"Shallow outfield only allowed in walk-off situations " f"(home team batting, bottom {state.regulation_innings}th+ inning, tied/trailing, runner on base)" @@ -107,7 +118,9 @@ class GameValidator: logger.debug("Defensive decision validated") @staticmethod - def validate_offensive_decision(decision: OffensiveDecision, state: GameState) -> None: + def validate_offensive_decision( + decision: OffensiveDecision, state: GameState + ) -> None: """ Validate offensive team decision against current game state. @@ -124,45 +137,55 @@ class GameValidator: occupied_bases = state.bases_occupied() # Validate steal action - requires steal_attempts to be specified - if decision.action == 'steal': + if decision.action == "steal": if not decision.steal_attempts: - raise ValidationError("Steal action requires steal_attempts to specify which bases to steal") + raise ValidationError( + "Steal action requires steal_attempts to specify which bases to steal" + ) # Validate squeeze_bunt - requires R3, not with 2 outs - if decision.action == 'squeeze_bunt': + if decision.action == "squeeze_bunt": if not state.is_runner_on_third(): raise ValidationError("Squeeze bunt requires a runner on third base") if state.outs >= 2: raise ValidationError("Squeeze bunt cannot be used with 2 outs") # Validate check_jump - requires runner on base (lead runner only OR both if 1st+3rd) - if decision.action == 'check_jump': + if decision.action == "check_jump": if len(occupied_bases) == 0: raise ValidationError("Check jump requires at least one runner on base") # Lead runner validation: can't check jump at 2nd if R3 exists if state.is_runner_on_second() and state.is_runner_on_third(): - raise ValidationError("Check jump not allowed for trail runner (R2) when R3 is on base") + raise ValidationError( + "Check jump not allowed for trail runner (R2) when R3 is on base" + ) # Validate sac_bunt - cannot be used with 2 outs - if decision.action == 'sac_bunt': + if decision.action == "sac_bunt": if state.outs >= 2: raise ValidationError("Sacrifice bunt cannot be used with 2 outs") # Validate hit_and_run action - requires runner on base - if decision.action == 'hit_and_run': + if decision.action == "hit_and_run": if len(occupied_bases) == 0: - raise ValidationError("Hit and run action requires at least one runner on base") + raise ValidationError( + "Hit and run action requires at least one runner on base" + ) # Validate steal attempts (when provided) for base in decision.steal_attempts: # Validate steal base is valid (2, 3, or 4 for home) if base not in [2, 3, 4]: - raise ValidationError(f"Invalid steal attempt to base {base} (must be 2, 3, or 4)") + raise ValidationError( + f"Invalid steal attempt to base {base} (must be 2, 3, or 4)" + ) # Must have runner on base-1 to steal base stealing_from = base - 1 if stealing_from not in occupied_bases: - raise ValidationError(f"Cannot steal base {base} - no runner on base {stealing_from}") + raise ValidationError( + f"Cannot steal base {base} - no runner on base {stealing_from}" + ) logger.debug("Offensive decision validated") @@ -177,7 +200,7 @@ class GameValidator: Raises: ValidationError: If any position is missing or duplicated """ - required_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] + required_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"] # Count active players per position position_counts: dict[str, int] = {} diff --git a/backend/app/core/x_check_advancement_tables.py b/backend/app/core/x_check_advancement_tables.py index fb75fad..2ccc92f 100644 --- a/backend/app/core/x_check_advancement_tables.py +++ b/backend/app/core/x_check_advancement_tables.py @@ -23,10 +23,14 @@ Date: 2025-11-02 """ import logging -from typing import Dict, Tuple -from app.core.runner_advancement import GroundballResultType, AdvancementResult, RunnerMovement -logger = logging.getLogger(f'{__name__}') +from app.core.runner_advancement import ( + AdvancementResult, + GroundballResultType, + RunnerMovement, +) + +logger = logging.getLogger(f"{__name__}") # ============================================================================ # GROUNDBALL ADVANCEMENT TABLES @@ -53,326 +57,353 @@ logger = logging.getLogger(f'{__name__}') # - 'RP': Rare play (currently stubbed as SAFE_ALL_ADVANCE_ONE) # G1 Advancement Table (from rulebook) -G1_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = { +G1_ADVANCEMENT_TABLE: dict[int, dict[tuple[bool, str], GroundballResultType]] = { # Base code 0: Bases empty 0: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, # TODO: Actual RP logic - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # N/A fallback to normal - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + ( + False, + "RP", + ): GroundballResultType.SAFE_ALL_ADVANCE_ONE, # TODO: Actual RP logic + ( + True, + "NO", + ): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # N/A fallback to normal + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 1: R1 only 1: { - (False, 'NO'): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 (Chart: Infield In = 2) - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + True, + "NO", + ): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 (Chart: Infield In = 2) + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 2: R2 only 2: { - (False, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 3: R3 only 3: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 (Chart: Normal = 3) - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 (Chart: Infield In = 1) - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + False, + "NO", + ): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 (Chart: Normal = 3) + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + True, + "NO", + ): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 (Chart: Infield In = 1) + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 4: R1 + R2 4: { - (False, 'NO'): GroundballResultType.CONDITIONAL_DOUBLE_PLAY, # Result 13 (Chart: Normal = 13) - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 (Chart: Infield In = 2) - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + False, + "NO", + ): GroundballResultType.CONDITIONAL_DOUBLE_PLAY, # Result 13 (Chart: Normal = 13) + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + True, + "NO", + ): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 (Chart: Infield In = 2) + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 5: R1 + R3 5: { - (False, 'NO'): GroundballResultType.CONDITIONAL_DOUBLE_PLAY, # Result 13 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.CONDITIONAL_DOUBLE_PLAY, # Result 13 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 6: R2 + R3 6: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 7: Bases loaded (R1 + R2 + R3) 7: { - (False, 'NO'): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST, # Result 10 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.DOUBLE_PLAY_AT_SECOND, # Result 2 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.DOUBLE_PLAY_HOME_TO_FIRST, # Result 10 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, } # G2 Advancement Table (from rulebook) -G2_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = { +G2_ADVANCEMENT_TABLE: dict[int, dict[tuple[bool, str], GroundballResultType]] = { # Base code 0: Bases empty 0: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # N/A fallback - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # N/A fallback + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 1: R1 only 1: { - (False, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 2: R2 only 2: { - (False, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 3: R3 only 3: { - (False, 'NO'): GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD, # Result 5 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 (Chart: Infield In = 1) - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD, # Result 5 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + True, + "NO", + ): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 (Chart: Infield In = 1) + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 4: R1 + R2 4: { - (False, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 (Chart: Normal = 4) - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + ( + False, + "NO", + ): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 (Chart: Normal = 4) + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 5: R1 + R3 5: { - (False, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 6: R2 + R3 6: { - (False, 'NO'): GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD, # Result 5 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.CONDITIONAL_ON_MIDDLE_INFIELD, # Result 5 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 7: Bases loaded (R1 + R2 + R3) 7: { - (False, 'NO'): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_SAFE_LEAD_OUT, # Result 11 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_SAFE_FORCE_OUT_AT_SECOND, # Result 4 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_SAFE_LEAD_OUT, # Result 11 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, } # G3 Advancement Table (from rulebook) -G3_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = { +G3_ADVANCEMENT_TABLE: dict[int, dict[tuple[bool, str], GroundballResultType]] = { # Base code 0: Bases empty 0: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # N/A fallback - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # Result 1 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_HOLD, # N/A fallback + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 1: R1 only 1: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 2: R2 only 2: { - (False, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # Result 12 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 3: R3 only 3: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # DECIDE - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # DECIDE + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 4: R1 + R2 4: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 5: R1 + R3 5: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 6: R2 + R3 6: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.DECIDE_OPPORTUNITY, # DECIDE - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.DECIDE_OPPORTUNITY, # DECIDE + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, # Base code 7: Bases loaded (R1 + R2 + R3) 7: { - (False, 'NO'): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 - (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (False, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'NO'): GroundballResultType.BATTER_SAFE_LEAD_OUT, # Result 11 - (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, - (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO, - (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE, - (True, 'RP'): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "NO"): GroundballResultType.BATTER_OUT_RUNNERS_ADVANCE, # Result 3 + (False, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (False, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (False, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (False, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "NO"): GroundballResultType.BATTER_SAFE_LEAD_OUT, # Result 11 + (True, "E1"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, + (True, "E2"): GroundballResultType.SAFE_ALL_ADVANCE_TWO, + (True, "E3"): GroundballResultType.SAFE_ALL_ADVANCE_THREE, + (True, "RP"): GroundballResultType.SAFE_ALL_ADVANCE_ONE, }, } @@ -381,7 +412,7 @@ def get_groundball_advancement( result_type: str, # 'G1', 'G2', or 'G3' on_base_code: int, defender_in: bool, - error_result: str + error_result: str, ) -> GroundballResultType: """ Get GroundballResultType for X-Check groundball. @@ -400,9 +431,9 @@ def get_groundball_advancement( """ # Select table tables = { - 'G1': G1_ADVANCEMENT_TABLE, - 'G2': G2_ADVANCEMENT_TABLE, - 'G3': G3_ADVANCEMENT_TABLE, + "G1": G1_ADVANCEMENT_TABLE, + "G2": G2_ADVANCEMENT_TABLE, + "G3": G3_ADVANCEMENT_TABLE, } if result_type not in tables: @@ -427,7 +458,7 @@ def get_groundball_advancement( def get_hit_advancement_with_error( hit_type: str, # 'SI1', 'SI2', 'DO2', 'DO3', 'TR3' - error_result: str # 'NO', 'E1', 'E2', 'E3', 'RP' + error_result: str, # 'NO', 'E1', 'E2', 'E3', 'RP' ) -> int: """ Calculate total bases advanced for hit + error. @@ -446,20 +477,20 @@ def get_hit_advancement_with_error( """ # Base hit advancement hit_bases = { - 'SI1': 1, - 'SI2': 1, - 'DO2': 2, - 'DO3': 2, - 'TR3': 3, + "SI1": 1, + "SI2": 1, + "DO2": 2, + "DO3": 2, + "TR3": 3, } # Error bonus error_bonus = { - 'NO': 0, - 'E1': 1, - 'E2': 2, - 'E3': 3, - 'RP': 1, # TODO: Actual RP logic (using E1 for now) + "NO": 0, + "E1": 1, + "E2": 2, + "E3": 3, + "RP": 1, # TODO: Actual RP logic (using E1 for now) } base_advancement = hit_bases.get(hit_type, 0) @@ -482,20 +513,18 @@ def get_error_advancement_bases(error_result: str) -> int: Number of bases to advance """ error_advances = { - 'NO': 0, - 'E1': 1, - 'E2': 2, - 'E3': 3, - 'RP': 1, # TODO: Actual RP logic (using E1 for now) + "NO": 0, + "E1": 1, + "E2": 2, + "E3": 3, + "RP": 1, # TODO: Actual RP logic (using E1 for now) } return error_advances.get(error_result, 0) def build_advancement_from_code( - on_base_code: int, - gb_type: GroundballResultType, - result_name: str = "G1" + on_base_code: int, gb_type: GroundballResultType, result_name: str = "G1" ) -> AdvancementResult: """ Build AdvancementResult from on_base_code and GroundballResultType. @@ -522,13 +551,13 @@ def build_advancement_from_code( # 0=Empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=Loaded on_base_mapping = { 0: (False, False, False), # Empty - 1: (True, False, False), # R1 - 2: (False, True, False), # R2 - 3: (False, False, True), # R3 - 4: (True, True, False), # R1+R2 - 5: (True, False, True), # R1+R3 - 6: (False, True, True), # R2+R3 - 7: (True, True, True), # Loaded + 1: (True, False, False), # R1 + 2: (False, True, False), # R2 + 3: (False, False, True), # R3 + 4: (True, True, False), # R1+R2 + 5: (True, False, True), # R1+R3 + 6: (False, True, True), # R2+R3 + 7: (True, True, True), # Loaded } r1_on, r2_on, r3_on = on_base_mapping.get(on_base_code, (False, False, False)) @@ -536,45 +565,69 @@ def build_advancement_from_code( if gb_type == GroundballResultType.SAFE_ALL_ADVANCE_ONE: # Error E1: Everyone advances 1 base # Batter to 1st - movements.append(RunnerMovement(lineup_id=0, from_base=0, to_base=1, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=1, is_out=False) + ) # Runners advance 1 if r1_on: - movements.append(RunnerMovement(lineup_id=0, from_base=1, to_base=2, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=1, to_base=2, is_out=False) + ) if r2_on: - movements.append(RunnerMovement(lineup_id=0, from_base=2, to_base=3, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=2, to_base=3, is_out=False) + ) if r3_on: - movements.append(RunnerMovement(lineup_id=0, from_base=3, to_base=4, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=3, to_base=4, is_out=False) + ) runs += 1 description = f"{result_name} + E1: Batter safe at 1st, all runners advance 1" elif gb_type == GroundballResultType.SAFE_ALL_ADVANCE_TWO: # Error E2: Everyone advances 2 bases # Batter to 2nd - movements.append(RunnerMovement(lineup_id=0, from_base=0, to_base=2, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=2, is_out=False) + ) # Runners advance 2 if r1_on: - movements.append(RunnerMovement(lineup_id=0, from_base=1, to_base=3, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=1, to_base=3, is_out=False) + ) if r2_on: - movements.append(RunnerMovement(lineup_id=0, from_base=2, to_base=4, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=2, to_base=4, is_out=False) + ) runs += 1 if r3_on: - movements.append(RunnerMovement(lineup_id=0, from_base=3, to_base=4, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=3, to_base=4, is_out=False) + ) runs += 1 description = f"{result_name} + E2: Batter safe at 2nd, all runners advance 2" elif gb_type == GroundballResultType.SAFE_ALL_ADVANCE_THREE: # Error E3: Everyone advances 3 bases # Batter to 3rd - movements.append(RunnerMovement(lineup_id=0, from_base=0, to_base=3, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=3, is_out=False) + ) # All runners score if r1_on: - movements.append(RunnerMovement(lineup_id=0, from_base=1, to_base=4, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=1, to_base=4, is_out=False) + ) runs += 1 if r2_on: - movements.append(RunnerMovement(lineup_id=0, from_base=2, to_base=4, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=2, to_base=4, is_out=False) + ) runs += 1 if r3_on: - movements.append(RunnerMovement(lineup_id=0, from_base=3, to_base=4, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=3, to_base=4, is_out=False) + ) runs += 1 description = f"{result_name} + E3: Batter safe at 3rd, all runners score" @@ -587,7 +640,9 @@ def build_advancement_from_code( f"for proper resolution. Returning placeholder." ) # Batter out as fallback - movements.append(RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True)) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True) + ) outs = 1 description = f"{result_name}: Result {gb_type} (requires GameState resolution)" @@ -596,14 +651,12 @@ def build_advancement_from_code( outs_recorded=outs, runs_scored=runs, result_type=gb_type, - description=description + description=description, ) def build_flyball_advancement_with_error( - on_base_code: int, - error_result: str, - flyball_type: str = "F1" + on_base_code: int, error_result: str, flyball_type: str = "F1" ) -> AdvancementResult: """ Build AdvancementResult for flyball + error. @@ -631,60 +684,72 @@ def build_flyball_advancement_with_error( if bases_to_advance == 0: # No error - should not be called this way # Return batter out as fallback - movements.append(RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True)) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=0, is_out=True) + ) description = f"{flyball_type}: Batter out (no error)" return AdvancementResult( movements=movements, outs_recorded=1, runs_scored=0, result_type=None, - description=description + description=description, ) # Decode on_base_code (sequential mapping, NOT bit field) on_base_mapping = { 0: (False, False, False), # Empty - 1: (True, False, False), # R1 - 2: (False, True, False), # R2 - 3: (False, False, True), # R3 - 4: (True, True, False), # R1+R2 - 5: (True, False, True), # R1+R3 - 6: (False, True, True), # R2+R3 - 7: (True, True, True), # Loaded + 1: (True, False, False), # R1 + 2: (False, True, False), # R2 + 3: (False, False, True), # R3 + 4: (True, True, False), # R1+R2 + 5: (True, False, True), # R1+R3 + 6: (False, True, True), # R2+R3 + 7: (True, True, True), # Loaded } r1_on, r2_on, r3_on = on_base_mapping.get(on_base_code, (False, False, False)) # Batter advances batter_to_base = min(bases_to_advance, 4) - movements.append(RunnerMovement(lineup_id=0, from_base=0, to_base=batter_to_base, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=0, to_base=batter_to_base, is_out=False) + ) if batter_to_base == 4: runs += 1 # Runners advance if r1_on: r1_to_base = min(1 + bases_to_advance, 4) - movements.append(RunnerMovement(lineup_id=0, from_base=1, to_base=r1_to_base, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=1, to_base=r1_to_base, is_out=False) + ) if r1_to_base == 4: runs += 1 if r2_on: r2_to_base = min(2 + bases_to_advance, 4) - movements.append(RunnerMovement(lineup_id=0, from_base=2, to_base=r2_to_base, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=2, to_base=r2_to_base, is_out=False) + ) if r2_to_base == 4: runs += 1 if r3_on: r3_to_base = min(3 + bases_to_advance, 4) - movements.append(RunnerMovement(lineup_id=0, from_base=3, to_base=r3_to_base, is_out=False)) + movements.append( + RunnerMovement(lineup_id=0, from_base=3, to_base=r3_to_base, is_out=False) + ) if r3_to_base == 4: runs += 1 - description = f"{flyball_type} + {error_result}: All runners advance {bases_to_advance} bases" + description = ( + f"{flyball_type} + {error_result}: All runners advance {bases_to_advance} bases" + ) return AdvancementResult( movements=movements, outs_recorded=0, # Error negates out runs_scored=runs, result_type=None, - description=description + description=description, ) diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index c4eaaa2..1791673 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -12,16 +12,16 @@ Date: 2025-10-22 # Note: SQLAlchemy Column descriptors cause false positives in Pylance/Pyright import logging -from typing import Optional, List, Dict from uuid import UUID + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import AsyncSessionLocal -from app.models.db_models import Game, Play, Lineup, GameSession, RosterLink, Roll +from app.models.db_models import Game, GameSession, Lineup, Play, Roll, RosterLink from app.models.roster_models import PdRosterLinkData, SbaRosterLinkData -logger = logging.getLogger(f'{__name__}.DatabaseOperations') +logger = logging.getLogger(f"{__name__}.DatabaseOperations") class DatabaseOperations: @@ -42,7 +42,7 @@ class DatabaseOperations: visibility: str, home_team_is_ai: bool = False, away_team_is_ai: bool = False, - ai_difficulty: Optional[str] = None + ai_difficulty: str | None = None, ) -> Game: """ Create new game in database. @@ -76,7 +76,7 @@ class DatabaseOperations: home_team_is_ai=home_team_is_ai, away_team_is_ai=away_team_is_ai, ai_difficulty=ai_difficulty, - status="pending" + status="pending", ) session.add(game) await session.commit() @@ -88,7 +88,7 @@ class DatabaseOperations: logger.error(f"Failed to create game {game_id}: {e}") raise - async def get_game(self, game_id: UUID) -> Optional[Game]: + async def get_game(self, game_id: UUID) -> Game | None: """ Get game by ID. @@ -99,9 +99,7 @@ class DatabaseOperations: Game model if found, None otherwise """ async with AsyncSessionLocal() as session: - result = await session.execute( - select(Game).where(Game.id == game_id) - ) + result = await session.execute(select(Game).where(Game.id == game_id)) game = result.scalar_one_or_none() if game: logger.debug(f"Retrieved game {game_id} from database") @@ -114,8 +112,8 @@ class DatabaseOperations: half: str, home_score: int, away_score: int, - status: Optional[str] = None, - session: Optional[AsyncSession] = None + status: str | None = None, + session: AsyncSession | None = None, ) -> None: """ Update game state fields using direct UPDATE (no SELECT). @@ -140,7 +138,7 @@ class DatabaseOperations: "current_inning": inning, "current_half": half, "home_score": home_score, - "away_score": away_score + "away_score": away_score, } if status: @@ -148,9 +146,7 @@ class DatabaseOperations: # Direct UPDATE statement (no SELECT needed) result = await sess.execute( - update(Game) - .where(Game.id == game_id) - .values(**update_values) + update(Game).where(Game.id == game_id).values(**update_values) ) if result.rowcount == 0: @@ -166,7 +162,9 @@ class DatabaseOperations: try: await _do_update(new_session) await new_session.commit() - logger.debug(f"Updated game {game_id} state (inning {inning}, {half})") + logger.debug( + f"Updated game {game_id} state (inning {inning}, {half})" + ) except Exception as e: await new_session.rollback() @@ -179,8 +177,8 @@ class DatabaseOperations: team_id: int, card_id: int, position: str, - batting_order: Optional[int] = None, - is_starter: bool = True + batting_order: int | None = None, + is_starter: bool = True, ) -> Lineup: """ Add PD card to lineup. @@ -209,7 +207,7 @@ class DatabaseOperations: position=position, batting_order=batting_order, is_starter=is_starter, - is_active=True + is_active=True, ) session.add(lineup) await session.commit() @@ -228,8 +226,8 @@ class DatabaseOperations: team_id: int, player_id: int, position: str, - batting_order: Optional[int] = None, - is_starter: bool = True + batting_order: int | None = None, + is_starter: bool = True, ) -> Lineup: """ Add SBA player to lineup. @@ -258,12 +256,14 @@ class DatabaseOperations: position=position, batting_order=batting_order, is_starter=is_starter, - is_active=True + is_active=True, ) session.add(lineup) await session.commit() await session.refresh(lineup) - logger.debug(f"Added SBA player {player_id} to lineup in game {game_id}") + logger.debug( + f"Added SBA player {player_id} to lineup in game {game_id}" + ) return lineup except Exception as e: @@ -271,7 +271,7 @@ class DatabaseOperations: logger.error(f"Failed to add SBA lineup player: {e}") raise - async def get_active_lineup(self, game_id: UUID, team_id: int) -> List[Lineup]: + async def get_active_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]: """ Get active lineup for team. @@ -288,12 +288,14 @@ class DatabaseOperations: .where( Lineup.game_id == game_id, Lineup.team_id == team_id, - Lineup.is_active == True + Lineup.is_active == True, ) .order_by(Lineup.batting_order) ) lineups = list(result.scalars().all()) - logger.debug(f"Retrieved {len(lineups)} active lineup entries for team {team_id}") + logger.debug( + f"Retrieved {len(lineups)} active lineup entries for team {team_id}" + ) return lineups async def create_substitution( @@ -303,9 +305,9 @@ class DatabaseOperations: player_out_lineup_id: int, player_in_card_id: int, position: str, - batting_order: Optional[int], + batting_order: int | None, inning: int, - play_number: int + play_number: int, ) -> int: """ Create substitution in database (DB-first pattern). @@ -355,14 +357,14 @@ class DatabaseOperations: game_id=game_id, team_id=team_id, card_id=player_in_card_id, # For PD, will use card_id - player_id=None, # For SBA, swap these + player_id=None, # For SBA, swap these position=position, batting_order=batting_order, - is_starter=False, # Substitutes are never starters - is_active=True, # New player is active + is_starter=False, # Substitutes are never starters + is_active=True, # New player is active entered_inning=inning, replacing_id=player_out_lineup_id, - after_play=play_number + after_play=play_number, ) session.add(new_lineup) @@ -383,10 +385,8 @@ class DatabaseOperations: raise async def get_eligible_substitutes( - self, - game_id: UUID, - team_id: int - ) -> List[Lineup]: + self, game_id: UUID, team_id: int + ) -> list[Lineup]: """ Get all inactive players (potential substitutes). @@ -403,18 +403,18 @@ class DatabaseOperations: .where( Lineup.game_id == game_id, Lineup.team_id == team_id, - Lineup.is_active == False + Lineup.is_active == False, ) .order_by(Lineup.batting_order) ) subs = list(result.scalars().all()) - logger.debug(f"Retrieved {len(subs)} eligible substitutes for team {team_id}") + logger.debug( + f"Retrieved {len(subs)} eligible substitutes for team {team_id}" + ) return subs async def save_play( - self, - play_data: dict, - session: Optional[AsyncSession] = None + self, play_data: dict, session: AsyncSession | None = None ) -> int: """ Save play to database. @@ -429,6 +429,7 @@ class DatabaseOperations: Raises: SQLAlchemyError: If database operation fails """ + async def _do_save(sess: AsyncSession) -> int: play = Play(**play_data) sess.add(play) @@ -441,19 +442,18 @@ class DatabaseOperations: if session: return await _do_save(session) # Don't commit - caller controls transaction - else: - async with AsyncSessionLocal() as new_session: - try: - play_id = await _do_save(new_session) - await new_session.commit() - return play_id + async with AsyncSessionLocal() as new_session: + try: + play_id = await _do_save(new_session) + await new_session.commit() + return play_id - except Exception as e: - await new_session.rollback() - logger.error(f"Failed to save play: {e}") - raise + except Exception as e: + await new_session.rollback() + logger.error(f"Failed to save play: {e}") + raise - async def get_plays(self, game_id: UUID) -> List[Play]: + async def get_plays(self, game_id: UUID) -> list[Play]: """ Get all plays for game. @@ -465,15 +465,13 @@ class DatabaseOperations: """ async with AsyncSessionLocal() as session: result = await session.execute( - select(Play) - .where(Play.game_id == game_id) - .order_by(Play.play_number) + select(Play).where(Play.game_id == game_id).order_by(Play.play_number) ) plays = list(result.scalars().all()) logger.debug(f"Retrieved {len(plays)} plays for game {game_id}") return plays - async def load_game_state(self, game_id: UUID) -> Optional[Dict]: + async def load_game_state(self, game_id: UUID) -> dict | None: """ Load complete game state for recovery. @@ -487,9 +485,7 @@ class DatabaseOperations: """ async with AsyncSessionLocal() as session: # Get game - game_result = await session.execute( - select(Game).where(Game.id == game_id) - ) + game_result = await session.execute(select(Game).where(Game.id == game_id)) game = game_result.scalar_one_or_none() if not game: @@ -498,67 +494,68 @@ class DatabaseOperations: # Get lineups lineup_result = await session.execute( - select(Lineup) - .where(Lineup.game_id == game_id, Lineup.is_active == True) + select(Lineup).where( + Lineup.game_id == game_id, Lineup.is_active == True + ) ) lineups = list(lineup_result.scalars().all()) # Get plays play_result = await session.execute( - select(Play) - .where(Play.game_id == game_id) - .order_by(Play.play_number) + select(Play).where(Play.game_id == game_id).order_by(Play.play_number) ) plays = list(play_result.scalars().all()) - logger.info(f"Loaded game state for {game_id}: {len(lineups)} lineups, {len(plays)} plays") + logger.info( + f"Loaded game state for {game_id}: {len(lineups)} lineups, {len(plays)} plays" + ) return { - 'game': { - 'id': game.id, - 'league_id': game.league_id, - 'home_team_id': game.home_team_id, - 'away_team_id': game.away_team_id, - 'home_team_is_ai': game.home_team_is_ai, - 'away_team_is_ai': game.away_team_is_ai, - 'status': game.status, - 'current_inning': game.current_inning, - 'current_half': game.current_half, - 'home_score': game.home_score, - 'away_score': game.away_score + "game": { + "id": game.id, + "league_id": game.league_id, + "home_team_id": game.home_team_id, + "away_team_id": game.away_team_id, + "home_team_is_ai": game.home_team_is_ai, + "away_team_is_ai": game.away_team_is_ai, + "status": game.status, + "current_inning": game.current_inning, + "current_half": game.current_half, + "home_score": game.home_score, + "away_score": game.away_score, }, - 'lineups': [ + "lineups": [ { - 'id': l.id, - 'team_id': l.team_id, - 'card_id': l.card_id, - 'player_id': l.player_id, - 'position': l.position, - 'batting_order': l.batting_order, - 'is_active': l.is_active + "id": l.id, + "team_id": l.team_id, + "card_id": l.card_id, + "player_id": l.player_id, + "position": l.position, + "batting_order": l.batting_order, + "is_active": l.is_active, } for l in lineups ], - 'plays': [ + "plays": [ { - 'play_number': p.play_number, - 'inning': p.inning, - 'half': p.half, - 'outs_before': p.outs_before, - 'result_description': p.result_description, - 'complete': p.complete, + "play_number": p.play_number, + "inning": p.inning, + "half": p.half, + "outs_before": p.outs_before, + "result_description": p.result_description, + "complete": p.complete, # Runner tracking for state recovery - 'batter_id': p.batter_id, - 'on_first_id': p.on_first_id, - 'on_second_id': p.on_second_id, - 'on_third_id': p.on_third_id, - 'batter_final': p.batter_final, - 'on_first_final': p.on_first_final, - 'on_second_final': p.on_second_final, - 'on_third_final': p.on_third_final + "batter_id": p.batter_id, + "on_first_id": p.on_first_id, + "on_second_id": p.on_second_id, + "on_third_id": p.on_third_id, + "batter_final": p.batter_final, + "on_first_final": p.on_first_final, + "on_second_final": p.on_second_final, + "on_third_final": p.on_third_final, } for p in plays - ] + ], } async def create_game_session(self, game_id: UUID) -> GameSession: @@ -586,9 +583,7 @@ class DatabaseOperations: raise async def update_session_snapshot( - self, - game_id: UUID, - state_snapshot: dict + self, game_id: UUID, state_snapshot: dict ) -> None: """ Update session state snapshot. @@ -620,10 +615,7 @@ class DatabaseOperations: raise async def add_pd_roster_card( - self, - game_id: UUID, - card_id: int, - team_id: int + self, game_id: UUID, card_id: int, team_id: int ) -> PdRosterLinkData: """ Add a PD card to game roster. @@ -642,9 +634,7 @@ class DatabaseOperations: async with AsyncSessionLocal() as session: try: roster_link = RosterLink( - game_id=game_id, - card_id=card_id, - team_id=team_id + game_id=game_id, card_id=card_id, team_id=team_id ) session.add(roster_link) await session.commit() @@ -654,8 +644,8 @@ class DatabaseOperations: return PdRosterLinkData( id=roster_link.id, game_id=roster_link.game_id, - card_id=roster_link.card_id, - team_id=roster_link.team_id + card_id=roster_link.card_id, + team_id=roster_link.team_id, ) except Exception as e: @@ -664,10 +654,7 @@ class DatabaseOperations: raise ValueError(f"Could not add card to roster: {e}") async def add_sba_roster_player( - self, - game_id: UUID, - player_id: int, - team_id: int + self, game_id: UUID, player_id: int, team_id: int ) -> SbaRosterLinkData: """ Add an SBA player to game roster. @@ -686,20 +673,20 @@ class DatabaseOperations: async with AsyncSessionLocal() as session: try: roster_link = RosterLink( - game_id=game_id, - player_id=player_id, - team_id=team_id + game_id=game_id, player_id=player_id, team_id=team_id ) session.add(roster_link) await session.commit() await session.refresh(roster_link) - logger.info(f"Added SBA player {player_id} to roster for game {game_id}") + logger.info( + f"Added SBA player {player_id} to roster for game {game_id}" + ) return SbaRosterLinkData( id=roster_link.id, game_id=roster_link.game_id, - player_id=roster_link.player_id, - team_id=roster_link.team_id + player_id=roster_link.player_id, + team_id=roster_link.team_id, ) except Exception as e: @@ -708,10 +695,8 @@ class DatabaseOperations: raise ValueError(f"Could not add player to roster: {e}") async def get_pd_roster( - self, - game_id: UUID, - team_id: Optional[int] = None - ) -> List[PdRosterLinkData]: + self, game_id: UUID, team_id: int | None = None + ) -> list[PdRosterLinkData]: """ Get PD cards for a game, optionally filtered by team. @@ -725,8 +710,7 @@ class DatabaseOperations: async with AsyncSessionLocal() as session: try: query = select(RosterLink).where( - RosterLink.game_id == game_id, - RosterLink.card_id.is_not(None) + RosterLink.game_id == game_id, RosterLink.card_id.is_not(None) ) if team_id is not None: @@ -739,8 +723,8 @@ class DatabaseOperations: PdRosterLinkData( id=link.id, game_id=link.game_id, - card_id=link.card_id, - team_id=link.team_id + card_id=link.card_id, + team_id=link.team_id, ) for link in roster_links ] @@ -750,10 +734,8 @@ class DatabaseOperations: raise async def get_sba_roster( - self, - game_id: UUID, - team_id: Optional[int] = None - ) -> List[SbaRosterLinkData]: + self, game_id: UUID, team_id: int | None = None + ) -> list[SbaRosterLinkData]: """ Get SBA players for a game, optionally filtered by team. @@ -767,8 +749,7 @@ class DatabaseOperations: async with AsyncSessionLocal() as session: try: query = select(RosterLink).where( - RosterLink.game_id == game_id, - RosterLink.player_id.is_not(None) + RosterLink.game_id == game_id, RosterLink.player_id.is_not(None) ) if team_id is not None: @@ -781,8 +762,8 @@ class DatabaseOperations: SbaRosterLinkData( id=link.id, game_id=link.game_id, - player_id=link.player_id, - team_id=link.team_id + player_id=link.player_id, + team_id=link.team_id, ) for link in roster_links ] @@ -820,7 +801,7 @@ class DatabaseOperations: logger.error(f"Failed to remove roster entry: {e}") raise - async def save_rolls_batch(self, rolls: List) -> None: + async def save_rolls_batch(self, rolls: list) -> None: """ Save multiple dice rolls in a single transaction. @@ -848,7 +829,7 @@ class DatabaseOperations: player_id=roll.player_id, roll_data=roll.to_dict(), # Store full roll as JSONB context=roll.context, - timestamp=roll.timestamp + timestamp=roll.timestamp, ) for roll in rolls ] @@ -865,10 +846,10 @@ class DatabaseOperations: async def get_rolls_for_game( self, game_id: UUID, - roll_type: Optional[str] = None, - team_id: Optional[int] = None, - limit: int = 100 - ) -> List[Roll]: + roll_type: str | None = None, + team_id: int | None = None, + limit: int = 100, + ) -> list[Roll]: """ Get roll history for a game with optional filtering. @@ -904,11 +885,7 @@ class DatabaseOperations: # ROLLBACK OPERATIONS # ============================================================================ - async def delete_plays_after( - self, - game_id: UUID, - after_play_number: int - ) -> int: + async def delete_plays_after(self, game_id: UUID, after_play_number: int) -> int: """ Delete all plays after a specific play number. @@ -926,15 +903,16 @@ class DatabaseOperations: from sqlalchemy import delete stmt = delete(Play).where( - Play.game_id == game_id, - Play.play_number > after_play_number + Play.game_id == game_id, Play.play_number > after_play_number ) result = await session.execute(stmt) await session.commit() deleted_count = result.rowcount - logger.info(f"Deleted {deleted_count} plays after play {after_play_number} for game {game_id}") + logger.info( + f"Deleted {deleted_count} plays after play {after_play_number} for game {game_id}" + ) return deleted_count except Exception as e: @@ -943,9 +921,7 @@ class DatabaseOperations: raise async def delete_substitutions_after( - self, - game_id: UUID, - after_play_number: int + self, game_id: UUID, after_play_number: int ) -> int: """ Delete all substitutions that occurred after a specific play number. @@ -964,15 +940,16 @@ class DatabaseOperations: from sqlalchemy import delete stmt = delete(Lineup).where( - Lineup.game_id == game_id, - Lineup.after_play >= after_play_number + Lineup.game_id == game_id, Lineup.after_play >= after_play_number ) result = await session.execute(stmt) await session.commit() deleted_count = result.rowcount - logger.info(f"Deleted {deleted_count} substitutions after play {after_play_number} for game {game_id}") + logger.info( + f"Deleted {deleted_count} substitutions after play {after_play_number} for game {game_id}" + ) return deleted_count except Exception as e: @@ -980,11 +957,7 @@ class DatabaseOperations: logger.error(f"Failed to delete substitutions: {e}") raise - async def delete_rolls_after( - self, - game_id: UUID, - after_play_number: int - ) -> int: + async def delete_rolls_after(self, game_id: UUID, after_play_number: int) -> int: """ Delete all dice rolls after a specific play number. @@ -1002,15 +975,16 @@ class DatabaseOperations: from sqlalchemy import delete stmt = delete(Roll).where( - Roll.game_id == game_id, - Roll.play_number > after_play_number + Roll.game_id == game_id, Roll.play_number > after_play_number ) result = await session.execute(stmt) await session.commit() deleted_count = result.rowcount - logger.info(f"Deleted {deleted_count} rolls after play {after_play_number} for game {game_id}") + logger.info( + f"Deleted {deleted_count} rolls after play {after_play_number} for game {game_id}" + ) return deleted_count except Exception as e: diff --git a/backend/app/database/session.py b/backend/app/database/session.py index 1ed0413..ab7ea34 100644 --- a/backend/app/database/session.py +++ b/backend/app/database/session.py @@ -1,11 +1,12 @@ import logging -from typing import AsyncGenerator -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import declarative_base from app.config import get_settings -logger = logging.getLogger(f'{__name__}.session') +logger = logging.getLogger(f"{__name__}.session") settings = get_settings() @@ -34,14 +35,13 @@ async def init_db() -> None: """Initialize database tables""" async with engine.begin() as conn: # Import all models here to ensure they're registered - from app.models import db_models # Create tables await conn.run_sync(Base.metadata.create_all) logger.info("Database tables created") -async def get_session() -> AsyncGenerator[AsyncSession, None]: +async def get_session() -> AsyncGenerator[AsyncSession]: """Dependency for getting database session""" async with AsyncSessionLocal() as session: try: diff --git a/backend/app/main.py b/backend/app/main.py index d0563ac..2da57e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,18 +1,19 @@ import logging from contextlib import asynccontextmanager + +import socketio from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -import socketio +from app.api.routes import auth, games, health from app.config import get_settings -from app.api.routes import games, auth, health +from app.database.session import init_db +from app.services import redis_client +from app.utils.logging import setup_logging from app.websocket.connection_manager import ConnectionManager from app.websocket.handlers import register_handlers -from app.database.session import init_db -from app.utils.logging import setup_logging -from app.services import redis_client -logger = logging.getLogger(f'{__name__}.main') +logger = logging.getLogger(f"{__name__}.main") @asynccontextmanager @@ -34,7 +35,9 @@ async def lifespan(app: FastAPI): await redis_client.connect(redis_url) logger.info(f"Redis initialized: {redis_url}") except Exception as e: - logger.warning(f"Redis connection failed: {e}. Position rating caching will be unavailable.") + logger.warning( + f"Redis connection failed: {e}. Position rating caching will be unavailable." + ) yield @@ -52,7 +55,7 @@ app = FastAPI( title="Paper Dynasty Game Backend", description="Real-time baseball game engine for Paper Dynasty leagues", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, ) # CORS middleware @@ -67,10 +70,10 @@ app.add_middleware( # Initialize Socket.io sio = socketio.AsyncServer( - async_mode='asgi', + async_mode="asgi", cors_allowed_origins=settings.cors_origins, logger=True, - engineio_logger=False + engineio_logger=False, ) # Create Socket.io ASGI app @@ -93,10 +96,7 @@ async def root(): if __name__ == "__main__": import uvicorn + uvicorn.run( - "app.main:socket_app", - host="0.0.0.0", - port=8000, - reload=True, - log_level="info" + "app.main:socket_app", host="0.0.0.0", port=8000, reload=True, log_level="info" ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8d74263..7d6a4de 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,35 +2,35 @@ from app.models.db_models import ( Game, - Play, - Lineup, - GameSession, - RosterLink, GameCardsetLink, + GameSession, + Lineup, + Play, + RosterLink, ) from app.models.game_models import ( + DefensiveDecision, GameState, LineupPlayerState, - TeamLineupState, - DefensiveDecision, OffensiveDecision, + TeamLineupState, +) +from app.models.player_models import ( + BasePlayer, + PdBattingCard, + PdBattingRating, + PdCardset, + PdPitchingCard, + PdPitchingRating, + PdPlayer, + PdRarity, + SbaPlayer, ) from app.models.roster_models import ( BaseRosterLinkData, PdRosterLinkData, - SbaRosterLinkData, RosterLinkCreate, -) -from app.models.player_models import ( - BasePlayer, - SbaPlayer, - PdPlayer, - PdCardset, - PdRarity, - PdBattingRating, - PdPitchingRating, - PdBattingCard, - PdPitchingCard, + SbaRosterLinkData, ) __all__ = [ diff --git a/backend/app/models/db_models.py b/backend/app/models/db_models.py index d095968..7b6a709 100644 --- a/backend/app/models/db_models.py +++ b/backend/app/models/db_models.py @@ -1,17 +1,34 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, ForeignKey, Float, CheckConstraint, UniqueConstraint, func -from sqlalchemy.orm import relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB import uuid + import pendulum +from sqlalchemy import ( + JSON, + Boolean, + CheckConstraint, + Column, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import relationship from app.database.session import Base class GameCardsetLink(Base): """Link table for PD games - tracks which cardsets are allowed""" + __tablename__ = "game_cardset_links" - game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True) + game_id = Column( + UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True + ) cardset_id = Column(Integer, primary_key=True) priority = Column(Integer, default=1, index=True) @@ -27,12 +44,18 @@ class RosterLink(Base): Exactly one of card_id or player_id must be populated per row. """ + __tablename__ = "roster_links" # Surrogate primary key (allows nullable card_id/player_id) id = Column(Integer, primary_key=True, autoincrement=True) - game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True) + game_id = Column( + UUID(as_uuid=True), + ForeignKey("games.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) card_id = Column(Integer, nullable=True) # PD only player_id = Column(Integer, nullable=True) # SBA only team_id = Column(Integer, nullable=False, index=True) @@ -44,18 +67,19 @@ class RosterLink(Base): __table_args__ = ( # Ensure exactly one ID is populated (XOR logic) CheckConstraint( - '(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1', - name='roster_link_one_id_required' + "(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1", + name="roster_link_one_id_required", ), # Unique constraint for PD: one card per game - UniqueConstraint('game_id', 'card_id', name='uq_game_card'), + UniqueConstraint("game_id", "card_id", name="uq_game_card"), # Unique constraint for SBA: one player per game - UniqueConstraint('game_id', 'player_id', name='uq_game_player'), + UniqueConstraint("game_id", "player_id", name="uq_game_player"), ) class Game(Base): """Game model""" + __tablename__ = "games" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -76,7 +100,9 @@ class Game(Base): ai_difficulty = Column(String(20), nullable=True) # Timestamps - created_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive(), index=True) + created_at = Column( + DateTime, default=lambda: pendulum.now("UTC").naive(), index=True + ) started_at = Column(DateTime) completed_at = Column(DateTime) @@ -86,19 +112,36 @@ class Game(Base): # Relationships plays = relationship("Play", back_populates="game", cascade="all, delete-orphan") - lineups = relationship("Lineup", back_populates="game", cascade="all, delete-orphan") - cardset_links = relationship("GameCardsetLink", back_populates="game", cascade="all, delete-orphan") - roster_links = relationship("RosterLink", back_populates="game", cascade="all, delete-orphan") - session = relationship("GameSession", back_populates="game", uselist=False, cascade="all, delete-orphan") + lineups = relationship( + "Lineup", back_populates="game", cascade="all, delete-orphan" + ) + cardset_links = relationship( + "GameCardsetLink", back_populates="game", cascade="all, delete-orphan" + ) + roster_links = relationship( + "RosterLink", back_populates="game", cascade="all, delete-orphan" + ) + session = relationship( + "GameSession", + back_populates="game", + uselist=False, + cascade="all, delete-orphan", + ) rolls = relationship("Roll", back_populates="game", cascade="all, delete-orphan") class Play(Base): """Play model - tracks individual plays/at-bats""" + __tablename__ = "plays" id = Column(Integer, primary_key=True, autoincrement=True) - game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True) + game_id = Column( + UUID(as_uuid=True), + ForeignKey("games.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) play_number = Column(Integer, nullable=False) # Game state at start of play @@ -139,8 +182,8 @@ class Play(Base): hit_type = Column( String(50), comment="Detailed hit/out type including errors. Examples: " - "'single_2_plus_error_1', 'f2_plus_error_2', 'g1_no_error'. " - "Used primarily for X-Check plays to preserve full resolution details." + "'single_2_plus_error_1', 'f2_plus_error_2', 'g1_no_error'. " + "Used primarily for X-Check plays to preserve full resolution details.", ) result_description = Column(Text) outs_recorded = Column(Integer, nullable=False, default=0) @@ -151,8 +194,8 @@ class Play(Base): String(10), nullable=True, comment="Position checked for X-Check plays (SS, LF, 3B, etc.). " - "Non-null indicates this was an X-Check play. " - "Used only for X-Checks - all other plays leave this null." + "Non-null indicates this was an X-Check play. " + "Used only for X-Checks - all other plays leave this null.", ) error = Column(Integer, default=0) @@ -206,7 +249,9 @@ class Play(Base): locked = Column(Boolean, default=False) # Timestamps - created_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive(), index=True) + created_at = Column( + DateTime, default=lambda: pendulum.now("UTC").naive(), index=True + ) # Extensibility (use for custom runner data like jump status, etc.) play_metadata = Column(JSON, default=dict) @@ -227,20 +272,18 @@ class Play(Base): """Determine if current batting team is AI-controlled""" # Type cast for Pylance - at runtime self.half is a string, not a Column half_value: str = self.half # type: ignore[assignment] - if half_value == 'top': + if half_value == "top": return self.game.away_team_is_ai - else: - return self.game.home_team_is_ai + return self.game.home_team_is_ai @property def ai_is_fielding(self) -> bool: """Determine if current fielding team is AI-controlled""" # Type cast for Pylance - at runtime self.half is a string, not a Column half_value: str = self.half # type: ignore[assignment] - if half_value == 'top': + if half_value == "top": return self.game.home_team_is_ai - else: - return self.game.away_team_is_ai + return self.game.away_team_is_ai class Lineup(Base): @@ -251,10 +294,16 @@ class Lineup(Base): Exactly one of card_id or player_id must be populated per row. """ + __tablename__ = "lineups" id = Column(Integer, primary_key=True, autoincrement=True) - game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True) + game_id = Column( + UUID(as_uuid=True), + ForeignKey("games.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) team_id = Column(Integer, nullable=False, index=True) # Polymorphic player reference @@ -269,7 +318,9 @@ class Lineup(Base): is_active = Column(Boolean, default=True, index=True) entered_inning = Column(Integer, default=1) replacing_id = Column(Integer, nullable=True) # Lineup ID of player being replaced - after_play = Column(Integer, nullable=True) # Play number when substitution occurred + after_play = Column( + Integer, nullable=True + ) # Play number when substitution occurred # Pitcher fatigue is_fatigued = Column(Boolean, nullable=True) @@ -284,19 +335,24 @@ class Lineup(Base): __table_args__ = ( # Ensure exactly one ID is populated (XOR logic) CheckConstraint( - '(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1', - name='lineup_one_id_required' + "(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1", + name="lineup_one_id_required", ), ) class GameSession(Base): """Game session tracking - real-time WebSocket state""" + __tablename__ = "game_sessions" - game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True) + game_id = Column( + UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True + ) connected_users = Column(JSON, default=dict) - last_action_at = Column(DateTime, default=lambda: pendulum.now('UTC').naive(), index=True) + last_action_at = Column( + DateTime, default=lambda: pendulum.now("UTC").naive(), index=True + ) state_snapshot = Column(JSON, default=dict) # Relationships @@ -310,16 +366,26 @@ class Roll(Base): Tracks all dice rolls with full context for game recovery and statistics. Supports both SBA and PD leagues with polymorphic player_id. """ + __tablename__ = "rolls" roll_id = Column(String, primary_key=True) - game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True) - roll_type = Column(String, nullable=False, index=True) # 'ab', 'jump', 'fielding', 'd20' + game_id = Column( + UUID(as_uuid=True), + ForeignKey("games.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + roll_type = Column( + String, nullable=False, index=True + ) # 'ab', 'jump', 'fielding', 'd20' league_id = Column(String, nullable=False, index=True) # Auditing/Analytics fields team_id = Column(Integer, index=True) - player_id = Column(Integer, index=True) # Polymorphic: Lineup.player_id (SBA) or Lineup.card_id (PD) + player_id = Column( + Integer, index=True + ) # Polymorphic: Lineup.player_id (SBA) or Lineup.card_id (PD) # Full roll data stored as JSONB for flexibility roll_data = Column(JSONB, nullable=False) # Complete roll with all dice values diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 47cab89..d05910f 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -13,21 +13,24 @@ Date: 2025-10-22 import logging from dataclasses import dataclass -from typing import Optional, Dict, List, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional from uuid import UUID -from pydantic import BaseModel, Field, field_validator, ConfigDict + +from pydantic import BaseModel, ConfigDict, Field, field_validator + from app.config.result_charts import PlayOutcome if TYPE_CHECKING: from app.models.player_models import PositionRating -logger = logging.getLogger(f'{__name__}') +logger = logging.getLogger(f"{__name__}") # ============================================================================ # LINEUP STATE # ============================================================================ + class LineupPlayerState(BaseModel): """ Represents a player in the game lineup. @@ -37,33 +40,34 @@ class LineupPlayerState(BaseModel): Phase 3E-Main: Now includes position_rating for X-Check resolution. """ + lineup_id: int card_id: int position: str - batting_order: Optional[int] = None + batting_order: int | None = None is_active: bool = True is_starter: bool = True # Player data (loaded at game start from SBA/PD API) - player_name: Optional[str] = None - player_image: Optional[str] = None # Card image - player_headshot: Optional[str] = None # Headshot for UI circles + player_name: str | None = None + player_image: str | None = None # Card image + player_headshot: str | None = None # Headshot for UI circles # Phase 3E-Main: Position rating (loaded at game start for PD league) - position_rating: Optional['PositionRating'] = None + position_rating: Optional["PositionRating"] = None - @field_validator('position') + @field_validator("position") @classmethod def validate_position(cls, v: str) -> str: """Ensure position is valid""" - valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH'] + valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"] if v not in valid_positions: raise ValueError(f"Position must be one of {valid_positions}") return v - @field_validator('batting_order') + @field_validator("batting_order") @classmethod - def validate_batting_order(cls, v: Optional[int]) -> Optional[int]: + def validate_batting_order(cls, v: int | None) -> int | None: """Ensure batting order is 1-9 if provided""" if v is not None and (v < 1 or v > 9): raise ValueError("batting_order must be between 1 and 9") @@ -76,10 +80,11 @@ class TeamLineupState(BaseModel): Provides helper methods for common lineup queries. """ - team_id: int - players: List[LineupPlayerState] = Field(default_factory=list) - def get_batting_order(self) -> List[LineupPlayerState]: + team_id: int + players: list[LineupPlayerState] = Field(default_factory=list) + + def get_batting_order(self) -> list[LineupPlayerState]: """ Get players in batting order (1-9). @@ -88,20 +93,20 @@ class TeamLineupState(BaseModel): """ return sorted( [p for p in self.players if p.batting_order is not None], - key=lambda x: x.batting_order or 0 # Type narrowing: filtered None above + key=lambda x: x.batting_order or 0, # Type narrowing: filtered None above ) - def get_pitcher(self) -> Optional[LineupPlayerState]: + def get_pitcher(self) -> LineupPlayerState | None: """ Get the active pitcher for this team. Returns: Active pitcher or None if not found """ - pitchers = [p for p in self.players if p.position == 'P' and p.is_active] + pitchers = [p for p in self.players if p.position == "P" and p.is_active] return pitchers[0] if pitchers else None - def get_player_by_lineup_id(self, lineup_id: int) -> Optional[LineupPlayerState]: + def get_player_by_lineup_id(self, lineup_id: int) -> LineupPlayerState | None: """ Get player by lineup ID. @@ -116,7 +121,7 @@ class TeamLineupState(BaseModel): return player return None - def get_batter(self, batting_order_idx: int) -> Optional[LineupPlayerState]: + def get_batter(self, batting_order_idx: int) -> LineupPlayerState | None: """ Get batter by batting order index (0-8). @@ -131,7 +136,7 @@ class TeamLineupState(BaseModel): return order[batting_order_idx] return None - def get_player_by_card_id(self, card_id: int) -> Optional[LineupPlayerState]: + def get_player_by_card_id(self, card_id: int) -> LineupPlayerState | None: """ Get player by card ID. @@ -151,30 +156,32 @@ class TeamLineupState(BaseModel): # DECISION STATE # ============================================================================ + class DefensiveDecision(BaseModel): """ Defensive team strategic decisions for a play. These decisions affect play outcomes (e.g., infield depth affects double play chances). """ + infield_depth: str = "normal" # infield_in, normal, corners_in outfield_depth: str = "normal" # normal, shallow - hold_runners: List[int] = Field(default_factory=list) # [1, 3] = hold 1st and 3rd + hold_runners: list[int] = Field(default_factory=list) # [1, 3] = hold 1st and 3rd - @field_validator('infield_depth') + @field_validator("infield_depth") @classmethod def validate_infield_depth(cls, v: str) -> str: """Validate infield depth""" - valid = ['infield_in', 'normal', 'corners_in'] + valid = ["infield_in", "normal", "corners_in"] if v not in valid: raise ValueError(f"infield_depth must be one of {valid}") return v - @field_validator('outfield_depth') + @field_validator("outfield_depth") @classmethod def validate_outfield_depth(cls, v: str) -> str: """Validate outfield depth""" - valid = ['normal', 'shallow'] + valid = ["normal", "shallow"] if v not in valid: raise ValueError(f"outfield_depth must be one of {valid}") return v @@ -191,28 +198,38 @@ class OffensiveDecision(BaseModel): When action="steal", steal_attempts must specify which bases to steal. """ + # Specific action choice action: str = "swing_away" # Base stealing - only used when action="steal" - steal_attempts: List[int] = Field(default_factory=list) # [2] = steal second, [2, 3] = double steal + steal_attempts: list[int] = Field( + default_factory=list + ) # [2] = steal second, [2, 3] = double steal - @field_validator('action') + @field_validator("action") @classmethod def validate_action(cls, v: str) -> str: """Validate action is one of the allowed values""" - valid_actions = ['swing_away', 'steal', 'check_jump', 'hit_and_run', 'sac_bunt', 'squeeze_bunt'] + valid_actions = [ + "swing_away", + "steal", + "check_jump", + "hit_and_run", + "sac_bunt", + "squeeze_bunt", + ] if v not in valid_actions: raise ValueError(f"action must be one of {valid_actions}") return v - @field_validator('steal_attempts') + @field_validator("steal_attempts") @classmethod - def validate_steal_attempts(cls, v: List[int]) -> List[int]: + def validate_steal_attempts(cls, v: list[int]) -> list[int]: """Validate steal attempt bases""" for base in v: if base not in [2, 3, 4]: # 2nd, 3rd, home - raise ValueError(f"steal_attempts must contain only bases 2, 3, or 4") + raise ValueError("steal_attempts must contain only bases 2, 3, or 4") return v @@ -235,17 +252,20 @@ class ManualOutcomeSubmission(BaseModel): hit_location='SS' ) """ - outcome: PlayOutcome # PlayOutcome enum from result_charts - hit_location: Optional[str] = None # '1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C' - @field_validator('hit_location') + outcome: PlayOutcome # PlayOutcome enum from result_charts + hit_location: str | None = ( + None # '1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C' + ) + + @field_validator("hit_location") @classmethod - def validate_hit_location(cls, v: Optional[str]) -> Optional[str]: + def validate_hit_location(cls, v: str | None) -> str | None: """Validate hit location is a valid position.""" if v is None: return v - valid_locations = ['1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C'] + valid_locations = ["1B", "2B", "SS", "3B", "LF", "CF", "RF", "P", "C"] if v not in valid_locations: raise ValueError(f"hit_location must be one of {valid_locations}") return v @@ -255,6 +275,7 @@ class ManualOutcomeSubmission(BaseModel): # X-CHECK RESULT # ============================================================================ + @dataclass class XCheckResult: """ @@ -300,29 +321,31 @@ class XCheckResult: hit_type: str # Optional: SPD test details if applicable - spd_test_roll: Optional[int] = None - spd_test_target: Optional[int] = None - spd_test_passed: Optional[bool] = None + spd_test_roll: int | None = None + spd_test_target: int | None = None + spd_test_passed: bool | None = None def to_dict(self) -> dict: """Convert to dict for WebSocket transmission.""" return { - 'position': self.position, - 'd20_roll': self.d20_roll, - 'd6_roll': self.d6_roll, - 'defender_range': self.defender_range, - 'defender_error_rating': self.defender_error_rating, - 'defender_id': self.defender_id, - 'base_result': self.base_result, - 'converted_result': self.converted_result, - 'error_result': self.error_result, - 'final_outcome': self.final_outcome.value, - 'hit_type': self.hit_type, - 'spd_test': { - 'roll': self.spd_test_roll, - 'target': self.spd_test_target, - 'passed': self.spd_test_passed - } if self.spd_test_roll else None + "position": self.position, + "d20_roll": self.d20_roll, + "d6_roll": self.d6_roll, + "defender_range": self.defender_range, + "defender_error_rating": self.defender_error_rating, + "defender_id": self.defender_id, + "base_result": self.base_result, + "converted_result": self.converted_result, + "error_result": self.error_result, + "final_outcome": self.final_outcome.value, + "hit_type": self.hit_type, + "spd_test": { + "roll": self.spd_test_roll, + "target": self.spd_test_target, + "passed": self.spd_test_passed, + } + if self.spd_test_roll + else None, } @@ -330,6 +353,7 @@ class XCheckResult: # GAME STATE # ============================================================================ + class GameState(BaseModel): """ Complete in-memory game state. @@ -362,6 +386,7 @@ class GameState(BaseModel): play_count: Total plays so far last_play_result: Description of last play outcome """ + game_id: UUID league_id: str @@ -372,7 +397,9 @@ class GameState(BaseModel): away_team_is_ai: bool = False # Resolution mode - auto_mode: bool = False # True = auto-generate outcomes (PD only), False = manual submissions + auto_mode: bool = ( + False # True = auto-generate outcomes (PD only), False = manual submissions + ) # Game rules (configurable per game) regulation_innings: int = Field(default=9, ge=1) # Standard 9, doubleheader 7, etc. @@ -389,9 +416,9 @@ class GameState(BaseModel): away_score: int = Field(default=0, ge=0) # Runners (direct references matching DB structure) - on_first: Optional[LineupPlayerState] = None - on_second: Optional[LineupPlayerState] = None - on_third: Optional[LineupPlayerState] = None + on_first: LineupPlayerState | None = None + on_second: LineupPlayerState | None = None + on_third: LineupPlayerState | None = None # Batting order tracking (per team) - indexes into batting order (0-8) away_team_batter_idx: int = Field(default=0, ge=0, le=8) @@ -401,68 +428,80 @@ class GameState(BaseModel): # These capture the state BEFORE each play for accurate record-keeping # Changed to full objects for consistency with on_first/on_second/on_third current_batter: LineupPlayerState - current_pitcher: Optional[LineupPlayerState] = None - current_catcher: Optional[LineupPlayerState] = None - current_on_base_code: int = Field(default=0, ge=0) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded + current_pitcher: LineupPlayerState | None = None + current_catcher: LineupPlayerState | None = None + current_on_base_code: int = Field( + default=0, ge=0 + ) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded # Decision tracking - pending_decision: Optional[str] = None # 'defensive', 'offensive', 'result_selection' - decisions_this_play: Dict[str, Any] = Field(default_factory=dict) + pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection' + decisions_this_play: dict[str, Any] = Field(default_factory=dict) # Phase 3: Enhanced decision workflow - pending_defensive_decision: Optional[DefensiveDecision] = None - pending_offensive_decision: Optional[OffensiveDecision] = None - decision_phase: str = "idle" # idle, awaiting_defensive, awaiting_offensive, resolving, completed - decision_deadline: Optional[str] = None # ISO8601 timestamp for timeout + pending_defensive_decision: DefensiveDecision | None = None + pending_offensive_decision: OffensiveDecision | None = None + decision_phase: str = ( + "idle" # idle, awaiting_defensive, awaiting_offensive, resolving, completed + ) + decision_deadline: str | None = None # ISO8601 timestamp for timeout # Manual mode support (Week 7 Task 7) - pending_manual_roll: Optional[Any] = None # AbRoll stored when dice rolled in manual mode + pending_manual_roll: Any | None = ( + None # AbRoll stored when dice rolled in manual mode + ) # Play tracking play_count: int = Field(default=0, ge=0) - last_play_result: Optional[str] = None + last_play_result: str | None = None - @field_validator('league_id') + @field_validator("league_id") @classmethod def validate_league_id(cls, v: str) -> str: """Ensure league_id is valid""" - valid_leagues = ['sba', 'pd'] + valid_leagues = ["sba", "pd"] if v not in valid_leagues: raise ValueError(f"league_id must be one of {valid_leagues}") return v - @field_validator('status') + @field_validator("status") @classmethod def validate_status(cls, v: str) -> str: """Ensure status is valid""" - valid_statuses = ['pending', 'active', 'paused', 'completed'] + valid_statuses = ["pending", "active", "paused", "completed"] if v not in valid_statuses: raise ValueError(f"status must be one of {valid_statuses}") return v - @field_validator('half') + @field_validator("half") @classmethod def validate_half(cls, v: str) -> str: """Ensure half is valid""" - if v not in ['top', 'bottom']: + if v not in ["top", "bottom"]: raise ValueError("half must be 'top' or 'bottom'") return v - @field_validator('pending_decision') + @field_validator("pending_decision") @classmethod - def validate_pending_decision(cls, v: Optional[str]) -> Optional[str]: + def validate_pending_decision(cls, v: str | None) -> str | None: """Ensure pending_decision is valid""" if v is not None: - valid = ['defensive', 'offensive', 'result_selection', 'substitution'] + valid = ["defensive", "offensive", "result_selection", "substitution"] if v not in valid: raise ValueError(f"pending_decision must be one of {valid}") return v - @field_validator('decision_phase') + @field_validator("decision_phase") @classmethod def validate_decision_phase(cls, v: str) -> str: """Ensure decision_phase is valid""" - valid = ['idle', 'awaiting_defensive', 'awaiting_offensive', 'resolving', 'completed'] + valid = [ + "idle", + "awaiting_defensive", + "awaiting_offensive", + "resolving", + "completed", + ] if v not in valid: raise ValueError(f"decision_phase must be one of {valid}") return v @@ -488,8 +527,8 @@ class GameState(BaseModel): def get_defender_for_position( self, position: str, - state_manager: Any # 'StateManager' - avoid circular import - ) -> Optional[LineupPlayerState]: + state_manager: Any, # 'StateManager' - avoid circular import + ) -> LineupPlayerState | None: """ Get the defender playing at specified position. @@ -517,7 +556,9 @@ class GameState(BaseModel): if player.position == position and player.is_active: return player - logger.warning(f"No active player found at position {position} for team {fielding_team_id}") + logger.warning( + f"No active player found at position {position} for team {fielding_team_id}" + ) return None def is_runner_on_first(self) -> bool: @@ -532,17 +573,17 @@ class GameState(BaseModel): """Check if there's a runner on third base""" return self.on_third is not None - def get_runner_at_base(self, base: int) -> Optional[LineupPlayerState]: + def get_runner_at_base(self, base: int) -> LineupPlayerState | None: """Get runner at specified base (1, 2, or 3)""" if base == 1: return self.on_first - elif base == 2: + if base == 2: return self.on_second - elif base == 3: + if base == 3: return self.on_third return None - def bases_occupied(self) -> List[int]: + def bases_occupied(self) -> list[int]: """Get list of occupied bases""" bases = [] if self.on_first: @@ -553,7 +594,7 @@ class GameState(BaseModel): bases.append(3) return bases - def get_all_runners(self) -> List[tuple[int, LineupPlayerState]]: + def get_all_runners(self) -> list[tuple[int, LineupPlayerState]]: """Returns list of (base, player) tuples for occupied bases""" runners = [] if self.on_first: @@ -669,7 +710,11 @@ class GameState(BaseModel): if self.home_score > self.away_score: return True - if self.inning >= reg and self.half == "top" and self.outs >= self.outs_per_inning: + if ( + self.inning >= reg + and self.half == "top" + and self.outs >= self.outs_per_inning + ): # Top of final inning or later just ended if self.home_score != self.away_score: return True @@ -692,18 +737,38 @@ class GameState(BaseModel): "home_score": 2, "away_score": 1, "on_first": None, - "on_second": {"lineup_id": 5, "card_id": 123, "position": "CF", "batting_order": 2}, + "on_second": { + "lineup_id": 5, + "card_id": 123, + "position": "CF", + "batting_order": 2, + }, "on_third": None, "away_team_batter_idx": 3, "home_team_batter_idx": 5, - "current_batter": {"lineup_id": 8, "card_id": 125, "position": "RF", "batting_order": 4}, - "current_pitcher": {"lineup_id": 10, "card_id": 130, "position": "P", "batting_order": 9}, - "current_catcher": {"lineup_id": 11, "card_id": 131, "position": "C", "batting_order": 2}, + "current_batter": { + "lineup_id": 8, + "card_id": 125, + "position": "RF", + "batting_order": 4, + }, + "current_pitcher": { + "lineup_id": 10, + "card_id": 130, + "position": "P", + "batting_order": 9, + }, + "current_catcher": { + "lineup_id": 11, + "card_id": 131, + "position": "C", + "batting_order": 2, + }, "current_on_base_code": 2, "pending_decision": None, "decisions_this_play": {}, "play_count": 15, - "last_play_result": "Single to left field" + "last_play_result": "Single to left field", } } ) @@ -726,9 +791,9 @@ GameState.model_rebuild() # ============================================================================ __all__ = [ - 'LineupPlayerState', - 'TeamLineupState', - 'DefensiveDecision', - 'OffensiveDecision', - 'GameState', + "LineupPlayerState", + "TeamLineupState", + "DefensiveDecision", + "OffensiveDecision", + "GameState", ] diff --git a/backend/app/models/player_models.py b/backend/app/models/player_models.py index 4b76555..a66dc03 100644 --- a/backend/app/models/player_models.py +++ b/backend/app/models/player_models.py @@ -8,13 +8,15 @@ Supports both SBA and PD leagues with different data complexity: 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 +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. @@ -25,29 +27,36 @@ 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: 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") + 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: 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") + 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" + active_position_rating: Optional["PositionRating"] = Field( + default=None, description="Defensive rating for current position" ) @abstractmethod - def get_positions(self) -> List[str]: + def get_positions(self) -> list[str]: """Get list of positions player can play (e.g., ['2B', 'SS']).""" pass @@ -64,15 +73,17 @@ class BasePlayer(BaseModel, ABC): 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. @@ -83,36 +94,56 @@ class SbaPlayer(BasePlayer): # 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") + 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: 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") + 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']: + 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)): + 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()}') - + 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: + 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): + 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()}') + raise ValueError(f"Batting card not found for {self.get_display_name()}") - def get_positions(self) -> List[str]: + 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 + 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] @@ -125,7 +156,7 @@ class SbaPlayer(BasePlayer): return self.image or self.image2 or self.headshot or "" @classmethod - def from_api_response(cls, data: Dict[str, Any]) -> "SbaPlayer": + def from_api_response(cls, data: dict[str, Any]) -> "SbaPlayer": """ Create SbaPlayer from API response. @@ -167,8 +198,10 @@ class SbaPlayer(BasePlayer): # ==================== PD Player Model ==================== + class PdCardset(BaseModel): """PD cardset information.""" + id: int name: str description: str @@ -177,6 +210,7 @@ class PdCardset(BaseModel): class PdRarity(BaseModel): """PD card rarity information.""" + id: int value: int name: str # MVP, Starter, Replacement, etc. @@ -189,6 +223,7 @@ class PdBattingRating(BaseModel): Contains all probability data for dice roll outcomes. """ + vs_hand: str = Field(..., description="Pitcher handedness: L or R") # Hit location rates @@ -232,6 +267,7 @@ class PdPitchingRating(BaseModel): Contains all probability data for dice roll outcomes. """ + vs_hand: str = Field(..., description="Batter handedness: L or R") # Outcome probabilities (sum to ~100.0) @@ -273,6 +309,7 @@ class PdPitchingRating(BaseModel): class PdBattingCard(BaseModel): """PD batting card information (contains multiple ratings).""" + steal_low: int steal_high: int steal_auto: bool @@ -284,23 +321,24 @@ class PdBattingCard(BaseModel): hand: str # L or R # Ratings for vs LHP and vs RHP - ratings: Dict[str, PdBattingRating] = Field(default_factory=dict) + 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 + 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) + ratings: dict[str, PdPitchingRating] = Field(default_factory=dict) class PositionRating(BaseModel): @@ -311,16 +349,17 @@ class PositionRating(BaseModel): - 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: Optional[int] = Field(None, description="Throwing arm rating") - pb: Optional[int] = Field(None, description="Passed balls (catchers only)") - overthrow: Optional[int] = Field(None, description="Overthrow risk") + 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": + def from_api_response(cls, data: dict[str, Any]) -> "PositionRating": """ Create PositionRating from PD API response. @@ -337,7 +376,7 @@ class PositionRating(BaseModel): error=data["error"], arm=data.get("arm"), pb=data.get("pb"), - overthrow=data.get("overthrow") + overthrow=data.get("overthrow"), ) @@ -364,28 +403,38 @@ class PdPlayer(BasePlayer): 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") + 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: Optional[PdBattingCard] = Field(None, description="Batting card with ratings") - pitching_card: Optional[PdPitchingCard] = Field(None, description="Pitching card with ratings") + 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]: + 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 + 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] @@ -397,7 +446,7 @@ class PdPlayer(BasePlayer): """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) -> Optional[PdBattingRating]: + def get_batting_rating(self, vs_hand: str) -> PdBattingRating | None: """ Get batting rating for specific pitcher handedness. @@ -411,7 +460,7 @@ class PdPlayer(BasePlayer): return None return self.batting_card.ratings.get(vs_hand) - def get_pitching_rating(self, vs_hand: str) -> Optional[PdPitchingRating]: + def get_pitching_rating(self, vs_hand: str) -> PdPitchingRating | None: """ Get pitching rating for specific batter handedness. @@ -428,9 +477,9 @@ class PdPlayer(BasePlayer): @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 + 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. @@ -463,7 +512,7 @@ class PdPlayer(BasePlayer): running=card_info["running"], offense_col=card_info["offense_col"], hand=card_info["hand"], - ratings=ratings_dict + ratings=ratings_dict, ) # Parse pitching card if provided @@ -486,21 +535,25 @@ class PdPlayer(BasePlayer): batting=card_info["batting"], offense_col=card_info["offense_col"], hand=card_info["hand"], - ratings=ratings_dict + 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', ''), + 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=""), + 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=""), + 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_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"), diff --git a/backend/app/models/roster_models.py b/backend/app/models/roster_models.py index 0050153..c99cf47 100644 --- a/backend/app/models/roster_models.py +++ b/backend/app/models/roster_models.py @@ -6,7 +6,6 @@ Provides league-specific type-safe models for roster operations: """ from abc import ABC, abstractmethod -from typing import Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, field_validator @@ -20,7 +19,7 @@ class BaseRosterLinkData(BaseModel, ABC): model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) - id: Optional[int] = None # Database ID (populated after save) + id: int | None = None # Database ID (populated after save) game_id: UUID team_id: int @@ -86,8 +85,8 @@ class RosterLinkCreate(BaseModel): game_id: UUID team_id: int - card_id: Optional[int] = None - player_id: Optional[int] = None + card_id: int | None = None + player_id: int | None = None @field_validator("team_id") @classmethod diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 6055a27..115d240 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -7,14 +7,21 @@ Author: Claude Date: 2025-11-03 """ -from app.services.pd_api_client import PdApiClient, pd_api_client -from app.services.sba_api_client import SbaApiClient, sba_api_client -from app.services.position_rating_service import PositionRatingService, position_rating_service -from app.services.redis_client import RedisClient, redis_client -from app.services.play_stat_calculator import PlayStatCalculator from app.services.box_score_service import BoxScoreService, box_score_service +from app.services.lineup_service import ( + LineupEntryWithPlayer, + LineupService, + lineup_service, +) +from app.services.pd_api_client import PdApiClient, pd_api_client +from app.services.play_stat_calculator import PlayStatCalculator +from app.services.position_rating_service import ( + PositionRatingService, + position_rating_service, +) +from app.services.redis_client import RedisClient, redis_client +from app.services.sba_api_client import SbaApiClient, sba_api_client from app.services.stat_view_refresher import StatViewRefresher, stat_view_refresher -from app.services.lineup_service import LineupService, LineupEntryWithPlayer, lineup_service __all__ = [ "PdApiClient", diff --git a/backend/app/services/box_score_service.py b/backend/app/services/box_score_service.py index 33aefb1..91f0a05 100644 --- a/backend/app/services/box_score_service.py +++ b/backend/app/services/box_score_service.py @@ -7,13 +7,15 @@ PostgreSQL materialized views created in migration 004. Author: Claude Date: 2025-11-07 """ + import logging -from typing import Optional, Dict, List from uuid import UUID + from sqlalchemy import text + from app.database.session import AsyncSessionLocal -logger = logging.getLogger(f'{__name__}.BoxScoreService') +logger = logging.getLogger(f"{__name__}.BoxScoreService") class BoxScoreService: @@ -26,7 +28,7 @@ class BoxScoreService: - pitching_game_stats: Player pitching statistics """ - async def get_box_score(self, game_id: UUID) -> Optional[Dict]: + async def get_box_score(self, game_id: UUID) -> dict | None: """ Get complete box score for a game. @@ -52,20 +54,24 @@ class BoxScoreService: batting_stats = await self._get_batting_stats(session, game_id) pitching_stats = await self._get_pitching_stats(session, game_id) - logger.info(f"Retrieved box score for game {game_id}: " - f"{len(batting_stats)} batters, {len(pitching_stats)} pitchers") + logger.info( + f"Retrieved box score for game {game_id}: " + f"{len(batting_stats)} batters, {len(pitching_stats)} pitchers" + ) return { - 'game_stats': game_stats, - 'batting_stats': batting_stats, - 'pitching_stats': pitching_stats + "game_stats": game_stats, + "batting_stats": batting_stats, + "pitching_stats": pitching_stats, } except Exception as e: - logger.error(f"Failed to get box score for game {game_id}: {e}", exc_info=True) + logger.error( + f"Failed to get box score for game {game_id}: {e}", exc_info=True + ) raise - async def _get_game_stats(self, session, game_id: UUID) -> Optional[Dict]: + async def _get_game_stats(self, session, game_id: UUID) -> dict | None: """ Query game_stats materialized view for team totals and linescore. @@ -91,25 +97,25 @@ class BoxScoreService: WHERE game_id = :game_id """) - result = await session.execute(query, {'game_id': str(game_id)}) + result = await session.execute(query, {"game_id": str(game_id)}) row = result.fetchone() if not row: return None return { - 'game_id': row[0], - 'home_runs': row[1] or 0, - 'away_runs': row[2] or 0, - 'home_hits': row[3] or 0, - 'away_hits': row[4] or 0, - 'home_errors': row[5] or 0, - 'away_errors': row[6] or 0, - 'home_linescore': row[7] or [], - 'away_linescore': row[8] or [] + "game_id": row[0], + "home_runs": row[1] or 0, + "away_runs": row[2] or 0, + "home_hits": row[3] or 0, + "away_hits": row[4] or 0, + "home_errors": row[5] or 0, + "away_errors": row[6] or 0, + "home_linescore": row[7] or [], + "away_linescore": row[8] or [], } - async def _get_batting_stats(self, session, game_id: UUID) -> List[Dict]: + async def _get_batting_stats(self, session, game_id: UUID) -> list[dict]: """ Query batting_game_stats materialized view for player batting lines. @@ -145,35 +151,37 @@ class BoxScoreService: ORDER BY lineup_id """) - result = await session.execute(query, {'game_id': str(game_id)}) + result = await session.execute(query, {"game_id": str(game_id)}) rows = result.fetchall() stats = [] for row in rows: - stats.append({ - 'lineup_id': row[0], - 'game_id': row[1], - 'player_card_id': row[2], - 'pa': row[3] or 0, - 'ab': row[4] or 0, - 'run': row[5] or 0, - 'hit': row[6] or 0, - 'rbi': row[7] or 0, - 'double': row[8] or 0, - 'triple': row[9] or 0, - 'hr': row[10] or 0, - 'bb': row[11] or 0, - 'so': row[12] or 0, - 'hbp': row[13] or 0, - 'sac': row[14] or 0, - 'sb': row[15] or 0, - 'cs': row[16] or 0, - 'gidp': row[17] or 0 - }) + stats.append( + { + "lineup_id": row[0], + "game_id": row[1], + "player_card_id": row[2], + "pa": row[3] or 0, + "ab": row[4] or 0, + "run": row[5] or 0, + "hit": row[6] or 0, + "rbi": row[7] or 0, + "double": row[8] or 0, + "triple": row[9] or 0, + "hr": row[10] or 0, + "bb": row[11] or 0, + "so": row[12] or 0, + "hbp": row[13] or 0, + "sac": row[14] or 0, + "sb": row[15] or 0, + "cs": row[16] or 0, + "gidp": row[17] or 0, + } + ) return stats - async def _get_pitching_stats(self, session, game_id: UUID) -> List[Dict]: + async def _get_pitching_stats(self, session, game_id: UUID) -> list[dict]: """ Query pitching_game_stats materialized view for player pitching lines. @@ -204,26 +212,28 @@ class BoxScoreService: ORDER BY lineup_id """) - result = await session.execute(query, {'game_id': str(game_id)}) + result = await session.execute(query, {"game_id": str(game_id)}) rows = result.fetchall() stats = [] for row in rows: - stats.append({ - 'lineup_id': row[0], - 'game_id': row[1], - 'player_card_id': row[2], - 'batters_faced': row[3] or 0, - 'hit_allowed': row[4] or 0, - 'run_allowed': row[5] or 0, - 'erun': row[6] or 0, - 'bb': row[7] or 0, - 'so': row[8] or 0, - 'hbp': row[9] or 0, - 'hr_allowed': row[10] or 0, - 'wp': row[11] or 0, - 'ip': float(row[12]) if row[12] is not None else 0.0 - }) + stats.append( + { + "lineup_id": row[0], + "game_id": row[1], + "player_card_id": row[2], + "batters_faced": row[3] or 0, + "hit_allowed": row[4] or 0, + "run_allowed": row[5] or 0, + "erun": row[6] or 0, + "bb": row[7] or 0, + "so": row[8] or 0, + "hbp": row[9] or 0, + "hr_allowed": row[10] or 0, + "wp": row[11] or 0, + "ip": float(row[12]) if row[12] is not None else 0.0, + } + ) return stats diff --git a/backend/app/services/lineup_service.py b/backend/app/services/lineup_service.py index cf3164b..c15d5fb 100644 --- a/backend/app/services/lineup_service.py +++ b/backend/app/services/lineup_service.py @@ -7,25 +7,26 @@ lineup entries with player data. Author: Claude Date: 2025-01-10 """ + import logging from dataclasses import dataclass -from typing import Optional, List from uuid import UUID from app.database.operations import DatabaseOperations +from app.models.game_models import LineupPlayerState, TeamLineupState from app.services.sba_api_client import sba_api_client -from app.models.game_models import TeamLineupState, LineupPlayerState -logger = logging.getLogger(f'{__name__}.LineupService') +logger = logging.getLogger(f"{__name__}.LineupService") @dataclass class LineupEntryWithPlayer: """Lineup entry with associated player data.""" + lineup_id: int player_id: int position: str - batting_order: Optional[int] + batting_order: int | None is_starter: bool is_active: bool # Player data from API @@ -42,7 +43,7 @@ class LineupService: complete lineup entries with player names and images. """ - def __init__(self, db_ops: Optional[DatabaseOperations] = None): + def __init__(self, db_ops: DatabaseOperations | None = None): self.db_ops = db_ops or DatabaseOperations() async def add_sba_player_to_lineup( @@ -51,8 +52,8 @@ class LineupService: team_id: int, player_id: int, position: str, - batting_order: Optional[int] = None, - is_starter: bool = True + batting_order: int | None = None, + is_starter: bool = True, ) -> LineupEntryWithPlayer: """ Add SBA player to lineup with player data. @@ -83,7 +84,7 @@ class LineupService: player_id=player_id, position=position, batting_order=batting_order, - is_starter=is_starter + is_starter=is_starter, ) # Step 2: Fetch player data from SBA API @@ -111,15 +112,12 @@ class LineupService: is_active=True, player_name=player_name, player_image=player_image, - player_headshot=player_headshot + player_headshot=player_headshot, ) async def load_team_lineup_with_player_data( - self, - game_id: UUID, - team_id: int, - league_id: str - ) -> Optional[TeamLineupState]: + self, game_id: UUID, team_id: int, league_id: str + ) -> TeamLineupState | None: """ Load existing team lineup from database with player data. @@ -142,14 +140,18 @@ class LineupService: # Step 2: Fetch player data for SBA league player_data = {} - if league_id == 'sba': + if league_id == "sba": player_ids = [p.player_id for p in lineup_entries if p.player_id] # type: ignore[misc] if player_ids: try: player_data = await sba_api_client.get_players_batch(player_ids) - logger.info(f"Loaded {len(player_data)}/{len(player_ids)} players for team {team_id}") + logger.info( + f"Loaded {len(player_data)}/{len(player_ids)} players for team {team_id}" + ) except Exception as e: - logger.warning(f"Failed to fetch player data for team {team_id}: {e}") + logger.warning( + f"Failed to fetch player data for team {team_id}: {e}" + ) # Step 3: Build TeamLineupState with player data players = [] @@ -158,23 +160,25 @@ class LineupService: player_image = None player_headshot = None - if league_id == 'sba' and p.player_id and player_data.get(p.player_id): # type: ignore[arg-type] + if league_id == "sba" and p.player_id and player_data.get(p.player_id): # type: ignore[arg-type] player = player_data.get(p.player_id) # type: ignore[arg-type] player_name = player.name player_image = player.get_image_url() player_headshot = player.headshot - players.append(LineupPlayerState( - lineup_id=p.id, # type: ignore[arg-type] - card_id=p.card_id if p.card_id else (p.player_id or 0), # type: ignore[arg-type] - position=p.position, # type: ignore[arg-type] - batting_order=p.batting_order, # type: ignore[arg-type] - is_active=p.is_active, # type: ignore[arg-type] - is_starter=p.is_starter, # type: ignore[arg-type] - player_name=player_name, - player_image=player_image, - player_headshot=player_headshot - )) + players.append( + LineupPlayerState( + lineup_id=p.id, # type: ignore[arg-type] + card_id=p.card_id if p.card_id else (p.player_id or 0), # type: ignore[arg-type] + position=p.position, # type: ignore[arg-type] + batting_order=p.batting_order, # type: ignore[arg-type] + is_active=p.is_active, # type: ignore[arg-type] + is_starter=p.is_starter, # type: ignore[arg-type] + player_name=player_name, + player_image=player_image, + player_headshot=player_headshot, + ) + ) return TeamLineupState(team_id=team_id, players=players) diff --git a/backend/app/services/pd_api_client.py b/backend/app/services/pd_api_client.py index ef725a9..22892a7 100644 --- a/backend/app/services/pd_api_client.py +++ b/backend/app/services/pd_api_client.py @@ -7,12 +7,14 @@ for use in X-Check resolution. Author: Claude Date: 2025-11-03 """ + import logging + import httpx -from typing import List, Optional + from app.models.player_models import PositionRating -logger = logging.getLogger(f'{__name__}.PdApiClient') +logger = logging.getLogger(f"{__name__}.PdApiClient") class PdApiClient: @@ -29,10 +31,8 @@ class PdApiClient: self.timeout = httpx.Timeout(10.0, connect=5.0) async def get_position_ratings( - self, - player_id: int, - positions: Optional[List[str]] = None - ) -> List[PositionRating]: + self, player_id: int, positions: list[str] | None = None + ) -> list[PositionRating]: """ Fetch all position ratings for a player. @@ -76,20 +76,30 @@ class PdApiClient: # API returns list of position objects directly if isinstance(data, list): for pos_data in data: - position_ratings.append(PositionRating.from_api_response(pos_data)) + position_ratings.append( + PositionRating.from_api_response(pos_data) + ) # Or may be wrapped in 'positions' key - elif isinstance(data, dict) and 'positions' in data: - for pos_data in data['positions']: - position_ratings.append(PositionRating.from_api_response(pos_data)) + elif isinstance(data, dict) and "positions" in data: + for pos_data in data["positions"]: + position_ratings.append( + PositionRating.from_api_response(pos_data) + ) - logger.info(f"Loaded {len(position_ratings)} position ratings for player {player_id}") + logger.info( + f"Loaded {len(position_ratings)} position ratings for player {player_id}" + ) return position_ratings except httpx.HTTPError as e: - logger.error(f"Failed to fetch position ratings for player {player_id}: {e}") + logger.error( + f"Failed to fetch position ratings for player {player_id}: {e}" + ) raise except Exception as e: - logger.error(f"Unexpected error fetching ratings for player {player_id}: {e}") + logger.error( + f"Unexpected error fetching ratings for player {player_id}: {e}" + ) raise diff --git a/backend/app/services/play_stat_calculator.py b/backend/app/services/play_stat_calculator.py index 955107b..f89a3f8 100644 --- a/backend/app/services/play_stat_calculator.py +++ b/backend/app/services/play_stat_calculator.py @@ -3,13 +3,14 @@ Calculate statistical fields from PlayOutcome. Called when recording a play to populate stat fields for materialized view aggregation. """ + import logging -from typing import Dict + from app.config.result_charts import PlayOutcome from app.core.play_resolver import PlayResult from app.models.game_models import GameState -logger = logging.getLogger(f'{__name__}.PlayStatCalculator') +logger = logging.getLogger(f"{__name__}.PlayStatCalculator") class PlayStatCalculator: @@ -25,8 +26,8 @@ class PlayStatCalculator: outcome: PlayOutcome, result: PlayResult, state_before: GameState, - state_after: GameState - ) -> Dict[str, int]: + state_after: GameState, + ) -> dict[str, int]: """ Calculate all stat fields for a play. @@ -39,13 +40,29 @@ class PlayStatCalculator: Returns: dict with stat fields (pa, ab, hit, run, etc.) """ - stats: Dict[str, int] = { - 'pa': 0, 'ab': 0, 'hit': 0, 'run': 0, 'rbi': 0, - 'double': 0, 'triple': 0, 'homerun': 0, 'bb': 0, 'so': 0, - 'hbp': 0, 'sac': 0, 'ibb': 0, 'sb': 0, 'cs': 0, 'gidp': 0, - 'error': 0, 'wild_pitch': 0, 'passed_ball': 0, - 'balk': 0, 'pick_off': 0, - 'outs_recorded': 0 + stats: dict[str, int] = { + "pa": 0, + "ab": 0, + "hit": 0, + "run": 0, + "rbi": 0, + "double": 0, + "triple": 0, + "homerun": 0, + "bb": 0, + "so": 0, + "hbp": 0, + "sac": 0, + "ibb": 0, + "sb": 0, + "cs": 0, + "gidp": 0, + "error": 0, + "wild_pitch": 0, + "passed_ball": 0, + "balk": 0, + "pick_off": 0, + "outs_recorded": 0, } # Plate appearance (everything except non-PA events) @@ -55,83 +72,83 @@ class PlayStatCalculator: PlayOutcome.WILD_PITCH, PlayOutcome.PASSED_BALL, PlayOutcome.BALK, - PlayOutcome.PICK_OFF + PlayOutcome.PICK_OFF, ]: - stats['pa'] = 1 + stats["pa"] = 1 # At bat (PA minus walks, HBP, sac) - if stats['pa'] == 1 and outcome not in [ + if stats["pa"] == 1 and outcome not in [ PlayOutcome.WALK, PlayOutcome.HIT_BY_PITCH, - PlayOutcome.INTENTIONAL_WALK + PlayOutcome.INTENTIONAL_WALK, ]: - stats['ab'] = 1 + stats["ab"] = 1 # Hits - check if outcome is a hit if outcome.is_hit(): - stats['hit'] = 1 + stats["hit"] = 1 # Determine hit type if outcome in [ PlayOutcome.DOUBLE_2, PlayOutcome.DOUBLE_3, - PlayOutcome.DOUBLE_UNCAPPED + PlayOutcome.DOUBLE_UNCAPPED, ]: - stats['double'] = 1 + stats["double"] = 1 elif outcome == PlayOutcome.TRIPLE: - stats['triple'] = 1 + stats["triple"] = 1 elif outcome == PlayOutcome.HOMERUN: - stats['homerun'] = 1 + stats["homerun"] = 1 # Other batting outcomes if outcome == PlayOutcome.WALK: - stats['bb'] = 1 + stats["bb"] = 1 elif outcome == PlayOutcome.INTENTIONAL_WALK: - stats['ibb'] = 1 - stats['bb'] = 1 # IBB counts as BB too + stats["ibb"] = 1 + stats["bb"] = 1 # IBB counts as BB too elif outcome == PlayOutcome.STRIKEOUT: - stats['so'] = 1 + stats["so"] = 1 elif outcome == PlayOutcome.HIT_BY_PITCH: - stats['hbp'] = 1 + stats["hbp"] = 1 elif outcome == PlayOutcome.GROUNDBALL_A: # GROUNDBALL_A is double play if possible - stats['gidp'] = 1 + stats["gidp"] = 1 # Baserunning events (non-PA) if outcome == PlayOutcome.STOLEN_BASE: - stats['sb'] = 1 + stats["sb"] = 1 elif outcome == PlayOutcome.CAUGHT_STEALING: - stats['cs'] = 1 + stats["cs"] = 1 # Pitching events (non-PA) if outcome == PlayOutcome.WILD_PITCH: - stats['wild_pitch'] = 1 + stats["wild_pitch"] = 1 elif outcome == PlayOutcome.PASSED_BALL: - stats['passed_ball'] = 1 + stats["passed_ball"] = 1 elif outcome == PlayOutcome.BALK: - stats['balk'] = 1 + stats["balk"] = 1 elif outcome == PlayOutcome.PICK_OFF: - stats['pick_off'] = 1 + stats["pick_off"] = 1 # Calculate runs scored from state change # Check which team is batting to determine which score changed - if state_before.half == 'top': - stats['run'] = state_after.away_score - state_before.away_score + if state_before.half == "top": + stats["run"] = state_after.away_score - state_before.away_score else: - stats['run'] = state_after.home_score - state_before.home_score + stats["run"] = state_after.home_score - state_before.home_score # RBI logic - runs scored minus runs on errors # If play had an error, don't credit RBI - if hasattr(result, 'error_occurred') and result.error_occurred: - stats['rbi'] = 0 + if hasattr(result, "error_occurred") and result.error_occurred: + stats["rbi"] = 0 else: - stats['rbi'] = stats['run'] + stats["rbi"] = stats["run"] # Outs recorded - stats['outs_recorded'] = state_after.outs - state_before.outs + stats["outs_recorded"] = state_after.outs - state_before.outs # Error tracking (from X-Check plays) - if hasattr(result, 'error_occurred') and result.error_occurred: - stats['error'] = 1 + if hasattr(result, "error_occurred") and result.error_occurred: + stats["error"] = 1 return stats diff --git a/backend/app/services/position_rating_service.py b/backend/app/services/position_rating_service.py index 6a17734..94e6c88 100644 --- a/backend/app/services/position_rating_service.py +++ b/backend/app/services/position_rating_service.py @@ -8,14 +8,16 @@ Author: Claude Date: 2025-11-03 Phase: 3E-Final """ + import json import logging -from typing import List, Optional + from redis.exceptions import RedisError + from app.models.player_models import PositionRating from app.services.pd_api_client import pd_api_client -logger = logging.getLogger(f'{__name__}.PositionRatingService') +logger = logging.getLogger(f"{__name__}.PositionRatingService") # Redis key pattern: "position_ratings:{card_id}" # TTL: 86400 seconds (24 hours) @@ -48,10 +50,8 @@ class PositionRatingService: return f"{REDIS_KEY_PREFIX}:{card_id}" async def get_ratings_for_card( - self, - card_id: int, - league_id: str - ) -> List[PositionRating]: + self, card_id: int, league_id: str + ) -> list[PositionRating]: """ Get all position ratings for a card with Redis caching. @@ -63,7 +63,7 @@ class PositionRatingService: List of PositionRating objects """ # Only cache for PD league - if league_id != 'pd': + if league_id != "pd": logger.debug(f"Skipping cache for non-PD league: {league_id}") return [] @@ -80,13 +80,14 @@ class PositionRatingService: logger.debug(f"Redis cache hit for card {card_id}") cached_data = json.loads(cached_json) return [PositionRating(**data) for data in cached_data] - else: - logger.debug(f"Redis cache miss for card {card_id}") + logger.debug(f"Redis cache miss for card {card_id}") else: logger.warning("Redis not connected, skipping cache") except RedisError as e: - logger.warning(f"Redis error for card {card_id}: {e}, falling back to API") + logger.warning( + f"Redis error for card {card_id}: {e}, falling back to API" + ) except Exception as e: logger.error(f"Unexpected cache error for card {card_id}: {e}") @@ -105,11 +106,11 @@ class PositionRatingService: redis_key = self._get_redis_key(card_id) ratings_json = json.dumps([r.model_dump() for r in ratings]) await redis_client.client.setex( - redis_key, - REDIS_TTL_SECONDS, - ratings_json + redis_key, REDIS_TTL_SECONDS, ratings_json + ) + logger.debug( + f"Cached {len(ratings)} ratings for card {card_id} (TTL: {REDIS_TTL_SECONDS}s)" ) - logger.debug(f"Cached {len(ratings)} ratings for card {card_id} (TTL: {REDIS_TTL_SECONDS}s)") except RedisError as e: logger.warning(f"Failed to cache ratings for card {card_id}: {e}") @@ -120,11 +121,8 @@ class PositionRatingService: return [] # Return empty list on error async def get_rating_for_position( - self, - card_id: int, - position: str, - league_id: str - ) -> Optional[PositionRating]: + self, card_id: int, position: str, league_id: str + ) -> PositionRating | None: """ Get rating for specific position. @@ -145,7 +143,7 @@ class PositionRatingService: logger.warning(f"No rating found for card {card_id} at position {position}") return None - async def clear_cache(self, card_id: Optional[int] = None) -> None: + async def clear_cache(self, card_id: int | None = None) -> None: """ Clear Redis cache for position ratings. diff --git a/backend/app/services/redis_client.py b/backend/app/services/redis_client.py index 1da646b..b0f2131 100644 --- a/backend/app/services/redis_client.py +++ b/backend/app/services/redis_client.py @@ -8,13 +8,14 @@ Author: Claude Date: 2025-11-03 Phase: 3E-Final """ + import logging -from typing import Optional + import redis.asyncio as redis from redis.asyncio import Redis from redis.exceptions import RedisError -logger = logging.getLogger(f'{__name__}.RedisClient') +logger = logging.getLogger(f"{__name__}.RedisClient") class RedisClient: @@ -25,8 +26,8 @@ class RedisClient: """ def __init__(self): - self._redis: Optional[Redis] = None - self._url: Optional[str] = None + self._redis: Redis | None = None + self._url: str | None = None async def connect(self, redis_url: str = "redis://localhost:6379/0") -> None: """ @@ -47,7 +48,7 @@ class RedisClient: redis_url, encoding="utf-8", decode_responses=True, - max_connections=10 # Connection pool size + max_connections=10, # Connection pool size ) # Test connection diff --git a/backend/app/services/sba_api_client.py b/backend/app/services/sba_api_client.py index 8f34275..525c6d1 100644 --- a/backend/app/services/sba_api_client.py +++ b/backend/app/services/sba_api_client.py @@ -7,12 +7,14 @@ for use in game lineup display. Author: Claude Date: 2025-01-10 """ + import logging -from typing import Dict, Any, List, Optional + import httpx + from app.models.player_models import SbaPlayer -logger = logging.getLogger(f'{__name__}.SbaApiClient') +logger = logging.getLogger(f"{__name__}.SbaApiClient") class SbaApiClient: @@ -66,10 +68,7 @@ class SbaApiClient: logger.error(f"Unexpected error fetching player {player_id}: {e}") raise - async def get_players_batch( - self, - player_ids: List[int] - ) -> Dict[int, SbaPlayer]: + async def get_players_batch(self, player_ids: list[int]) -> dict[int, SbaPlayer]: """ Fetch multiple players in parallel. @@ -88,7 +87,7 @@ class SbaApiClient: if not player_ids: return {} - results: Dict[int, SbaPlayer] = {} + results: dict[int, SbaPlayer] = {} async with httpx.AsyncClient(timeout=self.timeout) as client: for player_id in player_ids: diff --git a/backend/app/services/stat_view_refresher.py b/backend/app/services/stat_view_refresher.py index 56806d4..6bcd31b 100644 --- a/backend/app/services/stat_view_refresher.py +++ b/backend/app/services/stat_view_refresher.py @@ -7,11 +7,14 @@ Uses REFRESH MATERIALIZED VIEW CONCURRENTLY to allow reads during refresh. Author: Claude Date: 2025-11-07 """ + import logging + from sqlalchemy import text + from app.database.session import AsyncSessionLocal -logger = logging.getLogger(f'{__name__}.StatViewRefresher') +logger = logging.getLogger(f"{__name__}.StatViewRefresher") class StatViewRefresher: @@ -27,11 +30,7 @@ class StatViewRefresher: """ # View names in order (dependencies matter for concurrent refresh) - VIEWS = [ - 'batting_game_stats', - 'pitching_game_stats', - 'game_stats' - ] + VIEWS = ["batting_game_stats", "pitching_game_stats", "game_stats"] async def refresh_all(self) -> None: """ @@ -50,11 +49,15 @@ class StatViewRefresher: await self._refresh_view(session, view_name) await session.commit() - logger.info(f"Successfully refreshed all {len(self.VIEWS)} materialized views") + logger.info( + f"Successfully refreshed all {len(self.VIEWS)} materialized views" + ) except Exception as e: await session.rollback() - logger.error(f"Failed to refresh materialized views: {e}", exc_info=True) + logger.error( + f"Failed to refresh materialized views: {e}", exc_info=True + ) raise async def refresh_view(self, view_name: str) -> None: diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index 2bb1ade..32605a3 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -1,16 +1,17 @@ import logging -from typing import Dict, Any -from jose import jwt, JWTError +from typing import Any + import pendulum +from jose import JWTError, jwt from app.config import get_settings -logger = logging.getLogger(f'{__name__}.auth') +logger = logging.getLogger(f"{__name__}.auth") settings = get_settings() -def create_token(user_data: Dict[str, Any]) -> str: +def create_token(user_data: dict[str, Any]) -> str: """ Create JWT token for user @@ -20,15 +21,12 @@ def create_token(user_data: Dict[str, Any]) -> str: Returns: JWT token string """ - payload = { - **user_data, - "exp": pendulum.now('UTC').add(days=7).int_timestamp - } + payload = {**user_data, "exp": pendulum.now("UTC").add(days=7).int_timestamp} token = jwt.encode(payload, settings.secret_key, algorithm="HS256") return token -def verify_token(token: str) -> Dict[str, Any]: +def verify_token(token: str) -> dict[str, Any]: """ Verify and decode JWT token diff --git a/backend/app/utils/logging.py b/backend/app/utils/logging.py index a096d8e..f32d1d2 100644 --- a/backend/app/utils/logging.py +++ b/backend/app/utils/logging.py @@ -1,6 +1,7 @@ import logging import logging.handlers import os + import pendulum @@ -12,13 +13,13 @@ def setup_logging() -> None: os.makedirs(log_dir, exist_ok=True) # Log file name with date - now = pendulum.now('UTC') + now = pendulum.now("UTC") log_file = os.path.join(log_dir, f"app_{now.format('YYYYMMDD')}.log") # Create formatter formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) # Console handler @@ -30,7 +31,7 @@ def setup_logging() -> None: file_handler = logging.handlers.RotatingFileHandler( log_file, maxBytes=10 * 1024 * 1024, # 10MB - backupCount=5 + backupCount=5, ) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(formatter) diff --git a/backend/app/websocket/connection_manager.py b/backend/app/websocket/connection_manager.py index cc45936..7d12c68 100644 --- a/backend/app/websocket/connection_manager.py +++ b/backend/app/websocket/connection_manager.py @@ -1,8 +1,8 @@ import logging -from typing import Dict, Set + import socketio -logger = logging.getLogger(f'{__name__}.ConnectionManager') +logger = logging.getLogger(f"{__name__}.ConnectionManager") class ConnectionManager: @@ -10,8 +10,8 @@ class ConnectionManager: def __init__(self, sio: socketio.AsyncServer): self.sio = sio - self.user_sessions: Dict[str, str] = {} # sid -> user_id - self.game_rooms: Dict[str, Set[str]] = {} # game_id -> set of sids + self.user_sessions: dict[str, str] = {} # sid -> user_id + self.game_rooms: dict[str, set[str]] = {} # game_id -> set of sids async def connect(self, sid: str, user_id: str) -> None: """Register a new connection""" @@ -29,9 +29,7 @@ class ConnectionManager: if sid in sids: sids.remove(sid) await self.broadcast_to_game( - game_id, - "user_disconnected", - {"user_id": user_id} + game_id, "user_disconnected", {"user_id": user_id} ) async def join_game(self, sid: str, game_id: str, role: str) -> None: @@ -46,9 +44,7 @@ class ConnectionManager: logger.info(f"User {user_id} joined game {game_id} as {role}") await self.broadcast_to_game( - game_id, - "user_connected", - {"user_id": user_id, "role": role} + game_id, "user_connected", {"user_id": user_id, "role": role} ) async def leave_game(self, sid: str, game_id: str) -> None: @@ -61,12 +57,7 @@ class ConnectionManager: user_id = self.user_sessions.get(sid) logger.info(f"User {user_id} left game {game_id}") - async def broadcast_to_game( - self, - game_id: str, - event: str, - data: dict - ) -> None: + async def broadcast_to_game(self, game_id: str, event: str, data: dict) -> None: """Broadcast event to all users in game room""" await self.sio.emit(event, data, room=game_id) logger.debug(f"Broadcast {event} to game {game_id}") @@ -75,6 +66,6 @@ class ConnectionManager: """Emit event to specific user""" await self.sio.emit(event, data, room=sid) - def get_game_participants(self, game_id: str) -> Set[str]: + def get_game_participants(self, game_id: str) -> set[str]: """Get all session IDs in game room""" return self.game_rooms.get(game_id, set()) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index e35fc14..308eac8 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -1,23 +1,22 @@ import logging -from typing import Optional from uuid import UUID -from socketio import AsyncServer -from pydantic import ValidationError -from app.websocket.connection_manager import ConnectionManager -from app.utils.auth import verify_token -from app.models.game_models import ManualOutcomeSubmission +from pydantic import ValidationError +from socketio import AsyncServer + +from app.config.result_charts import PlayOutcome from app.core.dice import dice_system -from app.core.state_manager import state_manager from app.core.game_engine import game_engine +from app.core.state_manager import state_manager from app.core.substitution_manager import SubstitutionManager from app.core.validators import ValidationError as GameValidationError -from app.config.result_charts import PlayOutcome from app.database.operations import DatabaseOperations -from app.services.sba_api_client import sba_api_client +from app.models.game_models import ManualOutcomeSubmission from app.services.lineup_service import lineup_service +from app.utils.auth import verify_token +from app.websocket.connection_manager import ConnectionManager -logger = logging.getLogger(f'{__name__}.handlers') +logger = logging.getLogger(f"{__name__}.handlers") def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: @@ -63,29 +62,19 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: role = data.get("role", "player") if not game_id: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing game_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) return # TODO: Verify user has access to game await manager.join_game(sid, game_id, role) await manager.emit_to_user( - sid, - "game_joined", - {"game_id": game_id, "role": role} + sid, "game_joined", {"game_id": game_id, "role": role} ) except Exception as e: logger.error(f"Join game error: {e}") - await manager.emit_to_user( - sid, - "error", - {"message": str(e)} - ) + await manager.emit_to_user(sid, "error", {"message": str(e)}) @sio.event async def leave_game(sid, data): @@ -119,7 +108,9 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: try: game_id = UUID(game_id_str) except (ValueError, AttributeError): - await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"}) + await manager.emit_to_user( + sid, "error", {"message": "Invalid game_id format"} + ) return # Try to get from memory first @@ -132,10 +123,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: if state: # Use mode='json' to serialize UUIDs as strings - await manager.emit_to_user(sid, "game_state", state.model_dump(mode='json')) + await manager.emit_to_user( + sid, "game_state", state.model_dump(mode="json") + ) logger.info(f"Sent game state for {game_id} to {sid}") else: - await manager.emit_to_user(sid, "error", {"message": f"Game {game_id} not found"}) + await manager.emit_to_user( + sid, "error", {"message": f"Game {game_id} not found"} + ) logger.warning(f"Game {game_id} not found in memory or database") except Exception as e: @@ -161,20 +156,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Extract and validate game_id game_id_str = data.get("game_id") if not game_id_str: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing game_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) return try: game_id = UUID(game_id_str) except (ValueError, AttributeError): await manager.emit_to_user( - sid, - "error", - {"message": "Invalid game_id format"} + sid, "error", {"message": "Invalid game_id format"} ) return @@ -182,9 +171,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -195,10 +182,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # return # Roll dice - ab_roll = dice_system.roll_ab( - league_id=state.league_id, - game_id=game_id - ) + ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id) logger.info( f"Dice rolled for game {game_id}: " @@ -224,16 +208,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "check_wild_pitch": ab_roll.check_wild_pitch, "check_passed_ball": ab_roll.check_passed_ball, "timestamp": ab_roll.timestamp.to_iso8601_string(), - "message": "Dice rolled - read your card and submit outcome" - } + "message": "Dice rolled - read your card and submit outcome", + }, ) except Exception as e: logger.error(f"Roll dice error: {e}", exc_info=True) await manager.emit_to_user( - sid, - "error", - {"message": f"Failed to roll dice: {str(e)}"} + sid, "error", {"message": f"Failed to roll dice: {str(e)}"} ) @sio.event @@ -262,7 +244,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "outcome_rejected", - {"message": "Missing game_id", "field": "game_id"} + {"message": "Missing game_id", "field": "game_id"}, ) return @@ -272,7 +254,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "outcome_rejected", - {"message": "Invalid game_id format", "field": "game_id"} + {"message": "Invalid game_id format", "field": "game_id"}, ) return @@ -280,9 +262,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -297,30 +277,25 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "outcome_rejected", - {"message": "Missing outcome", "field": "outcome"} + {"message": "Missing outcome", "field": "outcome"}, ) return # Validate using ManualOutcomeSubmission model try: submission = ManualOutcomeSubmission( - outcome=outcome_str, - hit_location=hit_location + outcome=outcome_str, hit_location=hit_location ) except ValidationError as e: # Extract first error for user-friendly message first_error = e.errors()[0] - field = first_error['loc'][0] if first_error['loc'] else 'unknown' - message = first_error['msg'] + field = first_error["loc"][0] if first_error["loc"] else "unknown" + message = first_error["msg"] await manager.emit_to_user( sid, "outcome_rejected", - { - "message": message, - "field": field, - "errors": e.errors() - } + {"message": message, "field": field, "errors": e.errors()}, ) logger.warning( f"Manual outcome validation failed for game {game_id}: {message}" @@ -337,8 +312,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "outcome_rejected", { "message": f"Outcome {outcome.value} requires hit_location", - "field": "hit_location" - } + "field": "hit_location", + }, ) return @@ -349,8 +324,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "outcome_rejected", { "message": "No pending dice roll - call roll_dice first", - "field": "game_state" - } + "field": "game_state", + }, ) return @@ -358,7 +333,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: logger.info( f"Manual outcome submitted for game {game_id}: " - f"{outcome.value}" + (f" to {submission.hit_location}" if submission.hit_location else "") + f"{outcome.value}" + + (f" to {submission.hit_location}" if submission.hit_location else "") ) # Confirm acceptance to submitter @@ -368,11 +344,10 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "game_id": str(game_id), "outcome": outcome.value, - "hit_location": submission.hit_location - } + "hit_location": submission.hit_location, + }, ) - logger.info( f"Processing manual outcome with roll {ab_roll.roll_id}: " f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, " @@ -389,7 +364,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: game_id=game_id, ab_roll=ab_roll, outcome=outcome, - hit_location=submission.hit_location + hit_location=submission.hit_location, ) # Build play result data @@ -406,7 +381,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "is_hit": result.is_hit, "is_out": result.is_out, "is_walk": result.is_walk, - "roll_id": ab_roll.roll_id + "roll_id": ab_roll.roll_id, } # Include X-Check details if present (Phase 3E-Final) @@ -427,14 +402,12 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Optional SPD test details "spd_test_roll": xcheck.spd_test_roll, "spd_test_target": xcheck.spd_test_target, - "spd_test_passed": xcheck.spd_test_passed + "spd_test_passed": xcheck.spd_test_passed, } # Broadcast play result to game room await manager.broadcast_to_game( - str(game_id), - "play_resolved", - play_result_data + str(game_id), "play_resolved", play_result_data ) logger.info( @@ -444,12 +417,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: except GameValidationError as e: # Game engine validation error (e.g., missing hit location) await manager.emit_to_user( - sid, - "outcome_rejected", - { - "message": str(e), - "field": "validation" - } + sid, "outcome_rejected", {"message": str(e), "field": "validation"} ) logger.warning(f"Manual play validation failed: {e}") return @@ -458,18 +426,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Unexpected error during resolution logger.error(f"Error resolving manual play: {e}", exc_info=True) await manager.emit_to_user( - sid, - "error", - {"message": f"Failed to resolve play: {str(e)}"} + sid, "error", {"message": f"Failed to resolve play: {str(e)}"} ) return except Exception as e: logger.error(f"Submit manual outcome error: {e}", exc_info=True) await manager.emit_to_user( - sid, - "error", - {"message": f"Failed to process outcome: {str(e)}"} + sid, "error", {"message": f"Failed to process outcome: {str(e)}"} ) # ===== SUBSTITUTION EVENTS ===== @@ -501,7 +465,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing game_id", "code": "MISSING_FIELD"} + {"message": "Missing game_id", "code": "MISSING_FIELD"}, ) return @@ -511,7 +475,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Invalid game_id format", "code": "INVALID_FORMAT"} + {"message": "Invalid game_id format", "code": "INVALID_FORMAT"}, ) return @@ -519,9 +483,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -534,7 +496,10 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing player_out_lineup_id", "code": "MISSING_FIELD"} + { + "message": "Missing player_out_lineup_id", + "code": "MISSING_FIELD", + }, ) return @@ -542,7 +507,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing player_in_card_id", "code": "MISSING_FIELD"} + {"message": "Missing player_in_card_id", "code": "MISSING_FIELD"}, ) return @@ -550,7 +515,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing team_id", "code": "MISSING_FIELD"} + {"message": "Missing team_id", "code": "MISSING_FIELD"}, ) return @@ -571,7 +536,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: game_id=game_id, player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, - team_id=team_id + team_id=team_id, ) if result.success: @@ -587,8 +552,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "position": result.new_position, "batting_order": result.new_batting_order, "team_id": team_id, - "message": f"Pinch hitter: #{result.new_batting_order} now batting" - } + "message": f"Pinch hitter: #{result.new_batting_order} now batting", + }, ) # Send confirmation to requester @@ -598,8 +563,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "type": "pinch_hitter", "new_lineup_id": result.new_lineup_id, - "success": True - } + "success": True, + }, ) logger.info( @@ -614,8 +579,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "message": result.error_message, "code": result.error_code, - "type": "pinch_hitter" - } + "type": "pinch_hitter", + }, ) logger.warning( f"Pinch hitter failed for game {game_id}: {result.error_message}" @@ -624,9 +589,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: except Exception as e: logger.error(f"Pinch hitter request error: {e}", exc_info=True) await manager.emit_to_user( - sid, - "error", - {"message": f"Failed to process pinch hitter: {str(e)}"} + sid, "error", {"message": f"Failed to process pinch hitter: {str(e)}"} ) @sio.event @@ -658,7 +621,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing game_id", "code": "MISSING_FIELD"} + {"message": "Missing game_id", "code": "MISSING_FIELD"}, ) return @@ -668,7 +631,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Invalid game_id format", "code": "INVALID_FORMAT"} + {"message": "Invalid game_id format", "code": "INVALID_FORMAT"}, ) return @@ -676,9 +639,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -692,7 +653,10 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing player_out_lineup_id", "code": "MISSING_FIELD"} + { + "message": "Missing player_out_lineup_id", + "code": "MISSING_FIELD", + }, ) return @@ -700,7 +664,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing player_in_card_id", "code": "MISSING_FIELD"} + {"message": "Missing player_in_card_id", "code": "MISSING_FIELD"}, ) return @@ -708,7 +672,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing new_position", "code": "MISSING_FIELD"} + {"message": "Missing new_position", "code": "MISSING_FIELD"}, ) return @@ -716,7 +680,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing team_id", "code": "MISSING_FIELD"} + {"message": "Missing team_id", "code": "MISSING_FIELD"}, ) return @@ -738,7 +702,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, new_position=new_position, - team_id=team_id + team_id=team_id, ) if result.success: @@ -754,8 +718,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "position": result.new_position, "batting_order": result.new_batting_order, "team_id": team_id, - "message": f"Defensive replacement: {result.new_position}" - } + "message": f"Defensive replacement: {result.new_position}", + }, ) # Send confirmation to requester @@ -765,8 +729,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "type": "defensive_replacement", "new_lineup_id": result.new_lineup_id, - "success": True - } + "success": True, + }, ) logger.info( @@ -781,8 +745,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "message": result.error_message, "code": result.error_code, - "type": "defensive_replacement" - } + "type": "defensive_replacement", + }, ) logger.warning( f"Defensive replacement failed for game {game_id}: {result.error_message}" @@ -793,7 +757,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "error", - {"message": f"Failed to process defensive replacement: {str(e)}"} + {"message": f"Failed to process defensive replacement: {str(e)}"}, ) @sio.event @@ -823,7 +787,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing game_id", "code": "MISSING_FIELD"} + {"message": "Missing game_id", "code": "MISSING_FIELD"}, ) return @@ -833,7 +797,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Invalid game_id format", "code": "INVALID_FORMAT"} + {"message": "Invalid game_id format", "code": "INVALID_FORMAT"}, ) return @@ -841,9 +805,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -856,7 +818,10 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing player_out_lineup_id", "code": "MISSING_FIELD"} + { + "message": "Missing player_out_lineup_id", + "code": "MISSING_FIELD", + }, ) return @@ -864,7 +829,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing player_in_card_id", "code": "MISSING_FIELD"} + {"message": "Missing player_in_card_id", "code": "MISSING_FIELD"}, ) return @@ -872,7 +837,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "substitution_error", - {"message": "Missing team_id", "code": "MISSING_FIELD"} + {"message": "Missing team_id", "code": "MISSING_FIELD"}, ) return @@ -893,7 +858,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: game_id=game_id, player_out_lineup_id=player_out_lineup_id, player_in_card_id=player_in_card_id, - team_id=team_id + team_id=team_id, ) if result.success: @@ -909,8 +874,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "position": result.new_position, # Should be "P" "batting_order": result.new_batting_order, "team_id": team_id, - "message": f"Pitching change: New pitcher entering" - } + "message": "Pitching change: New pitcher entering", + }, ) # Send confirmation to requester @@ -920,8 +885,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "type": "pitching_change", "new_lineup_id": result.new_lineup_id, - "success": True - } + "success": True, + }, ) logger.info( @@ -936,8 +901,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: { "message": result.error_message, "code": result.error_code, - "type": "pitching_change" - } + "type": "pitching_change", + }, ) logger.warning( f"Pitching change failed for game {game_id}: {result.error_message}" @@ -948,7 +913,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "error", - {"message": f"Failed to process pitching change: {str(e)}"} + {"message": f"Failed to process pitching change: {str(e)}"}, ) @sio.event @@ -971,31 +936,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Extract and validate game_id game_id_str = data.get("game_id") if not game_id_str: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing game_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) return try: game_id = UUID(game_id_str) except (ValueError, AttributeError): await manager.emit_to_user( - sid, - "error", - {"message": "Invalid game_id format"} + sid, "error", {"message": "Invalid game_id format"} ) return # Extract team_id team_id = data.get("team_id") if team_id is None: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing team_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing team_id"}) return # TODO: Verify user has access to view this lineup @@ -1024,12 +979,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "id": p.card_id, "name": p.player_name or f"Player #{p.card_id}", "image": p.player_image or "", - "headshot": p.player_headshot or "" - } + "headshot": p.player_headshot or "", + }, } - for p in lineup.players if p.is_active - ] - } + for p in lineup.players + if p.is_active + ], + }, ) logger.info(f"Lineup data sent for game {game_id}, team {team_id}") else: @@ -1039,9 +995,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: league_id = state.league_id if state else "sba" lineup_state = await lineup_service.load_team_lineup_with_player_data( - game_id=game_id, - team_id=team_id, - league_id=league_id + game_id=game_id, team_id=team_id, league_id=league_id ) if lineup_state: @@ -1066,27 +1020,28 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "player": { "id": p.card_id, "name": p.player_name or f"Player #{p.card_id}", - "image": p.player_image or "" - } + "image": p.player_image or "", + }, } - for p in lineup_state.players if p.is_active - ] - } + for p in lineup_state.players + if p.is_active + ], + }, + ) + logger.info( + f"Lineup data loaded from DB with player data for game {game_id}, team {team_id}" ) - logger.info(f"Lineup data loaded from DB with player data for game {game_id}, team {team_id}") else: await manager.emit_to_user( sid, "error", - {"message": f"Lineup not found for team {team_id}"} + {"message": f"Lineup not found for team {team_id}"}, ) except Exception as e: logger.error(f"Get lineup error: {e}", exc_info=True) await manager.emit_to_user( - sid, - "error", - {"message": f"Failed to get lineup: {str(e)}"} + sid, "error", {"message": f"Failed to get lineup: {str(e)}"} ) @sio.event @@ -1109,20 +1064,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Extract and validate game_id game_id_str = data.get("game_id") if not game_id_str: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing game_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) return try: game_id = UUID(game_id_str) except (ValueError, AttributeError): await manager.emit_to_user( - sid, - "error", - {"message": "Invalid game_id format"} + sid, "error", {"message": "Invalid game_id format"} ) return @@ -1130,9 +1079,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -1152,11 +1099,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: alignment=alignment, infield_depth=infield_depth, outfield_depth=outfield_depth, - hold_runners=hold_runners + hold_runners=hold_runners, ) # Submit decision through game engine - updated_state = await game_engine.submit_defensive_decision(game_id, decision) + updated_state = await game_engine.submit_defensive_decision( + game_id, decision + ) logger.info( f"Defensive decision submitted for game {game_id}: " @@ -1173,10 +1122,10 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "alignment": alignment, "infield_depth": infield_depth, "outfield_depth": outfield_depth, - "hold_runners": hold_runners + "hold_runners": hold_runners, }, - "pending_decision": updated_state.pending_decision - } + "pending_decision": updated_state.pending_decision, + }, ) except Exception as e: @@ -1184,7 +1133,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "error", - {"message": f"Failed to submit defensive decision: {str(e)}"} + {"message": f"Failed to submit defensive decision: {str(e)}"}, ) @sio.event @@ -1207,20 +1156,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Extract and validate game_id game_id_str = data.get("game_id") if not game_id_str: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing game_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) return try: game_id = UUID(game_id_str) except (ValueError, AttributeError): await manager.emit_to_user( - sid, - "error", - {"message": "Invalid game_id format"} + sid, "error", {"message": "Invalid game_id format"} ) return @@ -1228,9 +1171,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: state = state_manager.get_state(game_id) if not state: await manager.emit_to_user( - sid, - "error", - {"message": f"Game {game_id} not found"} + sid, "error", {"message": f"Game {game_id} not found"} ) return @@ -1244,13 +1185,12 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Create offensive decision from app.models.game_models import OffensiveDecision - decision = OffensiveDecision( - action=action, - steal_attempts=steal_attempts - ) + decision = OffensiveDecision(action=action, steal_attempts=steal_attempts) # Submit decision through game engine - updated_state = await game_engine.submit_offensive_decision(game_id, decision) + updated_state = await game_engine.submit_offensive_decision( + game_id, decision + ) logger.info( f"Offensive decision submitted for game {game_id}: " @@ -1263,12 +1203,9 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "offensive_decision_submitted", { "game_id": str(game_id), - "decision": { - "action": action, - "steal_attempts": steal_attempts - }, - "pending_decision": updated_state.pending_decision - } + "decision": {"action": action, "steal_attempts": steal_attempts}, + "pending_decision": updated_state.pending_decision, + }, ) except Exception as e: @@ -1276,7 +1213,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "error", - {"message": f"Failed to submit offensive decision: {str(e)}"} + {"message": f"Failed to submit offensive decision: {str(e)}"}, ) @sio.event @@ -1295,20 +1232,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Extract and validate game_id game_id_str = data.get("game_id") if not game_id_str: - await manager.emit_to_user( - sid, - "error", - {"message": "Missing game_id"} - ) + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) return try: game_id = UUID(game_id_str) except (ValueError, AttributeError): await manager.emit_to_user( - sid, - "error", - {"message": "Invalid game_id format"} + sid, "error", {"message": "Invalid game_id format"} ) return @@ -1325,10 +1256,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "box_score_data", - { - "game_id": str(game_id), - "box_score": box_score - } + {"game_id": str(game_id), "box_score": box_score}, ) logger.info(f"Box score data sent for game {game_id}") else: @@ -1337,14 +1265,12 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: "error", { "message": "No box score found for game", - "hint": "Run migration (alembic upgrade head) and refresh views" - } + "hint": "Run migration (alembic upgrade head) and refresh views", + }, ) except Exception as e: logger.error(f"Get box score error: {e}", exc_info=True) await manager.emit_to_user( - sid, - "error", - {"message": f"Failed to get box score: {str(e)}"} + sid, "error", {"message": f"Failed to get box score: {str(e)}"} ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 04b72a2..4745fa8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -39,10 +39,53 @@ packages = ["app"] [dependency-groups] dev = [ - "black==24.10.0", - "flake8==7.1.1", "mypy==1.14.1", "pytest==8.3.4", "pytest-asyncio==0.25.2", "pytest-cov==6.0.0", + "ruff==0.8.6", ] + +[tool.ruff] +line-length = 88 +target-version = "py313" +exclude = [ + ".git", + ".venv", + "__pycache__", + "*.pyc", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "logs", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RET", # flake8-return +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults (FastAPI Depends) + "RET504", # unnecessary variable assignment before return +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "N802", # function name should be lowercase (test_X pattern is fine) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/backend/tests/unit/core/test_runner_advancement.py b/backend/tests/unit/core/test_runner_advancement.py index 4bfc0cd..abd3c8c 100644 --- a/backend/tests/unit/core/test_runner_advancement.py +++ b/backend/tests/unit/core/test_runner_advancement.py @@ -470,8 +470,7 @@ class TestResult7: class TestResult10: """Tests for Result 10: Double play home to first.""" - @patch('app.core.runner_advancement.random.random', return_value=0.3) - def test_successful_dp_home_to_first(self, mock_random, advancement, base_state, infield_in_defense): + def test_successful_dp_home_to_first(self, advancement, base_state, infield_in_defense): """Successful DP: R3 out at home, batter out.""" base_state.current_on_base_code = 7 base_state.outs = 0 diff --git a/backend/uv.lock b/backend/uv.lock index e5e8414..3544c78 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -138,26 +138,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] -[[package]] -name = "black" -version = "24.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813, upload-time = "2024-10-07T19:20:50.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986, upload-time = "2024-10-07T19:28:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085, upload-time = "2024-10-07T19:28:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928, upload-time = "2024-10-07T19:24:15.233Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875, upload-time = "2024-10-07T19:24:42.762Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, -] - [[package]] name = "certifi" version = "2025.10.5" @@ -376,20 +356,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843, upload-time = "2024-12-03T22:45:59.368Z" }, ] -[[package]] -name = "flake8" -version = "7.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/37/72/e8d66150c4fcace3c0a450466aa3480506ba2cae7b61e100a2613afc3907/flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", size = 48054, upload-time = "2024-08-04T20:32:44.311Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/42/65004373ac4617464f35ed15931b30d764f53cdd30cc78d5aea349c8c050/flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213", size = 57731, upload-time = "2024-08-04T20:32:42.661Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -571,15 +537,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -640,15 +597,6 @@ bcrypt = [ { name = "bcrypt" }, ] -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - [[package]] name = "pendulum" version = "3.0.0" @@ -660,15 +608,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/b8/fe/27c7438c6ac8b8f8bef3c6e571855602ee784b85d072efddfff0ceb1cd77/pendulum-3.0.0.tar.gz", hash = "sha256:5d034998dea404ec31fae27af6b22cff1708f830a1ed7353be4d1019bb9f584e", size = 84524, upload-time = "2023-12-16T21:27:19.742Z" } -[[package]] -name = "platformdirs" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -706,15 +645,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, ] -[[package]] -name = "pycodestyle" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232, upload-time = "2024-08-04T20:26:54.576Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284, upload-time = "2024-08-04T20:26:53.173Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -776,15 +706,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718, upload-time = "2024-12-31T11:27:43.201Z" }, ] -[[package]] -name = "pyflakes" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788, upload-time = "2024-01-05T00:28:47.703Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -978,6 +899,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruff" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/00/089db7890ea3be5709e3ece6e46408d6f1e876026ec3fd081ee585fef209/ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5", size = 3473116, upload-time = "2025-01-04T12:23:00.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/28/aa07903694637c2fa394a9f4fe93cf861ad8b09f1282fa650ef07ff9fe97/ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3", size = 10628735, upload-time = "2025-01-04T12:21:53.632Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/827bb1448f1fcb0fb42e9c6edf8fb067ca8244923bf0ddf12b7bf949065c/ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1", size = 10386758, upload-time = "2025-01-04T12:22:00.349Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/fc852a81c3cd315b14676db3b8327d2bb2d7508649ad60bfdb966d60738d/ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807", size = 10007808, upload-time = "2025-01-04T12:22:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/94/e9/e0ed4af1794335fb280c4fac180f2bf40f6a3b859cae93a5a3ada27325ae/ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25", size = 10861031, upload-time = "2025-01-04T12:22:09.252Z" }, + { url = "https://files.pythonhosted.org/packages/82/68/da0db02f5ecb2ce912c2bef2aa9fcb8915c31e9bc363969cfaaddbc4c1c2/ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d", size = 10388246, upload-time = "2025-01-04T12:22:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1d/b85383db181639019b50eb277c2ee48f9f5168f4f7c287376f2b6e2a6dc2/ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75", size = 11424693, upload-time = "2025-01-04T12:22:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b7/30bc78a37648d31bfc7ba7105b108cb9091cd925f249aa533038ebc5a96f/ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315", size = 12141921, upload-time = "2025-01-04T12:22:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/60/b3/ee0a14cf6a1fbd6965b601c88d5625d250b97caf0534181e151504498f86/ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188", size = 11692419, upload-time = "2025-01-04T12:22:23.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/c597062b2931ba3e3861e80bd2b147ca12b3370afc3889af46f29209037f/ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf", size = 12981648, upload-time = "2025-01-04T12:22:26.663Z" }, + { url = "https://files.pythonhosted.org/packages/68/84/21f578c2a4144917985f1f4011171aeff94ab18dfa5303ac632da2f9af36/ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117", size = 11251801, upload-time = "2025-01-04T12:22:29.59Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/1ac02537c8edeb13e0955b5db86b5c050a1dcba54f6d49ab567decaa59c1/ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe", size = 10849857, upload-time = "2025-01-04T12:22:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/eb/00/020cb222252d833956cb3b07e0e40c9d4b984fbb2dc3923075c8f944497d/ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d", size = 10470852, upload-time = "2025-01-04T12:22:36.374Z" }, + { url = "https://files.pythonhosted.org/packages/00/56/e6d6578202a0141cd52299fe5acb38b2d873565f4670c7a5373b637cf58d/ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a", size = 10972997, upload-time = "2025-01-04T12:22:41.424Z" }, + { url = "https://files.pythonhosted.org/packages/be/31/dd0db1f4796bda30dea7592f106f3a67a8f00bcd3a50df889fbac58e2786/ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76", size = 11317760, upload-time = "2025-01-04T12:22:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/70/cfcb693dc294e034c6fed837fa2ec98b27cc97a26db5d049345364f504bf/ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764", size = 8799729, upload-time = "2025-01-04T12:22:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/ae6bcaa0edc83af42751bd193138bfb7598b2990939d3e40494d6c00698c/ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905", size = 9673857, upload-time = "2025-01-04T12:22:53.052Z" }, + { url = "https://files.pythonhosted.org/packages/91/f8/3765e053acd07baa055c96b2065c7fab91f911b3c076dfea71006666f5b0/ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162", size = 9149556, upload-time = "2025-01-04T12:22:57.173Z" }, +] + [[package]] name = "simple-websocket" version = "1.1.0" @@ -1069,12 +1015,11 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "black" }, - { name = "flake8" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -1103,12 +1048,11 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "black", specifier = "==24.10.0" }, - { name = "flake8", specifier = "==7.1.1" }, { name = "mypy", specifier = "==1.14.1" }, { name = "pytest", specifier = "==8.3.4" }, { name = "pytest-asyncio", specifier = "==0.25.2" }, { name = "pytest-cov", specifier = "==6.0.0" }, + { name = "ruff", specifier = "==0.8.6" }, ] [[package]]