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>
155 lines
4.7 KiB
Python
155 lines
4.7 KiB
Python
"""
|
|
Calculate statistical fields from PlayOutcome.
|
|
|
|
Called when recording a play to populate stat fields for materialized view aggregation.
|
|
"""
|
|
|
|
import logging
|
|
|
|
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")
|
|
|
|
|
|
class PlayStatCalculator:
|
|
"""
|
|
Converts play outcome to statistical fields for database.
|
|
|
|
Stats are written to plays table and aggregated via materialized views.
|
|
Following legacy API pattern from major-domo /plays/batting and /plays/pitching.
|
|
"""
|
|
|
|
@staticmethod
|
|
def calculate_stats(
|
|
outcome: PlayOutcome,
|
|
result: PlayResult,
|
|
state_before: GameState,
|
|
state_after: GameState,
|
|
) -> dict[str, int]:
|
|
"""
|
|
Calculate all stat fields for a play.
|
|
|
|
Args:
|
|
outcome: The PlayOutcome enum value
|
|
result: PlayResult with detailed outcome info
|
|
state_before: GameState before play resolution
|
|
state_after: GameState after play resolution
|
|
|
|
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,
|
|
}
|
|
|
|
# Plate appearance (everything except non-PA events)
|
|
if outcome not in [
|
|
PlayOutcome.STOLEN_BASE,
|
|
PlayOutcome.CAUGHT_STEALING,
|
|
PlayOutcome.WILD_PITCH,
|
|
PlayOutcome.PASSED_BALL,
|
|
PlayOutcome.BALK,
|
|
PlayOutcome.PICK_OFF,
|
|
]:
|
|
stats["pa"] = 1
|
|
|
|
# At bat (PA minus walks, HBP, sac)
|
|
if stats["pa"] == 1 and outcome not in [
|
|
PlayOutcome.WALK,
|
|
PlayOutcome.HIT_BY_PITCH,
|
|
PlayOutcome.INTENTIONAL_WALK,
|
|
]:
|
|
stats["ab"] = 1
|
|
|
|
# Hits - check if outcome is a hit
|
|
if outcome.is_hit():
|
|
stats["hit"] = 1
|
|
|
|
# Determine hit type
|
|
if outcome in [
|
|
PlayOutcome.DOUBLE_2,
|
|
PlayOutcome.DOUBLE_3,
|
|
PlayOutcome.DOUBLE_UNCAPPED,
|
|
]:
|
|
stats["double"] = 1
|
|
elif outcome == PlayOutcome.TRIPLE:
|
|
stats["triple"] = 1
|
|
elif outcome == PlayOutcome.HOMERUN:
|
|
stats["homerun"] = 1
|
|
|
|
# Other batting outcomes
|
|
if outcome == PlayOutcome.WALK:
|
|
stats["bb"] = 1
|
|
elif outcome == PlayOutcome.INTENTIONAL_WALK:
|
|
stats["ibb"] = 1
|
|
stats["bb"] = 1 # IBB counts as BB too
|
|
elif outcome == PlayOutcome.STRIKEOUT:
|
|
stats["so"] = 1
|
|
elif outcome == PlayOutcome.HIT_BY_PITCH:
|
|
stats["hbp"] = 1
|
|
elif outcome == PlayOutcome.GROUNDBALL_A:
|
|
# GROUNDBALL_A is double play if possible
|
|
stats["gidp"] = 1
|
|
|
|
# Baserunning events (non-PA)
|
|
if outcome == PlayOutcome.STOLEN_BASE:
|
|
stats["sb"] = 1
|
|
elif outcome == PlayOutcome.CAUGHT_STEALING:
|
|
stats["cs"] = 1
|
|
|
|
# Pitching events (non-PA)
|
|
if outcome == PlayOutcome.WILD_PITCH:
|
|
stats["wild_pitch"] = 1
|
|
elif outcome == PlayOutcome.PASSED_BALL:
|
|
stats["passed_ball"] = 1
|
|
elif outcome == PlayOutcome.BALK:
|
|
stats["balk"] = 1
|
|
elif outcome == PlayOutcome.PICK_OFF:
|
|
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
|
|
else:
|
|
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
|
|
else:
|
|
stats["rbi"] = stats["run"]
|
|
|
|
# Outs recorded
|
|
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
|
|
|
|
return stats
|