strat-gameplay-webapp/backend/app/websocket/handlers.py
Cal Corum e90a907e9e CLAUDE: Implement server-side OAuth flow with HttpOnly cookies
Fixes iPad Safari authentication issue where async JavaScript is blocked
on OAuth callback pages after cross-origin redirects (Cloudflare + Safari ITP).

**Problem**: iPad Safari blocks all async operations (Promises, setTimeout,
onMounted) on the OAuth callback page, preventing frontend token exchange.

**Solution**: Move entire OAuth flow to backend with HttpOnly cookies,
eliminating JavaScript dependency on callback page.

## Backend Changes (7 files)

### New Files
- app/services/oauth_state.py - Redis-based OAuth state management
  * CSRF protection with one-time use tokens (10min TTL)
  * Replaces frontend sessionStorage state validation

- app/utils/cookies.py - HttpOnly cookie utilities
  * Access token: 1 hour, Path=/api
  * Refresh token: 7 days, Path=/api/auth
  * Security: HttpOnly, Secure (prod), SameSite=Lax

### Modified Files
- app/api/routes/auth.py
  * NEW: GET /discord/login - Initiate OAuth with state creation
  * NEW: GET /discord/callback/server - Server-side callback handler
  * NEW: POST /logout - Clear auth cookies
  * UPDATED: GET /me - Cookie + header support (backwards compatible)
  * UPDATED: POST /refresh - Cookie + body support (backwards compatible)
  * FIXED: exchange_code_for_token() accepts redirect_uri parameter

- app/config.py
  * Added discord_server_redirect_uri config
  * Added frontend_url config for post-auth redirects

- app/websocket/handlers.py
  * Updated connect handler to parse cookies from environ
  * Falls back to auth object for backwards compatibility

- .env.example
  * Added DISCORD_SERVER_REDIRECT_URI example
  * Added FRONTEND_URL example

## Frontend Changes (10 files)

### Core Auth Changes
- store/auth.ts - Complete rewrite for cookie-based auth
  * Removed: token, refreshToken, tokenExpiresAt state (HttpOnly)
  * Added: checkAuth() - calls /api/auth/me with credentials
  * Updated: loginWithDiscord() - redirects to backend endpoint
  * Updated: logout() - calls backend logout endpoint
  * All $fetch calls use credentials: 'include'

- pages/auth/callback.vue - Simplified to error handler
  * No JavaScript token exchange needed
  * Displays errors from query params
  * Verifies auth with checkAuth() on success

- plugins/auth.client.ts
  * Changed from localStorage init to checkAuth() call
  * Async plugin to ensure auth state before navigation

- middleware/auth.ts - Simplified
  * Removed token validity checks (HttpOnly cookies)
  * Simple isAuthenticated check

### Cleanup Changes
- composables/useWebSocket.ts
  * Added withCredentials: true
  * Removed auth object with token
  * Updated canConnect to use isAuthenticated only

- layouts/default.vue, layouts/game.vue, pages/index.vue, pages/games/[id].vue
  * Removed initializeAuth() calls (handled by plugin)

## Documentation
- OAUTH_IPAD_ISSUE.md - Problem analysis and investigation notes
- OAUTH_SERVER_SIDE_IMPLEMENTATION.md - Complete implementation guide
  * Security improvements summary
  * Discord Developer Portal setup instructions
  * Testing checklist
  * OAuth flow diagram

## Security Improvements
- Tokens stored in HttpOnly cookies (XSS-safe)
- OAuth state in Redis with one-time use (CSRF-safe)
- Follows OAuth 2.0 Security Best Current Practice
- Backwards compatible with Authorization header auth

## Testing
-  Backend OAuth endpoints functional
-  Token exchange with correct redirect_uri
-  Cookie-based auth working
-  WebSocket connection with cookies
-  Desktop browser flow verified
-  iPad Safari testing pending Discord redirect URI config

## Next Steps
1. Add Discord redirect URI in Developer Portal:
   https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
2. Test complete flow on iPad Safari
3. Verify WebSocket auto-reconnection with cookies

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:16:30 -06:00

