strat-gameplay-webapp/.claude/WEBSOCKET_PROTOCOL_SPEC.md
Cal Corum f3eb5e8200 CLAUDE: Add WebSocket protocol specification and implementation guides
Comprehensive documentation for real-time gameplay workflow:

**New Documentation**:

1. WEBSOCKET_PROTOCOL_SPEC.md (49KB)
   - Complete catalog of all 15 backend WebSocket event handlers
   - Complete catalog of all frontend event listeners
   - Game workflow sequences (connection → game start → play resolution)
   - Critical issues identified and resolution status
   - Event payload specifications with examples
   - Timing and performance expectations

2. DECISION_REQUIRED_IMPLEMENTATION.md (11KB)
   - Issue #1 detailed analysis and resolution
   - Backend implementation of decision_required event
   - Frontend integration approach
   - Before/After workflow comparison
   - Test validation results

3. GAMEPLAY_SESSION_HANDOFF.md (10KB)
   - Session work summary and accomplishments
   - Live testing results and observations
   - Known issues and next steps
   - Quick start guide for next session
   - Technical architecture notes

**Why**:
- Provides single source of truth for WebSocket protocol
- Documents complete event flow for frontend/backend alignment
- Captures implementation decisions and rationale
- Enables faster onboarding for new developers
- Creates reference for debugging WebSocket issues

**Impact**:
- Reduces protocol confusion between frontend and backend
- Accelerates future WebSocket feature development
- Provides clear integration patterns for new events

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 15:41:16 -06:00

