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:
parent
2521833afb
commit
a4b99ee53e
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
@ -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
@ -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
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] = {}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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__ = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)}"}
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
110
backend/uv.lock
generated
@ -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]]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user