strat-gameplay-webapp/backend/app/websocket/CLAUDE.md
Cal Corum eab61ad966 CLAUDE: Phases 3.5, F1-F5 Complete - Statistics & Frontend Components
This commit captures work from multiple sessions building the statistics
system and frontend component library.

Backend - Phase 3.5: Statistics System
- Box score statistics with materialized views
- Play stat calculator for real-time updates
- Stat view refresher service
- Alembic migration for materialized views
- Test coverage: 41 new tests (all passing)

Frontend - Phase F1: Foundation
- Composables: useGameState, useGameActions, useWebSocket
- Type definitions and interfaces
- Store setup with Pinia

Frontend - Phase F2: Game Display
- ScoreBoard, GameBoard, CurrentSituation, PlayByPlay components
- Demo page at /demo

Frontend - Phase F3: Decision Inputs
- DefensiveSetup, OffensiveApproach, StolenBaseInputs components
- DecisionPanel orchestration
- Demo page at /demo-decisions
- Test coverage: 213 tests passing

Frontend - Phase F4: Dice & Manual Outcome
- DiceRoller component
- ManualOutcomeEntry with validation
- PlayResult display
- GameplayPanel orchestration
- Demo page at /demo-gameplay
- Test coverage: 119 tests passing

Frontend - Phase F5: Substitutions
- PinchHitterSelector, DefensiveReplacementSelector, PitchingChangeSelector
- SubstitutionPanel with tab navigation
- Demo page at /demo-substitutions
- Test coverage: 114 tests passing

Documentation:
- PHASE_3_5_HANDOFF.md - Statistics system handoff
- PHASE_F2_COMPLETE.md - Game display completion
- Frontend phase planning docs
- NEXT_SESSION.md updated for Phase F6

Configuration:
- Package updates (Nuxt 4 fixes)
- Tailwind config enhancements
- Game store updates

Test Status:
- Backend: 731/731 passing (100%)
- Frontend: 446/446 passing (100%)
- Total: 1,177 tests passing

Next Phase: F6 - Integration (wire all components into game page)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 09:52:30 -06:00

2095 lines
55 KiB
Markdown

# 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)