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)}"} )