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"