strat-gameplay-webapp/backend/app/websocket/handlers.py
Cal Corum adf7c7646d 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>
2025-11-03 22:46:59 -06:00

431 lines
15 KiB
Python

import logging
from typing import Optional
from uuid import UUID
from socketio import AsyncServer
from pydantic import ValidationError
from app.websocket.connection_manager import ConnectionManager
from app.utils.auth import verify_token
from app.models.game_models import ManualOutcomeSubmission
from app.core.dice import dice_system
from app.core.state_manager import state_manager
from app.core.game_engine import game_engine
from app.core.validators import ValidationError as GameValidationError
from app.config.result_charts import PlayOutcome
logger = logging.getLogger(f'{__name__}.handlers')
def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"""Register all WebSocket event handlers"""
@sio.event
async def connect(sid, environ, auth):
"""Handle new connection"""
try:
# Verify JWT token
token = auth.get("token")
if not token:
logger.warning(f"Connection {sid} rejected: no token")
return False
user_data = verify_token(token)
user_id = user_data.get("user_id")
if not user_id:
logger.warning(f"Connection {sid} rejected: invalid token")
return False
await manager.connect(sid, user_id)
await sio.emit("connected", {"user_id": user_id}, room=sid)
logger.info(f"Connection {sid} accepted for user {user_id}")
return True
except Exception as e:
logger.error(f"Connection error: {e}")
return False
@sio.event
async def disconnect(sid):
"""Handle disconnection"""
await manager.disconnect(sid)
@sio.event
async def join_game(sid, data):
"""Handle join game request"""
try:
game_id = data.get("game_id")
role = data.get("role", "player")
if not game_id:
await manager.emit_to_user(
sid,
"error",
{"message": "Missing game_id"}
)
return
# TODO: Verify user has access to game
await manager.join_game(sid, game_id, role)
await manager.emit_to_user(
sid,
"game_joined",
{"game_id": game_id, "role": role}
)
except Exception as e:
logger.error(f"Join game error: {e}")
await manager.emit_to_user(
sid,
"error",
{"message": str(e)}
)
@sio.event
async def leave_game(sid, data):
"""Handle leave game request"""
try:
game_id = data.get("game_id")
if game_id:
await manager.leave_game(sid, game_id)
except Exception as e:
logger.error(f"Leave game error: {e}")
@sio.event
async def heartbeat(sid):
"""Handle heartbeat ping"""
await sio.emit("heartbeat_ack", {}, room=sid)
@sio.event
async def roll_dice(sid, data):
"""
Roll dice for manual outcome selection.
Server rolls dice and broadcasts to all players in game room.
Players then read their physical cards and submit outcomes.
Event data:
game_id: UUID of the game
Emits:
dice_rolled: Broadcast to game room with dice results
error: To requester if validation fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(
sid,
"error",
{"message": "Missing game_id"}
)
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid,
"error",
{"message": "Invalid game_id format"}
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid,
"error",
{"message": f"Game {game_id} not found"}
)
return
# TODO: Verify user is participant in this game
# user_id = manager.user_sessions.get(sid)
# if not is_game_participant(game_id, user_id):
# await manager.emit_to_user(sid, "error", {"message": "Not authorized"})
# return
# Roll dice
ab_roll = dice_system.roll_ab(
league_id=state.league_id,
game_id=game_id
)
logger.info(
f"Dice rolled for game {game_id}: "
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
f"chaos={ab_roll.chaos_d20}, resolution={ab_roll.resolution_d20}"
)
# Store roll in game state for manual outcome validation
state.pending_manual_roll = ab_roll
state_manager.update_state(game_id, state)
# Broadcast dice results to all players in game
await manager.broadcast_to_game(
str(game_id),
"dice_rolled",
{
"game_id": str(game_id),
"roll_id": ab_roll.roll_id,
"d6_one": ab_roll.d6_one,
"d6_two_total": ab_roll.d6_two_total,
"chaos_d20": ab_roll.chaos_d20,
"resolution_d20": ab_roll.resolution_d20,
"check_wild_pitch": ab_roll.check_wild_pitch,
"check_passed_ball": ab_roll.check_passed_ball,
"timestamp": ab_roll.timestamp.to_iso8601_string(),
"message": "Dice rolled - read your card and submit outcome"
}
)
except Exception as e:
logger.error(f"Roll dice error: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to roll dice: {str(e)}"}
)
@sio.event
async def submit_manual_outcome(sid, data):
"""
Submit manually-selected play outcome.
After dice are rolled, players read their physical cards and
submit the outcome they see. System validates and processes.
Event data:
game_id: UUID of the game
outcome: PlayOutcome enum value (e.g., "groundball_c")
hit_location: Optional position string (e.g., "SS")
Emits:
outcome_accepted: To requester if valid
play_resolved: Broadcast to game room with play result
outcome_rejected: To requester if validation fails
error: To requester if processing fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": "Missing game_id", "field": "game_id"}
)
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": "Invalid game_id format", "field": "game_id"}
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid,
"error",
{"message": f"Game {game_id} not found"}
)
return
# TODO: Verify user is active batter or authorized to submit
# user_id = manager.user_sessions.get(sid)
# Extract outcome data
outcome_str = data.get("outcome")
hit_location = data.get("hit_location")
if not outcome_str:
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": "Missing outcome", "field": "outcome"}
)
return
# Validate using ManualOutcomeSubmission model
try:
submission = ManualOutcomeSubmission(
outcome=outcome_str,
hit_location=hit_location
)
except ValidationError as e:
# Extract first error for user-friendly message
first_error = e.errors()[0]
field = first_error['loc'][0] if first_error['loc'] else 'unknown'
message = first_error['msg']
await manager.emit_to_user(
sid,
"outcome_rejected",
{
"message": message,
"field": field,
"errors": e.errors()
}
)
logger.warning(
f"Manual outcome validation failed for game {game_id}: {message}"
)
return
# Convert to PlayOutcome enum
outcome = PlayOutcome(submission.outcome)
# Validate hit location is provided when required
if outcome.requires_hit_location() and not submission.hit_location:
await manager.emit_to_user(
sid,
"outcome_rejected",
{
"message": f"Outcome {outcome.value} requires hit_location",
"field": "hit_location"
}
)
return
# Check for pending roll BEFORE accepting outcome
if not state.pending_manual_roll:
await manager.emit_to_user(
sid,
"outcome_rejected",
{
"message": "No pending dice roll - call roll_dice first",
"field": "game_state"
}
)
return
ab_roll = state.pending_manual_roll
logger.info(
f"Manual outcome submitted for game {game_id}: "
f"{outcome.value}" + (f" to {submission.hit_location}" if submission.hit_location else "")
)
# Confirm acceptance to submitter
await manager.emit_to_user(
sid,
"outcome_accepted",
{
"game_id": str(game_id),
"outcome": outcome.value,
"hit_location": submission.hit_location
}
)
logger.info(
f"Processing manual outcome with roll {ab_roll.roll_id}: "
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
f"chaos={ab_roll.chaos_d20}"
)
# Clear pending roll (one-time use)
state.pending_manual_roll = None
state_manager.update_state(game_id, state)
# Process manual outcome through game engine
try:
result = await game_engine.resolve_manual_play(
game_id=game_id,
ab_roll=ab_roll,
outcome=outcome,
hit_location=submission.hit_location
)
# Build play result data
play_result_data = {
"game_id": str(game_id),
"play_number": state.play_count,
"outcome": result.outcome.value, # Use resolved outcome, not submitted
"hit_location": submission.hit_location,
"description": result.description,
"outs_recorded": result.outs_recorded,
"runs_scored": result.runs_scored,
"batter_result": result.batter_result,
"runners_advanced": result.runners_advanced,
"is_hit": result.is_hit,
"is_out": result.is_out,
"is_walk": result.is_walk,
"roll_id": ab_roll.roll_id
}
# Include X-Check details if present (Phase 3E-Final)
if result.x_check_details:
xcheck = result.x_check_details
play_result_data["x_check_details"] = {
"position": xcheck.position,
"d20_roll": xcheck.d20_roll,
"d6_roll": xcheck.d6_roll,
"defender_range": xcheck.defender_range,
"defender_error_rating": xcheck.defender_error_rating,
"defender_id": xcheck.defender_id,
"base_result": xcheck.base_result,
"converted_result": xcheck.converted_result,
"error_result": xcheck.error_result,
"final_outcome": xcheck.final_outcome.value,
"hit_type": xcheck.hit_type,
# Optional SPD test details
"spd_test_roll": xcheck.spd_test_roll,
"spd_test_target": xcheck.spd_test_target,
"spd_test_passed": xcheck.spd_test_passed
}
# Broadcast play result to game room
await manager.broadcast_to_game(
str(game_id),
"play_resolved",
play_result_data
)
logger.info(
f"Manual play resolved for game {game_id}: {result.description}"
)
except GameValidationError as e:
# Game engine validation error (e.g., missing hit location)
await manager.emit_to_user(
sid,
"outcome_rejected",
{
"message": str(e),
"field": "validation"
}
)
logger.warning(f"Manual play validation failed: {e}")
return
except Exception as e:
# Unexpected error during resolution
logger.error(f"Error resolving manual play: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to resolve play: {str(e)}"}
)
return
except Exception as e:
logger.error(f"Submit manual outcome error: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to process outcome: {str(e)}"}
)