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:
Cal Corum 2025-11-03 22:46:59 -06:00
parent 7d150183d4
commit adf7c7646d
10 changed files with 1866 additions and 36 deletions

View File

@ -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

View File

@ -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(

View File

@ -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",
]

View File

@ -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

View 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()

View 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

View 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

View File

@ -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
View 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())

View 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"