# WebSocket Module - Real-Time Game Communication ## Purpose Real-time bidirectional communication layer for Paper Dynasty game engine using Socket.io. Handles connection lifecycle, room management, game event broadcasting, and player action processing. **Critical Role**: This is the primary interface between players and the game engine. All game actions flow through WebSocket events, ensuring real-time updates for all participants. ## Architecture Overview ``` Client (Browser) ↓ Socket.io ConnectionManager ↓ Event Handlers ↓ Game Engine → StateManager → Database ↓ Broadcast to All Players ``` **Key Characteristics**: - **Async-first**: All handlers use async/await - **Room-based**: Games are isolated rooms (game_id as room name) - **JWT Authentication**: All connections require valid token - **Event-driven**: Actions trigger events, results broadcast to rooms - **Error isolation**: Exceptions caught per-event, emit error to client ## Structure ### Module Files ``` app/websocket/ ├── __init__.py # Package marker (minimal/empty) ├── connection_manager.py # Connection lifecycle & broadcasting └── handlers.py # Event handler registration ``` ### Dependencies **Internal**: - `app.core.state_manager` - In-memory game state - `app.core.game_engine` - Play resolution logic - `app.core.dice` - Dice rolling system - `app.core.validators` - Rule validation - `app.models.game_models` - Pydantic game state models - `app.utils.auth` - JWT token verification - `app.config.result_charts` - PlayOutcome enum **External**: - `socketio.AsyncServer` - Socket.io server implementation - `pydantic` - Data validation ## Key Components ### 1. ConnectionManager (`connection_manager.py`) **Purpose**: Manages WebSocket connection lifecycle, room membership, and message broadcasting. **State Tracking**: ```python self.sio: socketio.AsyncServer # Socket.io server instance self.user_sessions: Dict[str, str] # sid → user_id mapping self.game_rooms: Dict[str, Set[str]] # game_id → set of sids ``` **Core Methods**: #### `async connect(sid: str, user_id: str) -> None` Register a new connection after authentication. ```python await manager.connect(sid, user_id) # Logs: "User {user_id} connected with session {sid}" ``` #### `async disconnect(sid: str) -> None` Handle disconnection - cleanup sessions and notify game rooms. ```python await manager.disconnect(sid) # Automatically: # - Removes user from user_sessions # - Removes from all game_rooms # - Broadcasts "user_disconnected" to affected games ``` #### `async join_game(sid: str, game_id: str, role: str) -> None` Add user to game room and broadcast join event. ```python await manager.join_game(sid, game_id, role="player") # - Calls sio.enter_room(sid, game_id) # - Tracks in game_rooms dict # - Broadcasts "user_connected" to room ``` #### `async leave_game(sid: str, game_id: str) -> None` Remove user from game room. ```python await manager.leave_game(sid, game_id) # - Calls sio.leave_room(sid, game_id) # - Updates game_rooms tracking ``` #### `async broadcast_to_game(game_id: str, event: str, data: dict) -> None` Send event to all users in game room. ```python await manager.broadcast_to_game( game_id="123e4567-e89b-12d3-a456-426614174000", event="play_resolved", data={"description": "Single to CF", "runs_scored": 1} ) # All players in game receive event ``` #### `async emit_to_user(sid: str, event: str, data: dict) -> None` Send event to specific user. ```python await manager.emit_to_user( sid="abc123", event="error", data={"message": "Invalid action"} ) # Only that user receives event ``` #### `get_game_participants(game_id: str) -> Set[str]` Get all session IDs currently in game room. ```python sids = manager.get_game_participants(game_id) print(f"{len(sids)} players connected") ``` --- ### 2. Event Handlers (`handlers.py`) **Purpose**: Register and process all client-initiated events. Validates inputs, coordinates with game engine, emits responses. **Registration Pattern**: ```python def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: """Register all WebSocket event handlers""" @sio.event async def event_name(sid, data): # Handler implementation ``` **Handler Design Pattern**: ```python @sio.event async def some_event(sid, data): """ Event description. Event data: field1: type - description field2: type - description Emits: success_event: To requester/room on success error: To requester on failure """ try: # 1. Extract and validate inputs game_id = UUID(data.get("game_id")) field = data.get("field") # 2. Get game state state = state_manager.get_state(game_id) if not state: await manager.emit_to_user(sid, "error", {"message": "Game not found"}) return # 3. Validate authorization (TODO: implement) # user_id = manager.user_sessions.get(sid) # 4. Process action result = await game_engine.do_something(game_id, field) # 5. Emit success await manager.emit_to_user(sid, "success_event", result) # 6. Broadcast to game room if needed await manager.broadcast_to_game(game_id, "state_update", data) except ValidationError as e: # Pydantic validation error - user-friendly message await manager.emit_to_user(sid, "error_event", {"message": str(e)}) except Exception as e: # Unexpected error - log and return generic message logger.error(f"Event error: {e}", exc_info=True) await manager.emit_to_user(sid, "error", {"message": str(e)}) ``` --- ### Core Event Handlers #### `connect(sid, environ, auth) -> bool` **Purpose**: Authenticate new WebSocket connections using JWT. **Flow**: 1. Extract JWT token from `auth` dict 2. Verify token using `verify_token()` 3. Extract `user_id` from token payload 4. Register connection with ConnectionManager 5. Emit "connected" event to user **Returns**: `True` to accept, `False` to reject connection **Security**: First line of defense - all connections must have valid JWT. ```python # Client connection attempt socket.connect({ auth: { token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } }) # On success, receives: {"user_id": "12345"} ``` --- #### `disconnect(sid)` **Purpose**: Clean up when user disconnects (intentional or network failure). **Flow**: 1. Remove from `user_sessions` 2. Remove from all `game_rooms` 3. Broadcast "user_disconnected" to affected games **Automatic**: Called by Socket.io on connection loss. --- #### `join_game(sid, data)` **Purpose**: Add user to game room for real-time updates. **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "role": "player" # or "spectator" } ``` **Emits**: - `game_joined` → To requester with confirmation - `user_connected` → Broadcast to game room **TODO**: Verify user has access to game (authorization check) --- #### `leave_game(sid, data)` **Purpose**: Remove user from game room. **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000" } ``` **Use Case**: User navigates away, switches games, or voluntarily leaves. --- #### `heartbeat(sid)` **Purpose**: Keep-alive mechanism to detect stale connections. **Flow**: 1. Client sends periodic "heartbeat" events 2. Server immediately responds with "heartbeat_ack" **Usage**: Client can detect server unresponsiveness if ack not received. --- #### `roll_dice(sid, data)` **Purpose**: Roll dice for manual outcome selection (core gameplay event). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000" } ``` **Flow**: 1. Validate game_id (UUID format, game exists) 2. Verify user is participant (TODO: implement authorization) 3. Roll dice using `dice_system.roll_ab()` 4. Store roll in `state.pending_manual_roll` (one-time use) 5. Broadcast dice results to all players in game **Emits**: - `dice_rolled` → Broadcast to game room with roll results - `error` → To requester if validation fails **Dice Roll Structure**: ```python { "game_id": "123e4567-...", "roll_id": "unique-roll-identifier", "d6_one": 4, # First d6 (card selection) "d6_two_total": 7, # Sum of 2d6 (row selection) "chaos_d20": 14, # d20 for split results "resolution_d20": 8, # d20 for secondary checks "check_wild_pitch": False, "check_passed_ball": False, "timestamp": "2025-10-31T12:34:56Z", "message": "Dice rolled - read your card and submit outcome" } ``` **Players' Workflow**: 1. Receive `dice_rolled` event 2. d6_one determines column (1-3: batter card, 4-6: pitcher card) 3. d6_two_total determines row on card (2-12) 4. Read physical card result at that position 5. Submit outcome using `submit_manual_outcome` **Security**: Roll stored in `pending_manual_roll` to prevent replay attacks. Cleared after single use. --- #### `submit_manual_outcome(sid, data)` **Purpose**: Submit manually-selected play outcome after reading physical card. **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "outcome": "single", # PlayOutcome enum value "hit_location": "CF" # Optional: required for hits } ``` **Flow**: 1. Validate game_id (UUID format, game exists) 2. Verify user is authorized (TODO: implement - active batter or game admin) 3. Extract outcome and hit_location 4. Validate using `ManualOutcomeSubmission` Pydantic model 5. Convert outcome string to `PlayOutcome` enum 6. Check if outcome requires hit_location (groundball, flyball, line drive) 7. Verify `pending_manual_roll` exists (must call `roll_dice` first) 8. Emit `outcome_accepted` to requester (immediate feedback) 9. Process play through `game_engine.resolve_manual_play()` 10. Clear `pending_manual_roll` (one-time use) 11. Broadcast `play_resolved` to game room with full result **Emits**: - `outcome_accepted` → To requester (immediate confirmation) - `play_resolved` → Broadcast to game room (full play result) - `outcome_rejected` → To requester if validation fails - `error` → To requester if processing fails **Validation Errors**: ```python # Missing game_id {"message": "Missing game_id", "field": "game_id"} # Invalid UUID format {"message": "Invalid game_id format", "field": "game_id"} # Game not found {"message": "Game {game_id} not found"} # Missing outcome {"message": "Missing outcome", "field": "outcome"} # Invalid outcome value {"message": "Invalid outcome", "field": "outcome", "errors": [...]} # Missing required hit_location {"message": "Outcome groundball_c requires hit_location", "field": "hit_location"} # No pending roll {"message": "No pending dice roll - call roll_dice first", "field": "game_state"} ``` **Play Result Structure**: ```python { "game_id": "123e4567-...", "play_number": 15, "outcome": "single", "hit_location": "CF", "description": "Single to center field", "outs_recorded": 0, "runs_scored": 1, "batter_result": "1B", "runners_advanced": [ {"from": 2, "to": 4}, # Runner scored from 2nd {"from": 0, "to": 1} # Batter to 1st ], "is_hit": true, "is_out": false, "is_walk": false, "roll_id": "unique-roll-identifier" } ``` **Error Handling**: - `ValidationError` (Pydantic): User-friendly field-level errors → `outcome_rejected` - `GameValidationError`: Business rule violations → `outcome_rejected` - `Exception`: Unexpected errors → logged with stack trace, `error` emitted **Security**: - Validates `pending_manual_roll` exists (prevents fabricated submissions) - One-time use: roll cleared after processing - TODO: Verify user authorization (active batter or game admin) --- ### Substitution Event Handlers (2025-11-04) The substitution system enables real-time player substitutions during gameplay. All substitution events follow the same pattern: validate → execute → broadcast. --- #### `request_pinch_hitter(sid, data)` **Purpose**: Replace current batter with a bench player (pinch hitter substitution). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "player_out_lineup_id": 10, # Lineup ID of player being removed "player_in_card_id": 201, # Card/player ID of substitute "team_id": 1 # Team making substitution } ``` **Flow**: 1. Validate game_id (UUID format, game exists) 2. Validate all required fields present 3. TODO: Verify user is authorized for this team 4. Create `SubstitutionManager` instance 5. Execute `pinch_hit()` with DB-first pattern (validate → DB → state) 6. If successful: Broadcast `player_substituted` to game room 7. If successful: Send `substitution_confirmed` to requester 8. If failed: Send `substitution_error` with error code **Emits**: - `player_substituted` → Broadcast to game room on success - `substitution_confirmed` → To requester on success - `substitution_error` → To requester if validation fails - `error` → To requester if processing fails **Success Broadcast Structure**: ```python { "type": "pinch_hitter", "player_out_lineup_id": 10, "player_in_card_id": 201, "new_lineup_id": 25, "position": "RF", "batting_order": 3, "team_id": 1, "message": "Pinch hitter: #3 now batting" } ``` **Error Codes**: - `MISSING_FIELD` - Required field not provided - `INVALID_FORMAT` - Invalid game_id UUID - `NOT_CURRENT_BATTER` - Can only pinch hit for current batter - `PLAYER_ALREADY_OUT` - Player has already been removed from game - `NOT_IN_ROSTER` - Substitute not on team roster - `ALREADY_ACTIVE` - Substitute already in game **Rules Enforced** (by SubstitutionManager): - Can only pinch hit for current batter - Substitute must be on roster and inactive - No re-entry: removed players can't return - Substitute takes batting order of replaced player --- #### `request_defensive_replacement(sid, data)` **Purpose**: Replace a defensive player (improve defense, defensive substitution). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "player_out_lineup_id": 12, # Lineup ID of player being removed "player_in_card_id": 203, # Card/player ID of substitute "new_position": "SS", # Position for substitute (P, C, 1B, 2B, 3B, SS, LF, CF, RF) "team_id": 1 # Team making substitution } ``` **Flow**: 1. Validate game_id and all required fields (including new_position) 2. TODO: Verify user is authorized for this team 3. Execute `defensive_replace()` via SubstitutionManager 4. Broadcast `player_substituted` to game room 5. Send `substitution_confirmed` to requester **Emits**: - `player_substituted` → Broadcast to game room on success - `substitution_confirmed` → To requester on success - `substitution_error` → To requester if validation fails - `error` → To requester if processing fails **Success Broadcast Structure**: ```python { "type": "defensive_replacement", "player_out_lineup_id": 12, "player_in_card_id": 203, "new_lineup_id": 26, "position": "SS", "batting_order": 6, # Keeps original batting order if in lineup "team_id": 1, "message": "Defensive replacement: SS" } ``` **Rules Enforced**: - Substitute must be on roster and inactive - If replaced player was in batting order, substitute takes their spot - Valid defensive positions: P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH - No position eligibility check in MVP (any player can play any position) --- #### `request_pitching_change(sid, data)` **Purpose**: Replace current pitcher with a reliever (pitching change). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "player_out_lineup_id": 1, # Lineup ID of pitcher being removed "player_in_card_id": 205, # Card/player ID of relief pitcher "team_id": 1 # Team making substitution } ``` **Flow**: 1. Validate game_id and all required fields 2. TODO: Verify user is authorized for this team 3. Execute `change_pitcher()` via SubstitutionManager 4. Broadcast `player_substituted` to game room 5. Send `substitution_confirmed` to requester **Emits**: - `player_substituted` → Broadcast to game room on success - `substitution_confirmed` → To requester on success - `substitution_error` → To requester if validation fails - `error` → To requester if processing fails **Success Broadcast Structure**: ```python { "type": "pitching_change", "player_out_lineup_id": 1, "player_in_card_id": 205, "new_lineup_id": 27, "position": "P", "batting_order": 9, # Typically 9th in lineup "team_id": 1, "message": "Pitching change: New pitcher entering" } ``` **Rules Enforced**: - Pitcher must have faced at least 1 batter (unless injury - not yet implemented) - New pitcher must be on roster and inactive - New pitcher takes pitching position immediately --- #### `get_lineup(sid, data)` **Purpose**: Retrieve current active lineup for a team (UI refresh after substitutions). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "team_id": 1 # Team to get lineup for } ``` **Flow**: 1. Validate game_id and team_id 2. TODO: Verify user has access to view this lineup 3. Try StateManager cache (O(1) lookup) 4. If not cached, load from database 5. Send `lineup_data` with active players only **Emits**: - `lineup_data` → To requester with active lineup - `error` → To requester if validation fails **Response Structure**: ```python { "game_id": "123e4567-...", "team_id": 1, "players": [ { "lineup_id": 10, "card_id": 101, "position": "RF", "batting_order": 3, "is_active": true, "is_starter": true }, { "lineup_id": 25, # Pinch hitter "card_id": 201, "position": "RF", "batting_order": 3, "is_active": true, "is_starter": false # Substitute }, # ... 7 more active players ] } ``` **Use Cases**: - Refresh lineup display after substitution - Show bench players (is_active=false) for substitution UI - Verify substitution was applied correctly **Performance**: - Cache hit: O(1) - instant response - Cache miss: Single DB query to load lineup --- ### Substitution Event Flow **Complete Substitution Workflow**: ``` Client (Manager) ↓ socket.emit('request_pinch_hitter', { game_id, player_out_lineup_id, player_in_card_id, team_id }) ↓ WebSocket Handler (validate inputs) ↓ SubstitutionManager.pinch_hit() ├─ SubstitutionRules.validate_pinch_hitter() ├─ DatabaseOperations.create_substitution() │ ├─ Mark old player inactive │ └─ Create new lineup entry ├─ StateManager.update_lineup_cache() └─ Update GameState.current_batter (if applicable) ↓ Success Response ├─ player_substituted (broadcast to all clients) └─ substitution_confirmed (to requester) ↓ Client Updates ├─ Lineup display refreshed ├─ Bench updated └─ Game log updated ``` **Client-Side Integration Example**: ```javascript // Request pinch hitter socket.emit('request_pinch_hitter', { game_id: currentGameId, player_out_lineup_id: currentBatterLineupId, player_in_card_id: selectedBenchPlayerId, team_id: myTeamId }); // Handle confirmation socket.on('substitution_confirmed', (data) => { console.log('Substitution successful:', data.type, data.new_lineup_id); showSuccessMessage('Pinch hitter entered the game'); }); // Handle broadcast (all clients receive) socket.on('player_substituted', (data) => { console.log('Substitution:', data.type, data.message); updateLineupDisplay(data.team_id); addToGameLog(data.message); // Refresh lineup from server socket.emit('get_lineup', { game_id: currentGameId, team_id: data.team_id }); }); // Handle errors socket.on('substitution_error', (data) => { console.error('Substitution failed:', data.message, data.code); showErrorMessage(data.message); // Show user-friendly error based on code if (data.code === 'NOT_CURRENT_BATTER') { alert('Can only pinch hit for the current batter'); } else if (data.code === 'PLAYER_ALREADY_OUT') { alert('This player has already been removed from the game'); } }); // Receive lineup data socket.on('lineup_data', (data) => { const activePlayers = data.players.filter(p => p.is_active); const benchPlayers = data.players.filter(p => !p.is_active); renderLineup(activePlayers); renderBench(benchPlayers); }); ``` --- ### Strategic Decision Event Handlers (2025-01-10) The strategic decision handlers enable teams to submit their defensive and offensive strategies before each play. --- #### `submit_defensive_decision(sid, data)` **Purpose**: Receive defensive team decision (alignment, positioning, hold runners). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "alignment": "normal", # normal, shifted_left, shifted_right, extreme_shift "infield_depth": "normal", # in, normal, back, double_play "outfield_depth": "normal", # in, normal, back "hold_runners": [3] # List of bases to hold } ``` **Flow**: 1. Validate game_id (UUID format, game exists) 2. TODO: Verify user is authorized (fielding team manager) 3. Extract decision data with defaults 4. Create DefensiveDecision Pydantic model 5. Submit through game_engine 6. Broadcast decision to game room **Emits**: - `defensive_decision_submitted` → Broadcast to game room - `error` → To requester if validation fails **Success Broadcast Structure**: ```python { "game_id": "123e4567-...", "decision": { "alignment": "shifted_left", "infield_depth": "double_play", "outfield_depth": "normal", "hold_runners": [3] }, "pending_decision": "offensive" # or "resolution" } ``` **Client Example**: ```javascript socket.emit('submit_defensive_decision', { game_id: currentGameId, alignment: 'shifted_left', infield_depth: 'double_play', hold_runners: [3] }); socket.on('defensive_decision_submitted', (data) => { console.log('Defense set:', data.decision); console.log('Next:', data.pending_decision); // Update UI to show offensive decision needed }); ``` --- #### `submit_offensive_decision(sid, data)` **Purpose**: Receive offensive team decision (approach, steals, hit-and-run, bunt). **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000", "approach": "normal", # normal, contact, power, patient "steal_attempts": [2], # List of bases "hit_and_run": false, "bunt_attempt": false } ``` **Flow**: 1. Validate game_id (UUID format, game exists) 2. TODO: Verify user is authorized (batting team manager) 3. Extract decision data with defaults 4. Create OffensiveDecision Pydantic model 5. Submit through game_engine 6. Broadcast decision to game room **Emits**: - `offensive_decision_submitted` → Broadcast to game room - `error` → To requester if validation fails **Success Broadcast Structure**: ```python { "game_id": "123e4567-...", "decision": { "approach": "power", "steal_attempts": [2, 3], "hit_and_run": true, "bunt_attempt": false }, "pending_decision": "resolution" # Ready to resolve play } ``` **Client Example**: ```javascript socket.emit('submit_offensive_decision', { game_id: currentGameId, approach: 'power', steal_attempts: [2], hit_and_run: true }); socket.on('offensive_decision_submitted', (data) => { console.log('Offense set:', data.decision); console.log('Next:', data.pending_decision); // Update UI to show play resolution ready }); ``` --- #### `get_box_score(sid, data)` **Purpose**: Retrieve box score data from materialized views. **Event Data**: ```python { "game_id": "123e4567-e89b-12d3-a456-426614174000" } ``` **Flow**: 1. Validate game_id (UUID format) 2. TODO: Verify user has access to view box score 3. Retrieve from box_score_service (materialized views) 4. Send box_score_data to requester **Emits**: - `box_score_data` → To requester with complete stats - `error` → To requester if not found or validation fails **Success Response Structure**: ```python { "game_id": "123e4567-...", "box_score": { # Complete box score data from materialized views # Structure defined by box_score_service "batting_stats": [...], "pitching_stats": [...], "team_stats": {...} } } ``` **Client Example**: ```javascript socket.emit('get_box_score', { game_id: currentGameId }); socket.on('box_score_data', (data) => { console.log('Box score:', data.box_score); renderBoxScore(data.box_score); }); socket.on('error', (data) => { if (data.hint) { console.warn('Hint:', data.hint); // "Run migration (alembic upgrade head) and refresh views" } }); ``` --- ## Patterns & Conventions ### 1. Error Handling **Three-tier error handling**: ```python try: # Main logic result = await process_action() except ValidationError as e: # Pydantic validation - user-friendly error 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", # or "error" {"message": message, "field": field, "errors": e.errors()} ) logger.warning(f"Validation failed: {message}") return # Don't continue except GameValidationError as e: # Business rule violation await manager.emit_to_user( sid, "outcome_rejected", {"message": str(e), "field": "validation"} ) logger.warning(f"Game validation failed: {e}") return except Exception as e: # Unexpected error - log full stack trace logger.error(f"Unexpected error: {e}", exc_info=True) await manager.emit_to_user( sid, "error", {"message": f"Failed to process action: {str(e)}"} ) return ``` **Error Event Types**: - `error` - Generic error (connection, processing failures) - `outcome_rejected` - Play-specific validation failure (user-friendly) ### 2. Logging All logs use structured format with module name: ```python import logging logger = logging.getLogger(f'{__name__}.ConnectionManager') logger = logging.getLogger(f'{__name__}.handlers') # Log levels logger.info(f"User {user_id} connected") # Normal operations logger.warning(f"Validation failed: {message}") # Expected errors logger.error(f"Error: {e}", exc_info=True) # Unexpected errors logger.debug(f"Broadcast {event} to game") # Verbose details ``` ### 3. UUID Validation All game_id values must be validated as UUIDs: ```python from uuid import UUID try: game_id = UUID(data.get("game_id")) except (ValueError, AttributeError): await manager.emit_to_user( sid, "error", {"message": "Invalid game_id format"} ) return ``` ### 4. State Validation Always verify game state exists: ```python 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 ``` ### 5. Authorization Pattern (TODO) Framework for future authorization checks: ```python # Get user_id from session user_id = manager.user_sessions.get(sid) # Verify user has access (not yet implemented) # if not is_game_participant(game_id, user_id): # await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) # return # Verify user can perform action (not yet implemented) # if not is_active_batter(game_id, user_id): # await manager.emit_to_user(sid, "error", {"message": "Not your turn"}) # return ``` ### 6. Pydantic Validation Use Pydantic models for input validation: ```python from app.models.game_models import ManualOutcomeSubmission try: submission = ManualOutcomeSubmission( outcome=data.get("outcome"), hit_location=data.get("hit_location") ) except ValidationError as e: # Extract user-friendly error first_error = e.errors()[0] field = first_error['loc'][0] message = first_error['msg'] await manager.emit_to_user(sid, "outcome_rejected", { "message": message, "field": field, "errors": e.errors() }) return ``` ### 7. Event Response Flow **Request → Validate → Process → Respond → Broadcast** ```python @sio.event async def some_action(sid, data): # 1. VALIDATE inputs game_id = validate_game_id(data) state = get_and_verify_state(game_id) # 2. PROCESS action result = await game_engine.process(game_id, data) # 3. RESPOND to requester await manager.emit_to_user(sid, "action_accepted", {"status": "success"}) # 4. BROADCAST to game room await manager.broadcast_to_game(game_id, "state_update", result) ``` ### 8. Async Best Practices - All handlers are `async def` - Use `await` for I/O operations (database, game engine) - Non-blocking: multiple events can be processed concurrently - Game engine operations are async for database writes --- ## Integration Points ### With Game Engine Event handlers coordinate with game engine for play resolution: ```python from app.core.game_engine import game_engine # Roll dice ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id) # Store in state (pending) state.pending_manual_roll = ab_roll state_manager.update_state(game_id, state) # Resolve manual play result = await game_engine.resolve_manual_play( game_id=game_id, ab_roll=ab_roll, outcome=PlayOutcome.SINGLE, hit_location="CF" ) ``` ### With State Manager Real-time state access and updates: ```python from app.core.state_manager import state_manager # Get current state (O(1) lookup) state = state_manager.get_state(game_id) # Update state (in-memory) state.pending_manual_roll = ab_roll state_manager.update_state(game_id, state) # Clear pending roll after use state.pending_manual_roll = None state_manager.update_state(game_id, state) ``` ### With Database Async database writes happen in game engine (non-blocking): ```python from app.core.game_engine import game_engine # Game engine handles async DB operations result = await game_engine.resolve_manual_play(...) # - Updates in-memory state (immediate) # - Writes to database (async, non-blocking) # - Returns result for broadcasting ``` ### With Clients **Client-side Socket.io integration**: ```javascript // Connect with JWT const socket = io('ws://localhost:8000', { auth: { token: localStorage.getItem('jwt') } }); // Connection confirmed socket.on('connected', (data) => { console.log('Connected as user', data.user_id); }); // Join game room socket.emit('join_game', { game_id: '123e4567-e89b-12d3-a456-426614174000', role: 'player' }); // Roll dice socket.emit('roll_dice', { game_id: '123e4567-e89b-12d3-a456-426614174000' }); // Receive dice results socket.on('dice_rolled', (data) => { console.log('Dice:', data.d6_one, data.d6_two_total); // Show UI for outcome selection }); // Submit outcome socket.emit('submit_manual_outcome', { game_id: '123e4567-e89b-12d3-a456-426614174000', outcome: 'single', hit_location: 'CF' }); // Receive play result socket.on('play_resolved', (data) => { console.log('Play:', data.description); console.log('Runs scored:', data.runs_scored); // Update game UI }); // Handle errors socket.on('error', (data) => { console.error('Error:', data.message); }); socket.on('outcome_rejected', (data) => { console.error('Rejected:', data.message, 'Field:', data.field); }); ``` --- ## Common Tasks ### Adding a New Event Handler 1. **Define handler function** in `handlers.py`: ```python @sio.event async def new_event(sid, data): """ Description of what this event does. Event data: field1: type - description field2: type - description Emits: success_event: To requester/room on success error: To requester on failure """ try: # 1. Extract and validate inputs game_id = UUID(data.get("game_id")) # 2. Get game state state = state_manager.get_state(game_id) if not state: await manager.emit_to_user(sid, "error", {"message": "Game not found"}) return # 3. Process action result = await game_engine.some_action(game_id, data) # 4. Emit success await manager.emit_to_user(sid, "success_event", result) # 5. Broadcast to game room await manager.broadcast_to_game(game_id, "state_update", result) except Exception as e: logger.error(f"New event error: {e}", exc_info=True) await manager.emit_to_user(sid, "error", {"message": str(e)}) ``` 2. **Register automatically**: `@sio.event` decorator auto-registers 3. **Add client-side handler**: ```javascript socket.emit('new_event', { game_id: '...', field1: 'value' }); socket.on('success_event', (data) => { /* handle */ }); ``` ### Modifying Broadcast Logic **To broadcast to specific users**: ```python # Get participants sids = manager.get_game_participants(game_id) # Emit to each with custom logic for sid in sids: user_id = manager.user_sessions.get(sid) # Custom data per user custom_data = build_user_specific_data(user_id) await manager.emit_to_user(sid, "custom_event", custom_data) ``` **To broadcast to teams separately**: ```python # Get participants sids = manager.get_game_participants(game_id) for sid in sids: user_id = manager.user_sessions.get(sid) # Determine team if is_home_team(user_id, game_id): await manager.emit_to_user(sid, "home_event", data) else: await manager.emit_to_user(sid, "away_event", data) ``` ### Adding Authorization Checks **TODO**: Implement authorization service and add checks: ```python from app.utils.auth import verify_game_access, verify_active_player @sio.event async def protected_event(sid, data): game_id = UUID(data.get("game_id")) user_id = manager.user_sessions.get(sid) # Verify user has access to game if not verify_game_access(user_id, game_id): await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) return # Verify user is active player if not verify_active_player(user_id, game_id): await manager.emit_to_user(sid, "error", {"message": "Not your turn"}) return # Process action ... ``` ### Testing Event Handlers **Unit tests** (using pytest-asyncio): ```python import pytest from unittest.mock import AsyncMock, MagicMock from app.websocket.handlers import register_handlers @pytest.mark.asyncio async def test_roll_dice(): # Mock Socket.io server sio = MagicMock() manager = MagicMock() # Register handlers register_handlers(sio, manager) # Get the roll_dice handler roll_dice_handler = sio.event.call_args_list[2][0][0] # 3rd registered event # Mock data sid = "test-sid" data = {"game_id": "123e4567-e89b-12d3-a456-426614174000"} # Call handler await roll_dice_handler(sid, data) # Verify broadcast manager.broadcast_to_game.assert_called_once() ``` **Integration tests** (with test database): ```python import pytest from socketio import AsyncClient from app.main import app @pytest.mark.asyncio async def test_roll_dice_integration(): # Create test client client = AsyncClient() await client.connect('http://localhost:8000', auth={'token': test_jwt}) # Join game await client.emit('join_game', {'game_id': test_game_id}) # Roll dice await client.emit('roll_dice', {'game_id': test_game_id}) # Wait for response result = await client.receive() assert result[0] == 'dice_rolled' assert 'roll_id' in result[1] await client.disconnect() ``` --- ## Troubleshooting ### Connection Issues **Problem**: Client can't connect to WebSocket **Checklist**: 1. Verify JWT token is valid and not expired 2. Check CORS settings in `app/config.py` 3. Ensure Socket.io versions match (client and server) 4. Check server logs for connection rejection reasons 5. Verify network/firewall allows WebSocket connections 6. Test with curl: `curl -H "Authorization: Bearer TOKEN" http://localhost:8000/socket.io/` **Debug logs**: ```python # Enable Socket.io debug logging import socketio sio = socketio.AsyncServer(logger=True, engineio_logger=True) ``` --- ### Events Not Received **Problem**: Client emits event but no response **Checklist**: 1. Verify event name matches exactly (case-sensitive) 2. Check server logs for handler errors 3. Ensure game_id exists and is valid UUID 4. Verify user is in game room (call `join_game` first) 5. Check data format matches expected structure **Debug**: ```javascript // Client-side logging socket.onAny((event, data) => { console.log('Received:', event, data); }); socket.on('error', (data) => { console.error('Error:', data); }); ``` --- ### Game State Desynchronization **Problem**: Client UI doesn't match server state **Common Causes**: 1. Client missed broadcast due to disconnection 2. Event handler error prevented broadcast 3. Client state update logic has bug **Solutions**: 1. **Add state synchronization event**: ```python @sio.event async def request_game_state(sid, data): """Client requests full game state (recovery after disconnect)""" game_id = UUID(data.get("game_id")) state = state_manager.get_state(game_id) if state: await manager.emit_to_user(sid, "game_state", state.model_dump()) else: await manager.emit_to_user(sid, "error", {"message": "Game not found"}) ``` 2. **Implement reconnection logic**: ```javascript socket.on('reconnect', () => { console.log('Reconnected - requesting state'); socket.emit('request_game_state', { game_id: currentGameId }); }); ``` --- ### Broadcast Not Reaching All Players **Problem**: Some users don't receive broadcasts **Checklist**: 1. Verify all users called `join_game` 2. Check `game_rooms` dict in ConnectionManager 3. Ensure room name matches game_id exactly 4. Verify users haven't silently disconnected **Debug**: ```python # Add logging to broadcasts async def broadcast_to_game(self, game_id: str, event: str, data: dict) -> None: participants = self.get_game_participants(game_id) logger.info(f"Broadcasting {event} to {len(participants)} participants") await self.sio.emit(event, data, room=game_id) # Verify delivery for sid in participants: user_id = self.user_sessions.get(sid) logger.debug(f"Sent to user {user_id} (sid={sid})") ``` --- ### Pending Roll Not Found **Problem**: `submit_manual_outcome` fails with "No pending dice roll" **Common Causes**: 1. User didn't call `roll_dice` first 2. Roll expired due to timeout 3. Another user already submitted outcome 4. Server restarted (in-memory state lost) **Solutions**: 1. Enforce UI workflow: disable submit button until `dice_rolled` received 2. Add roll expiration check (optional): ```python # In roll_dice handler state.pending_manual_roll = ab_roll state.pending_manual_roll_expires_at = pendulum.now('UTC').add(minutes=5) # In submit_manual_outcome handler if state.pending_manual_roll_expires_at < pendulum.now('UTC'): state.pending_manual_roll = None await manager.emit_to_user(sid, "outcome_rejected", { "message": "Roll expired - please roll again", "field": "game_state" }) return ``` 3. Implement roll persistence in database for recovery --- ### Authorization Not Enforced **Problem**: Users can perform actions they shouldn't be able to **Current Status**: Authorization checks are stubbed out with TODO comments **Implementation Plan**: 1. **Create authorization service**: ```python # app/utils/authorization.py async def is_game_participant(game_id: UUID, user_id: str) -> bool: """Check if user is a participant in this game""" # Query database for game participants pass async def is_active_batter(game_id: UUID, user_id: str) -> bool: """Check if user is the active batter""" state = state_manager.get_state(game_id) # Check current batter lineup ID against user's lineup assignments pass async def is_game_admin(game_id: UUID, user_id: str) -> bool: """Check if user is game creator or admin""" pass ``` 2. **Add checks to handlers**: ```python from app.utils.authorization import is_game_participant, is_active_batter @sio.event async def submit_manual_outcome(sid, data): game_id = UUID(data.get("game_id")) user_id = manager.user_sessions.get(sid) # Verify participation if not await is_game_participant(game_id, user_id): await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) return # Verify active player if not await is_active_batter(game_id, user_id): await manager.emit_to_user(sid, "error", {"message": "Not your turn"}) return # Process action ... ``` --- ### Memory Leaks in ConnectionManager **Problem**: `user_sessions` or `game_rooms` grows indefinitely **Prevention**: 1. `disconnect()` handler automatically cleans up sessions 2. Monitor dict sizes: ```python @sio.event async def heartbeat(sid): await sio.emit("heartbeat_ack", {}, room=sid) # Periodic cleanup (every 100 heartbeats) if random.randint(1, 100) == 1: logger.info(f"Active sessions: {len(manager.user_sessions)}") logger.info(f"Active games: {len(manager.game_rooms)}") ``` 3. Add periodic cleanup task: ```python async def cleanup_stale_sessions(): """Remove sessions that haven't sent heartbeat in 5 minutes""" while True: await asyncio.sleep(300) # 5 minutes stale_sids = [] for sid, user_id in manager.user_sessions.items(): # Check last heartbeat timestamp if is_stale(sid): stale_sids.append(sid) for sid in stale_sids: await manager.disconnect(sid) if stale_sids: logger.info(f"Cleaned up {len(stale_sids)} stale sessions") ``` --- ## Examples ### Example 1: Complete Dice Roll → Outcome Flow **Server-side handlers**: ```python # handlers.py @sio.event async def roll_dice(sid, data): game_id = UUID(data.get("game_id")) state = state_manager.get_state(game_id) # Roll dice ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id) # Store pending roll state.pending_manual_roll = ab_roll state_manager.update_state(game_id, state) # Broadcast results await manager.broadcast_to_game( str(game_id), "dice_rolled", { "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, "message": "Read your card and submit outcome" } ) @sio.event async def submit_manual_outcome(sid, data): game_id = UUID(data.get("game_id")) outcome_str = data.get("outcome") hit_location = data.get("hit_location") # Validate submission = ManualOutcomeSubmission( outcome=outcome_str, hit_location=hit_location ) outcome = PlayOutcome(submission.outcome) # Get pending roll state = state_manager.get_state(game_id) ab_roll = state.pending_manual_roll # Confirm acceptance await manager.emit_to_user(sid, "outcome_accepted", { "outcome": outcome.value, "hit_location": submission.hit_location }) # Clear pending roll state.pending_manual_roll = None state_manager.update_state(game_id, state) # Resolve play result = await game_engine.resolve_manual_play( game_id=game_id, ab_roll=ab_roll, outcome=outcome, hit_location=submission.hit_location ) # Broadcast result await manager.broadcast_to_game( str(game_id), "play_resolved", { "description": result.description, "runs_scored": result.runs_scored, "outs_recorded": result.outs_recorded } ) ``` **Client-side flow**: ```javascript // 1. Roll dice button clicked document.getElementById('roll-btn').addEventListener('click', () => { socket.emit('roll_dice', { game_id: currentGameId }); setButtonState('rolling'); }); // 2. Receive dice results socket.on('dice_rolled', (data) => { console.log('Dice:', data.d6_one, data.d6_two_total, data.chaos_d20); // Show dice animation displayDiceResults(data); // Enable outcome selection showOutcomeSelector(); }); // 3. User selects outcome from UI document.getElementById('submit-outcome-btn').addEventListener('click', () => { const outcome = document.getElementById('outcome-select').value; const hitLocation = document.getElementById('hit-location-select').value; socket.emit('submit_manual_outcome', { game_id: currentGameId, outcome: outcome, hit_location: hitLocation }); setButtonState('submitting'); }); // 4. Receive confirmation socket.on('outcome_accepted', (data) => { console.log('Outcome accepted:', data.outcome); showSuccessMessage('Outcome accepted - resolving play...'); }); // 5. Receive play result socket.on('play_resolved', (data) => { console.log('Play result:', data.description); // Update game state UI updateScore(data.runs_scored); updateOuts(data.outs_recorded); addPlayToLog(data.description); // Reset for next play resetDiceRoller(); setButtonState('ready'); }); // 6. Handle errors socket.on('outcome_rejected', (data) => { console.error('Outcome rejected:', data.message, data.field); showErrorMessage(`Error: ${data.message}`); setButtonState('ready'); }); ``` --- ### Example 2: Broadcasting Team-Specific Data ```python @sio.event async def request_hand_cards(sid, data): """Send player's hand to them (but not opponents)""" game_id = UUID(data.get("game_id")) user_id = manager.user_sessions.get(sid) # Get user's team team_id = get_user_team(user_id, game_id) # Get hand for that team hand = get_team_hand(game_id, team_id) # Send ONLY to requesting user (private data) await manager.emit_to_user(sid, "hand_cards", { "cards": hand, "team_id": team_id }) # Broadcast to game that player viewed hand (no details) await manager.broadcast_to_game(str(game_id), "player_action", { "user_id": user_id, "action": "viewed_hand" }) ``` --- ### Example 3: Handling Spectators vs Players ```python @sio.event async def join_game(sid, data): game_id = data.get("game_id") role = data.get("role", "player") # "player" or "spectator" await manager.join_game(sid, game_id, role) # Store role in session data (extend ConnectionManager) manager.user_roles[sid] = role if role == "spectator": # Send spectator-specific state (no hidden info) state = state_manager.get_state(UUID(game_id)) spectator_state = state.to_spectator_view() await manager.emit_to_user(sid, "game_state", spectator_state) else: # Send full player state state = state_manager.get_state(UUID(game_id)) await manager.emit_to_user(sid, "game_state", state.model_dump()) # When broadcasting, respect roles async def broadcast_play_result(game_id: str, result: PlayResult): sids = manager.get_game_participants(game_id) for sid in sids: role = manager.user_roles.get(sid, "player") if role == "spectator": # Send spectator-safe data (no hole cards, etc.) await manager.emit_to_user(sid, "play_resolved", result.to_spectator_view()) else: # Send full data await manager.emit_to_user(sid, "play_resolved", result.model_dump()) ``` --- ### Example 4: Reconnection Recovery ```python @sio.event async def request_game_state(sid, data): """ Client requests full game state after reconnection. Use this to recover from disconnections without reloading page. """ game_id = UUID(data.get("game_id")) user_id = manager.user_sessions.get(sid) # Verify user is participant if not await is_game_participant(game_id, user_id): await manager.emit_to_user(sid, "error", {"message": "Not authorized"}) return # Get current state state = state_manager.get_state(game_id) if not state: # Try to recover from database state = await state_manager.recover_game(game_id) if not state: await manager.emit_to_user(sid, "error", {"message": "Game not found"}) return # Get recent plays for context plays = await db_ops.get_plays(game_id, limit=10) # Send full state await manager.emit_to_user(sid, "game_state_sync", { "state": state.model_dump(), "recent_plays": [p.to_dict() for p in plays], "timestamp": pendulum.now('UTC').to_iso8601_string() }) logger.info(f"Game state synced for user {user_id} in game {game_id}") ``` **Client-side**: ```javascript socket.on('reconnect', () => { console.log('Reconnected - syncing state'); socket.emit('request_game_state', { game_id: currentGameId }); }); socket.on('game_state_sync', (data) => { console.log('State synced:', data.timestamp); // Rebuild UI from full state rebuildGameUI(data.state); // Show recent plays displayRecentPlays(data.recent_plays); // Resume normal operation enableGameControls(); }); ``` --- ## Performance Considerations ### Broadcasting Efficiency - Socket.io's room-based broadcasting is O(n) where n = room size - Keep room sizes reasonable (players + spectators, not entire league) - Use targeted `emit_to_user()` for private data - Serialize Pydantic models once, broadcast same dict to all users ### Connection Scalability - Each connection consumes one socket + memory for session tracking - Target: Support 100+ concurrent games (1000+ connections) - Consider horizontal scaling with Redis pub/sub for multi-server: ```python # Future: Redis-backed Socket.io manager sio = socketio.AsyncServer( client_manager=socketio.AsyncRedisManager('redis://localhost:6379') ) ``` ### Event Loop Blocking - Never use blocking I/O in event handlers (always `async/await`) - Database writes are async (non-blocking) - Heavy computation should use thread pool executor ### Memory Management - ConnectionManager dicts are bounded by active connections - StateManager handles game state eviction (idle timeout) - No memory leaks if `disconnect()` handler works correctly --- ## Security Considerations ### Authentication - ✅ All connections require valid JWT token - ✅ Token verified in `connect()` handler before accepting - ❌ TODO: Token expiration handling (refresh mechanism) ### Authorization - ❌ TODO: Verify user is participant before allowing actions - ❌ TODO: Verify user is active player for turn-based actions - ❌ TODO: Prevent spectators from performing player actions ### Input Validation - ✅ Pydantic models validate all inputs - ✅ UUID validation for game_id - ✅ Enum validation for outcomes - ✅ Required field checks ### Anti-Cheating - ✅ Dice rolls are cryptographically secure (server-side) - ✅ Pending roll is one-time use (cleared after submission) - ❌ TODO: Rate limiting on dice rolls (prevent spam) - ❌ TODO: Verify outcome matches roll (if cards are digitized) - ❌ TODO: Track submission history for audit trail ### Data Privacy - Emit private data only to authorized users - Use `emit_to_user()` for sensitive information - Broadcasts should only contain public game state - TODO: Implement spectator-safe data filtering --- ## Future Enhancements ### Planned Features 1. **Authorization System** - User-game participant mapping - Role-based permissions (player, spectator, admin) - Turn-based action validation 2. **Reconnection Improvements** - Automatic state synchronization on reconnect - Missed event replay - Persistent pending actions 3. **Spectator Mode** - Separate spectator rooms - Filtered game state (no hidden information) - Spectator chat 4. **Rate Limiting** - Prevent event spam - Configurable limits per event type - IP-based blocking for abuse 5. **Analytics Events** - Track user actions for analytics - Performance monitoring - Error rate tracking 6. **Advanced Broadcasting** - Team-specific channels - Private player-to-player messaging - Game admin announcements --- ## Related Documentation - **Game Engine**: `../core/CLAUDE.md` - Play resolution logic - **State Manager**: `../core/CLAUDE.md` - In-memory state management - **Database**: `../database/CLAUDE.md` - Persistence layer - **Models**: `../models/CLAUDE.md` - Pydantic game state models - **WebSocket Protocol**: `../../../.claude/implementation/websocket-protocol.md` - Event specifications --- ## Quick Reference ### Event Summary | Event | Direction | Purpose | Authentication | |-------|-----------|---------|----------------| | `connect` | Client → Server | Establish connection | JWT required | | `disconnect` | Client → Server | End connection | Automatic | | `join_game` | Client → Server | Join game room | ✅ Token | | `leave_game` | Client → Server | Leave game room | ✅ Token | | `heartbeat` | Client → Server | Keep-alive ping | ✅ Token | | `submit_defensive_decision` | Client → Server | Submit defense strategy | ✅ Token | | `submit_offensive_decision` | Client → Server | Submit offense strategy | ✅ Token | | `roll_dice` | Client → Server | Roll dice for play | ✅ Token | | `submit_manual_outcome` | Client → Server | Submit card outcome | ✅ Token | | `get_box_score` | Client → Server | Get game statistics | ✅ Token | | `request_pinch_hitter` | Client → Server | Pinch hitter substitution | ✅ Token | | `request_defensive_replacement` | Client → Server | Defensive replacement | ✅ Token | | `request_pitching_change` | Client → Server | Pitching change | ✅ Token | | `get_lineup` | Client → Server | Get active lineup | ✅ Token | | `connected` | Server → Client | Connection confirmed | - | | `defensive_decision_submitted` | Server → Room | Defense strategy set | - | | `offensive_decision_submitted` | Server → Room | Offense strategy set | - | | `dice_rolled` | Server → Room | Dice results | - | | `outcome_accepted` | Server → Client | Outcome confirmed | - | | `play_resolved` | Server → Room | Play result | - | | `outcome_rejected` | Server → Client | Validation error | - | | `box_score_data` | Server → Client | Game statistics | - | | `player_substituted` | Server → Room | Substitution result | - | | `substitution_confirmed` | Server → Client | Substitution confirmed | - | | `substitution_error` | Server → Client | Substitution validation error | - | | `lineup_data` | Server → Client | Active lineup data | - | | `error` | Server → Client | Generic error | - | ### Common Imports ```python # WebSocket from socketio import AsyncServer from app.websocket.connection_manager import ConnectionManager # Game Logic from app.core.state_manager import state_manager from app.core.game_engine import game_engine from app.core.dice import dice_system from app.core.substitution_manager import SubstitutionManager # Database from app.database.operations import DatabaseOperations # Models from app.models.game_models import ManualOutcomeSubmission from app.config.result_charts import PlayOutcome # Validation from uuid import UUID from pydantic import ValidationError # Logging import logging logger = logging.getLogger(f'{__name__}.handlers') ``` --- **Last Updated**: 2025-01-10 **Module Version**: Phase 3E-Final Complete **Status**: ✅ Production-ready - All 15 event handlers implemented (strategic decisions, gameplay, substitutions, statistics)