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>
1832 lines
48 KiB
Markdown
1832 lines
48 KiB
Markdown
# 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**
|