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>
431 lines
15 KiB
Python
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)}"}
|
|
)
|