CLAUDE: Phase 3E-Final - Redis Caching & X-Check WebSocket Integration
Completed Phase 3E-Final with Redis caching upgrade and WebSocket X-Check
integration for real-time defensive play resolution.
## Redis Caching System
### New Files
- app/services/redis_client.py - Async Redis client with connection pooling
* 10 connection pool size
* Automatic connect/disconnect lifecycle
* Ping health checks
* Environment-configurable via REDIS_URL
### Modified Files
- app/services/position_rating_service.py - Migrated from in-memory to Redis
* Redis key pattern: "position_ratings:{card_id}"
* TTL: 86400 seconds (24 hours)
* Graceful fallback if Redis unavailable
* Individual and bulk cache clearing (scan_iter)
* 760x performance improvement (0.274s API → 0.000361s Redis)
- app/main.py - Added Redis startup/shutdown events
* Connect on app startup with settings.redis_url
* Disconnect on shutdown
* Warning logged if Redis connection fails
- app/config.py - Added redis_url setting
* Default: "redis://localhost:6379/0"
* Override via REDIS_URL environment variable
- app/services/__init__.py - Export redis_client
### Testing
- test_redis_cache.py - Live integration test
* 10-step validation: connect, cache miss, cache hit, performance, etc.
* Verified 760x speedup with player 8807 (7 positions)
* Data integrity checks pass
## X-Check WebSocket Integration
### Modified Files
- app/websocket/handlers.py - Enhanced submit_manual_outcome handler
* Serialize XCheckResult to JSON when present
* Include x_check_details in play_resolved broadcast
* Fixed bug: Use result.outcome instead of submitted outcome
* Includes defender ratings, dice rolls, resolution steps
### New Files
- app/websocket/X_CHECK_FRONTEND_GUIDE.md - Comprehensive frontend documentation
* Event structure and field definitions
* Implementation examples (basic, enhanced, polished)
* Error handling and common pitfalls
* Test scenarios with expected data
* League differences (SBA vs PD)
* 500+ lines of frontend integration guide
- app/websocket/MANUAL_VS_AUTO_MODE.md - Workflow documentation
* Manual mode: Players read cards, submit outcomes
* Auto mode: System generates from ratings (PD only)
* X-Check resolution comparison
* UI recommendations for each mode
* Configuration reference
* Testing considerations
### Testing
- tests/integration/test_xcheck_websocket.py - WebSocket integration tests
* Test X-Check play includes x_check_details ✅
* Test non-X-Check plays don't include details ✅
* Full event structure validation
## Performance Impact
- Redis caching: 760x speedup for position ratings
- WebSocket: No performance impact (optional field)
- Graceful degradation: System works without Redis
## Phase 3E-Final Progress
- ✅ WebSocket event handlers for X-Check UI
- ✅ Frontend integration documentation
- ✅ Redis caching upgrade (from in-memory)
- ✅ Redis connection pool in app lifecycle
- ✅ Integration tests (2 WebSocket, 1 Redis)
- ✅ Manual vs Auto mode workflow documentation
Phase 3E-Final: 100% Complete
Phase 3 Overall: ~98% Complete
## Testing Results
All tests passing:
- X-Check table tests: 36/36 ✅
- WebSocket integration: 2/2 ✅
- Redis live test: 10/10 steps ✅
## Configuration
Development:
REDIS_URL=redis://localhost:6379/0 (Docker Compose)
Production options:
REDIS_URL=redis://10.10.0.42:6379/0 (DB server)
REDIS_URL=redis://your-redis-cloud.com:6379/0 (Managed)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7d150183d4
commit
adf7c7646d
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
112
backend/app/services/redis_client.py
Normal file
112
backend/app/services/redis_client.py
Normal file
@ -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()
|
||||
588
backend/app/websocket/MANUAL_VS_AUTO_MODE.md
Normal file
588
backend/app/websocket/MANUAL_VS_AUTO_MODE.md
Normal file
@ -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 (
|
||||
<div className="xcheck-result">
|
||||
<DefenderCard
|
||||
name={defender.name}
|
||||
position={xcheck.position}
|
||||
range={xcheck.defender_range}
|
||||
error={xcheck.defender_error_rating}
|
||||
/>
|
||||
|
||||
<DiceRolls
|
||||
d20={xcheck.d20_roll}
|
||||
d6={xcheck.d6_roll}
|
||||
/>
|
||||
|
||||
<ResolutionFlow
|
||||
base={xcheck.base_result}
|
||||
converted={xcheck.converted_result}
|
||||
error={xcheck.error_result}
|
||||
final={xcheck.final_outcome}
|
||||
/>
|
||||
|
||||
<OutcomeDescription text={data.description} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 (
|
||||
<div className="auto-mode-result">
|
||||
<PlayAnimation result={playResult} />
|
||||
{playResult.x_check_details && (
|
||||
<XCheckBreakdown details={playResult.x_check_details} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
517
backend/app/websocket/X_CHECK_FRONTEND_GUIDE.md
Normal file
517
backend/app/websocket/X_CHECK_FRONTEND_GUIDE.md
Normal file
@ -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 `
|
||||
<div class="xcheck-defender">
|
||||
<div class="defender-name">${defender.name}</div>
|
||||
<div class="defender-position">${stats.position}</div>
|
||||
<div class="defender-ratings">
|
||||
<div class="rating-range">
|
||||
<label>Range:</label>
|
||||
<span class="rating-value">${stats.range}/5</span>
|
||||
${renderRatingBars(stats.range, 5)}
|
||||
</div>
|
||||
<div class="rating-error">
|
||||
<label>Error:</label>
|
||||
<span class="rating-value">${stats.error_rating}/25</span>
|
||||
${renderRatingBars(25 - stats.error_rating, 25)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Result Flow Visualization
|
||||
|
||||
Show the resolution steps:
|
||||
|
||||
```javascript
|
||||
function showResultFlow(result) {
|
||||
return `
|
||||
<div class="xcheck-flow">
|
||||
<div class="flow-step">
|
||||
<div class="step-label">Defense Table</div>
|
||||
<div class="step-value">${result.base}</div>
|
||||
</div>
|
||||
${result.converted !== result.base ? `
|
||||
<div class="flow-arrow">→</div>
|
||||
<div class="flow-step">
|
||||
<div class="step-label">Converted</div>
|
||||
<div class="step-value">${result.converted}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="flow-arrow">+</div>
|
||||
<div class="flow-step">
|
||||
<div class="step-label">Error Check</div>
|
||||
<div class="step-value">${result.error}</div>
|
||||
</div>
|
||||
<div class="flow-arrow">=</div>
|
||||
<div class="flow-step final">
|
||||
<div class="step-label">Result</div>
|
||||
<div class="step-value">${result.final}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@ -349,14 +349,11 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
hit_location=submission.hit_location
|
||||
)
|
||||
|
||||
# Broadcast play result to game room
|
||||
await manager.broadcast_to_game(
|
||||
str(game_id),
|
||||
"play_resolved",
|
||||
{
|
||||
# Build play result data
|
||||
play_result_data = {
|
||||
"game_id": str(game_id),
|
||||
"play_number": state.play_count,
|
||||
"outcome": outcome.value,
|
||||
"outcome": result.outcome.value, # Use resolved outcome, not submitted
|
||||
"hit_location": submission.hit_location,
|
||||
"description": result.description,
|
||||
"outs_recorded": result.outs_recorded,
|
||||
@ -368,6 +365,33 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
"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",
|
||||
play_result_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
151
backend/test_redis_cache.py
Normal file
151
backend/test_redis_cache.py
Normal file
@ -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())
|
||||
335
backend/tests/integration/test_xcheck_websocket.py
Normal file
335
backend/tests/integration/test_xcheck_websocket.py
Normal file
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user