CLAUDE: Replace black and flake8 with ruff for formatting and linting

Migrated to ruff for faster, modern code formatting and linting:

Configuration changes:
- pyproject.toml: Added ruff 0.8.6, removed black/flake8
- Configured ruff with black-compatible formatting (88 chars)
- Enabled comprehensive linting rules (pycodestyle, pyflakes, isort,
  pyupgrade, bugbear, comprehensions, simplify, return)
- Updated CLAUDE.md: Changed code quality commands to use ruff

Code improvements (490 auto-fixes):
- Modernized type hints: List[T] → list[T], Dict[K,V] → dict[K,V],
  Optional[T] → T | None
- Sorted all imports (isort integration)
- Removed unused imports
- Fixed whitespace issues
- Reformatted 38 files for consistency

Bug fixes:
- app/core/play_resolver.py: Fixed type hint bug (any → Any)
- tests/unit/core/test_runner_advancement.py: Removed obsolete random mock

Testing:
- All 739 unit tests passing (100%)
- No regressions introduced

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-20 15:33:21 -06:00
parent 2521833afb
commit a4b99ee53e
46 changed files with 3706 additions and 3104 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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(),
}

View File

@ -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()

View File

@ -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",
]

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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(

View File

@ -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)")

View File

@ -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:

View File

@ -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 {},
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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:

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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",
)

View File

@ -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

View File

@ -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] = {}

View File

@ -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,
)

View File

@ -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:

View File

@ -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:

View File

@ -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"
)

View File

@ -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__ = [

View File

@ -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

View File

@ -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",
]

View File

@ -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"),

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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())

View File

@ -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)}"}
)

View File

@ -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"

View File

@ -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

110
backend/uv.lock generated
View File

@ -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]]