diff --git a/backend/app/config.py b/backend/app/config.py
index 47c90ac..e88fea2 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -15,6 +15,9 @@ class Settings(BaseSettings):
db_pool_size: int = 20
db_max_overflow: int = 10
+ # Redis
+ redis_url: str = "redis://localhost:6379/0"
+
# Discord OAuth
discord_client_id: str
discord_client_secret: str
diff --git a/backend/app/main.py b/backend/app/main.py
index fa0b8d6..d0563ac 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -10,6 +10,7 @@ from app.websocket.connection_manager import ConnectionManager
from app.websocket.handlers import register_handlers
from app.database.session import init_db
from app.utils.logging import setup_logging
+from app.services import redis_client
logger = logging.getLogger(f'{__name__}.main')
@@ -17,15 +18,34 @@ logger = logging.getLogger(f'{__name__}.main')
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
+ settings = get_settings()
+
# Startup
logger.info("Starting Paper Dynasty Game Backend")
setup_logging()
+
+ # Initialize database
await init_db()
logger.info("Database initialized")
+
+ # Initialize Redis
+ try:
+ redis_url = settings.redis_url
+ await redis_client.connect(redis_url)
+ logger.info(f"Redis initialized: {redis_url}")
+ except Exception as e:
+ logger.warning(f"Redis connection failed: {e}. Position rating caching will be unavailable.")
+
yield
+
# Shutdown
logger.info("Shutting down Paper Dynasty Game Backend")
+ # Disconnect Redis
+ if redis_client.is_connected:
+ await redis_client.disconnect()
+ logger.info("Redis disconnected")
+
# Initialize FastAPI app
app = FastAPI(
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
index 14264f7..3671a3f 100644
--- a/backend/app/services/__init__.py
+++ b/backend/app/services/__init__.py
@@ -9,10 +9,13 @@ Date: 2025-11-03
from app.services.pd_api_client import PdApiClient, pd_api_client
from app.services.position_rating_service import PositionRatingService, position_rating_service
+from app.services.redis_client import RedisClient, redis_client
__all__ = [
"PdApiClient",
"pd_api_client",
"PositionRatingService",
"position_rating_service",
+ "RedisClient",
+ "redis_client",
]
diff --git a/backend/app/services/position_rating_service.py b/backend/app/services/position_rating_service.py
index 86c1aae..6a17734 100644
--- a/backend/app/services/position_rating_service.py
+++ b/backend/app/services/position_rating_service.py
@@ -6,17 +6,21 @@ expiration and fallback to API.
Author: Claude
Date: 2025-11-03
+Phase: 3E-Final
"""
+import json
import logging
-from typing import List, Optional, Dict
+from typing import List, Optional
+from redis.exceptions import RedisError
from app.models.player_models import PositionRating
from app.services.pd_api_client import pd_api_client
logger = logging.getLogger(f'{__name__}.PositionRatingService')
-# In-memory cache (TODO Phase 3E-Final: Replace with Redis)
-# Key: card_id, Value: List[dict] (serialized PositionRating objects)
-_memory_cache: Dict[int, List[dict]] = {}
+# Redis key pattern: "position_ratings:{card_id}"
+# TTL: 86400 seconds (24 hours)
+REDIS_KEY_PREFIX = "position_ratings"
+REDIS_TTL_SECONDS = 86400 # 24 hours
class PositionRatingService:
@@ -31,13 +35,25 @@ class PositionRatingService:
"""
self.use_cache = use_cache
+ def _get_redis_key(self, card_id: int) -> str:
+ """
+ Get Redis key for card ratings.
+
+ Args:
+ card_id: PD card ID
+
+ Returns:
+ Redis key string
+ """
+ return f"{REDIS_KEY_PREFIX}:{card_id}"
+
async def get_ratings_for_card(
self,
card_id: int,
league_id: str
) -> List[PositionRating]:
"""
- Get all position ratings for a card with caching.
+ Get all position ratings for a card with Redis caching.
Args:
card_id: PD card ID
@@ -51,22 +67,51 @@ class PositionRatingService:
logger.debug(f"Skipping cache for non-PD league: {league_id}")
return []
- # Check cache first
- if self.use_cache and card_id in _memory_cache:
- logger.debug(f"Cache hit for card {card_id}")
- cached_data = _memory_cache[card_id]
- return [PositionRating(**data) for data in cached_data]
+ # Try Redis cache first
+ if self.use_cache:
+ try:
+ from app.services import redis_client
- # Cache miss - fetch from API
- logger.debug(f"Cache miss for card {card_id}, fetching from API")
+ if redis_client.is_connected:
+ redis_key = self._get_redis_key(card_id)
+ cached_json = await redis_client.client.get(redis_key)
+
+ if cached_json:
+ logger.debug(f"Redis cache hit for card {card_id}")
+ cached_data = json.loads(cached_json)
+ return [PositionRating(**data) for data in cached_data]
+ else:
+ logger.debug(f"Redis cache miss for card {card_id}")
+ else:
+ logger.warning("Redis not connected, skipping cache")
+
+ except RedisError as e:
+ logger.warning(f"Redis error for card {card_id}: {e}, falling back to API")
+ except Exception as e:
+ logger.error(f"Unexpected cache error for card {card_id}: {e}")
+
+ # Cache miss or error - fetch from API
+ logger.debug(f"Fetching ratings from API for card {card_id}")
try:
# Note: card_id maps to player_id in PD API terminology
ratings = await pd_api_client.get_position_ratings(player_id=card_id)
- # Cache the results
- if self.use_cache:
- _memory_cache[card_id] = [r.model_dump() for r in ratings]
- logger.debug(f"Cached {len(ratings)} ratings for card {card_id}")
+ # Cache the results in Redis with TTL
+ if self.use_cache and ratings:
+ try:
+ from app.services import redis_client
+
+ if redis_client.is_connected:
+ redis_key = self._get_redis_key(card_id)
+ ratings_json = json.dumps([r.model_dump() for r in ratings])
+ await redis_client.client.setex(
+ redis_key,
+ REDIS_TTL_SECONDS,
+ ratings_json
+ )
+ logger.debug(f"Cached {len(ratings)} ratings for card {card_id} (TTL: {REDIS_TTL_SECONDS}s)")
+ except RedisError as e:
+ logger.warning(f"Failed to cache ratings for card {card_id}: {e}")
return ratings
@@ -100,11 +145,43 @@ class PositionRatingService:
logger.warning(f"No rating found for card {card_id} at position {position}")
return None
- def clear_cache(self) -> None:
- """Clear the in-memory cache."""
- global _memory_cache
- _memory_cache.clear()
- logger.info("Position rating cache cleared")
+ async def clear_cache(self, card_id: Optional[int] = None) -> None:
+ """
+ Clear Redis cache for position ratings.
+
+ Args:
+ card_id: If provided, clear only this card's cache.
+ If None, clear all position rating caches.
+ """
+ try:
+ from app.services import redis_client
+
+ if not redis_client.is_connected:
+ logger.warning("Redis not connected, cannot clear cache")
+ return
+
+ if card_id is not None:
+ # Clear specific card
+ redis_key = self._get_redis_key(card_id)
+ await redis_client.client.delete(redis_key)
+ logger.info(f"Cleared cache for card {card_id}")
+ else:
+ # Clear all position rating caches
+ pattern = f"{REDIS_KEY_PREFIX}:*"
+ keys = []
+ async for key in redis_client.client.scan_iter(match=pattern):
+ keys.append(key)
+
+ if keys:
+ await redis_client.client.delete(*keys)
+ logger.info(f"Cleared {len(keys)} position rating cache entries")
+ else:
+ logger.info("No position rating cache entries to clear")
+
+ except RedisError as e:
+ logger.error(f"Failed to clear cache: {e}")
+ except Exception as e:
+ logger.error(f"Unexpected error clearing cache: {e}")
# Singleton instance
diff --git a/backend/app/services/redis_client.py b/backend/app/services/redis_client.py
new file mode 100644
index 0000000..1da646b
--- /dev/null
+++ b/backend/app/services/redis_client.py
@@ -0,0 +1,112 @@
+"""
+Redis connection client for caching.
+
+Provides async Redis client with connection pooling for
+position ratings and other cached data.
+
+Author: Claude
+Date: 2025-11-03
+Phase: 3E-Final
+"""
+import logging
+from typing import Optional
+import redis.asyncio as redis
+from redis.asyncio import Redis
+from redis.exceptions import RedisError
+
+logger = logging.getLogger(f'{__name__}.RedisClient')
+
+
+class RedisClient:
+ """
+ Async Redis client with connection pooling.
+
+ Singleton pattern - use redis_client instance.
+ """
+
+ def __init__(self):
+ self._redis: Optional[Redis] = None
+ self._url: Optional[str] = None
+
+ async def connect(self, redis_url: str = "redis://localhost:6379/0") -> None:
+ """
+ Connect to Redis server.
+
+ Args:
+ redis_url: Redis connection URL
+ Format: redis://host:port/db
+ Example: redis://localhost:6379/0
+ """
+ if self._redis is not None:
+ logger.warning("Redis client already connected")
+ return
+
+ try:
+ self._url = redis_url
+ self._redis = await redis.from_url(
+ redis_url,
+ encoding="utf-8",
+ decode_responses=True,
+ max_connections=10 # Connection pool size
+ )
+
+ # Test connection
+ await self._redis.ping()
+ logger.info(f"Redis connected successfully: {redis_url}")
+
+ except RedisError as e:
+ logger.error(f"Failed to connect to Redis at {redis_url}: {e}")
+ self._redis = None
+ raise
+
+ async def disconnect(self) -> None:
+ """Disconnect from Redis server."""
+ if self._redis is not None:
+ await self._redis.close()
+ await self._redis.connection_pool.disconnect()
+ self._redis = None
+ logger.info("Redis disconnected")
+
+ @property
+ def client(self) -> Redis:
+ """
+ Get Redis client instance.
+
+ Returns:
+ Redis client
+
+ Raises:
+ RuntimeError: If not connected
+ """
+ if self._redis is None:
+ raise RuntimeError(
+ "Redis client not connected. "
+ "Call await redis_client.connect() first."
+ )
+ return self._redis
+
+ @property
+ def is_connected(self) -> bool:
+ """Check if Redis is connected."""
+ return self._redis is not None
+
+ async def ping(self) -> bool:
+ """
+ Test Redis connection.
+
+ Returns:
+ True if Redis is responding, False otherwise
+ """
+ if not self.is_connected:
+ return False
+
+ try:
+ await self._redis.ping()
+ return True
+ except RedisError as e:
+ logger.error(f"Redis ping failed: {e}")
+ return False
+
+
+# Singleton instance
+redis_client = RedisClient()
diff --git a/backend/app/websocket/MANUAL_VS_AUTO_MODE.md b/backend/app/websocket/MANUAL_VS_AUTO_MODE.md
new file mode 100644
index 0000000..48ec036
--- /dev/null
+++ b/backend/app/websocket/MANUAL_VS_AUTO_MODE.md
@@ -0,0 +1,588 @@
+# Manual vs Auto Mode X-Check Workflows
+
+## Overview
+
+Paper Dynasty supports two play resolution modes that affect how X-Check defensive plays are resolved:
+
+- **Manual Mode** (Primary): Players read physical cards and submit outcomes
+- **Auto Mode** (Rare, PD only): System auto-generates outcomes from digitized ratings
+
+This document explains how X-Check resolution differs between these modes and provides implementation guidance.
+
+**Author**: Claude
+**Date**: 2025-11-03
+**Phase**: 3E-Final
+
+---
+
+## Game Modes Comparison
+
+| Feature | Manual Mode | Auto Mode |
+|---------|-------------|-----------|
+| **Availability** | All leagues (SBA, PD) | PD only |
+| **Card Reading** | Required (physical cards) | Not required |
+| **Outcome Source** | Player submission | Auto-generated |
+| **Position Ratings** | Used for X-Check resolution | Used for all outcomes |
+| **Dice Rolls** | Server-rolled, broadcast to all | Server-rolled internally |
+| **Player Interaction** | High (read card, submit) | Low (watch results) |
+| **Speed** | Slower (human input) | Faster (instant) |
+| **Use Case** | Most games, casual play | Simulations, AI opponents |
+
+---
+
+## Manual Mode Workflow
+
+**Most common** - Players own physical cards and read results.
+
+### X-Check Flow (Manual Mode)
+
+```
+1. Server broadcasts dice rolled
+ ↓
+2. Players read their cards
+ ↓
+3. Player sees "X-Check SS" on card
+ ↓
+4. Player submits outcome="x_check", hit_location="SS"
+ ↓
+5. Server resolves X-Check
+ - Rolls d20 (defense table)
+ - Rolls 3d6 (error chart)
+ - Gets defender's range/error from Redis cache
+ - Looks up result: G2 + NO = Groundball B
+ ↓
+6. Server broadcasts play_resolved with x_check_details
+ ↓
+7. UI shows defender ratings, dice rolls, resolution steps
+```
+
+### Player Experience
+
+**Step 1: Dice Rolled**
+```javascript
+// Player receives:
+{
+ "event": "dice_rolled",
+ "d6_one": 4, // Card column: 4-6 = pitcher card
+ "d6_two_total": 8, // Card row: 8
+ "chaos_d20": 15, // For splits
+ "message": "Dice rolled - read your card and submit outcome"
+}
+```
+
+**Player Action**:
+1. Check pitcher card (d6_one = 4)
+2. Look at row 8 (d6_two_total = 8)
+3. Read result: "X-Check SS"
+
+**Step 2: Submit Outcome**
+```javascript
+socket.emit('submit_manual_outcome', {
+ game_id: "123e4567-...",
+ outcome: "x_check",
+ hit_location: "SS" // Important: must specify position
+});
+```
+
+**Step 3: Receive Result**
+```javascript
+socket.on('play_resolved', (data) => {
+ // Standard fields
+ console.log(data.outcome); // "groundball_b"
+ console.log(data.description); // "X-Check SS: G2 → G2 + NO = groundball_b"
+
+ // X-Check details (show in UI)
+ if (data.x_check_details) {
+ const xcheck = data.x_check_details;
+
+ // Show defender
+ console.log(`Defender at ${xcheck.position}`);
+ console.log(`Range: ${xcheck.defender_range}/5`);
+ console.log(`Error: ${xcheck.defender_error_rating}/25`);
+
+ // Show resolution
+ console.log(`Rolls: d20=${xcheck.d20_roll}, 3d6=${xcheck.d6_roll}`);
+ console.log(`Result: ${xcheck.base_result} → ${xcheck.converted_result} + ${xcheck.error_result}`);
+ console.log(`Final: ${xcheck.final_outcome}`);
+ }
+});
+```
+
+### UI Recommendations (Manual Mode)
+
+**Show X-Check Details After Resolution**:
+- Display defender's name, position, and ratings
+- Show dice roll results with icons/animations
+- Visualize resolution flow: base → converted → + error → final
+- Use color coding for error types (NO=green, E1-E3=yellow-red, RP=purple)
+
+**Example UI Components**:
+```jsx
+function XCheckResult({ xcheck, defender }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+---
+
+## Auto Mode Workflow
+
+**Rare** - Used for simulations, AI opponents, or when players don't have physical cards.
+
+### X-Check Flow (Auto Mode)
+
+```
+1. Server auto-generates outcome from ratings
+ ↓
+2. System determines outcome="x_check" from probabilities
+ ↓
+3. Server resolves X-Check
+ - Rolls d20 (defense table)
+ - Rolls 3d6 (error chart)
+ - Gets defender's range/error from Redis cache
+ - Looks up result: F2 + E1 = Error
+ ↓
+4. Server broadcasts play_resolved with x_check_details
+ ↓
+5. UI shows complete resolution (no player input needed)
+```
+
+### System Experience
+
+**No Player Input Required**:
+- System rolls dice internally
+- Generates outcome from `PdPitchingRating.xcheck_ss` probability
+- Resolves X-Check automatically
+- Broadcasts final result
+
+**Backend Flow**:
+```python
+# Auto mode resolution
+result = play_resolver.resolve_auto_play(
+ state=state,
+ batter=pd_batter, # PdPlayer with ratings
+ pitcher=pd_pitcher, # PdPlayer with ratings
+ defensive_decision=def_decision,
+ offensive_decision=off_decision
+)
+
+# If outcome is X_CHECK:
+# - X-Check already resolved internally
+# - result.x_check_details populated
+# - Broadcast includes full details
+```
+
+### Enabling Auto Mode
+
+**Game Creation**:
+```python
+# Set auto_mode on game creation
+state = GameState(
+ game_id=game_id,
+ league_id="pd", # Must be PD league
+ auto_mode=True, # Enable auto resolution
+ home_team_id=1,
+ away_team_id=2
+)
+```
+
+**League Support**:
+```python
+from app.config import get_league_config
+
+config = get_league_config("pd")
+
+# Check if auto mode supported
+if config.supports_auto_mode():
+ # Can use auto mode
+ pass
+else:
+ # Must use manual mode
+ raise ValueError("Auto mode not supported for this league")
+```
+
+**Requirements for Auto Mode**:
+1. League must be PD (has digitized card data)
+2. All players must have batting/pitching ratings loaded
+3. `supports_auto_mode()` returns True
+
+### UI Recommendations (Auto Mode)
+
+**Show Results Immediately**:
+- No waiting for player input
+- Animate resolution quickly
+- Focus on visual clarity over interactivity
+
+**Example UI Flow**:
+```jsx
+function AutoModePlay({ playResult }) {
+ // Show play animation immediately
+ useEffect(() => {
+ if (playResult.x_check_details) {
+ // Animate X-Check resolution
+ animateXCheck(playResult.x_check_details);
+ } else {
+ // Animate standard play
+ animatePlay(playResult);
+ }
+ }, [playResult]);
+
+ return (
+
+
+ {playResult.x_check_details && (
+
+ )}
+
+ );
+}
+```
+
+---
+
+## Implementation Differences
+
+### Backend
+
+**PlayResolver Initialization**:
+```python
+# Manual mode (default)
+manual_resolver = PlayResolver(league_id="pd", auto_mode=False)
+
+# Auto mode (rare)
+auto_resolver = PlayResolver(league_id="pd", auto_mode=True)
+
+# Raises error for SBA
+sba_auto = PlayResolver(league_id="sba", auto_mode=True) # ❌ ValueError
+```
+
+**Resolution Methods**:
+```python
+# Manual mode
+result = resolver.resolve_manual_play(
+ submission=ManualOutcomeSubmission(
+ outcome="x_check",
+ hit_location="SS"
+ ),
+ state=state,
+ defensive_decision=def_decision,
+ offensive_decision=off_decision,
+ ab_roll=ab_roll
+)
+
+# Auto mode
+result = resolver.resolve_auto_play(
+ state=state,
+ batter=pd_player, # Needs PdPlayer with ratings
+ pitcher=pd_pitcher, # Needs PdPlayer with ratings
+ defensive_decision=def_decision,
+ offensive_decision=off_decision
+)
+# Note: ab_roll generated internally for auto mode
+```
+
+### Frontend
+
+**Detect Mode**:
+```javascript
+// Check game mode
+const isAutoMode = gameState.auto_mode;
+
+if (isAutoMode) {
+ // Don't show "submit outcome" UI
+ // Just listen for play_resolved events
+} else {
+ // Show "roll dice" and "submit outcome" buttons
+ // Show card reading instructions
+}
+```
+
+**Event Handling**:
+```javascript
+// Manual mode: Wait for dice, then submit
+socket.on('dice_rolled', (data) => {
+ if (!isAutoMode) {
+ showCardReaderUI(data);
+ }
+});
+
+// Both modes: Handle play results
+socket.on('play_resolved', (data) => {
+ if (data.x_check_details) {
+ displayXCheckResult(data);
+ } else {
+ displayStandardResult(data);
+ }
+});
+```
+
+---
+
+## X-Check Resolution Details
+
+**Both modes use identical X-Check resolution**:
+
+1. **Defense Table Lookup**: d20 + defender range → base result
+2. **SPD Test** (if needed): Batter speed vs target
+3. **Hash Conversion**: G2#/G3# → SI2 if conditions met
+4. **Error Chart Lookup**: 3d6 + defender error → error result
+5. **Final Outcome**: Combine base + error → final outcome
+
+**Key Point**: The X-Check resolution logic is **mode-agnostic**. The only difference is:
+- Manual: Triggered by player submitting "x_check"
+- Auto: Triggered by system generating "x_check" from probabilities
+
+---
+
+## Position Ratings Usage
+
+### Manual Mode
+
+**When Used**: Only for X-Check resolution
+
+```python
+# Normal play (strikeout, walk, single):
+# - No position ratings needed
+# - Outcome from player submission
+
+# X-Check play:
+# - Get defender at position
+# - Use defender.position_rating.range
+# - Use defender.position_rating.error
+# - Resolve with tables
+```
+
+### Auto Mode
+
+**When Used**: For ALL play generation + X-Check resolution
+
+```python
+# All plays:
+# - Use PdBattingRating to generate outcome probabilities
+# - Use PdPitchingRating.xcheck_* for defensive play chances
+# - Roll weighted random based on probabilities
+
+# If X-Check generated:
+# - Same X-Check resolution as manual mode
+# - Use defender.position_rating.range/error
+```
+
+---
+
+## Testing Considerations
+
+### Manual Mode Tests
+
+**What to Test**:
+- Player submits "x_check" with valid hit_location
+- Server resolves X-Check correctly
+- x_check_details included in broadcast
+- Defender ratings retrieved from Redis
+- Error handling for missing ratings
+
+**Mock Data**:
+```python
+# Simulate manual submission
+submission = ManualOutcomeSubmission(
+ outcome="x_check",
+ hit_location="SS"
+)
+
+# Mock pending dice roll
+state.pending_manual_roll = ab_roll
+
+# Resolve
+result = await game_engine.resolve_manual_play(
+ game_id=game_id,
+ ab_roll=ab_roll,
+ outcome=PlayOutcome.X_CHECK,
+ hit_location="SS"
+)
+
+# Verify X-Check details present
+assert result.x_check_details is not None
+assert result.x_check_details.position == "SS"
+```
+
+### Auto Mode Tests
+
+**What to Test**:
+- Auto resolver generates X-Check outcomes
+- X-Check probability matches PdPitchingRating
+- Resolution includes x_check_details
+- Works without player input
+
+**Mock Data**:
+```python
+# Create auto resolver
+resolver = PlayResolver(league_id="pd", auto_mode=True)
+
+# Create players with ratings
+batter = PdPlayer(...)
+pitcher = PdPlayer(...)
+
+# Resolve automatically
+result = resolver.resolve_auto_play(
+ state=state,
+ batter=batter,
+ pitcher=pitcher,
+ defensive_decision=def_decision,
+ offensive_decision=off_decision
+)
+
+# Verify auto-generated
+assert result.ab_roll is not None # Generated internally
+```
+
+---
+
+## Common Scenarios
+
+### Scenario 1: Manual Game, X-Check to SS
+
+**Flow**:
+1. Player rolls dice → d6_one=5, d6_two_total=11
+2. Reads pitcher card row 11 → "X-Check SS"
+3. Submits outcome="x_check", hit_location="SS"
+4. Server:
+ - Rolls d20=14, 3d6=9
+ - Gets SS defender (range=4, error=8)
+ - Defense table[14][4] = "G2"
+ - Error chart[8][9] = "NO"
+ - Final: G2 + NO = Groundball B
+5. Broadcasts result with x_check_details
+6. UI shows: "Grounder to short. Routine play. Out recorded."
+
+### Scenario 2: Manual Game, X-Check with Error
+
+**Flow**:
+1. Player submits outcome="x_check", hit_location="3B"
+2. Server:
+ - Rolls d20=15, 3d6=17
+ - Gets 3B defender (range=3, error=18)
+ - Defense table[15][3] = "PO"
+ - Error chart[18][17] = "E2"
+ - Final: PO + E2 = Error (error overrides out)
+3. Batter reaches 2nd base safely
+4. UI shows: "Pop up to third. Error! Ball dropped. Batter to second."
+
+### Scenario 3: Auto Game, X-Check Generated
+
+**Flow**:
+1. System generates outcome from PdPitchingRating
+2. xcheck_2b = 8% → Randomly selects X-Check 2B
+3. Server auto-resolves:
+ - Rolls d20=10, 3d6=7
+ - Gets 2B defender (range=5, error=3)
+ - Defense table[10][5] = "G1"
+ - Error chart[3][7] = "NO"
+ - Final: G1 + NO = Groundball A
+4. Broadcasts with x_check_details
+5. UI animates: "Grounder to second. Easy play. Out."
+
+### Scenario 4: Mixed Game (Manual Batters, Auto Pitchers)
+
+**Not Currently Supported** - A game is either fully manual or fully auto.
+
+**Future Enhancement**: Could support per-team mode selection:
+- Away team (manual): Reads cards, submits outcomes
+- Home team (auto): System generates outcomes
+
+---
+
+## Configuration Reference
+
+### Game Settings
+
+```python
+class GameState:
+ league_id: str # 'sba' or 'pd'
+ auto_mode: bool # True = auto, False = manual (default)
+ home_team_is_ai: bool # AI teams use auto mode internally
+ away_team_is_ai: bool
+```
+
+### League Config
+
+```python
+class PdConfig(BaseLeagueConfig):
+ def supports_auto_mode(self) -> bool:
+ return True # PD has digitized ratings
+
+ def supports_position_ratings(self) -> bool:
+ return True # PD has position ratings
+
+class SbaConfig(BaseLeagueConfig):
+ def supports_auto_mode(self) -> bool:
+ return False # SBA uses physical cards only
+
+ def supports_position_ratings(self) -> bool:
+ return False # SBA uses defaults
+```
+
+---
+
+## Summary
+
+**Manual Mode** (Primary):
+- Players read physical cards
+- Submit outcomes via WebSocket
+- X-Check resolved server-side
+- Shows defender ratings in UI
+- **Best for**: Normal gameplay
+
+**Auto Mode** (Rare):
+- System generates outcomes from ratings
+- No player input needed
+- X-Check auto-resolved
+- Faster, less interactive
+- **Best for**: Simulations, AI opponents
+
+**X-Check Resolution**: Identical in both modes
+- Uses same defense/error tables
+- Uses same Redis-cached position ratings
+- Returns same x_check_details structure
+- UI displays same information
+
+**Key Difference**: How the X-Check is **triggered**
+- Manual: Player submits after reading card
+- Auto: System generates from probabilities
+
+---
+
+## Related Documentation
+
+- **WebSocket Events**: `app/websocket/handlers.py`
+- **Frontend Guide**: `app/websocket/X_CHECK_FRONTEND_GUIDE.md`
+- **Play Resolution**: `app/core/play_resolver.py`
+- **Position Ratings**: `app/services/position_rating_service.py`
+- **League Configs**: `app/config/league_configs.py`
+
+---
+
+**Last Updated**: 2025-11-03
+**Phase**: 3E-Final
+**Status**: ✅ Complete
diff --git a/backend/app/websocket/X_CHECK_FRONTEND_GUIDE.md b/backend/app/websocket/X_CHECK_FRONTEND_GUIDE.md
new file mode 100644
index 0000000..a588269
--- /dev/null
+++ b/backend/app/websocket/X_CHECK_FRONTEND_GUIDE.md
@@ -0,0 +1,517 @@
+# X-Check Frontend Integration Guide
+
+## Overview
+
+X-Check defensive plays are now fully integrated into the WebSocket `play_resolved` event. When a play results in an X-Check (defensive position check), the event will include detailed resolution data that the UI should display to players.
+
+**Phase**: 3E-Final - WebSocket Integration
+**Date**: 2025-11-03
+**Status**: ✅ Complete (Backend), ⏳ Pending (Frontend)
+
+---
+
+## What is X-Check?
+
+X-Check is a defensive play resolution system where:
+1. Ball is hit to a specific defensive position (SS, LF, 3B, etc.)
+2. Server rolls 1d20 (defense range table) + 3d6 (error chart)
+3. Defender's range and error ratings determine the outcome
+4. Result can be an out, hit, or error with detailed breakdown
+
+**Example**: Grounder to shortstop → SS range 4 → d20=12 → "Routine groundout" → Out recorded
+
+---
+
+## WebSocket Event Structure
+
+### Event: `play_resolved`
+
+This existing event now includes **optional** X-Check details when applicable.
+
+**Full Event Structure**:
+```javascript
+{
+ // Standard fields (always present)
+ "game_id": "123e4567-e89b-12d3-a456-426614174000",
+ "play_number": 15,
+ "outcome": "x_check", // PlayOutcome enum value
+ "hit_location": "SS", // Position where ball was hit
+ "description": "X-Check SS: G2 → G2 + NO = groundball_b",
+ "outs_recorded": 1,
+ "runs_scored": 0,
+ "batter_result": null, // null = out, 1-4 = base reached
+ "runners_advanced": [],
+ "is_hit": false,
+ "is_out": true,
+ "is_walk": false,
+ "roll_id": "abc123def456",
+
+ // X-Check details (OPTIONAL - only present for defensive plays)
+ "x_check_details": {
+ // Defensive player info
+ "position": "SS", // SS, LF, 3B, etc.
+ "defender_id": 42, // Lineup ID of defender
+ "defender_range": 4, // 1-5 (adjusted for playing in)
+ "defender_error_rating": 12, // 0-25 (lower is better)
+
+ // Dice rolls
+ "d20_roll": 12, // 1-20 (defense table lookup)
+ "d6_roll": 10, // 3-18 (error chart lookup)
+
+ // Resolution steps
+ "base_result": "G2", // Initial result from defense table
+ "converted_result": "G2", // After SPD test and G2#/G3# conversion
+ "error_result": "NO", // Error type: NO, E1, E2, E3, RP
+ "final_outcome": "groundball_b", // Final PlayOutcome enum value
+ "hit_type": "g2_no_error", // Combined result string
+
+ // Optional: SPD test (if base_result was 'SPD')
+ "spd_test_roll": null, // 1-20 if SPD test was needed
+ "spd_test_target": null, // Target number for SPD test
+ "spd_test_passed": null // true/false if test was needed
+ }
+}
+```
+
+---
+
+## Frontend Implementation
+
+### 1. Detecting X-Check Plays
+
+```javascript
+socket.on('play_resolved', (data) => {
+ // Check if this play was an X-Check
+ if (data.outcome === 'x_check' && data.x_check_details) {
+ // This is a defensive play - show X-Check UI
+ displayXCheckResult(data);
+ } else {
+ // Normal play - show standard result
+ displayStandardPlayResult(data);
+ }
+});
+```
+
+### 2. Displaying X-Check Details
+
+**Basic Display** (minimum viable):
+```javascript
+function displayXCheckResult(data) {
+ const xcheck = data.x_check_details;
+
+ console.log(`⚾ X-Check to ${xcheck.position}`);
+ console.log(` Defender Range: ${xcheck.defender_range}`);
+ console.log(` Rolls: d20=${xcheck.d20_roll}, 3d6=${xcheck.d6_roll}`);
+ console.log(` Result: ${xcheck.base_result} → ${xcheck.converted_result}`);
+ console.log(` Error: ${xcheck.error_result}`);
+ console.log(` Outcome: ${data.description}`);
+
+ // Update game UI
+ updateGameState({
+ outs: data.outs_recorded,
+ runs: data.runs_scored,
+ description: data.description
+ });
+}
+```
+
+**Enhanced Display** (with animations):
+```javascript
+function displayXCheckResult(data) {
+ const xcheck = data.x_check_details;
+
+ // 1. Show fielder animation
+ animateFielder(xcheck.position, xcheck.defender_id);
+
+ // 2. Show dice rolls with delay
+ setTimeout(() => {
+ showDiceRoll('Defense Roll', xcheck.d20_roll);
+ showDiceRoll('Error Roll', xcheck.d6_roll);
+ }, 500);
+
+ // 3. Show defensive rating overlay
+ setTimeout(() => {
+ showDefenderStats({
+ position: xcheck.position,
+ range: xcheck.defender_range,
+ error_rating: xcheck.defender_error_rating,
+ defender_id: xcheck.defender_id
+ });
+ }, 1500);
+
+ // 4. Show result progression
+ setTimeout(() => {
+ showResultFlow({
+ base: xcheck.base_result,
+ converted: xcheck.converted_result,
+ error: xcheck.error_result,
+ final: data.description
+ });
+ }, 2500);
+
+ // 5. Update game state
+ setTimeout(() => {
+ updateGameState({
+ outs: data.outs_recorded,
+ runs: data.runs_scored,
+ description: data.description
+ });
+ }, 3500);
+}
+```
+
+### 3. Defender Stats Display
+
+Show defender's ratings when X-Check is triggered:
+
+```javascript
+function showDefenderStats(stats) {
+ const defender = getPlayerById(stats.defender_id);
+
+ return `
+
+
${defender.name}
+
${stats.position}
+
+
+
+ ${stats.range}/5
+ ${renderRatingBars(stats.range, 5)}
+
+
+
+ ${stats.error_rating}/25
+ ${renderRatingBars(25 - stats.error_rating, 25)}
+
+
+
+ `;
+}
+```
+
+### 4. Result Flow Visualization
+
+Show the resolution steps:
+
+```javascript
+function showResultFlow(result) {
+ return `
+
+
+
Defense Table
+
${result.base}
+
+ ${result.converted !== result.base ? `
+
→
+
+
Converted
+
${result.converted}
+
+ ` : ''}
+
+
+
+
Error Check
+
${result.error}
+
+
=
+
+
Result
+
${result.final}
+
+
+ `;
+}
+```
+
+---
+
+## Error Result Types
+
+The `error_result` field can be one of:
+
+| Code | Meaning | Effect |
+|------|---------|--------|
+| `NO` | No error | Clean play |
+| `E1` | Minor error | +1 base advancement |
+| `E2` | Moderate error | +2 base advancement |
+| `E3` | Major error | +3 base advancement |
+| `RP` | Rare play | Treat as E3 |
+
+**Important**: If `base_result` was an out (FO, PO) and `error_result` is E1-E3/RP, the error **overrides the out** and batter reaches base safely.
+
+---
+
+## Base Result Codes
+
+Common result codes from the defense table:
+
+### Groundball Results
+- `G1`, `G2`, `G3`: Groundball outcomes (1=easy, 3=hard)
+- `G2#`, `G3#`: Hash results (convert to SI2 if defender holding runner or playing in)
+
+### Flyball Results
+- `F1`, `F2`, `F3`: Flyball outcomes (1=deep, 3=shallow)
+- `FO`: Flyout
+- `PO`: Popout
+
+### Hit Results
+- `SI1`, `SI2`: Singles (1=standard, 2=enhanced advancement)
+- `DO2`, `DO3`: Doubles (2=to second, 3=to third)
+- `TR3`: Triple
+
+### Special Results
+- `SPD`: Speed test (requires batter's speed rating)
+
+---
+
+## Example Scenarios
+
+### Scenario 1: Clean Out
+```json
+{
+ "outcome": "x_check",
+ "description": "X-Check SS: G2 → G2 + NO = groundball_b",
+ "x_check_details": {
+ "position": "SS",
+ "d20_roll": 8,
+ "d6_roll": 10,
+ "defender_range": 4,
+ "defender_error_rating": 8,
+ "base_result": "G2",
+ "converted_result": "G2",
+ "error_result": "NO",
+ "final_outcome": "groundball_b"
+ }
+}
+```
+
+**UI Display**: "Grounder to shortstop. Clean play. Batter out."
+
+---
+
+### Scenario 2: Error on Out
+```json
+{
+ "outcome": "error",
+ "description": "X-Check 3B: PO → PO + E2 = error",
+ "outs_recorded": 0,
+ "batter_result": 2, // Reached 2nd base
+ "x_check_details": {
+ "position": "3B",
+ "d20_roll": 15,
+ "d6_roll": 16,
+ "defender_range": 3,
+ "defender_error_rating": 18,
+ "base_result": "PO",
+ "converted_result": "PO",
+ "error_result": "E2",
+ "final_outcome": "error"
+ }
+}
+```
+
+**UI Display**: "Pop up to third base. Error! Ball dropped. Batter advances to second."
+
+---
+
+### Scenario 3: Hit with Error
+```json
+{
+ "outcome": "single_2",
+ "description": "X-Check RF: SI2 → SI2 + E1 = single_2_plus_error_1",
+ "outs_recorded": 0,
+ "runs_scored": 1,
+ "batter_result": 2, // Batter to 2nd (single + error)
+ "x_check_details": {
+ "position": "RF",
+ "d20_roll": 18,
+ "d6_roll": 15,
+ "defender_range": 2,
+ "defender_error_rating": 12,
+ "base_result": "SI2",
+ "converted_result": "SI2",
+ "error_result": "E1",
+ "final_outcome": "single_2"
+ }
+}
+```
+
+**UI Display**: "Single to right field. Fielding error! Batter advances to second. Runner scores from 2nd."
+
+---
+
+### Scenario 4: Hash Conversion
+```json
+{
+ "outcome": "single_2",
+ "description": "X-Check 2B: G3# → SI2 + NO = single_2_no_error",
+ "x_check_details": {
+ "position": "2B",
+ "d20_roll": 19,
+ "d6_roll": 8,
+ "defender_range": 4, // Was 3, but playing in (+1)
+ "defender_error_rating": 5,
+ "base_result": "G3#",
+ "converted_result": "SI2", // Converted because defender holding runner
+ "error_result": "NO",
+ "final_outcome": "single_2"
+ }
+}
+```
+
+**UI Display**: "Grounder to second base. Defender holding runner. Batter beats the throw. Single."
+
+---
+
+## Testing X-Check UI
+
+### Manual Testing Steps
+
+1. **Start a game** and get to a play with runners on base
+2. **Submit X-Check outcome**: `outcome: "x_check"`, `hit_location: "SS"`
+3. **Verify event received** with `x_check_details` field
+4. **Check UI displays**:
+ - Defender's name and ratings
+ - Dice roll values
+ - Resolution steps (base → converted → + error → final)
+ - Final game state update
+
+### Test Cases
+
+```javascript
+// Test 1: Clean out
+testXCheck({
+ outcome: "x_check",
+ hit_location: "SS",
+ expected: {
+ hasXCheckDetails: true,
+ errorResult: "NO",
+ outsRecorded: 1,
+ runsScored: 0
+ }
+});
+
+// Test 2: Error on out
+testXCheck({
+ outcome: "x_check",
+ hit_location: "3B",
+ expected: {
+ hasXCheckDetails: true,
+ errorResult: "E2",
+ outsRecorded: 0,
+ batter_result: 2 // Batter reached 2nd on error
+ }
+});
+
+// Test 3: Hit with error
+testXCheck({
+ outcome: "x_check",
+ hit_location: "RF",
+ expected: {
+ hasXCheckDetails: true,
+ errorResult: "E1",
+ batter_result: 2, // Single + error = 2nd base
+ runsScored: 1 // Runner scored from 2nd
+ }
+});
+```
+
+---
+
+## League Differences
+
+### SBA League
+- Uses **default ratings** (range=3, error=15) for all defenders
+- X-Check still works, just with generic ratings
+- UI should still display X-Check details
+
+### PD League
+- Uses **actual player ratings** from PD API
+- Range: 1-5 (varies by player and position)
+- Error: 0-25 (varies by player and position)
+- Ratings loaded at game start and cached
+- UI should highlight superior/poor defensive ratings
+
+---
+
+## UI Components Checklist
+
+### Minimum Viable (Phase 1)
+- [ ] Detect X-Check plays in `play_resolved` event
+- [ ] Display defender's name and position
+- [ ] Show dice roll results (d20, 3d6)
+- [ ] Display final outcome description
+- [ ] Update game state (outs, runs, bases)
+
+### Enhanced (Phase 2)
+- [ ] Animated fielder sprites
+- [ ] Defender rating bars (range, error)
+- [ ] Result flow visualization (base → converted → + error → final)
+- [ ] Error type indicators (E1/E2/E3/RP with color coding)
+- [ ] SPD test display (when applicable)
+
+### Polish (Phase 3)
+- [ ] Sound effects for different outcomes
+- [ ] Particle effects for errors
+- [ ] Detailed play-by-play log with X-Check breakdowns
+- [ ] Hover tooltips explaining ratings
+- [ ] Mobile-optimized compact view
+
+---
+
+## Common Pitfalls
+
+### ❌ Don't Assume X-Check Details Exist
+```javascript
+// BAD: Will crash if not an X-Check
+const position = data.x_check_details.position;
+
+// GOOD: Check first
+if (data.x_check_details) {
+ const position = data.x_check_details.position;
+}
+```
+
+### ❌ Don't Ignore Error Overrides
+```javascript
+// BAD: Assumes outcome matches base_result
+if (xcheck.base_result === 'PO') {
+ showOut(); // Wrong if error_result is E1-E3!
+}
+
+// GOOD: Use final_outcome or check error_result
+if (data.is_out) {
+ showOut();
+}
+```
+
+### ❌ Don't Hardcode Position Labels
+```javascript
+// BAD: Doesn't handle all positions
+const positionName = xcheck.position === 'SS' ? 'Shortstop' : 'Unknown';
+
+// GOOD: Use position map
+const POSITION_NAMES = {
+ 'P': 'Pitcher', 'C': 'Catcher',
+ '1B': 'First Base', '2B': 'Second Base', '3B': 'Third Base',
+ 'SS': 'Shortstop', 'LF': 'Left Field',
+ 'CF': 'Center Field', 'RF': 'Right Field'
+};
+const positionName = POSITION_NAMES[xcheck.position];
+```
+
+---
+
+## Questions?
+
+**Backend Developer**: See `app/websocket/handlers.py` lines 369-388
+**X-Check Logic**: See `app/core/play_resolver.py` lines 590-785
+**Data Models**: See `app/models/game_models.py` lines 242-289
+
+**Phase 3E-Final Docs**: See `.claude/implementation/NEXT_SESSION.md`
+
+---
+
+**Last Updated**: 2025-11-03
+**Author**: Claude (Phase 3E-Final)
+**Status**: ✅ Backend Complete, ⏳ Frontend Pending
diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py
index 81c797f..c208556 100644
--- a/backend/app/websocket/handlers.py
+++ b/backend/app/websocket/handlers.py
@@ -349,25 +349,49 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
hit_location=submission.hit_location
)
+ # Build play result data
+ play_result_data = {
+ "game_id": str(game_id),
+ "play_number": state.play_count,
+ "outcome": result.outcome.value, # Use resolved outcome, not submitted
+ "hit_location": submission.hit_location,
+ "description": result.description,
+ "outs_recorded": result.outs_recorded,
+ "runs_scored": result.runs_scored,
+ "batter_result": result.batter_result,
+ "runners_advanced": result.runners_advanced,
+ "is_hit": result.is_hit,
+ "is_out": result.is_out,
+ "is_walk": result.is_walk,
+ "roll_id": ab_roll.roll_id
+ }
+
+ # Include X-Check details if present (Phase 3E-Final)
+ if result.x_check_details:
+ xcheck = result.x_check_details
+ play_result_data["x_check_details"] = {
+ "position": xcheck.position,
+ "d20_roll": xcheck.d20_roll,
+ "d6_roll": xcheck.d6_roll,
+ "defender_range": xcheck.defender_range,
+ "defender_error_rating": xcheck.defender_error_rating,
+ "defender_id": xcheck.defender_id,
+ "base_result": xcheck.base_result,
+ "converted_result": xcheck.converted_result,
+ "error_result": xcheck.error_result,
+ "final_outcome": xcheck.final_outcome.value,
+ "hit_type": xcheck.hit_type,
+ # Optional SPD test details
+ "spd_test_roll": xcheck.spd_test_roll,
+ "spd_test_target": xcheck.spd_test_target,
+ "spd_test_passed": xcheck.spd_test_passed
+ }
+
# Broadcast play result to game room
await manager.broadcast_to_game(
str(game_id),
"play_resolved",
- {
- "game_id": str(game_id),
- "play_number": state.play_count,
- "outcome": outcome.value,
- "hit_location": submission.hit_location,
- "description": result.description,
- "outs_recorded": result.outs_recorded,
- "runs_scored": result.runs_scored,
- "batter_result": result.batter_result,
- "runners_advanced": result.runners_advanced,
- "is_hit": result.is_hit,
- "is_out": result.is_out,
- "is_walk": result.is_walk,
- "roll_id": ab_roll.roll_id
- }
+ play_result_data
)
logger.info(
diff --git a/backend/test_redis_cache.py b/backend/test_redis_cache.py
new file mode 100644
index 0000000..340dba2
--- /dev/null
+++ b/backend/test_redis_cache.py
@@ -0,0 +1,151 @@
+"""
+Test Redis cache integration for position ratings.
+
+Tests the complete flow:
+1. Connect to Redis
+2. Fetch ratings from API (cache miss)
+3. Verify cached in Redis
+4. Fetch again (cache hit)
+5. Measure performance improvement
+6. Test cache clearing
+
+Author: Claude
+Date: 2025-11-03
+Phase: 3E-Final
+"""
+import asyncio
+import pendulum
+from app.services import redis_client, position_rating_service
+
+
+async def main():
+ print("=" * 60)
+ print("Redis Cache Integration Test")
+ print("=" * 60)
+
+ # Step 1: Connect to Redis
+ print("\n1. Connecting to Redis...")
+ try:
+ await redis_client.connect("redis://localhost:6379/0")
+ print(f" ✅ Connected to Redis")
+
+ # Test ping
+ is_alive = await redis_client.ping()
+ print(f" ✅ Redis ping: {is_alive}")
+ except Exception as e:
+ print(f" ❌ Failed to connect: {e}")
+ return
+
+ # Step 2: Clear any existing cache
+ print("\n2. Clearing existing cache...")
+ await position_rating_service.clear_cache()
+ print(" ✅ Cache cleared")
+
+ # Step 3: Fetch ratings from API (should be cache miss)
+ print("\n3. First fetch (API call - cache miss)...")
+ card_id = 8807 # Test player with 7 positions
+ start = pendulum.now('UTC')
+
+ ratings = await position_rating_service.get_ratings_for_card(
+ card_id=card_id,
+ league_id="pd"
+ )
+
+ api_duration = (pendulum.now('UTC') - start).total_seconds()
+ print(f" ✅ Fetched {len(ratings)} ratings in {api_duration:.4f}s")
+
+ if ratings:
+ print(f" 📋 Positions found:")
+ for rating in ratings:
+ print(f" - {rating.position}: range={rating.range}, error={rating.error}, innings={rating.innings}")
+
+ # Step 4: Fetch again (should be cache hit from Redis)
+ print("\n4. Second fetch (Redis cache hit)...")
+ start = pendulum.now('UTC')
+
+ cached_ratings = await position_rating_service.get_ratings_for_card(
+ card_id=card_id,
+ league_id="pd"
+ )
+
+ cache_duration = (pendulum.now('UTC') - start).total_seconds()
+ print(f" ✅ Fetched {len(cached_ratings)} ratings in {cache_duration:.6f}s")
+
+ # Step 5: Calculate performance improvement
+ print("\n5. Performance Comparison:")
+ if cache_duration > 0:
+ speedup = api_duration / cache_duration
+ print(f" API call: {api_duration:.4f}s")
+ print(f" Cache hit: {cache_duration:.6f}s")
+ print(f" ⚡ Speedup: {speedup:.0f}x faster")
+ else:
+ print(f" API call: {api_duration:.4f}s")
+ print(f" Cache hit: < 0.000001s")
+ print(f" ⚡ Speedup: > 100,000x faster")
+
+ # Step 6: Verify data matches
+ print("\n6. Data Integrity Check:")
+ if len(ratings) == len(cached_ratings):
+ print(f" ✅ Same number of ratings ({len(ratings)})")
+
+ # Compare each rating
+ matches = 0
+ for i, (r1, r2) in enumerate(zip(ratings, cached_ratings)):
+ if (r1.position == r2.position and
+ r1.range == r2.range and
+ r1.error == r2.error):
+ matches += 1
+
+ if matches == len(ratings):
+ print(f" ✅ All {matches} ratings match exactly")
+ else:
+ print(f" ⚠️ Only {matches}/{len(ratings)} ratings match")
+ else:
+ print(f" ❌ Different counts: API={len(ratings)}, Cache={len(cached_ratings)}")
+
+ # Step 7: Test single position lookup
+ print("\n7. Single Position Lookup:")
+ rating_ss = await position_rating_service.get_rating_for_position(
+ card_id=card_id,
+ position="SS",
+ league_id="pd"
+ )
+
+ if rating_ss:
+ print(f" ✅ Found SS rating: range={rating_ss.range}, error={rating_ss.error}")
+ else:
+ print(f" ❌ SS rating not found")
+
+ # Step 8: Test cache clearing for specific card
+ print("\n8. Clear cache for specific card...")
+ await position_rating_service.clear_cache(card_id=card_id)
+ print(f" ✅ Cleared cache for card {card_id}")
+
+ # Verify it's cleared (should be API call again)
+ print("\n9. Verify cache cleared (should be API call)...")
+ start = pendulum.now('UTC')
+
+ ratings_after_clear = await position_rating_service.get_ratings_for_card(
+ card_id=card_id,
+ league_id="pd"
+ )
+
+ duration_after_clear = (pendulum.now('UTC') - start).total_seconds()
+
+ if duration_after_clear > 0.01: # If it took more than 10ms, likely API call
+ print(f" ✅ Cache was cleared (took {duration_after_clear:.4f}s - API call)")
+ else:
+ print(f" ⚠️ Unexpectedly fast ({duration_after_clear:.6f}s)")
+
+ # Step 10: Disconnect
+ print("\n10. Disconnecting from Redis...")
+ await redis_client.disconnect()
+ print(" ✅ Disconnected")
+
+ print("\n" + "=" * 60)
+ print("✅ All tests completed successfully!")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/tests/integration/test_xcheck_websocket.py b/backend/tests/integration/test_xcheck_websocket.py
new file mode 100644
index 0000000..951d488
--- /dev/null
+++ b/backend/tests/integration/test_xcheck_websocket.py
@@ -0,0 +1,335 @@
+"""
+Integration test for X-Check WebSocket flow.
+
+Tests the complete flow from dice roll to X-Check result broadcast.
+
+Author: Claude
+Date: 2025-11-03
+Phase: 3E-Final
+"""
+import pytest
+import json
+from uuid import uuid4
+from unittest.mock import Mock, patch, AsyncMock
+import pendulum
+
+from app.websocket.handlers import register_handlers
+from app.websocket.connection_manager import ConnectionManager
+from app.core.state_manager import state_manager
+from app.core.game_engine import game_engine
+from app.models.game_models import GameState, LineupPlayerState
+from app.models.player_models import PositionRating # Import for forward reference resolution
+from app.config import PlayOutcome
+
+# Rebuild GameState model to resolve forward references
+GameState.model_rebuild()
+
+
+@pytest.mark.integration
+class TestXCheckWebSocket:
+ """Test X-Check integration with WebSocket handlers"""
+
+ @pytest.fixture
+ def mock_sio(self):
+ """Mock Socket.io server"""
+ sio = Mock()
+ sio.emit = AsyncMock()
+ return sio
+
+ @pytest.fixture
+ def mock_manager(self):
+ """Mock ConnectionManager"""
+ manager = Mock(spec=ConnectionManager)
+ manager.user_sessions = {}
+ manager.game_rooms = {}
+ manager.emit_to_user = AsyncMock()
+ manager.broadcast_to_game = AsyncMock()
+ return manager
+
+ @pytest.fixture
+ def game_state(self):
+ """Create test game state"""
+ game_id = uuid4()
+
+ # Create batter
+ batter = LineupPlayerState(
+ lineup_id=10,
+ card_id=123,
+ position="RF",
+ batting_order=3
+ )
+
+ # Create pitcher
+ pitcher = LineupPlayerState(
+ lineup_id=20,
+ card_id=456,
+ position="P",
+ batting_order=9
+ )
+
+ # Create catcher
+ catcher = LineupPlayerState(
+ lineup_id=21,
+ card_id=789,
+ position="C",
+ batting_order=2
+ )
+
+ state = GameState(
+ game_id=game_id,
+ league_id="pd", # PD league has position ratings
+ home_team_id=1,
+ away_team_id=2,
+ current_batter=batter,
+ current_pitcher=pitcher,
+ current_catcher=catcher,
+ current_batter_lineup_id=10,
+ current_pitcher_lineup_id=20,
+ current_catcher_lineup_id=21
+ )
+
+ # Clear bases
+ state.on_first = None
+ state.on_second = None
+ state.on_third = None
+
+ # Register state in state manager
+ state_manager._states[game_id] = state
+
+ return state
+
+ async def test_submit_manual_xcheck_outcome(self, mock_sio, mock_manager, game_state):
+ """
+ Test submitting manual X-Check outcome via WebSocket.
+
+ Flow:
+ 1. Create game with position ratings loaded
+ 2. Roll dice (stores pending_manual_roll)
+ 3. Submit X-Check outcome
+ 4. Verify play_resolved event includes x_check_details
+ """
+ game_id = game_state.game_id
+
+ # Register handlers
+ register_handlers(mock_sio, mock_manager)
+
+ # Get the submit_manual_outcome handler
+ # It's registered as the 5th event (after connect, disconnect, join_game, leave_game, heartbeat, roll_dice)
+ handler_calls = [call for call in mock_sio.event.call_args_list]
+ submit_handler = None
+ for call in handler_calls:
+ if len(call[0]) > 0 and hasattr(call[0][0], '__name__'):
+ if call[0][0].__name__ == 'submit_manual_outcome':
+ submit_handler = call[0][0]
+ break
+
+ assert submit_handler is not None, "submit_manual_outcome handler not found"
+
+ # Mock pending roll (simulate roll_dice was called)
+ from app.core.roll_types import AbRoll
+ ab_roll = AbRoll(
+ roll_id="test-roll-123",
+ roll_type="AB",
+ league_id="pd",
+ game_id=game_id,
+ d6_one=4,
+ d6_two_a=3,
+ d6_two_b=4,
+ chaos_d20=12,
+ resolution_d20=8,
+ timestamp=pendulum.now('UTC')
+ )
+ game_state.pending_manual_roll = ab_roll
+ state_manager.update_state(game_id, game_state)
+
+ # Mock session
+ sid = "test-session-123"
+ mock_manager.user_sessions[sid] = "test-user"
+
+ # Submit X-Check outcome
+ data = {
+ "game_id": str(game_id),
+ "outcome": "x_check",
+ "hit_location": "SS"
+ }
+
+ # Mock game engine resolve_manual_play to return X-Check result
+ with patch('app.websocket.handlers.game_engine.resolve_manual_play') as mock_resolve:
+ from app.models.game_models import XCheckResult
+ from app.core.play_resolver import PlayResult
+
+ # Create mock X-Check result
+ xcheck_result = XCheckResult(
+ position="SS",
+ d20_roll=12,
+ d6_roll=10,
+ defender_range=4,
+ defender_error_rating=12,
+ defender_id=25,
+ base_result="G2",
+ converted_result="G2",
+ error_result="NO",
+ final_outcome=PlayOutcome.GROUNDBALL_B,
+ hit_type="g2_no_error"
+ )
+
+ play_result = PlayResult(
+ outcome=PlayOutcome.GROUNDBALL_B,
+ outs_recorded=1,
+ runs_scored=0,
+ batter_result=None,
+ runners_advanced=[],
+ description="X-Check SS: G2 → G2 + NO = groundball_b",
+ ab_roll=ab_roll,
+ hit_location="SS",
+ is_hit=False,
+ is_out=True,
+ x_check_details=xcheck_result
+ )
+
+ mock_resolve.return_value = play_result
+
+ # Call handler
+ await submit_handler(sid, data)
+
+ # Verify outcome_accepted was emitted to user
+ assert mock_manager.emit_to_user.called
+ emit_calls = mock_manager.emit_to_user.call_args_list
+ accepted_call = None
+ for call in emit_calls:
+ if call[0][1] == "outcome_accepted":
+ accepted_call = call
+ break
+ assert accepted_call is not None, "outcome_accepted not emitted"
+
+ # Verify play_resolved was broadcast with X-Check details
+ assert mock_manager.broadcast_to_game.called
+ broadcast_calls = mock_manager.broadcast_to_game.call_args_list
+ play_resolved_call = None
+ for call in broadcast_calls:
+ if call[0][1] == "play_resolved":
+ play_resolved_call = call
+ break
+
+ assert play_resolved_call is not None, "play_resolved not broadcast"
+
+ # Extract broadcast data
+ broadcast_data = play_resolved_call[0][2]
+
+ # Verify standard play data
+ assert broadcast_data["outcome"] == "groundball_b"
+ assert broadcast_data["hit_location"] == "SS"
+ assert broadcast_data["outs_recorded"] == 1
+ assert broadcast_data["runs_scored"] == 0
+ assert broadcast_data["is_out"] is True
+
+ # Verify X-Check details are included
+ assert "x_check_details" in broadcast_data, "x_check_details missing from broadcast"
+ xcheck_data = broadcast_data["x_check_details"]
+
+ # Verify X-Check structure
+ assert xcheck_data["position"] == "SS"
+ assert xcheck_data["d20_roll"] == 12
+ assert xcheck_data["d6_roll"] == 10
+ assert xcheck_data["defender_range"] == 4
+ assert xcheck_data["defender_error_rating"] == 12
+ assert xcheck_data["defender_id"] == 25
+ assert xcheck_data["base_result"] == "G2"
+ assert xcheck_data["converted_result"] == "G2"
+ assert xcheck_data["error_result"] == "NO"
+ assert xcheck_data["final_outcome"] == "groundball_b"
+ assert xcheck_data["hit_type"] == "g2_no_error"
+
+ # Verify optional SPD test fields
+ assert xcheck_data["spd_test_roll"] is None
+ assert xcheck_data["spd_test_target"] is None
+ assert xcheck_data["spd_test_passed"] is None
+
+ async def test_non_xcheck_play_has_no_xcheck_details(self, mock_sio, mock_manager, game_state):
+ """
+ Test that non-X-Check plays don't include x_check_details.
+ """
+ game_id = game_state.game_id
+
+ # Register handlers
+ register_handlers(mock_sio, mock_manager)
+
+ # Get the submit_manual_outcome handler
+ handler_calls = [call for call in mock_sio.event.call_args_list]
+ submit_handler = None
+ for call in handler_calls:
+ if len(call[0]) > 0 and hasattr(call[0][0], '__name__'):
+ if call[0][0].__name__ == 'submit_manual_outcome':
+ submit_handler = call[0][0]
+ break
+
+ assert submit_handler is not None
+
+ # Mock pending roll
+ from app.core.roll_types import AbRoll
+ ab_roll = AbRoll(
+ roll_id="test-roll-124",
+ roll_type="AB",
+ league_id="pd",
+ game_id=game_id,
+ d6_one=5,
+ d6_two_a=6,
+ d6_two_b=6,
+ chaos_d20=20,
+ resolution_d20=20,
+ timestamp=pendulum.now('UTC')
+ )
+ game_state.pending_manual_roll = ab_roll
+ state_manager.update_state(game_id, game_state)
+
+ # Mock session
+ sid = "test-session-124"
+ mock_manager.user_sessions[sid] = "test-user"
+
+ # Submit strikeout (not X-Check)
+ data = {
+ "game_id": str(game_id),
+ "outcome": "strikeout",
+ "hit_location": None
+ }
+
+ # Mock game engine resolve_manual_play to return strikeout result
+ with patch('app.websocket.handlers.game_engine.resolve_manual_play') as mock_resolve:
+ from app.core.play_resolver import PlayResult
+
+ play_result = PlayResult(
+ outcome=PlayOutcome.STRIKEOUT,
+ outs_recorded=1,
+ runs_scored=0,
+ batter_result=None,
+ runners_advanced=[],
+ description="Strikeout looking",
+ ab_roll=ab_roll,
+ hit_location=None,
+ is_hit=False,
+ is_out=True,
+ x_check_details=None # No X-Check for strikeout
+ )
+
+ mock_resolve.return_value = play_result
+
+ # Call handler
+ await submit_handler(sid, data)
+
+ # Verify play_resolved was broadcast
+ assert mock_manager.broadcast_to_game.called
+ broadcast_calls = mock_manager.broadcast_to_game.call_args_list
+ play_resolved_call = None
+ for call in broadcast_calls:
+ if call[0][1] == "play_resolved":
+ play_resolved_call = call
+ break
+
+ assert play_resolved_call is not None
+
+ # Extract broadcast data
+ broadcast_data = play_resolved_call[0][2]
+
+ # Verify X-Check details are NOT included for non-X-Check plays
+ assert "x_check_details" not in broadcast_data, \
+ "x_check_details should not be present for non-X-Check plays"