strat-gameplay-webapp/.claude/implementation/phase-3.5-polish-stats.md
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

40 KiB

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:

"""
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):

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

"""
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):

# 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:

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):

@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)

"""
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:

# 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:

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:

# 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:

# 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:

# 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:

# 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:

@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

  1. Day 3 Morning: Uncapped hit logic + code cleanup
  2. 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

"""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