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>
1351 lines
40 KiB
Markdown
1351 lines
40 KiB
Markdown
# 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
|