# 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
