strat-gameplay-webapp/backend/app/services/play_stat_calculator.py
Cal Corum eab61ad966 CLAUDE: Phases 3.5, F1-F5 Complete - Statistics & Frontend Components
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>
2025-11-14 09:52:30 -06:00

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