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>
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:
-
End of Half-Inning (Background Task)
- Natural pause in gameplay
- Players not waiting for action
asyncio.create_task()- non-blocking
-
Player Substitution (Background Task)
- Good checkpoint moment
- Substitutions relatively rare
asyncio.create_task()- non-blocking
-
End of Game (Awaited)
- MUST persist final stats
- Game ending anyway - acceptable wait
await- blocks until complete
-
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 handlersbackend/app/api/routes/auth.py- Implement session → user_id lookupbackend/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
- Day 1 Morning: Stat database models + migration
- Day 1 Afternoon: StatTracker service implementation
- Day 2 Morning: AuthService implementation
- Day 2 Afternoon: WebSocket auth integration
Week 2 (Day 3): Cleanup & Testing
- Day 3 Morning: Uncapped hit logic + code cleanup
- 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):
backend/app/models/stat_models.py(~150 lines - in-memory cache dataclasses)backend/app/services/stat_tracker.py(~500 lines - in-memory tracking + background persistence)backend/app/services/auth_service.py(~300 lines)backend/alembic/versions/004_add_game_stats.py(~100 lines)backend/tests/unit/services/test_stat_tracker.py(~300 lines)
Modified Files (8):
backend/app/models/db_models.py(+120 lines - stat persistence tables)backend/app/database/operations.py(+150 lines - upsert operations, recovery)backend/app/core/game_engine.py(+60 lines - stat integration, background flush)backend/app/core/play_resolver.py(+80 lines - uncapped hits, handedness)backend/app/core/x_check_advancement_tables.py(+40 lines - SPD, RP logic)backend/app/config/base_config.py(-5 lines - remove unused fields)backend/app/websocket/handlers.py(+160 lines - auth checks, box score event)backend/terminal_client/commands.py(+50 lines - real box_score)
Test Files (5):
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)
backend/tests/unit/services/test_auth_service.py(NEW - 12 tests)backend/tests/integration/test_stat_tracking.py(NEW - 6 tests)- End-to-end stat tracking (3 tests)
- Background flush timing (3 tests)
backend/tests/integration/test_websocket_auth.py(NEW - 8 tests)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
- Player Model Handedness: Need to add
handednessfield to player models - User Session Management: Need Discord OAuth user_id from WebSocket sessions
- Database Migration: Need to run migration on existing games
Mitigation
- Add handedness to SbaPlayer/PdPlayer models (quick fix)
- Implement basic session store (Redis or in-memory for MVP)
- 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