1832 lines
48 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WebSocket Protocol Specification
## Strat-O-Matic Baseball Game Engine
**Version**: 1.1
**Date**: 2025-11-21
**Status**: Analysis Complete + Issue #1 Resolved (decision_required implemented)
---
## Executive Summary
This document defines the complete WebSocket communication protocol between the frontend (Vue 3/Nuxt) and backend (FastAPI/Socket.io) for real-time baseball gameplay. It identifies the complete game workflow, all 15 backend event handlers, all frontend event listeners, and critical gaps/mismatches discovered during analysis.
## Table of Contents
1. [Game Workflow Overview](#game-workflow-overview)
2. [WebSocket Events - Complete Reference](#websocket-events---complete-reference)
3. [Game State Flow](#game-state-flow)
4. [Critical Issues Identified](#critical-issues-identified)
5. [Event Sequences by Workflow](#event-sequences-by-workflow)
6. [Type Mappings](#type-mappings)
7. [Recommendations](#recommendations)
---
## Game Workflow Overview
### High-Level Flow
```
1. Game Creation (REST API)
├─→ POST /games (create game)
├─→ POST /games/{id}/lineups (submit lineups)
└─→ Game auto-starts after lineups submitted
2. WebSocket Connection
├─→ Client connects with JWT token
├─→ Backend validates and accepts connection
├─→ Client joins game room
└─→ Client requests game state
3. Decision Phase Workflow (Per Play)
├─→ Backend emits decision_required (defense)
├─→ Defense submits decision
├─→ Backend emits decision_required (offense)
├─→ Offense submits decision
└─→ Backend advances to resolution phase
4. Resolution Workflow
├─→ Manual Mode:
│ ├─→ Client rolls dice
│ ├─→ Backend broadcasts dice results
│ ├─→ Client submits outcome from card
│ └─→ Backend resolves play and broadcasts result
└─→ Auto Mode (future):
└─→ Backend auto-resolves and broadcasts
5. Play Completion
├─→ Backend broadcasts play_resolved
├─→ Backend broadcasts game_state_update
└─→ Loop back to step 3 (next play)
6. Game End
└─→ Backend broadcasts game_ended
```
---
## WebSocket Events - Complete Reference
### Connection Events
#### `connect` (Internal Socket.io)
**Direction**: Client → Server
**Purpose**: Establish WebSocket connection with JWT authentication
**Backend**: `handlers.py:26-50`
**Frontend**: N/A (Socket.io internal)
**Auth Data**:
```typescript
{
token: string // JWT from Discord OAuth
}
```
**Backend Behavior**:
- Validates JWT token
- Extracts user_id from token
- Registers connection in ConnectionManager
- Returns `true` to accept, `false` to reject
**Success**: Emits `connected` event
**Failure**: Rejects connection
---
#### `connected` ✅
**Direction**: Server → Client
**Purpose**: Confirm connection successful
**Backend**: `handlers.py:43`
**Frontend**: `useWebSocket.ts:234`
**Payload**:
```typescript
{
user_id: string
}
```
**Frontend Handling**:
- Logs user_id
- No state changes (already set `isConnected=true` from Socket.io `connect`)
---
#### `disconnect` (Internal Socket.io)
**Direction**: Client → Server
**Purpose**: Clean up when client disconnects
**Backend**: `handlers.py:53-55`
**Frontend**: `useWebSocket.ts:201`
**Backend Behavior**:
- Removes from all game rooms
- Cleans up user_sessions map
- No broadcast to other clients
**Frontend Behavior**:
- Sets `isConnected = false`
- Shows warning toast
- Attempts auto-reconnection
- Broadcasts to game store: `gameStore.setConnected(false)`
---
#### `heartbeat` / `heartbeat_ack` ✅
**Direction**: Bidirectional
**Purpose**: Keep connection alive
**Backend**: `handlers.py:91-93`
**Frontend**: `useWebSocket.ts:238, 455-463`
**Flow**:
1. Client sends `heartbeat` every 30 seconds
2. Server responds with `heartbeat_ack`
3. Client receives ack (connection healthy)
**No payload required**
---
### Game Room Management
#### `join_game` ✅
**Direction**: Client → Server
**Purpose**: Join game room as player or spectator
**Backend**: `handlers.py:58-77`
**Frontend**: `useGameActions.ts:69-78`
**Request**:
```typescript
{
game_id: string,
role: 'player' | 'spectator'
}
```
**Backend Behavior**:
- Validates game_id
- TODO: Verify user has access to game
- Adds client to game room
- Emits `game_joined` confirmation
**Success Response**: `game_joined` event
---
#### `game_joined` ✅
**Direction**: Server → Client
**Purpose**: Confirm successful join
**Backend**: `handlers.py:71-73`
**Frontend**: Not explicitly handled (should be added)
**Payload**:
```typescript
{
game_id: string,
role: 'player' | 'spectator'
}
```
⚠️ **ISSUE**: Frontend doesn't have explicit handler for this event
---
#### `leave_game` ✅
**Direction**: Client → Server
**Purpose**: Leave game room
**Backend**: `handlers.py:80-88`
**Frontend**: `useGameActions.ts:83-94`
**Request**:
```typescript
{
game_id: string
}
```
**Backend Behavior**:
- Removes client from game room
- No broadcast (silent exit)
**Frontend Behavior**:
- Resets game store
- Called on component unmount
---
### Game State Synchronization
#### `request_game_state` ✅
**Direction**: Client → Server
**Purpose**: Request full game state (initial load or reconnection)
**Backend**: `handlers.py:96-138`
**Frontend**: `useGameActions.ts:283-293`
**Request**:
```typescript
{
game_id: string
}
```
**Backend Behavior**:
- Validates game_id UUID format
- Checks in-memory state first
- If not in memory, recovers from database
- Serializes with `model_dump(mode='json')` for UUIDs
**Success Response**: `game_state` event
**Failure Response**: `error` event with "Game not found"
---
#### `game_state` ✅
**Direction**: Server → Client
**Purpose**: Send complete game state (response to request_game_state)
**Backend**: `handlers.py:127`
**Frontend**: `useWebSocket.ts:247-250`
**Payload**: Full `GameState` object
```typescript
{
game_id: string,
league_id: 'sba' | 'pd',
home_team_id: number,
away_team_id: number,
status: 'pending' | 'active' | 'paused' | 'completed',
inning: number,
half: 'top' | 'bottom',
outs: number,
balls: number,
strikes: number,
home_score: number,
away_score: number,
on_first: LineupPlayerState | null,
on_second: LineupPlayerState | null,
on_third: LineupPlayerState | null,
current_batter: LineupPlayerState,
current_pitcher: LineupPlayerState | null,
current_catcher: LineupPlayerState | null,
decision_phase: 'defense' | 'stolen_base' | 'offensive_approach' | 'resolution' | 'complete',
pending_decision: string | null,
decisions_this_play: Record<string, boolean>,
pending_defensive_decision: DefensiveDecision | null,
pending_offensive_decision: OffensiveDecision | null,
pending_manual_roll: RollData | null,
play_count: number,
// ... additional fields
}
```
**Frontend Handling**:
- Calls `gameStore.setGameState(state)`
- Completely replaces existing game state
- Does NOT update play history (use `game_state_sync` for that)
---
#### `game_state_update` ✅
**Direction**: Server → Client
**Purpose**: Broadcast incremental state change to all players
**Backend**: Not directly emitted (only via game_engine)
**Frontend**: `useWebSocket.ts:252-255`
**Payload**: Full `GameState` object (same as `game_state`)
**Frontend Handling**:
- Calls `gameStore.setGameState(state)`
- Replaces state entirely (not incremental)
**Usage**: Backend broadcasts this after:
- Decision submissions
- Play resolutions
- Inning changes
- Score changes
⚠️ **NAMING CONFUSION**: Both `game_state` and `game_state_update` send full state, not partial updates
---
#### `game_state_sync` ✅
**Direction**: Server → Client
**Purpose**: Full state + recent play history (for reconnection)
**Backend**: Not implemented yet
**Frontend**: `useWebSocket.ts:257-264`
**Payload**:
```typescript
{
state: GameState,
recent_plays: PlayResult[],
timestamp: string
}
```
**Frontend Handling**:
- Sets game state
- Adds recent plays to history
- Used for reconnection recovery
⚠️ **IMPLEMENTATION STATUS**: Frontend has handler, backend doesn't emit yet
---
### Strategic Decisions
#### `submit_defensive_decision` ✅
**Direction**: Client → Server
**Purpose**: Submit defensive team's strategic setup
**Backend**: `handlers.py:1048-1137`
**Frontend**: `useGameActions.ts:103-114`
**Request**:
```typescript
{
game_id: string,
infield_depth: 'infield_in' | 'normal' | 'corners_in',
outfield_depth: 'normal' | 'shallow',
hold_runners: number[] // Bases to hold (e.g., [1, 3])
}
```
**Backend Behavior**:
- Validates game exists
- TODO: Verify user is fielding team manager
- Calls `game_engine.submit_defensive_decision()`
- Updates state.decision_phase
- Broadcasts result
**Success Response**: `defensive_decision_submitted` event
---
#### `defensive_decision_submitted` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Notify all players defense is set
**Backend**: `handlers.py:1116-1129`
**Frontend**: `useWebSocket.ts:294-310`
**Payload**:
```typescript
{
game_id: string,
decision: {
infield_depth: string,
outfield_depth: string,
hold_runners: number[]
},
pending_decision: 'offensive' | 'resolution' | null
}
```
**Frontend Handling**:
- Clears decision prompt
- Requests updated game state
- Shows toast based on pending_decision
- If still pending → "Waiting for offense..."
- If no pending → "Ready to play!"
---
#### `submit_offensive_decision` ✅
**Direction**: Client → Server
**Purpose**: Submit offensive team's strategy
**Backend**: `handlers.py:1140-1217`
**Frontend**: `useGameActions.ts:119-129`
**Request**:
```typescript
{
game_id: string,
action: 'swing_away' | 'steal' | 'check_jump' | 'hit_and_run' | 'sac_bunt' | 'squeeze_bunt',
steal_attempts: number[] // Required when action="steal"
}
```
**Backend Behavior**:
- Validates game exists
- TODO: Verify user is batting team manager
- Creates OffensiveDecision model
- Calls `game_engine.submit_offensive_decision()`
- Broadcasts result
**Success Response**: `offensive_decision_submitted` event
---
#### `offensive_decision_submitted` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Notify all players offense is set
**Backend**: `handlers.py:1200-1209`
**Frontend**: `useWebSocket.ts:312-324`
**Payload**:
```typescript
{
game_id: string,
decision: {
action: string,
steal_attempts: number[]
},
pending_decision: 'resolution' | null
}
```
**Frontend Handling**:
- Clears decision prompt
- Requests updated game state
- Shows "Ready to play!" toast
---
#### `decision_required` ✅ IMPLEMENTED
**Direction**: Server → Client
**Purpose**: Prompt player for decision
**Backend**: `game_engine.py:60-100, 308-310` (Implemented 2025-11-21)
**Frontend**: `useWebSocket.ts:289-292`
**Payload**:
```typescript
{
phase: 'awaiting_defensive' | 'awaiting_offensive' | 'awaiting_stolen_base' | 'resolution',
role: 'home' | 'away',
timeout_seconds: number,
message: string
}
```
**Backend Implementation**:
- Emitted at game start (awaiting_defensive)
- Emitted after defensive decision submitted (awaiting_offensive)
- Auto-determines team role based on inning half
- Uses `_emit_decision_required()` helper method
**Frontend Handling**:
- Calls `gameStore.setDecisionPrompt(prompt)`
- DecisionPanel component shows UI based on phase
- Updates proactively without polling
**RESOLVED**: Backend now emits this event at appropriate phase transitions
---
### Manual Outcome Workflow
#### `roll_dice` ✅
**Direction**: Client → Server
**Purpose**: Roll dice for manual play resolution
**Backend**: `handlers.py:141-219`
**Frontend**: `useGameActions.ts:138-153`
**Request**:
```typescript
{
game_id: string
}
```
**Backend Behavior**:
- Validates game exists
- TODO: Verify user is participant
- Calls `dice_system.roll_ab()` for cryptographic random rolls
- Stores roll in `state.pending_manual_roll`
- Broadcasts dice results to all players
**Success Response**: `dice_rolled` event (broadcast)
---
#### `dice_rolled` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Show dice results to all players
**Backend**: `handlers.py:198-213`
**Frontend**: `useWebSocket.ts:330-345`
**Payload**:
```typescript
{
game_id: string,
roll_id: string,
d6_one: number, // First d6
d6_two_total: number, // Sum of second 2d6
chaos_d20: number, // Chaos die
resolution_d20: number, // Resolution die
check_wild_pitch: boolean,
check_passed_ball: boolean,
timestamp: string,
message: string
}
```
**Frontend Handling**:
- Stores in `gameStore.setPendingRoll()`
- Shows toast with message
- Enables outcome submission UI
- NOTE: Frontend adds d6_two_a and d6_two_b (not provided by backend)
⚠️ **MINOR ISSUE**: Frontend expects individual d6_two dice, backend only sends total
---
#### `submit_manual_outcome` ✅
**Direction**: Client → Server
**Purpose**: Submit card reading result
**Backend**: `handlers.py:222-437`
**Frontend**: `useGameActions.ts:158-175`
**Request**:
```typescript
{
game_id: string,
outcome: string, // PlayOutcome enum value
hit_location?: string // Required for groundballs/flyballs
}
```
**Backend Behavior**:
- Validates game exists
- Validates using ManualOutcomeSubmission model
- Checks for pending_manual_roll
- Validates hit_location if required
- Confirms acceptance
- Clears pending_manual_roll (one-time use)
- Calls `game_engine.resolve_manual_play()`
- Broadcasts play result
**Success Responses**:
1. `outcome_accepted` (to submitter)
2. `play_resolved` (broadcast to all)
**Failure Response**: `outcome_rejected`
---
#### `outcome_accepted` ✅
**Direction**: Server → Client (To submitter only)
**Purpose**: Confirm outcome was valid
**Backend**: `handlers.py:341-349`
**Frontend**: `useWebSocket.ts:347-351`
**Payload**:
```typescript
{
game_id: string,
outcome: string,
hit_location?: string
}
```
**Frontend Handling**:
- Clears pending roll
- Shows "Submitting..." toast
---
#### `play_resolved` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Broadcast complete play result
**Backend**: `handlers.py:409-411`
**Frontend**: `useWebSocket.ts:353-376`
**Payload**:
```typescript
{
game_id: string,
play_number: number,
outcome: string, // Resolved outcome (may differ from submitted)
hit_location?: string,
description: string, // Human-readable play description
outs_recorded: number,
runs_scored: number,
batter_result: number | null, // Where batter ended up (1-4, null=out)
runners_advanced: RunnerAdvancement[],
is_hit: boolean,
is_out: boolean,
is_walk: boolean,
roll_id: string,
x_check_details?: XCheckResult // If defensive play required X-Check
}
```
**Frontend Handling**:
- Converts to PlayResult type
- Adds to play history: `gameStore.addPlayToHistory()`
- Stores last result: `gameStore.setLastPlayResult()`
- Shows description toast
---
#### `outcome_rejected` ✅
**Direction**: Server → Client (To submitter only)
**Purpose**: Notify submission was invalid
**Backend**: `handlers.py:297-298, 420-421`
**Frontend**: `useWebSocket.ts:424-427`
**Payload**:
```typescript
{
message: string,
field: string,
errors?: Array<{
loc: string[],
msg: string,
type: string
}>
}
```
**Frontend Handling**:
- Shows error toast with message
**Common Rejection Reasons**:
- Missing hit_location for groundball/flyball
- No pending dice roll
- Invalid outcome enum value
- Invalid hit_location format
---
### Play Progression
#### `play_completed` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Legacy event (may be redundant with play_resolved)
**Backend**: Not explicitly emitted in handlers
**Frontend**: `useWebSocket.ts:266-270`
**Payload**: `PlayResult` object
**Frontend Handling**:
- Adds to play history
- Shows description toast
⚠️ **REDUNDANCY**: Both `play_completed` and `play_resolved` exist. Recommend consolidating.
---
#### `inning_change` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Notify inning transition
**Backend**: Not explicitly emitted in handlers
**Frontend**: `useWebSocket.ts:272-275`
**Payload**:
```typescript
{
inning: number,
half: 'top' | 'bottom'
}
```
**Frontend Handling**:
- Shows toast: "Top 1" or "Bottom 9", etc.
---
#### `game_ended` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Notify game completion
**Backend**: Not explicitly emitted in handlers
**Frontend**: `useWebSocket.ts:277-283`
**Payload**:
```typescript
{
game_id: string,
winner_team_id: number,
final_score: {
home: number,
away: number
},
completed_at: string
}
```
**Frontend Handling**:
- Shows game over toast with final score
---
### Substitutions
#### `request_pinch_hitter` ✅
**Direction**: Client → Server
**Purpose**: Replace current batter
**Backend**: `handlers.py:442-593`
**Frontend**: `useGameActions.ts:184-201`
**Request**:
```typescript
{
game_id: string,
player_out_lineup_id: number,
player_in_card_id: number,
team_id: number
}
```
**Backend Behavior**:
- Validates all required fields
- TODO: Verify user is team manager
- Creates SubstitutionManager instance
- Calls `sub_manager.pinch_hit()`
- Broadcasts result
**Success Responses**:
1. `player_substituted` (broadcast)
2. `substitution_confirmed` (to requester)
**Failure Response**: `substitution_error`
---
#### `request_defensive_replacement` ✅
**Direction**: Client → Server
**Purpose**: Replace defensive player
**Backend**: `handlers.py:596-761`
**Frontend**: `useGameActions.ts:206-225`
**Request**:
```typescript
{
game_id: string,
player_out_lineup_id: number,
player_in_card_id: number,
new_position: string, // e.g., "SS"
team_id: number
}
```
**Backend Behavior**:
- Validates all required fields
- Creates SubstitutionManager instance
- Calls `sub_manager.defensive_replace()`
- Broadcasts result
**Success Responses**:
1. `player_substituted` (broadcast)
2. `substitution_confirmed` (to requester)
**Failure Response**: `substitution_error`
---
#### `request_pitching_change` ✅
**Direction**: Client → Server
**Purpose**: Replace current pitcher
**Backend**: `handlers.py:764-917`
**Frontend**: `useGameActions.ts:230-247`
**Request**:
```typescript
{
game_id: string,
player_out_lineup_id: number,
player_in_card_id: number,
team_id: number
}
```
**Backend Behavior**:
- Validates all required fields
- Creates SubstitutionManager instance
- Calls `sub_manager.change_pitcher()`
- Validates pitcher has faced 1+ batter (unless injury)
- Broadcasts result
**Success Responses**:
1. `player_substituted` (broadcast)
2. `substitution_confirmed` (to requester)
**Failure Response**: `substitution_error`
---
#### `player_substituted` ✅
**Direction**: Server → Client (Broadcast)
**Purpose**: Notify all players of substitution
**Backend**: `handlers.py:544-557, 710-723, 866-879`
**Frontend**: `useWebSocket.ts:382-392`
**Payload**:
```typescript
{
type: 'pinch_hitter' | 'defensive_replacement' | 'pitching_change',
player_out_lineup_id: number,
player_in_card_id: number,
new_lineup_id: number,
position: string,
batting_order: number | null,
team_id: number,
message: string
}
```
**Frontend Handling**:
- Shows toast with message
- Requests updated lineup for affected team
---
#### `substitution_confirmed` ✅
**Direction**: Server → Client (To requester only)
**Purpose**: Confirm substitution success
**Backend**: `handlers.py:560-568, 726-734, 882-890`
**Frontend**: `useWebSocket.ts:394-397`
**Payload**:
```typescript
{
type: string,
new_lineup_id: number,
success: true,
message: string
}
```
**Frontend Handling**:
- Shows success toast
---
#### `substitution_error` ✅
**Direction**: Server → Client (To requester only)
**Purpose**: Notify substitution failed
**Backend**: `handlers.py:576-587, 742-753, 898-909`
**Frontend**: `useWebSocket.ts:429-432`
**Payload**:
```typescript
{
message: string,
code: string,
type: string
}
```
**Frontend Handling**:
- Shows error toast
**Common Error Codes**:
- `MISSING_FIELD`: Required parameter missing
- `INVALID_FORMAT`: game_id not valid UUID
- `PLAYER_NOT_FOUND`: lineup_id doesn't exist
- `PLAYER_NOT_ACTIVE`: Player already substituted
- `NO_ELIGIBLE_REPLACEMENT`: Bench player not eligible
---
### Data Requests
#### `get_lineup` ✅
**Direction**: Client → Server
**Purpose**: Get full lineup with player data
**Backend**: `handlers.py:920-1045`
**Frontend**: `useGameActions.ts:256-265`
**Request**:
```typescript
{
game_id: string,
team_id: number
}
```
**Backend Behavior**:
- Checks state_manager cache first (O(1) lookup)
- If not cached, loads from database with player data
- Caches for future requests
- Returns full lineup including nested player info
**Success Response**: `lineup_data` event
---
#### `lineup_data` ✅
**Direction**: Server → Client (To requester only)
**Purpose**: Return lineup with player details
**Backend**: `handlers.py:964-989, 1006-1030`
**Frontend**: `useWebSocket.ts:403-406`
**Payload**:
```typescript
{
game_id: string,
team_id: number,
players: Array<{
lineup_id: number,
card_id: number,
position: string,
batting_order: number | null,
is_active: boolean,
is_starter: boolean,
player: {
id: number,
name: string,
image: string,
headshot: string
}
}>
}
```
**Frontend Handling**:
- Calls `gameStore.updateLineup(team_id, players)`
- Caches for player name/headshot lookups
**Critical Optimization**: Lineup sent once per team, not on every state update
---
#### `get_box_score` ✅
**Direction**: Client → Server
**Purpose**: Get game statistics
**Backend**: `handlers.py:1220-1276`
**Frontend**: `useGameActions.ts:270-278`
**Request**:
```typescript
{
game_id: string
}
```
**Backend Behavior**:
- Queries materialized views for stats
- Returns batting and pitching stats for both teams
**Success Response**: `box_score_data` event
---
#### `box_score_data` ✅
**Direction**: Server → Client (To requester only)
**Purpose**: Return game statistics
**Backend**: `handlers.py:1257-1260`
**Frontend**: `useWebSocket.ts:408-412`
**Payload**:
```typescript
{
game_id: string,
box_score: {
game_stats: {
home_runs: number,
away_runs: number,
home_hits: number,
away_hits: number,
home_errors: number,
away_errors: number,
linescore_home: number[],
linescore_away: number[]
},
batting_stats: BattingStatLine[],
pitching_stats: PitchingStatLine[]
}
}
```
**Frontend Handling**:
- Logs for now
- TODO: Display in BoxScore component
---
### Error Events
#### `error` ✅
**Direction**: Server → Client
**Purpose**: Generic error message
**Backend**: Used throughout handlers
**Frontend**: `useWebSocket.ts:418-422`
**Payload**:
```typescript
{
message: string,
code?: string,
field?: string,
hint?: string
}
```
**Frontend Handling**:
- Sets error in game store
- Shows error toast (7 seconds)
**Usage**: Validation failures, game not found, unauthorized actions
---
#### `invalid_action` ❓
**Direction**: Server → Client
**Purpose**: Action not allowed in current state
**Backend**: Not emitted in handlers
**Frontend**: `useWebSocket.ts:434-437`
**Payload**:
```typescript
{
action: string,
reason: string
}
```
**Frontend Handling**:
- Shows error toast
⚠️ **IMPLEMENTATION STATUS**: Frontend has handler, backend never emits
---
#### `connection_error` ❓
**Direction**: Server → Client
**Purpose**: Connection-level error
**Backend**: Not emitted in handlers
**Frontend**: `useWebSocket.ts:439-443`
**Payload**:
```typescript
{
error: string,
details?: string
}
```
**Frontend Handling**:
- Stores error
- Shows error toast
⚠️ **IMPLEMENTATION STATUS**: Frontend has handler, backend never emits
---
## Game State Flow
### Complete Play Lifecycle
```
START: Game active, awaiting decisions
1. Backend checks decision_phase
└─→ 'defense' or 'awaiting_defensive'
2. Frontend checks gameState.decision_phase
└─→ Shows DefensiveSetup UI if fielding team
3. Defense submits decision
Client: submit_defensive_decision → Server
4. Backend processes
└─→ Calls game_engine.submit_defensive_decision()
└─→ Updates state.decision_phase = 'offensive_approach'
└─→ Broadcasts defensive_decision_submitted
5. Frontend receives defensive_decision_submitted
└─→ Clears decision prompt
└─→ Requests updated game_state
└─→ Checks pending_decision field
6. Frontend checks updated gameState.decision_phase
└─→ Now 'offensive_approach' or 'awaiting_offensive'
└─→ Shows OffensiveApproach UI if batting team
7. Offense submits decision
Client: submit_offensive_decision → Server
8. Backend processes
└─→ Calls game_engine.submit_offensive_decision()
└─→ Updates state.decision_phase = 'resolution'
└─→ Broadcasts offensive_decision_submitted
9. Frontend receives offensive_decision_submitted
└─→ Clears decision prompt
└─→ Requests updated game_state
└─→ Decision phase now 'resolution'
10. Frontend shows GameplayPanel
└─→ Checks canRollDice = (decision_phase === 'resolution' && !pendingRoll)
└─→ Enables "Roll Dice" button
11. User clicks "Roll Dice"
Client: roll_dice → Server
12. Backend rolls dice
└─→ Calls dice_system.roll_ab()
└─→ Stores in state.pending_manual_roll
└─→ Broadcasts dice_rolled to all players
13. Frontend receives dice_rolled
└─→ Stores in gameStore.pendingRoll
└─→ Shows dice animation
└─→ Enables outcome submission UI
14. User reads card and selects outcome
Client: submit_manual_outcome → Server
15. Backend validates and accepts
└─→ Sends outcome_accepted to submitter
└─→ Clears state.pending_manual_roll
└─→ Calls game_engine.resolve_manual_play()
16. Game engine resolves play
└─→ Processes outcome with X-Check if needed
└─→ Updates runners, score, outs
└─→ Advances batter
└─→ Checks for inning change
└─→ Persists play to database
17. Backend broadcasts results
└─→ Emits play_resolved to all players
└─→ Emits game_state_update with new state
18. Frontend receives play_resolved
└─→ Adds to play history
└─→ Shows play description
└─→ Clears pending roll
19. Frontend receives game_state_update
└─→ Updates game state
└─→ Checks decision_phase
20. Loop back to step 1 for next play
```
### State Transition Diagram
```
┌─────────────────────────┐
│ Game Created │
│ (status: pending) │
└────────────┬────────────┘
│ Lineups submitted
┌─────────────────────────┐
│ Game Active │
│ (status: active) │
│ inning: 1, half: top │
└────────────┬────────────┘
┌───────────────┴───────────────┐
│ │
↓ ↓
┌─────────────────────┐ ┌─────────────────────┐
│ Defense Phase │ │ Stored in DB │
│ (decision_phase: │ │ (persisted state) │
│ 'defense') │ └─────────────────────┘
└──────────┬──────────┘
│ submit_defensive_decision
┌─────────────────────┐
│ Offense Phase │
│ (decision_phase: │
│ 'offensive_ │
│ approach') │
└──────────┬──────────┘
│ submit_offensive_decision
┌─────────────────────┐
│ Resolution Phase │
│ (decision_phase: │
│ 'resolution') │
└──────────┬──────────┘
├─ roll_dice → dice_rolled
├─ submit_manual_outcome → outcome_accepted
└─ play_resolved → game_state_update
┌────────────────────────────┐
│ Check Inning Complete? │
└────────┬───────────────────┘
┌─────────┴─────────┐
│ No │ Yes
↓ ↓
Loop to next ┌─────────────────┐
at-bat │ Inning Change │
(Defense Phase) │ inning_change │
└────────┬────────┘
├─ Advance to next half/inning
└─ Check Game Complete?
┌─────────┴─────────┐
│ No │ Yes
↓ ↓
Loop to next ┌─────────────────┐
inning │ Game Ended │
(Defense Phase) │ game_ended │
│ (status: │
│ completed) │
└─────────────────┘
```
---
## Critical Issues Identified
### 1. Missing Backend Event Emission ✅ RESOLVED (2025-11-21)
**Issue**: Frontend expects `decision_required` event, backend never emits it.
**Resolution**: Implemented `decision_required` event emission in three locations:
1. **Game Start** (`game_engine.py:253`): Emits `awaiting_defensive` when game starts
2. **After Defensive Decision** (`game_engine.py:308-310`): Emits `awaiting_offensive` after defense submits
3. **Event Helper** (`game_engine.py:60-100`): `_emit_decision_required()` method with team determination logic
**Implementation Details**:
- Added `_connection_manager` attribute to `GameEngine` singleton
- Added `set_connection_manager()` called from `main.py:88`
- Auto-determines team role based on inning half and decision phase
- Broadcasts to entire game room via WebSocket
**Critical Fix Applied During Testing**:
- Also updated `state.decision_phase` field in both `submit_defensive_decision` (line 291) and `submit_offensive_decision` (line 335)
- Frontend checks `gameState.decision_phase`, so state must be in sync with events
- See `.claude/DECISION_REQUIRED_IMPLEMENTATION.md` Issue #4 for details
**Testing Results**:
- ✅ Event emitted at game start (awaiting_defensive)
- ✅ Event emitted after defensive decision (awaiting_offensive)
- ✅ Frontend DecisionPanel updates proactively without polling
- ✅ Game state `decision_phase` field stays in sync
**Files Modified**:
- `backend/app/core/game_engine.py` (+65 lines)
- `backend/app/main.py` (+3 lines)
- Frontend already had handler implemented
---
### 2. Event Naming Inconsistency ⚠️ MEDIUM PRIORITY
**Issue**: Three different events send full game state:
- `game_state` (response to request)
- `game_state_update` (broadcast after changes)
- `game_state_sync` (reconnection with plays)
**Problem**: All three send the FULL GameState object, but naming suggests `game_state_update` is incremental.
**Recommendation**:
- Rename `game_state_update``game_state_broadcast`
- Reserve `game_state_update` for future incremental updates
- Or consolidate: only use `game_state` for all cases
---
### 3. Missing `d6_two_a` and `d6_two_b` in Dice Roll LOW PRIORITY
**Backend** (`handlers.py:204`):
```python
"d6_two_total": ab_roll.d6_two_total,
```
**Frontend** (`useWebSocket.ts:335-336`):
```typescript
d6_two_a: 0, // Not provided by server
d6_two_b: 0, // Not provided by server
```
**Impact**: Frontend can't show individual 2d6 dice animation
**Recommendation**: Add to backend emission:
```python
"d6_two_a": ab_roll.d6_two_a,
"d6_two_b": ab_roll.d6_two_b,
```
---
### 4. Redundant Events LOW PRIORITY
**Issue**: Both `play_completed` and `play_resolved` exist for same purpose.
**Frontend Handling**:
- `play_completed`: Adds to history, shows toast
- `play_resolved`: Adds to history, shows toast (identical behavior)
**Recommendation**: Remove `play_completed`, use only `play_resolved`
---
### 5. Missing Error Event Implementations LOW PRIORITY
**Frontend Handlers with No Backend Emission**:
- `invalid_action` (defined in types, handler exists)
- `connection_error` (defined in types, handler exists)
**Recommendation**:
- Either implement backend emission
- Or remove unused frontend handlers
---
### 6. `game_joined` Not Handled ⚠️ MEDIUM PRIORITY
**Backend** (`handlers.py:71-73`):
```python
await manager.emit_to_user(
sid, "game_joined", {"game_id": game_id, "role": role}
)
```
**Frontend**: No handler for this event
**Impact**: Client doesn't confirm successful room join
**Recommendation**: Add handler in `useWebSocket.ts`:
```typescript
socketInstance.on('game_joined', (data) => {
console.log('[WebSocket] Successfully joined game:', data.game_id, 'as', data.role)
gameStore.setGameJoined(true)
uiStore.showSuccess(`Joined game as ${data.role}`)
})
```
---
### 7. Decision Phase Naming Mismatch ✅ RESOLVED (2025-01-21)
**Issue**: Frontend and backend used inconsistent names for decision phases.
**Resolution**: Standardized on backend convention using `'awaiting_*'` prefix:
- `'awaiting_defensive'` - Defense must submit
- `'awaiting_offensive'` - Offense must submit
- `'awaiting_stolen_base'` - Stolen base decision
- `'resolution'` - Ready for dice/outcome
- `'complete'` - Play resolved
**Changes Made**:
- ✅ Updated `frontend-sba/types/game.ts` DecisionPhase type
- ✅ Simplified `frontend-sba/store/game.ts` conditionals (removed dual checks)
- ✅ Updated test files with new phase names
- ✅ Verified backend already uses standard naming
- ✅ Type checking passes
**Impact**: Cleaner code, no more dual-condition checks, consistent naming across stack
---
### 8. Missing Auto Mode Implementation INFO
**Current State**: Only manual mode implemented (roll dice + submit outcome)
**Future**: Auto mode should:
1. Skip `roll_dice` step
2. Backend automatically resolves outcome
3. Emits `play_resolved` directly
**No Code Changes Needed**: This is expected future work, not a bug.
---
### 9. TODO Comments Need Resolution ⚠️ MEDIUM PRIORITY
**Across Backend Handlers**:
```python
# TODO: Verify user is participant in this game
# TODO: Verify user is authorized to submit for this team
# TODO: Verify user has access to view lineup
```
**Impact**: No authorization checks currently enforced
**Security Risk**: Any authenticated user can:
- Submit decisions for any team
- Make substitutions for any team
- View any game's data
**Recommendation**: Implement authorization layer before production:
```python
def is_team_manager(user_id: str, team_id: int) -> bool:
# Check user owns this team
pass
def is_game_participant(user_id: str, game_id: UUID) -> bool:
# Check user is player or spectator in game
pass
```
---
## Event Sequences by Workflow
### Game Creation and Start
```
1. REST: POST /games
Response: { game_id, status: "pending" }
2. REST: POST /games/{id}/lineups
Backend: Auto-starts game
Response: { message: "Lineups submitted" }
3. WebSocket: connect (with JWT)
Server → Client: connected { user_id }
4. WebSocket: join_game { game_id, role: "player" }
Server → Client: game_joined { game_id, role }
5. WebSocket: request_game_state { game_id }
Server → Client: game_state { ...full state }
6. WebSocket: get_lineup { game_id, team_id: home }
Server → Client: lineup_data { players: [...] }
7. WebSocket: get_lineup { game_id, team_id: away }
Server → Client: lineup_data { players: [...] }
READY TO PLAY
```
---
### Decision Submission Workflow
```
SCENARIO: Top of 1st, no outs, bases empty
1. Frontend checks gameState.decision_phase
Value: 'defense' or 'awaiting_defensive'
2. Fielding team (home) sees DefensiveSetup UI
User selects: infield_depth='normal', outfield_depth='normal'
3. Client: submit_defensive_decision
Request: { game_id, infield_depth: 'normal', outfield_depth: 'normal', hold_runners: [] }
4. Backend: game_engine.submit_defensive_decision()
- Stores decision in state.pending_defensive_decision
- Transitions state.decision_phase = 'offensive_approach'
- Persists to database
5. Server → All Clients: defensive_decision_submitted
Payload: { decision: {...}, pending_decision: 'offensive' }
6. Frontend: Clears decision UI, requests game_state
Server → Client: game_state { decision_phase: 'offensive_approach' }
7. Batting team (away) sees OffensiveApproach UI
User selects: action='swing_away', steal_attempts=[]
8. Client: submit_offensive_decision
Request: { game_id, action: 'swing_away', steal_attempts: [] }
9. Backend: game_engine.submit_offensive_decision()
- Stores decision in state.pending_offensive_decision
- Transitions state.decision_phase = 'resolution'
- Persists to database
10. Server → All Clients: offensive_decision_submitted
Payload: { decision: {...}, pending_decision: 'resolution' }
11. Frontend: Shows GameplayPanel with "Roll Dice" button
```
---
### Manual Outcome Workflow
```
CONTINUING FROM ABOVE: decision_phase = 'resolution'
1. User clicks "Roll Dice"
Client: roll_dice { game_id }
2. Backend: dice_system.roll_ab()
Result: { d6_one: 4, d6_two_total: 7, chaos_d20: 12, resolution_d20: 8 }
Stores in state.pending_manual_roll
3. Server → All Clients: dice_rolled (broadcast)
Payload: { roll_id, d6_one: 4, d6_two_total: 7, chaos: 12, resolution: 8, ... }
4. Frontend: Stores pending roll, shows dice animation
Enables outcome selection UI
5. User reads batter card (4-7 column, d20=12)
Card shows: "SINGLE_1" (single, batter to 1st)
6. User clicks "Submit" with outcome
Client: submit_manual_outcome { game_id, outcome: 'SINGLE_1' }
7. Backend: Validates outcome, clears pending roll
Server → Client: outcome_accepted (to submitter)
8. Backend: game_engine.resolve_manual_play()
- Processes SINGLE_1
- Moves batter to 1st base
- Increments play count
- Advances batting order
- Checks decision_phase → transitions back to 'defense'
- Persists play to database
9. Server → All Clients: play_resolved (broadcast)
Payload: {
play_number: 1,
outcome: 'SINGLE_1',
description: 'Mike Trout singles to right field',
outs_recorded: 0,
runs_scored: 0,
batter_result: 1,
runners_advanced: [{ from: 0, to: 1 }],
...
}
10. Server → All Clients: game_state_update (broadcast)
Payload: { ...full updated game state }
11. Frontend: Updates game state
- Adds play to history
- Shows play description toast
- Clears pending roll
- Updates scoreboard (on_first now populated)
12. Frontend checks decision_phase
Value: 'defense' (back to step 1 for next batter)
```
---
### Substitution Workflow
```
SCENARIO: Bottom of 3rd, manager wants pinch hitter
1. User clicks floating "Substitutions" button
Shows SubstitutionPanel modal
2. User selects:
- Substitute type: Pinch Hitter
- Player out: Lineup ID 42 (batting order 5)
- Player in: Card ID 1234 (from bench)
3. Client: request_pinch_hitter
Request: {
game_id,
player_out_lineup_id: 42,
player_in_card_id: 1234,
team_id: 101
}
4. Backend: SubstitutionManager.pinch_hit()
- Validates player_out is active
- Validates player_in exists and not used
- Marks player_out as inactive
- Creates new lineup entry for player_in
- Assigns batting order from player_out
- Persists to database
5. Server → All Clients: player_substituted (broadcast)
Payload: {
type: 'pinch_hitter',
player_out_lineup_id: 42,
player_in_card_id: 1234,
new_lineup_id: 98,
position: 'RF',
batting_order: 5,
team_id: 101,
message: 'Pinch hitter: #5 now batting'
}
6. Server → Requester: substitution_confirmed
Payload: { type: 'pinch_hitter', new_lineup_id: 98, success: true }
7. Frontend: Shows success toast, requests updated lineup
Client: get_lineup { game_id, team_id: 101 }
8. Server → Client: lineup_data
Payload: { players: [...updated lineup with new player] }
9. Frontend: Updates cached lineup
gameStore.updateLineup(101, players)
10. User closes substitution modal
```
---
## Type Mappings
### Backend Models → Frontend Types
| Backend (Python) | Frontend (TypeScript) | File |
|-----------------|----------------------|------|
| `GameState` | `GameState` | types/game.ts:61 |
| `LineupPlayerState` | `LineupPlayerState` | types/game.ts:44 |
| `DefensiveDecision` | `DefensiveDecision` | types/game.ts:124 |
| `OffensiveDecision` | `OffensiveDecision` | types/game.ts:136 |
| `PlayResult` | `PlayResult` | types/game.ts:217 |
| `AbRoll` | `RollData` | types/game.ts:145 |
| `PlayOutcome` (enum) | `PlayOutcome` (union) | types/game.ts:161 |
| `Lineup` (db model) | `Lineup` | types/player.ts |
### WebSocket Event Types
Complete type definitions:
**Client → Server**:
```typescript
interface ClientToServerEvents {
// Connection
join_game: (data: JoinGameRequest) => void
leave_game: (data: LeaveGameRequest) => void
heartbeat: () => void
// Decisions
submit_defensive_decision: (data: DefensiveDecisionRequest) => void
submit_offensive_decision: (data: OffensiveDecisionRequest) => void
// Manual workflow
roll_dice: (data: RollDiceRequest) => void
submit_manual_outcome: (data: SubmitManualOutcomeRequest) => void
// Substitutions
request_pinch_hitter: (data: PinchHitterRequest) => void
request_defensive_replacement: (data: DefensiveReplacementRequest) => void
request_pitching_change: (data: PitchingChangeRequest) => void
// Data
get_lineup: (data: GetLineupRequest) => void
get_box_score: (data: GetBoxScoreRequest) => void
request_game_state: (data: RequestGameStateRequest) => void
}
```
**Server → Client**:
```typescript
interface ServerToClientEvents {
// Connection
connected: (data: ConnectedEvent) => void
game_joined: (data: GameJoinedEvent) => void
heartbeat_ack: () => void
// Decisions
decision_required: (data: DecisionPrompt) => void
defensive_decision_submitted: (data: DefensiveDecisionSubmittedEvent) => void
offensive_decision_submitted: (data: OffensiveDecisionSubmittedEvent) => void
// State
game_state: (data: GameState) => void
game_state_update: (data: GameState) => void
game_state_sync: (data: GameStateSyncEvent) => void
// Plays
play_completed: (data: PlayResult) => void
play_resolved: (data: PlayResult) => void
inning_change: (data: InningChangeEvent) => void
game_ended: (data: GameEndedEvent) => void
// Manual workflow
dice_rolled: (data: DiceRolledEvent) => void
outcome_accepted: (data: OutcomeAcceptedEvent) => void
// Substitutions
player_substituted: (data: SubstitutionResult) => void
substitution_confirmed: (data: SubstitutionConfirmedEvent) => void
// Data
lineup_data: (data: LineupDataResponse) => void
box_score_data: (data: BoxScoreDataEvent) => void
// Errors
error: (data: ErrorEvent) => void
outcome_rejected: (data: OutcomeRejectedEvent) => void
substitution_error: (data: SubstitutionError) => void
invalid_action: (data: InvalidActionEvent) => void
connection_error: (data: ConnectionErrorEvent) => void
}
```
---
## Recommendations
### Immediate Action Items (Pre-Launch)
1. ~~**Implement `decision_required` emission**~~ ✅ COMPLETED (2025-11-21)
- Added to game_engine after phase transitions
- Real-time decision prompting now working
- Also fixed `decision_phase` field synchronization
2. **Add `game_joined` frontend handler** ⚠️ MEDIUM
- Confirm room join success
- Better error handling if join fails
3. ~~**Standardize decision phase naming**~~ ✅ COMPLETED (2025-01-21)
- Standardized on `'awaiting_*'` convention
- Reduces conditional complexity
4. **Add authorization checks** ⚠️ HIGH SECURITY
- Verify team ownership before actions
- Prevent unauthorized substitutions/decisions
5. **Include d6_two individual dice** LOW
- Better dice animation UX
- Trivial backend change
### Future Improvements
6. **Consolidate state broadcast events** MEDIUM
- Unify `game_state`, `game_state_update`, `game_state_sync`
- Clearer semantics
7. **Remove redundant `play_completed`** LOW
- Use only `play_resolved`
- Simplifies protocol
8. **Implement auto mode** FUTURE
- Skip manual dice/outcome workflow
- Faster gameplay option
9. **Add incremental state updates** ⚠️ OPTIMIZATION
- Instead of full GameState (~2KB), send diffs (~200 bytes)
- Reduces bandwidth 10x
- Important for mobile 3G connections
10. **Add connection recovery logic** ⚠️ MEDIUM
- Detect missed events during brief disconnect
- Request only missing plays, not full state
---
## Appendix A: Backend Handlers Summary
**File**: `backend/app/websocket/handlers.py`
| Handler | Lines | Description |
|---------|-------|-------------|
| `connect` | 26-50 | JWT auth and accept |
| `disconnect` | 53-55 | Cleanup |
| `join_game` | 58-77 | Join room |
| `leave_game` | 80-88 | Leave room |
| `heartbeat` | 91-93 | Keep-alive |
| `request_game_state` | 96-138 | Get full state |
| `roll_dice` | 141-219 | Manual dice roll |
| `submit_manual_outcome` | 222-437 | Manual outcome |
| `request_pinch_hitter` | 442-593 | Pinch hitter sub |
| `request_defensive_replacement` | 596-761 | Defensive sub |
| `request_pitching_change` | 764-917 | Pitcher sub |
| `get_lineup` | 920-1045 | Get lineup data |
| `submit_defensive_decision` | 1048-1137 | Defense strategy |
| `submit_offensive_decision` | 1140-1217 | Offense strategy |
| `get_box_score` | 1220-1276 | Game statistics |
**Total**: 15 handlers, 1,251 lines
---
## Appendix B: Frontend Event Listeners Summary
**File**: `frontend-sba/composables/useWebSocket.ts`
| Event | Lines | Description |
|-------|-------|-------------|
| `connect` (Socket.io) | 187-199 | Connection established |
| `disconnect` (Socket.io) | 201-216 | Connection lost |
| `connect_error` (Socket.io) | 218-232 | Connection failed |
| `connected` | 234-236 | Server confirms |
| `heartbeat_ack` | 238-241 | Keep-alive ack |
| `game_state` | 247-250 | Full state |
| `game_state_update` | 252-255 | State broadcast |
| `game_state_sync` | 257-264 | State + plays |
| `play_completed` | 266-270 | Play finished |
| `inning_change` | 272-275 | Inning transition |
| `game_ended` | 277-283 | Game over |
| `decision_required` | 289-292 | Need input |
| `defensive_decision_submitted` | 294-310 | Defense set |
| `offensive_decision_submitted` | 312-324 | Offense set |
| `dice_rolled` | 330-345 | Dice results |
| `outcome_accepted` | 347-351 | Outcome valid |
| `play_resolved` | 353-376 | Play complete |
| `player_substituted` | 382-392 | Sub broadcast |
| `substitution_confirmed` | 394-397 | Sub success |
| `lineup_data` | 403-406 | Lineup info |
| `box_score_data` | 408-412 | Stats data |
| `error` | 418-422 | Generic error |
| `outcome_rejected` | 424-427 | Invalid outcome |
| `substitution_error` | 429-432 | Sub failed |
| `invalid_action` | 434-437 | Action denied |
| `connection_error` | 439-443 | Connection issue |
**Total**: 26 listeners, 257 lines
---
## Document Change Log
| Date | Version | Changes |
|------|---------|---------|
| 2025-01-21 | 1.0 | Initial comprehensive specification |
| 2025-11-21 | 1.1 | Issue #1 resolved: Implemented `decision_required` event emission in backend, updated event descriptions, marked recommendations as complete |
---
**End of WebSocket Protocol Specification**