This commit captures work from multiple sessions building the statistics system and frontend component library. Backend - Phase 3.5: Statistics System - Box score statistics with materialized views - Play stat calculator for real-time updates - Stat view refresher service - Alembic migration for materialized views - Test coverage: 41 new tests (all passing) Frontend - Phase F1: Foundation - Composables: useGameState, useGameActions, useWebSocket - Type definitions and interfaces - Store setup with Pinia Frontend - Phase F2: Game Display - ScoreBoard, GameBoard, CurrentSituation, PlayByPlay components - Demo page at /demo Frontend - Phase F3: Decision Inputs - DefensiveSetup, OffensiveApproach, StolenBaseInputs components - DecisionPanel orchestration - Demo page at /demo-decisions - Test coverage: 213 tests passing Frontend - Phase F4: Dice & Manual Outcome - DiceRoller component - ManualOutcomeEntry with validation - PlayResult display - GameplayPanel orchestration - Demo page at /demo-gameplay - Test coverage: 119 tests passing Frontend - Phase F5: Substitutions - PinchHitterSelector, DefensiveReplacementSelector, PitchingChangeSelector - SubstitutionPanel with tab navigation - Demo page at /demo-substitutions - Test coverage: 114 tests passing Documentation: - PHASE_3_5_HANDOFF.md - Statistics system handoff - PHASE_F2_COMPLETE.md - Game display completion - Frontend phase planning docs - NEXT_SESSION.md updated for Phase F6 Configuration: - Package updates (Nuxt 4 fixes) - Tailwind config enhancements - Game store updates Test Status: - Backend: 731/731 passing (100%) - Frontend: 446/446 passing (100%) - Total: 1,177 tests passing Next Phase: F6 - Integration (wire all components into game page) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
138 lines
4.5 KiB
Python
138 lines
4.5 KiB
Python
"""
|
|
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')
|
|
|
|
|
|
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
|