# Phase 3.5: Code Polish & Stat Tracking **Duration**: 2-3 days **Status**: Not Started **Prerequisites**: Phase 3 Complete (100% ✅) --- ## Overview Phase 3.5 is a polish and enhancement phase that cleans up technical debt (TODOs), implements game stat tracking, and prepares the codebase for Phase 4 (Spectator & Polish). This phase focuses on production readiness by addressing deferred items and implementing the stat tracking system needed for game history views. ## Key Objectives By end of Phase 3.5, you should have: - ✅ All critical TODOs resolved or documented - ✅ Authorization framework implemented for WebSocket handlers - ✅ Full game stat tracking (box scores, player stats) - ✅ Uncapped hit decision trees implemented - ✅ Code cleanup and refactoring complete - ✅ Integration test infrastructure improved - ✅ 100% test coverage maintained --- ## Major Components ### 1. Game Statistics System (Priority: HIGH) **Rationale**: With play resolution complete, stat tracking is a natural next step. Stats are needed for: - Completed games view (PRD requirement) - Box score display (terminal client has placeholder) - League REST API submission (PRD line 297) - Player performance analysis **Performance Requirement**: ZERO database calls added to play resolution critical path (< 500ms target maintained) **Architecture**: In-memory stat cache (like GameState) with async background persistence at strategic moments **Scope**: #### A. In-Memory Cache Models (NEW) **File**: `backend/app/models/stat_models.py` (NEW file) Add in-memory stat cache dataclasses: ```python """ In-memory statistics cache models. These dataclasses track stats during gameplay without database calls. Periodically flushed to database at non-blocking moments. """ from dataclasses import dataclass, field from typing import Dict from uuid import UUID @dataclass class PlayerStatsCache: """In-memory representation of player stats for a game.""" game_id: UUID lineup_id: int card_id: int team_id: int # Batting stats plate_appearances: int = 0 at_bats: int = 0 hits: int = 0 doubles: int = 0 triples: int = 0 home_runs: int = 0 rbis: int = 0 walks: int = 0 strikeouts: int = 0 stolen_bases: int = 0 caught_stealing: int = 0 # Pitching stats innings_pitched: float = 0.0 pitches_thrown: int = 0 batters_faced: int = 0 hits_allowed: int = 0 runs_allowed: int = 0 earned_runs: int = 0 walks_allowed: int = 0 strikeouts_pitched: int = 0 home_runs_allowed: int = 0 # Fielding stats (future) putouts: int = 0 assists: int = 0 errors: int = 0 def to_db_model(self) -> 'PlayerGameStats': """Convert to SQLAlchemy model for persistence.""" from app.models.db_models import PlayerGameStats return PlayerGameStats(**self.__dict__) @dataclass class GameStatsCache: """In-memory representation of aggregate game stats.""" game_id: UUID # Team stats home_runs: int = 0 away_runs: int = 0 home_hits: int = 0 away_hits: int = 0 home_errors: int = 0 away_errors: int = 0 # Linescore home_linescore: list = field(default_factory=lambda: [0] * 9) away_linescore: list = field(default_factory=lambda: [0] * 9) # Player stats cache player_stats: Dict[int, PlayerStatsCache] = field(default_factory=dict) def to_db_model(self) -> 'GameStats': """Convert to SQLAlchemy model for persistence.""" from app.models.db_models import GameStats return GameStats( game_id=self.game_id, home_runs=self.home_runs, away_runs=self.away_runs, home_hits=self.home_hits, away_hits=self.away_hits, home_errors=self.home_errors, away_errors=self.away_errors, home_linescore=self.home_linescore, away_linescore=self.away_linescore ) def get_or_create_player_stats( self, lineup_id: int, card_id: int, team_id: int ) -> PlayerStatsCache: """Get existing player stats or create new entry.""" if lineup_id not in self.player_stats: self.player_stats[lineup_id] = PlayerStatsCache( game_id=self.game_id, lineup_id=lineup_id, card_id=card_id, team_id=team_id ) return self.player_stats[lineup_id] ``` #### B. Database Models (NEW) **File**: `backend/app/models/db_models.py` Add stat persistence tables (used for storage only, NOT during gameplay): ```python class GameStats(Base): """Aggregate game statistics.""" __tablename__ = "game_stats" id = Column(Integer, primary_key=True) game_id = Column(UUID, ForeignKey("games.id"), nullable=False, unique=True) created_at = Column(DateTime, default=func.now()) # Team stats home_runs = Column(Integer, default=0) away_runs = Column(Integer, default=0) home_hits = Column(Integer, default=0) away_hits = Column(Integer, default=0) home_errors = Column(Integer, default=0) away_errors = Column(Integer, default=0) # Linescore (JSON array of inning scores) home_linescore = Column(JSON) # [0, 1, 0, 3, ...] away_linescore = Column(JSON) # [1, 0, 2, 0, ...] class PlayerGameStats(Base): """Individual player statistics for a game.""" __tablename__ = "player_game_stats" id = Column(Integer, primary_key=True) game_id = Column(UUID, ForeignKey("games.id"), nullable=False) lineup_id = Column(Integer, ForeignKey("lineups.id"), nullable=False) card_id = Column(Integer, nullable=False) team_id = Column(Integer, nullable=False) # Batting stats plate_appearances = Column(Integer, default=0) at_bats = Column(Integer, default=0) hits = Column(Integer, default=0) doubles = Column(Integer, default=0) triples = Column(Integer, default=0) home_runs = Column(Integer, default=0) rbis = Column(Integer, default=0) walks = Column(Integer, default=0) strikeouts = Column(Integer, default=0) stolen_bases = Column(Integer, default=0) caught_stealing = Column(Integer, default=0) # Pitching stats (if pitcher) innings_pitched = Column(Float, default=0.0) # 5.1 = 5 1/3 innings pitches_thrown = Column(Integer, default=0) batters_faced = Column(Integer, default=0) hits_allowed = Column(Integer, default=0) runs_allowed = Column(Integer, default=0) earned_runs = Column(Integer, default=0) walks_allowed = Column(Integer, default=0) strikeouts_pitched = Column(Integer, default=0) home_runs_allowed = Column(Integer, default=0) # Fielding stats (future - Phase 4+) putouts = Column(Integer, default=0) assists = Column(Integer, default=0) errors = Column(Integer, default=0) __table_args__ = ( Index('idx_player_game_stats_game', 'game_id'), Index('idx_player_game_stats_lineup', 'lineup_id'), ) ``` #### C. Stat Tracker Service (NEW) **File**: `backend/app/services/stat_tracker.py` (~500 lines) **CRITICAL**: In-memory stat tracking with ZERO database calls on critical path ```python """ Real-time game statistics tracking service. Architecture: - In-memory cache for all active games (NO database calls during play) - Background persistence at strategic moments (end of inning, substitutions) - Recovery from play history on reconnect Performance: ~1-5ms overhead per play (dict operations only) """ import asyncio import logging from typing import Dict, Set, Optional from uuid import UUID from app.models.stat_models import GameStatsCache, PlayerStatsCache from app.models.game_models import GameState, PlayResult from app.database.operations import DatabaseOperations from app.config.play_outcome import PlayOutcome logger = logging.getLogger(f'{__name__}.StatTracker') class StatTracker: """ Tracks statistics in-memory with async background persistence. Pattern: 1. GameEngine resolves play → PlayResult 2. StatTracker.record_play() → update in-memory cache (SYNC, instant) 3. Background task flushes to DB at strategic moments 4. Zero blocking on critical path """ def __init__(self, db_ops: DatabaseOperations): self.db_ops = db_ops # In-memory cache - instant lookups self._game_stats: Dict[UUID, GameStatsCache] = {} # Track which games need DB persistence self._dirty_games: Set[UUID] = set() def record_play( self, game_id: UUID, play_result: PlayResult, state_before: GameState, state_after: GameState ) -> None: """ Record statistics in-memory ONLY. NO database calls - returns instantly. Performance: ~1-5ms (dict operations only) """ # Get or create game stats cache if game_id not in self._game_stats: self._game_stats[game_id] = GameStatsCache(game_id=game_id) cache = self._game_stats[game_id] # Extract participants batter = state_before.current_batter pitcher = state_before.current_pitcher # Update batter stats (in-memory) self._update_batter_cache( cache=cache, batter=batter, outcome=play_result.outcome, rbis=self._calculate_rbis(state_before, state_after) ) # Update pitcher stats (in-memory) self._update_pitcher_cache( cache=cache, pitcher=pitcher, outcome=play_result.outcome, runs_allowed=state_after.home_score - state_before.home_score ) # Update game totals (in-memory) self._update_game_totals(cache, state_after) # Mark dirty for later persistence self._dirty_games.add(game_id) # Return immediately - no awaits! async def flush_stats_to_db(self, game_id: UUID) -> None: """ Async write stats to database. Called ONLY at non-blocking moments: - End of half-inning - Substitution - End of game - Periodic backup (every 60s) Never called during play resolution! """ if game_id not in self._dirty_games: logger.debug(f"No dirty stats for game {game_id}") return if game_id not in self._game_stats: logger.warning(f"No stats cache for game {game_id}") return cache = self._game_stats[game_id] try: # Single atomic transaction for all stats async with self.db_ops.transaction(): # Persist game stats await self.db_ops.upsert_game_stats(cache.to_db_model()) # Persist all player stats for player_cache in cache.player_stats.values(): await self.db_ops.upsert_player_game_stats( player_cache.to_db_model() ) # Mark clean self._dirty_games.remove(game_id) logger.info(f"Flushed stats to DB for game {game_id}") except Exception as e: logger.error(f"Failed to flush stats for game {game_id}: {e}", exc_info=True) # Keep in dirty set to retry later async def rebuild_stats_from_plays(self, game_id: UUID) -> None: """ Recover stats by replaying all plays from database. Used on reconnect if cache is lost. """ logger.info(f"Rebuilding stats from plays for game {game_id}") # Load all plays from database plays = await self.db_ops.get_plays_for_game(game_id) # Reset cache self._game_stats[game_id] = GameStatsCache(game_id=game_id) # Replay each play for play in plays: # TODO: Apply play to stats pass # Mark as clean (just rebuilt from source of truth) self._dirty_games.discard(game_id) logger.info(f"Rebuilt {len(plays)} plays for game {game_id}") def get_box_score(self, game_id: UUID) -> Optional[dict]: """ Get box score from in-memory cache. Instant - no database calls. Returns None if game not in cache (need to load from DB). """ if game_id not in self._game_stats: return None cache = self._game_stats[game_id] return { 'game_id': str(game_id), 'home_runs': cache.home_runs, 'away_runs': cache.away_runs, 'home_hits': cache.home_hits, 'away_hits': cache.away_hits, 'home_linescore': cache.home_linescore, 'away_linescore': cache.away_linescore, 'player_stats': [ self._player_cache_to_dict(p) for p in cache.player_stats.values() ] } async def get_box_score_from_db(self, game_id: UUID) -> dict: """ Load box score from database (cache miss). Also populates cache for future instant lookups. """ game_stats = await self.db_ops.get_game_stats(game_id) player_stats = await self.db_ops.get_player_game_stats(game_id) # Populate cache cache = GameStatsCache(game_id=game_id) if game_stats: cache.home_runs = game_stats.home_runs cache.away_runs = game_stats.away_runs # ... populate all fields ... self._game_stats[game_id] = cache return self.get_box_score(game_id) # Private helper methods (pure logic, no I/O) def _update_batter_cache( self, cache: GameStatsCache, batter, outcome: PlayOutcome, rbis: int ) -> None: """Update batter stats in cache (in-memory only).""" player_stats = cache.get_or_create_player_stats( lineup_id=batter.lineup_id, card_id=batter.card_id, team_id=batter.team_id ) # Increment stats based on outcome player_stats.plate_appearances += 1 if self._is_at_bat(outcome): player_stats.at_bats += 1 if outcome in [PlayOutcome.SINGLE, PlayOutcome.SINGLE_1, PlayOutcome.SINGLE_2]: player_stats.hits += 1 elif outcome in [PlayOutcome.DOUBLE, PlayOutcome.DOUBLE_1]: player_stats.hits += 1 player_stats.doubles += 1 elif outcome == PlayOutcome.TRIPLE: player_stats.hits += 1 player_stats.triples += 1 elif outcome == PlayOutcome.HOME_RUN: player_stats.hits += 1 player_stats.home_runs += 1 elif outcome == PlayOutcome.WALK: player_stats.walks += 1 elif outcome == PlayOutcome.STRIKEOUT: player_stats.strikeouts += 1 player_stats.rbis += rbis def _update_pitcher_cache( self, cache: GameStatsCache, pitcher, outcome: PlayOutcome, runs_allowed: int ) -> None: """Update pitcher stats in cache (in-memory only).""" player_stats = cache.get_or_create_player_stats( lineup_id=pitcher.lineup_id, card_id=pitcher.card_id, team_id=pitcher.team_id ) player_stats.batters_faced += 1 if outcome == PlayOutcome.STRIKEOUT: player_stats.strikeouts_pitched += 1 elif outcome == PlayOutcome.WALK: player_stats.walks_allowed += 1 elif outcome == PlayOutcome.HOME_RUN: player_stats.home_runs_allowed += 1 player_stats.runs_allowed += runs_allowed def _update_game_totals( self, cache: GameStatsCache, state: GameState ) -> None: """Update game totals from state (in-memory only).""" cache.home_runs = state.home_score cache.away_runs = state.away_score # Update linescore for current inning if state.current_half == 'bottom': inning_runs = state.home_score - cache.home_runs cache.home_linescore[state.inning - 1] = inning_runs def _calculate_rbis( self, before: GameState, after: GameState ) -> int: """Calculate RBIs from state change.""" runs_scored = after.home_score - before.home_score # Don't count RBI on errors if hasattr(after, 'last_play_had_error') and after.last_play_had_error: return 0 return runs_scored def _is_at_bat(self, outcome: PlayOutcome) -> bool: """Determine if outcome counts as an at-bat.""" # Walks, HBP, sacrifice don't count as AB return outcome not in [PlayOutcome.WALK, PlayOutcome.HBP] def _player_cache_to_dict(self, player: PlayerStatsCache) -> dict: """Convert player stats cache to dict.""" return { 'lineup_id': player.lineup_id, 'card_id': player.card_id, 'team_id': player.team_id, 'pa': player.plate_appearances, 'ab': player.at_bats, 'h': player.hits, '2b': player.doubles, '3b': player.triples, 'hr': player.home_runs, 'rbi': player.rbis, 'bb': player.walks, 'k': player.strikeouts, } ``` #### D. Database Operations (EXTEND) **File**: `backend/app/database/operations.py` Add batch persistence methods (called ONLY during background flush): ```python # In DatabaseOperations class async def upsert_game_stats(self, game_stats: GameStats) -> None: """ Insert or update game stats record. Called during background flush - NOT during play resolution. """ async with self.session() as session: stmt = insert(GameStats).values(**game_stats.dict()) stmt = stmt.on_conflict_do_update( index_elements=['game_id'], set_=game_stats.dict() ) await session.execute(stmt) await session.commit() async def upsert_player_game_stats(self, player_stats: PlayerGameStats) -> None: """ Insert or update player game stats. Called during background flush - NOT during play resolution. """ async with self.session() as session: stmt = insert(PlayerGameStats).values(**player_stats.dict()) stmt = stmt.on_conflict_do_update( index_elements=['game_id', 'lineup_id'], set_=player_stats.dict() ) await session.execute(stmt) await session.commit() async def get_game_stats(self, game_id: UUID) -> Optional[GameStats]: """Get aggregate game statistics (for loading from DB).""" async with self.session() as session: result = await session.execute( select(GameStats).where(GameStats.game_id == game_id) ) return result.scalar_one_or_none() async def get_player_game_stats(self, game_id: UUID) -> list[PlayerGameStats]: """Get all player stats for a game (for loading from DB).""" async with self.session() as session: result = await session.execute( select(PlayerGameStats).where(PlayerGameStats.game_id == game_id) ) return result.scalars().all() async def get_plays_for_game(self, game_id: UUID) -> list: """ Get all plays for a game (for stat recovery). Used to rebuild stats cache from play history on reconnect. """ async with self.session() as session: result = await session.execute( select(Play) .where(Play.game_id == game_id) .order_by(Play.created_at) ) return result.scalars().all() def transaction(self): """Context manager for atomic transactions.""" return self.session() ``` #### E. GameEngine Integration (MODIFY) **File**: `backend/app/core/game_engine.py` Integrate non-blocking stat tracking with background persistence: ```python class GameEngine: def __init__(self, db_ops: DatabaseOperations): self.db_ops = db_ops self.stat_tracker = StatTracker(db_ops) # NEW # ... existing code async def resolve_play( self, game_id: UUID, outcome: PlayOutcome ) -> PlayResult: """ Resolve play and track stats. Stats are recorded in-memory ONLY - zero DB blocking. """ state_before = self.state_manager.get_state(game_id) # Existing resolution logic result = await self._resolve_play_internal(game_id, outcome) state_after = self.state_manager.get_state(game_id) # ✅ FAST: In-memory stat update (no await, ~1-5ms) self.stat_tracker.record_play( game_id=game_id, play_result=result, state_before=state_before, state_after=state_after ) # Return immediately - no DB blocking! return result async def end_half_inning(self, game_id: UUID) -> None: """ End the current half-inning. Natural moment to persist stats to database. """ # ... existing half-inning logic ... # Background task - doesn't block gameplay asyncio.create_task( self.stat_tracker.flush_stats_to_db(game_id) ) async def make_substitution( self, game_id: UUID, lineup_id: int, replacement_id: int ) -> None: """Make a player substitution.""" # ... existing substitution logic ... # Checkpoint stats at substitution asyncio.create_task( self.stat_tracker.flush_stats_to_db(game_id) ) async def end_game(self, game_id: UUID) -> None: """End the game - MUST persist final stats.""" # ... existing end game logic ... # CRITICAL: Await final flush (don't lose data) await self.stat_tracker.flush_stats_to_db(game_id) logger.info(f"Final stats persisted for game {game_id}") ``` #### F. Persistence Strategy (NEW) **When Stats Are Written to Database**: 1. **End of Half-Inning** (Background Task) - Natural pause in gameplay - Players not waiting for action - `asyncio.create_task()` - non-blocking 2. **Player Substitution** (Background Task) - Good checkpoint moment - Substitutions relatively rare - `asyncio.create_task()` - non-blocking 3. **End of Game** (Awaited) - MUST persist final stats - Game ending anyway - acceptable wait - `await` - blocks until complete 4. **Periodic Backup** (Background Task - Optional Phase 3.6+) - Every 60 seconds as safety net - Prevents data loss on crash - `asyncio.create_task()` - non-blocking **Performance Impact**: - Play resolution: **+0ms** (no DB calls added) - End of inning: **+0ms** (background task) - End of game: **+50-100ms** (acceptable - game over) **Recovery Strategy**: - Stats can be rebuilt from play history - On reconnect: Check cache → Load from DB if missing - Worst case: Replay all plays to rebuild cache ``` #### G. Terminal Client Enhancement (MODIFY) **File**: `backend/terminal_client/commands.py` Replace placeholder box_score with real implementation: ```python async def show_box_score(game_id: UUID, db_ops: DatabaseOperations) -> None: """Display full box score with player stats.""" stat_tracker = StatTracker(db_ops) # Try in-memory cache first (instant) box_score = stat_tracker.get_box_score(game_id) if not box_score: # Cache miss - load from database box_score = await stat_tracker.get_box_score_from_db(game_id) # Format and display display.print_box_score(box_score) ``` #### H. WebSocket Event (NEW) **File**: `backend/app/websocket/handlers.py` Add event to retrieve stats (instant from cache): ```python @sio.event async def get_box_score(sid, data): """ Get box score for a game. Performance: Instant from in-memory cache (< 5ms) Data: - game_id: UUID Returns: - event: 'box_score_data' - data: {game_stats, player_stats, linescore} """ try: game_id = UUID(data['game_id']) # Get from in-memory cache (instant) box_score = stat_tracker.get_box_score(game_id) if not box_score: # Cache miss - load from DB box_score = await stat_tracker.get_box_score_from_db(game_id) await sio.emit('box_score_data', { 'game_id': str(game_id), 'box_score': box_score }, room=sid) except Exception as e: logger.error(f"Error getting box score: {e}", exc_info=True) await sio.emit('error', {'message': str(e)}, room=sid) ``` **Estimated Time**: 6-8 hours **Performance Summary**: - ✅ Zero DB calls added to play resolution critical path - ✅ < 5ms overhead per play (in-memory dict operations) - ✅ Box score retrieval: < 5ms from cache (vs 50-100ms from DB) - ✅ Background persistence at strategic moments only - ✅ < 500ms play resolution target maintained **Tests Required**: - Unit tests for StatTracker (~15 tests) - Integration tests for stat updates (5 tests) - Terminal client box_score display test --- ### 2. Authorization Framework (Priority: HIGH) **Current State**: 15+ TODO comments for authorization checks in WebSocket handlers **Goal**: Implement centralized authorization service for WebSocket events **Scope**: #### A. Authorization Service (NEW) **File**: `backend/app/services/auth_service.py` (~300 lines) ```python """ Authorization service for WebSocket events. Validates that users have permission to perform actions. """ from typing import Optional from uuid import UUID from app.models.db_models import Game, Lineup from app.database.operations import DatabaseOperations class AuthorizationError(Exception): """Raised when user is not authorized.""" pass class AuthService: """ Centralized authorization for game actions. """ def __init__(self, db_ops: DatabaseOperations): self.db_ops = db_ops async def verify_game_access( self, user_id: str, game_id: UUID ) -> bool: """ Verify user has access to view game. Returns True if: - User is a participant (home or away GM) - Game is public and user is logged in - User is a spectator (future) """ game = await self.db_ops.get_game(game_id) if not game: raise AuthorizationError(f"Game {game_id} not found") # Check if user is participant if game.home_gm_id == user_id or game.away_gm_id == user_id: return True # Check if game is public (spectator access) if game.visibility == 'public': return True return False async def verify_team_control( self, user_id: str, game_id: UUID, team_id: int ) -> bool: """ Verify user controls the specified team. Required for: substitutions, defensive decisions """ game = await self.db_ops.get_game(game_id) if not game: raise AuthorizationError(f"Game {game_id} not found") # Map team_id to GM if team_id == game.home_team_id: return user_id == game.home_gm_id elif team_id == game.away_team_id: return user_id == game.away_gm_id return False async def verify_active_batter( self, user_id: str, game_id: UUID ) -> bool: """ Verify user is the active batter's GM. Required for: offensive decisions, manual outcome submission """ from app.core.state_manager import state_manager state = state_manager.get_state(game_id) if not state: raise AuthorizationError(f"Game state not found: {game_id}") # Determine which team is batting batting_team_id = ( state.away_team_id if state.current_half == 'top' else state.home_team_id ) return await self.verify_team_control(user_id, game_id, batting_team_id) async def verify_game_participant( self, user_id: str, game_id: UUID ) -> bool: """ Verify user is a participant (home or away GM). Required for: rolling dice, making decisions """ game = await self.db_ops.get_game(game_id) if not game: raise AuthorizationError(f"Game {game_id} not found") return user_id in [game.home_gm_id, game.away_gm_id] ``` #### B. WebSocket Handler Integration (MODIFY) **File**: `backend/app/websocket/handlers.py` Replace all TODO comments with actual auth checks: ```python # Example: roll_dice handler @sio.event async def roll_dice(sid, data): """Roll dice for play resolution.""" try: game_id = UUID(data['game_id']) # Get user_id from session user_id = await get_user_from_session(sid) # REPLACE TODO with actual check if not await auth_service.verify_game_participant(user_id, game_id): await sio.emit('error', { 'message': 'Unauthorized: You are not a participant in this game', 'code': 'NOT_PARTICIPANT' }, room=sid) return # ... rest of handler ``` **Files to Update**: - `backend/app/websocket/handlers.py` - Add auth checks to all handlers - `backend/app/api/routes/auth.py` - Implement session → user_id lookup - `backend/app/websocket/CLAUDE.md` - Update documentation **Estimated Time**: 4-5 hours **Tests Required**: - Unit tests for AuthService (~12 tests) - Integration tests for WebSocket auth (8 tests) --- ### 3. Uncapped Hit Decision Trees (Priority: MEDIUM) **Current State**: 2 TODO comments in `play_resolver.py` for uncapped hit logic **Goal**: Implement full decision tree for uncapped hits (1B+, 2B+) **Reference**: PRD lines on hit location and advancement **Scope**: #### A. Uncapped Hit Resolution (MODIFY) **File**: `backend/app/core/play_resolver.py` Replace TODO placeholders: ```python def _resolve_uncapped_single( self, metadata: dict, runners: dict ) -> PlayOutcome: """ Resolve 1B+ (uncapped single) using hit location. Decision tree: - Hit to LF/CF/RF → runner advancement varies - Hit to IF → different advancement rules """ location = metadata.get('hit_location', 'OF') # Implement full decision tree if location in ['LF', 'CF', 'RF']: # Outfield single logic return self._resolve_outfield_single(location, runners) else: # Infield single logic (rare) return PlayOutcome.SINGLE_1 def _resolve_uncapped_double( self, metadata: dict, runners: dict ) -> PlayOutcome: """ Resolve 2B+ (uncapped double) using hit location. Decision tree: - Hit down the line → more advancement - Hit to gap → standard advancement """ location = metadata.get('hit_location', 'LF-CF') # Implement full decision tree # ... ``` **Estimated Time**: 3-4 hours **Tests Required**: - Unit tests for uncapped hit logic (~8 tests) --- ### 4. Code Cleanup & Refactoring (Priority: MEDIUM) **Goal**: Remove obsolete code, clean up config fields, implement deferred features **Scope**: #### A. Config Field Cleanup (MODIFY) **File**: `backend/app/config/base_config.py` Remove marked TODO fields: ```python # REMOVE these fields (not used, basic baseball rules) strikes_for_out: int = Field(default=3) # TODO: remove - unneeded balls_for_walk: int = Field(default=4) # TODO: remove - unneeded ``` **Rationale**: These are hardcoded in rule logic and don't vary by league #### B. Batter Handedness Integration (MODIFY) **File**: `backend/app/core/play_resolver.py` Replace hardcoded handedness: ```python # Currently: batter_handedness = 'R' # TODO: player model # Change to: batter_handedness = batter.handedness # From player model ``` **Dependency**: Requires `handedness` field on player models #### C. SPD Test Implementation (MODIFY) **File**: `backend/app/core/x_check_advancement_tables.py` Implement actual SPD test instead of default fail: ```python # Currently: def _run_speed_test(batter_speed: int) -> bool: """Test if batter has enough speed.""" # TODO: batter speed rating return False # Default to G3 fail # Change to: def _run_speed_test(batter_speed: int) -> bool: """ Test if batter beats throw. Speed ratings: 1-20 (1=slow, 20=fast) Threshold: 11+ = beats throw """ return batter_speed >= 11 ``` #### D. Relief Pitcher (RP) Logic in X-Check (MODIFY) **File**: `backend/app/core/x_check_advancement_tables.py` Replace placeholder RP logic: ```python # Currently uses E1 as placeholder for RP position 'RP': 1, # TODO: Actual RP logic (using E1 for now) # Implement proper RP fielding position # RP should use pitcher (1) fielding tables ``` **Estimated Time**: 2-3 hours total for all cleanup **Tests Required**: - Update existing tests for config changes - Add tests for handedness integration - Add tests for SPD test logic --- ### 5. Integration Test Infrastructure (Priority: LOW) **Current State**: TODO in `tests/CLAUDE.md` for integration test refactor **Goal**: Improve integration test reliability and database session handling **Scope**: #### A. Test Database Fixtures (IMPROVE) **File**: `backend/tests/conftest.py` Add better fixtures for integration tests: ```python @pytest.fixture async def test_game_with_stats(db_session): """Create a test game with pre-populated stats.""" # Useful for stat tracker tests pass @pytest.fixture async def test_game_with_substitutions(db_session): """Create a test game with substitution history.""" # Useful for lineup tests pass ``` #### B. Async Session Management (IMPROVE) **File**: `backend/tests/integration/conftest.py` Better async session handling to avoid connection pool issues **Estimated Time**: 2-3 hours --- ## Implementation Order ### Week 1 (Days 1-2): Stats & Auth 1. **Day 1 Morning**: Stat database models + migration 2. **Day 1 Afternoon**: StatTracker service implementation 3. **Day 2 Morning**: AuthService implementation 4. **Day 2 Afternoon**: WebSocket auth integration ### Week 2 (Day 3): Cleanup & Testing 5. **Day 3 Morning**: Uncapped hit logic + code cleanup 6. **Day 3 Afternoon**: Testing & documentation --- ## Testing Strategy ### Unit Tests - StatTracker: 18 tests - In-memory cache operations (8 tests) - Background persistence (5 tests) - Recovery from plays (5 tests) - AuthService: 12 tests (all authorization paths) - Uncapped hits: 8 tests (decision tree coverage) - **Target**: 38+ new tests ### Integration Tests - Stat tracking end-to-end: 6 tests - Full game stat tracking (3 tests) - Background flush timing (3 tests) - WebSocket auth checks: 8 tests - Box score retrieval: 3 tests (cache hit/miss scenarios) - **Target**: 17+ new tests ### Performance Tests (NEW) - Verify stat recording adds < 5ms per play - Verify box score retrieval < 5ms from cache - Verify background flush doesn't block gameplay ### Manual Testing - Terminal client box_score command (instant response) - Play full game and verify stats accuracy - Test auth rejections with different users - Verify stats persist across reconnects --- ## Database Migration **Migration File**: `backend/alembic/versions/004_add_game_stats.py` ```python """Add game statistics tables. Revision ID: 004 Revises: 003 Create Date: 2025-11-06 """ def upgrade(): # Create game_stats table op.create_table( 'game_stats', sa.Column('id', sa.Integer(), primary_key=True), sa.Column('game_id', sa.UUID(), nullable=False, unique=True), # ... all stat columns ) # Create player_game_stats table op.create_table( 'player_game_stats', sa.Column('id', sa.Integer(), primary_key=True), sa.Column('game_id', sa.UUID(), nullable=False), # ... all stat columns ) # Create indexes op.create_index('idx_player_game_stats_game', 'player_game_stats', ['game_id']) def downgrade(): op.drop_table('player_game_stats') op.drop_table('game_stats') ``` --- ## Success Criteria **Phase 3.5 Complete** when: - ✅ Full box score available for any game (< 5ms from cache) - ✅ All WebSocket handlers have auth checks (zero TODO comments) - ✅ Uncapped hit decision trees implemented - ✅ Code cleanup complete (config fields removed, handedness integrated) - ✅ 743+ total tests passing (688 + 55+ new tests) - ✅ All integration tests reliable and passing - ✅ Performance tests verify < 5ms stat overhead - ✅ Background persistence working at strategic moments - ✅ Stat recovery from plays working - ✅ Documentation updated --- ## Deliverable A production-ready codebase with: - Complete game statistics system - Robust authorization framework - All Phase 3 TODOs resolved - Enhanced code quality and test coverage - Ready for Phase 4 (Spectator & Polish) --- ## Files to Create/Modify ### New Files (5): 1. `backend/app/models/stat_models.py` (~150 lines - in-memory cache dataclasses) 2. `backend/app/services/stat_tracker.py` (~500 lines - in-memory tracking + background persistence) 3. `backend/app/services/auth_service.py` (~300 lines) 4. `backend/alembic/versions/004_add_game_stats.py` (~100 lines) 5. `backend/tests/unit/services/test_stat_tracker.py` (~300 lines) ### Modified Files (8): 1. `backend/app/models/db_models.py` (+120 lines - stat persistence tables) 2. `backend/app/database/operations.py` (+150 lines - upsert operations, recovery) 3. `backend/app/core/game_engine.py` (+60 lines - stat integration, background flush) 4. `backend/app/core/play_resolver.py` (+80 lines - uncapped hits, handedness) 5. `backend/app/core/x_check_advancement_tables.py` (+40 lines - SPD, RP logic) 6. `backend/app/config/base_config.py` (-5 lines - remove unused fields) 7. `backend/app/websocket/handlers.py` (+160 lines - auth checks, box score event) 8. `backend/terminal_client/commands.py` (+50 lines - real box_score) ### Test Files (5): 1. `backend/tests/unit/services/test_stat_tracker.py` (NEW - 18 tests) - In-memory cache operations (8 tests) - Background persistence (5 tests) - Recovery from plays (5 tests) 2. `backend/tests/unit/services/test_auth_service.py` (NEW - 12 tests) 3. `backend/tests/integration/test_stat_tracking.py` (NEW - 6 tests) - End-to-end stat tracking (3 tests) - Background flush timing (3 tests) 4. `backend/tests/integration/test_websocket_auth.py` (NEW - 8 tests) 5. `backend/tests/unit/core/test_play_resolver.py` (MODIFY - +8 tests) **Total Lines of Code**: ~1,600 new lines + ~650 modified lines **Architecture Highlights**: - ✅ In-memory stat cache (GameStatsCache, PlayerStatsCache) - ✅ Zero database calls on critical path - ✅ Background persistence at strategic moments - ✅ Recovery from play history on cache miss - ✅ < 5ms overhead per play (maintains < 500ms target) --- ## Blockers & Risks ### Potential Blockers 1. **Player Model Handedness**: Need to add `handedness` field to player models 2. **User Session Management**: Need Discord OAuth user_id from WebSocket sessions 3. **Database Migration**: Need to run migration on existing games ### Mitigation 1. Add handedness to SbaPlayer/PdPlayer models (quick fix) 2. Implement basic session store (Redis or in-memory for MVP) 3. Migration only adds new tables, doesn't modify existing data --- ## Next Phase After Phase 3.5 completion → **Phase 4: Spectator Mode & Polish** - Spectator view implementation - UI/UX refinements - Performance optimization - Accessibility improvements --- ## Performance Characteristics Summary ### Play Resolution Critical Path | Architecture | DB Calls/Play | Latency Added | Total Play Resolution | |--------------|---------------|---------------|----------------------| | **Original Plan (DB writes)** | 3 writes | +100-300ms | ~400-600ms ⚠️ | | **Revised Plan (In-Memory)** | 0 writes | +1-5ms | ~301-305ms ✅ | **Result**: Stat tracking adds negligible overhead while maintaining < 500ms target ### Box Score Retrieval | Source | Latency | When Used | |--------|---------|-----------| | **In-Memory Cache** | < 5ms | Active games (99% of requests) | | **Database Load** | 50-100ms | Completed games, cache miss | ### Background Persistence | Trigger | Frequency | Blocking | Latency | |---------|-----------|----------|---------| | End of half-inning | ~18x per game | No (async task) | 0ms to players | | Substitution | ~2-5x per game | No (async task) | 0ms to players | | End of game | 1x per game | Yes (awaited) | +50-100ms (acceptable) | ### Memory Usage - Per active game: ~2-5 KB (GameStatsCache + 18 PlayerStatsCache) - 100 concurrent games: ~500 KB total - Negligible impact on server memory --- **Status**: Not Started **Estimated Duration**: 2-3 days (16-24 hours of work) **Priority**: High (production readiness) **Blocking**: No (can proceed to Phase 4 in parallel if needed) **Performance Guarantee**: Zero database calls added to play resolution critical path