Implemented complete WebSocket integration for real-time player substitutions.
System is now 80% complete - only tests remain.
## WebSocket Events Implemented (600 lines)
### Event Handlers (backend/app/websocket/handlers.py):
1. request_pinch_hitter - Pinch hitter substitution
- Validates: game_id, player_out_lineup_id, player_in_card_id, team_id
- Executes: SubstitutionManager.pinch_hit()
- Broadcasts: player_substituted (all clients), substitution_confirmed (requester)
- Error codes: MISSING_FIELD, INVALID_FORMAT, NOT_CURRENT_BATTER, etc.
2. request_defensive_replacement - Defensive replacement
- Additional field: new_position (P, C, 1B, 2B, 3B, SS, LF, CF, RF)
- Executes: SubstitutionManager.defensive_replace()
- Same broadcast pattern as pinch hitter
3. request_pitching_change - Pitching change
- Validates minimum batters faced (handled in SubstitutionManager)
- Executes: SubstitutionManager.change_pitcher()
- Broadcasts new pitcher to all clients
4. get_lineup - Get active lineup for team
- Returns: lineup_data with all active players
- Uses: StateManager cache (O(1)) or database fallback
- Purpose: UI refresh after substitutions
### Event Pattern (follows existing handlers):
- Validate inputs (UUID format, required fields, game exists)
- Create SubstitutionManager instance with DatabaseOperations
- Execute substitution (validate → DB → state)
- Broadcast player_substituted to game room
- Send substitution_confirmed to requester
- Error handling with specific error codes
### Events Emitted:
- player_substituted (broadcast) - Includes: type, lineup IDs, position, batting_order
- substitution_confirmed (requester) - Success confirmation with new_lineup_id
- substitution_error (requester) - Validation error with error code
- lineup_data (requester) - Active lineup response
- error (requester) - Generic error
## Documentation Updates (350 lines)
### backend/app/websocket/CLAUDE.md:
- Complete handler documentation with examples
- Event data structures and response formats
- Error code reference (MISSING_FIELD, INVALID_FORMAT, NOT_CURRENT_BATTER, etc.)
- Client integration examples (JavaScript)
- Complete workflow diagrams
- Updated event summary table (+8 events)
- Updated Common Imports section
### .claude/implementation/ updates:
- NEXT_SESSION.md: Marked Task 1 complete, updated to 80% done
- SUBSTITUTION_SYSTEM_SUMMARY.md: Added WebSocket section, updated status
- GAMESTATE_REFACTOR_PLAN.md: Marked complete
- PHASE_3_OVERVIEW.md: Updated all phases to reflect completion
- phase-3e-COMPLETED.md: Created comprehensive completion doc
## Architecture
### DB-First Pattern (maintained):
```
Client Request → WebSocket Handler
↓
SubstitutionManager
├─ SubstitutionRules.validate_*()
├─ DatabaseOperations.create_substitution() (DB first!)
├─ StateManager.update_lineup_cache()
└─ Update GameState if applicable
↓
Success Responses
├─ player_substituted (broadcast to room)
└─ substitution_confirmed (to requester)
```
### Error Handling:
- Three-tier: ValidationError, GameValidationError, Exception
- Specific error codes for each failure type
- User-friendly error messages
- Comprehensive logging at each step
## Status Update
**Phase 3F Substitution System**: 80% Complete
- ✅ Core logic (SubstitutionRules, SubstitutionManager) - 1,027 lines
- ✅ Database operations (create_substitution, get_eligible_substitutes)
- ✅ WebSocket events (4 handlers) - 600 lines
- ✅ Documentation (350 lines)
- ⏳ Unit tests (20% remaining) - ~300 lines needed
- ⏳ Integration tests - ~400 lines needed
**Phase 3 Overall**: ~97% Complete
- Phase 3A-D (X-Check Core): 100%
- Phase 3E (GameState, Ratings, Redis, Testing): 100%
- Phase 3F (Substitutions): 80%
## Files Modified
backend/app/websocket/handlers.py (+600 lines)
backend/app/websocket/CLAUDE.md (+350 lines)
.claude/implementation/NEXT_SESSION.md (updated progress)
.claude/implementation/SUBSTITUTION_SYSTEM_SUMMARY.md (added WebSocket section)
.claude/implementation/GAMESTATE_REFACTOR_PLAN.md (marked complete)
.claude/implementation/PHASE_3_OVERVIEW.md (updated all phases)
.claude/implementation/phase-3e-COMPLETED.md (new file, 400+ lines)
## Next Steps
Remaining work (2-3 hours):
1. Unit tests for SubstitutionRules (~300 lines)
- 15+ pinch hitter tests
- 12+ defensive replacement tests
- 10+ pitching change tests
2. Integration tests for SubstitutionManager (~400 lines)
- Full DB + state sync flow
- State recovery verification
- Error handling and rollback
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
1028 lines
37 KiB
Python
1028 lines
37 KiB
Python
import logging
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
from socketio import AsyncServer
|
|
from pydantic import ValidationError
|
|
|
|
from app.websocket.connection_manager import ConnectionManager
|
|
from app.utils.auth import verify_token
|
|
from app.models.game_models import ManualOutcomeSubmission
|
|
from app.core.dice import dice_system
|
|
from app.core.state_manager import state_manager
|
|
from app.core.game_engine import game_engine
|
|
from app.core.substitution_manager import SubstitutionManager
|
|
from app.core.validators import ValidationError as GameValidationError
|
|
from app.config.result_charts import PlayOutcome
|
|
from app.database.operations import DatabaseOperations
|
|
|
|
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"""
|
|
try:
|
|
# Verify JWT token
|
|
token = auth.get("token")
|
|
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 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_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)
|
|
|
|
# Validate hit location is provided when required
|
|
if outcome.requires_hit_location() and not submission.hit_location:
|
|
await manager.emit_to_user(
|
|
sid,
|
|
"outcome_rejected",
|
|
{
|
|
"message": f"Outcome {outcome.value} requires hit_location",
|
|
"field": "hit_location"
|
|
}
|
|
)
|
|
return
|
|
|
|
# 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 "")
|
|
)
|
|
|
|
# Confirm acceptance to submitter
|
|
await manager.emit_to_user(
|
|
sid,
|
|
"outcome_accepted",
|
|
{
|
|
"game_id": str(game_id),
|
|
"outcome": outcome.value,
|
|
"hit_location": submission.hit_location
|
|
}
|
|
)
|
|
|
|
|
|
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}"
|
|
)
|
|
|
|
# Clear pending roll (one-time use)
|
|
state.pending_manual_roll = None
|
|
state_manager.update_state(game_id, state)
|
|
|
|
# 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
|
|
)
|
|
|
|
# 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": 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}"
|
|
)
|
|
|
|
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 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": f"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
|
|
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
|
|
}
|
|
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
|
|
db_ops = DatabaseOperations()
|
|
lineup_entries = await db_ops.get_active_lineup(game_id, team_id)
|
|
|
|
if lineup_entries:
|
|
await manager.emit_to_user(
|
|
sid,
|
|
"lineup_data",
|
|
{
|
|
"game_id": str(game_id),
|
|
"team_id": team_id,
|
|
"players": [
|
|
{
|
|
"lineup_id": entry.id,
|
|
"card_id": entry.card_id or entry.player_id,
|
|
"position": entry.position,
|
|
"batting_order": entry.batting_order,
|
|
"is_active": entry.is_active,
|
|
"is_starter": entry.is_starter
|
|
}
|
|
for entry in lineup_entries
|
|
]
|
|
}
|
|
)
|
|
logger.info(f"Lineup data loaded from DB 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)}"}
|
|
)
|