1313 lines
49 KiB
Python

import logging
from uuid import UUID
from pydantic import ValidationError
from socketio import AsyncServer
from app.config.result_charts import PlayOutcome
from app.core.dice import dice_system
from app.core.game_engine import game_engine
from app.core.state_manager import state_manager
from app.core.substitution_manager import SubstitutionManager
from app.core.validators import ValidationError as GameValidationError
from app.database.operations import DatabaseOperations
from app.models.game_models import ManualOutcomeSubmission
from app.services.lineup_service import lineup_service
from app.utils.auth import verify_token
from app.websocket.connection_manager import ConnectionManager
logger = logging.getLogger(f"{__name__}.handlers")
def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"""Register all WebSocket event handlers"""
@sio.event
async def connect(sid, environ, auth):
"""
Handle new connection with cookie or auth object support.
Tries cookie-based auth first (from HttpOnly cookies),
falls back to auth object (for direct JS clients).
"""
try:
token = None
# Try cookie first (from HTTP headers in environ)
cookie_header = environ.get("HTTP_COOKIE", "")
if "pd_access_token=" in cookie_header:
from http.cookies import SimpleCookie
cookie = SimpleCookie()
cookie.load(cookie_header)
if "pd_access_token" in cookie:
token = cookie["pd_access_token"].value
logger.debug(f"Connection {sid} using cookie auth")
# Fall back to auth object (for direct JS clients)
if not token and auth:
token = auth.get("token")
if token:
logger.debug(f"Connection {sid} using auth object")
if not token:
logger.warning(f"Connection {sid} rejected: no token")
return False
user_data = verify_token(token)
user_id = user_data.get("user_id")
if not user_id:
logger.warning(f"Connection {sid} rejected: invalid token")
return False
await manager.connect(sid, user_id)
await sio.emit("connected", {"user_id": user_id}, room=sid)
logger.info(f"Connection {sid} accepted for user {user_id}")
return True
except Exception as e:
logger.error(f"Connection error: {e}")
return False
@sio.event
async def disconnect(sid):
"""Handle disconnection"""
await manager.disconnect(sid)
@sio.event
async def join_game(sid, data):
"""Handle join game request"""
try:
game_id = data.get("game_id")
role = data.get("role", "player")
if not game_id:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
# TODO: Verify user has access to game
await manager.join_game(sid, game_id, role)
await manager.emit_to_user(
sid, "game_joined", {"game_id": game_id, "role": role}
)
except Exception as e:
logger.error(f"Join game error: {e}")
await manager.emit_to_user(sid, "error", {"message": str(e)})
@sio.event
async def leave_game(sid, data):
"""Handle leave game request"""
try:
game_id = data.get("game_id")
if game_id:
await manager.leave_game(sid, game_id)
except Exception as e:
logger.error(f"Leave game error: {e}")
@sio.event
async def heartbeat(sid):
"""Handle heartbeat ping"""
await sio.emit("heartbeat_ack", {}, room=sid)
@sio.event
async def request_game_state(sid, data):
"""
Client requests full game state (recovery after disconnect or initial load).
Recovers game from database if not in memory.
"""
try:
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
# Try to get from memory first
state = state_manager.get_state(game_id)
# If not in memory, recover from database
if not state:
logger.info(f"Game {game_id} not in memory, recovering from database")
state = await state_manager.recover_game(game_id)
if state:
# Use mode='json' to serialize UUIDs as strings
await manager.emit_to_user(
sid, "game_state", state.model_dump(mode="json")
)
logger.info(f"Sent game state for {game_id} to {sid}")
else:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
logger.warning(f"Game {game_id} not found in memory or database")
except Exception as e:
logger.error(f"Request game state error: {e}", exc_info=True)
await manager.emit_to_user(sid, "error", {"message": str(e)})
@sio.event
async def roll_dice(sid, data):
"""
Roll dice for manual outcome selection.
Server rolls dice and broadcasts to all players in game room.
Players then read their physical cards and submit outcomes.
Event data:
game_id: UUID of the game
Emits:
dice_rolled: Broadcast to game room with dice results
error: To requester if validation fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# TODO: Verify user is participant in this game
# user_id = manager.user_sessions.get(sid)
# if not is_game_participant(game_id, user_id):
# await manager.emit_to_user(sid, "error", {"message": "Not authorized"})
# return
# Roll dice
ab_roll = dice_system.roll_ab(league_id=state.league_id, game_id=game_id)
logger.info(
f"Dice rolled for game {game_id}: "
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
f"chaos={ab_roll.chaos_d20}, resolution={ab_roll.resolution_d20}"
)
# Store roll in game state for manual outcome validation
state.pending_manual_roll = ab_roll
state_manager.update_state(game_id, state)
# Broadcast dice results to all players in game
await manager.broadcast_to_game(
str(game_id),
"dice_rolled",
{
"game_id": str(game_id),
"roll_id": ab_roll.roll_id,
"d6_one": ab_roll.d6_one,
"d6_two_a": ab_roll.d6_two_a,
"d6_two_b": ab_roll.d6_two_b,
"d6_two_total": ab_roll.d6_two_total,
"chaos_d20": ab_roll.chaos_d20,
"resolution_d20": ab_roll.resolution_d20,
"check_wild_pitch": ab_roll.check_wild_pitch,
"check_passed_ball": ab_roll.check_passed_ball,
"timestamp": ab_roll.timestamp.to_iso8601_string(),
"message": "Dice rolled - read your card and submit outcome",
},
)
except Exception as e:
logger.error(f"Roll dice error: {e}", exc_info=True)
await manager.emit_to_user(
sid, "error", {"message": f"Failed to roll dice: {str(e)}"}
)
@sio.event
async def submit_manual_outcome(sid, data):
"""
Submit manually-selected play outcome.
After dice are rolled, players read their physical cards and
submit the outcome they see. System validates and processes.
Event data:
game_id: UUID of the game
outcome: PlayOutcome enum value (e.g., "groundball_c")
hit_location: Optional position string (e.g., "SS")
Emits:
outcome_accepted: To requester if valid
play_resolved: Broadcast to game room with play result
outcome_rejected: To requester if validation fails
error: To requester if processing fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": "Missing game_id", "field": "game_id"},
)
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": "Invalid game_id format", "field": "game_id"},
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# TODO: Verify user is active batter or authorized to submit
# user_id = manager.user_sessions.get(sid)
# Extract outcome data
outcome_str = data.get("outcome")
hit_location = data.get("hit_location")
if not outcome_str:
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": "Missing outcome", "field": "outcome"},
)
return
# Validate using ManualOutcomeSubmission model
try:
submission = ManualOutcomeSubmission(
outcome=outcome_str, hit_location=hit_location
)
except ValidationError as e:
# Extract first error for user-friendly message
first_error = e.errors()[0]
field = first_error["loc"][0] if first_error["loc"] else "unknown"
message = first_error["msg"]
await manager.emit_to_user(
sid,
"outcome_rejected",
{"message": message, "field": field, "errors": e.errors()},
)
logger.warning(
f"Manual outcome validation failed for game {game_id}: {message}"
)
return
# Convert to PlayOutcome enum
outcome = PlayOutcome(submission.outcome)
# NOTE: Business rule validation (e.g., when hit_location is required based on
# game state) is handled in PlayResolver, not here. This layer only validates
# basic input format and type checking.
# Check for pending roll BEFORE accepting outcome
if not state.pending_manual_roll:
await manager.emit_to_user(
sid,
"outcome_rejected",
{
"message": "No pending dice roll - call roll_dice first",
"field": "game_state",
},
)
return
ab_roll = state.pending_manual_roll
logger.info(
f"Manual outcome submitted for game {game_id}: "
f"{outcome.value}"
+ (f" to {submission.hit_location}" if submission.hit_location else "")
)
logger.info(
f"Processing manual outcome with roll {ab_roll.roll_id}: "
f"d6={ab_roll.d6_one}, 2d6={ab_roll.d6_two_total}, "
f"chaos={ab_roll.chaos_d20}"
)
# Process manual outcome through game engine
try:
result = await game_engine.resolve_manual_play(
game_id=game_id,
ab_roll=ab_roll,
outcome=outcome,
hit_location=submission.hit_location,
)
# Clear pending roll only AFTER successful validation (one-time use)
state.pending_manual_roll = None
state_manager.update_state(game_id, state)
# Confirm acceptance to submitter AFTER successful validation
await manager.emit_to_user(
sid,
"outcome_accepted",
{
"game_id": str(game_id),
"outcome": outcome.value,
"hit_location": submission.hit_location,
},
)
# Build play result data
play_result_data = {
"game_id": str(game_id),
"play_number": state.play_count,
"outcome": result.outcome.value, # Use resolved outcome, not submitted
"hit_location": submission.hit_location,
"description": result.description,
"outs_recorded": result.outs_recorded,
"runs_scored": result.runs_scored,
"batter_result": result.batter_result,
"runners_advanced": [
{"from": adv[0], "to": adv[1]} for adv in result.runners_advanced
],
"is_hit": result.is_hit,
"is_out": result.is_out,
"is_walk": result.is_walk,
"roll_id": ab_roll.roll_id,
}
# Include X-Check details if present (Phase 3E-Final)
if result.x_check_details:
xcheck = result.x_check_details
play_result_data["x_check_details"] = {
"position": xcheck.position,
"d20_roll": xcheck.d20_roll,
"d6_roll": xcheck.d6_roll,
"defender_range": xcheck.defender_range,
"defender_error_rating": xcheck.defender_error_rating,
"defender_id": xcheck.defender_id,
"base_result": xcheck.base_result,
"converted_result": xcheck.converted_result,
"error_result": xcheck.error_result,
"final_outcome": xcheck.final_outcome.value,
"hit_type": xcheck.hit_type,
# Optional SPD test details
"spd_test_roll": xcheck.spd_test_roll,
"spd_test_target": xcheck.spd_test_target,
"spd_test_passed": xcheck.spd_test_passed,
}
# Broadcast play result to game room
await manager.broadcast_to_game(
str(game_id), "play_resolved", play_result_data
)
logger.info(
f"Manual play resolved for game {game_id}: {result.description}"
)
# Broadcast updated game state so frontend sees new batter, outs, etc.
updated_state = state_manager.get_state(game_id)
if updated_state:
await manager.broadcast_to_game(
str(game_id),
"game_state_update",
updated_state.model_dump(mode="json"),
)
logger.debug(f"Broadcast updated game state after play resolution")
except GameValidationError as e:
# Game engine validation error (e.g., missing hit location)
await manager.emit_to_user(
sid, "outcome_rejected", {"message": str(e), "field": "validation"}
)
logger.warning(f"Manual play validation failed: {e}")
return
except ValueError as e:
# Business logic validation error from PlayResolver
await manager.emit_to_user(
sid, "outcome_rejected", {"message": str(e), "field": "validation"}
)
logger.warning(f"Manual play business logic validation failed: {e}")
return
except Exception as e:
# Unexpected error during resolution
logger.error(f"Error resolving manual play: {e}", exc_info=True)
await manager.emit_to_user(
sid, "error", {"message": f"Failed to resolve play: {str(e)}"}
)
return
except Exception as e:
logger.error(f"Submit manual outcome error: {e}", exc_info=True)
await manager.emit_to_user(
sid, "error", {"message": f"Failed to process outcome: {str(e)}"}
)
# ===== SUBSTITUTION EVENTS =====
@sio.event
async def request_pinch_hitter(sid, data):
"""
Request pinch hitter substitution.
Replaces current batter with a player from the bench. The substitute
takes the batting order position of the replaced player.
Event data:
game_id: UUID of the game
player_out_lineup_id: int - lineup ID of player being removed
player_in_card_id: int - card/player ID of substitute
team_id: int - team making substitution
Emits:
player_substituted: Broadcast to game room on success
substitution_confirmed: To requester with new lineup_id
substitution_error: To requester if validation fails
error: To requester if processing fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing game_id", "code": "MISSING_FIELD"},
)
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Invalid game_id format", "code": "INVALID_FORMAT"},
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# Extract substitution data
player_out_lineup_id = data.get("player_out_lineup_id")
player_in_card_id = data.get("player_in_card_id")
team_id = data.get("team_id")
if player_out_lineup_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{
"message": "Missing player_out_lineup_id",
"code": "MISSING_FIELD",
},
)
return
if player_in_card_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing player_in_card_id", "code": "MISSING_FIELD"},
)
return
if team_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing team_id", "code": "MISSING_FIELD"},
)
return
# TODO: Verify user is authorized to make substitution for this team
# user_id = manager.user_sessions.get(sid)
logger.info(
f"Pinch hitter request for game {game_id}: "
f"Replacing {player_out_lineup_id} with card {player_in_card_id}"
)
# Create SubstitutionManager instance
db_ops = DatabaseOperations()
sub_manager = SubstitutionManager(db_ops)
# Execute pinch hitter substitution
result = await sub_manager.pinch_hit(
game_id=game_id,
player_out_lineup_id=player_out_lineup_id,
player_in_card_id=player_in_card_id,
team_id=team_id,
)
if result.success:
# Broadcast to all clients in game
await manager.broadcast_to_game(
str(game_id),
"player_substituted",
{
"type": "pinch_hitter",
"player_out_lineup_id": result.player_out_lineup_id,
"player_in_card_id": result.player_in_card_id,
"new_lineup_id": result.new_lineup_id,
"position": result.new_position,
"batting_order": result.new_batting_order,
"team_id": team_id,
"message": f"Pinch hitter: #{result.new_batting_order} now batting",
},
)
# Send confirmation to requester
await manager.emit_to_user(
sid,
"substitution_confirmed",
{
"type": "pinch_hitter",
"new_lineup_id": result.new_lineup_id,
"success": True,
},
)
logger.info(
f"Pinch hitter successful for game {game_id}: "
f"New lineup ID {result.new_lineup_id}"
)
else:
# Send error to requester with error code
await manager.emit_to_user(
sid,
"substitution_error",
{
"message": result.error_message,
"code": result.error_code,
"type": "pinch_hitter",
},
)
logger.warning(
f"Pinch hitter failed for game {game_id}: {result.error_message}"
)
except Exception as e:
logger.error(f"Pinch hitter request error: {e}", exc_info=True)
await manager.emit_to_user(
sid, "error", {"message": f"Failed to process pinch hitter: {str(e)}"}
)
@sio.event
async def request_defensive_replacement(sid, data):
"""
Request defensive replacement substitution.
Replaces a defensive player with a better fielder. Player can be
swapped at any position. If player is in batting order, substitute
takes their batting order spot.
Event data:
game_id: UUID of the game
player_out_lineup_id: int - lineup ID of player being removed
player_in_card_id: int - card/player ID of substitute
new_position: str - defensive position for substitute (e.g., "SS")
team_id: int - team making substitution
Emits:
player_substituted: Broadcast to game room on success
substitution_confirmed: To requester with new lineup_id
substitution_error: To requester if validation fails
error: To requester if processing fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing game_id", "code": "MISSING_FIELD"},
)
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Invalid game_id format", "code": "INVALID_FORMAT"},
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# Extract substitution data
player_out_lineup_id = data.get("player_out_lineup_id")
player_in_card_id = data.get("player_in_card_id")
new_position = data.get("new_position")
team_id = data.get("team_id")
if player_out_lineup_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{
"message": "Missing player_out_lineup_id",
"code": "MISSING_FIELD",
},
)
return
if player_in_card_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing player_in_card_id", "code": "MISSING_FIELD"},
)
return
if not new_position:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing new_position", "code": "MISSING_FIELD"},
)
return
if team_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing team_id", "code": "MISSING_FIELD"},
)
return
# TODO: Verify user is authorized to make substitution for this team
# user_id = manager.user_sessions.get(sid)
logger.info(
f"Defensive replacement request for game {game_id}: "
f"Replacing {player_out_lineup_id} with card {player_in_card_id} at {new_position}"
)
# Create SubstitutionManager instance
db_ops = DatabaseOperations()
sub_manager = SubstitutionManager(db_ops)
# Execute defensive replacement
result = await sub_manager.defensive_replace(
game_id=game_id,
player_out_lineup_id=player_out_lineup_id,
player_in_card_id=player_in_card_id,
new_position=new_position,
team_id=team_id,
)
if result.success:
# Broadcast to all clients in game
await manager.broadcast_to_game(
str(game_id),
"player_substituted",
{
"type": "defensive_replacement",
"player_out_lineup_id": result.player_out_lineup_id,
"player_in_card_id": result.player_in_card_id,
"new_lineup_id": result.new_lineup_id,
"position": result.new_position,
"batting_order": result.new_batting_order,
"team_id": team_id,
"message": f"Defensive replacement: {result.new_position}",
},
)
# Send confirmation to requester
await manager.emit_to_user(
sid,
"substitution_confirmed",
{
"type": "defensive_replacement",
"new_lineup_id": result.new_lineup_id,
"success": True,
},
)
logger.info(
f"Defensive replacement successful for game {game_id}: "
f"New lineup ID {result.new_lineup_id}"
)
else:
# Send error to requester with error code
await manager.emit_to_user(
sid,
"substitution_error",
{
"message": result.error_message,
"code": result.error_code,
"type": "defensive_replacement",
},
)
logger.warning(
f"Defensive replacement failed for game {game_id}: {result.error_message}"
)
except Exception as e:
logger.error(f"Defensive replacement request error: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to process defensive replacement: {str(e)}"},
)
@sio.event
async def request_pitching_change(sid, data):
"""
Request pitching change substitution.
Replaces current pitcher with a reliever. Pitcher must have faced
at least 1 batter unless injury. New pitcher takes mound immediately.
Event data:
game_id: UUID of the game
player_out_lineup_id: int - lineup ID of pitcher being removed
player_in_card_id: int - card/player ID of relief pitcher
team_id: int - team making substitution
Emits:
player_substituted: Broadcast to game room on success
substitution_confirmed: To requester with new lineup_id
substitution_error: To requester if validation fails
error: To requester if processing fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing game_id", "code": "MISSING_FIELD"},
)
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Invalid game_id format", "code": "INVALID_FORMAT"},
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# Extract substitution data
player_out_lineup_id = data.get("player_out_lineup_id")
player_in_card_id = data.get("player_in_card_id")
team_id = data.get("team_id")
if player_out_lineup_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{
"message": "Missing player_out_lineup_id",
"code": "MISSING_FIELD",
},
)
return
if player_in_card_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing player_in_card_id", "code": "MISSING_FIELD"},
)
return
if team_id is None:
await manager.emit_to_user(
sid,
"substitution_error",
{"message": "Missing team_id", "code": "MISSING_FIELD"},
)
return
# TODO: Verify user is authorized to make substitution for this team
# user_id = manager.user_sessions.get(sid)
logger.info(
f"Pitching change request for game {game_id}: "
f"Replacing {player_out_lineup_id} with card {player_in_card_id}"
)
# Create SubstitutionManager instance
db_ops = DatabaseOperations()
sub_manager = SubstitutionManager(db_ops)
# Execute pitching change
result = await sub_manager.change_pitcher(
game_id=game_id,
player_out_lineup_id=player_out_lineup_id,
player_in_card_id=player_in_card_id,
team_id=team_id,
)
if result.success:
# Broadcast to all clients in game
await manager.broadcast_to_game(
str(game_id),
"player_substituted",
{
"type": "pitching_change",
"player_out_lineup_id": result.player_out_lineup_id,
"player_in_card_id": result.player_in_card_id,
"new_lineup_id": result.new_lineup_id,
"position": result.new_position, # Should be "P"
"batting_order": result.new_batting_order,
"team_id": team_id,
"message": "Pitching change: New pitcher entering",
},
)
# Send confirmation to requester
await manager.emit_to_user(
sid,
"substitution_confirmed",
{
"type": "pitching_change",
"new_lineup_id": result.new_lineup_id,
"success": True,
},
)
logger.info(
f"Pitching change successful for game {game_id}: "
f"New lineup ID {result.new_lineup_id}"
)
else:
# Send error to requester with error code
await manager.emit_to_user(
sid,
"substitution_error",
{
"message": result.error_message,
"code": result.error_code,
"type": "pitching_change",
},
)
logger.warning(
f"Pitching change failed for game {game_id}: {result.error_message}"
)
except Exception as e:
logger.error(f"Pitching change request error: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to process pitching change: {str(e)}"},
)
@sio.event
async def get_lineup(sid, data):
"""
Get current active lineup for a team.
Returns all active players in the lineup with their positions
and batting orders. Used by UI to refresh lineup display.
Event data:
game_id: UUID of the game
team_id: int - team to get lineup for
Emits:
lineup_data: To requester with active lineup
error: To requester if validation fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
# Extract team_id
team_id = data.get("team_id")
if team_id is None:
await manager.emit_to_user(sid, "error", {"message": "Missing team_id"})
return
# TODO: Verify user has access to view this lineup
# user_id = manager.user_sessions.get(sid)
# Get lineup from state manager cache (fast O(1) lookup)
lineup = state_manager.get_lineup(game_id, team_id)
if lineup:
# Send lineup data with player info
await manager.emit_to_user(
sid,
"lineup_data",
{
"game_id": str(game_id),
"team_id": team_id,
"players": [
{
"lineup_id": p.lineup_id,
"card_id": p.card_id,
"position": p.position,
"batting_order": p.batting_order,
"is_active": p.is_active,
"is_starter": p.is_starter,
"player": {
"id": p.card_id,
"name": p.player_name or f"Player #{p.card_id}",
"image": p.player_image or "",
"headshot": p.player_headshot or "",
},
}
for p in lineup.players
if p.is_active
],
},
)
logger.info(f"Lineup data sent for game {game_id}, team {team_id}")
else:
# Lineup not in cache - try to load from database with player data
# Get league_id from game state or database
state = state_manager.get_state(game_id)
league_id = state.league_id if state else "sba"
lineup_state = await lineup_service.load_team_lineup_with_player_data(
game_id=game_id, team_id=team_id, league_id=league_id
)
if lineup_state:
# Cache the lineup for future requests
state_manager.set_lineup(game_id, team_id, lineup_state)
# Send lineup data with player info
await manager.emit_to_user(
sid,
"lineup_data",
{
"game_id": str(game_id),
"team_id": team_id,
"players": [
{
"lineup_id": p.lineup_id,
"card_id": p.card_id,
"position": p.position,
"batting_order": p.batting_order,
"is_active": p.is_active,
"is_starter": p.is_starter,
"player": {
"id": p.card_id,
"name": p.player_name or f"Player #{p.card_id}",
"image": p.player_image or "",
},
}
for p in lineup_state.players
if p.is_active
],
},
)
logger.info(
f"Lineup data loaded from DB with player data for game {game_id}, team {team_id}"
)
else:
await manager.emit_to_user(
sid,
"error",
{"message": f"Lineup not found for team {team_id}"},
)
except Exception as e:
logger.error(f"Get lineup error: {e}", exc_info=True)
await manager.emit_to_user(
sid, "error", {"message": f"Failed to get lineup: {str(e)}"}
)
@sio.event
async def submit_defensive_decision(sid, data):
"""
Submit defensive team decision.
Event data:
game_id: UUID of the game
alignment: Defensive alignment (normal, shifted_left, shifted_right, extreme_shift)
infield_depth: Infield positioning (in, normal, back, double_play)
outfield_depth: Outfield positioning (in, normal, back)
hold_runners: List of bases to hold runners (e.g., [1, 3])
Emits:
defensive_decision_submitted: To requester and broadcast to game room
error: To requester if validation fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# TODO: Verify user is authorized (fielding team manager)
# user_id = manager.user_sessions.get(sid)
# Extract decision data
alignment = data.get("alignment", "normal")
infield_depth = data.get("infield_depth", "normal")
outfield_depth = data.get("outfield_depth", "normal")
hold_runners = data.get("hold_runners", [])
# Create defensive decision
from app.models.game_models import DefensiveDecision
decision = DefensiveDecision(
alignment=alignment,
infield_depth=infield_depth,
outfield_depth=outfield_depth,
hold_runners=hold_runners,
)
# Submit decision through game engine
updated_state = await game_engine.submit_defensive_decision(
game_id, decision
)
logger.info(
f"Defensive decision submitted for game {game_id}: "
f"alignment={alignment}, infield={infield_depth}, outfield={outfield_depth}"
)
# Broadcast to game room
await manager.broadcast_to_game(
str(game_id),
"defensive_decision_submitted",
{
"game_id": str(game_id),
"decision": {
"alignment": alignment,
"infield_depth": infield_depth,
"outfield_depth": outfield_depth,
"hold_runners": hold_runners,
},
"pending_decision": updated_state.pending_decision,
},
)
except Exception as e:
logger.error(f"Submit defensive decision error: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to submit defensive decision: {str(e)}"},
)
@sio.event
async def submit_offensive_decision(sid, data):
"""
Submit offensive team decision.
Event data:
game_id: UUID of the game
action: String - offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt)
steal_attempts: List of bases for steal attempts - REQUIRED when action="steal" (e.g., [2, 3])
Emits:
offensive_decision_submitted: To requester and broadcast to game room
error: To requester if validation fails
Session 2 Update (2025-01-14): Replaced approach with action field. Stealing is now an action choice.
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
# Get game state
state = state_manager.get_state(game_id)
if not state:
await manager.emit_to_user(
sid, "error", {"message": f"Game {game_id} not found"}
)
return
# TODO: Verify user is authorized (batting team manager)
# user_id = manager.user_sessions.get(sid)
# Extract decision data
action = data.get("action", "swing_away") # Default: swing_away
steal_attempts = data.get("steal_attempts", [])
# Create offensive decision
from app.models.game_models import OffensiveDecision
decision = OffensiveDecision(action=action, steal_attempts=steal_attempts)
# Submit decision through game engine
updated_state = await game_engine.submit_offensive_decision(
game_id, decision
)
logger.info(
f"Offensive decision submitted for game {game_id}: "
f"action={action}, steal={steal_attempts}"
)
# Broadcast to game room
await manager.broadcast_to_game(
str(game_id),
"offensive_decision_submitted",
{
"game_id": str(game_id),
"decision": {"action": action, "steal_attempts": steal_attempts},
"pending_decision": updated_state.pending_decision,
},
)
except Exception as e:
logger.error(f"Submit offensive decision error: {e}", exc_info=True)
await manager.emit_to_user(
sid,
"error",
{"message": f"Failed to submit offensive decision: {str(e)}"},
)
@sio.event
async def get_box_score(sid, data):
"""
Get box score using materialized views.
Event data:
game_id: UUID of the game
Emits:
box_score_data: To requester with box score
error: To requester if validation fails
"""
try:
# Extract and validate game_id
game_id_str = data.get("game_id")
if not game_id_str:
await manager.emit_to_user(sid, "error", {"message": "Missing game_id"})
return
try:
game_id = UUID(game_id_str)
except (ValueError, AttributeError):
await manager.emit_to_user(
sid, "error", {"message": "Invalid game_id format"}
)
return
# TODO: Verify user has access to view this game's box score
# user_id = manager.user_sessions.get(sid)
# Get box score from materialized views
from app.services import box_score_service
box_score = await box_score_service.get_box_score(game_id)
if box_score:
# Send box score data to requester
await manager.emit_to_user(
sid,
"box_score_data",
{"game_id": str(game_id), "box_score": box_score},
)
logger.info(f"Box score data sent for game {game_id}")
else:
await manager.emit_to_user(
sid,
"error",
{
"message": "No box score found for game",
"hint": "Run migration (alembic upgrade head) and refresh views",
},
)
except Exception as e:
logger.error(f"Get box score error: {e}", exc_info=True)
await manager.emit_to_user(
sid, "error", {"message": f"Failed to get box score: {str(e)}"}
)