strat-gameplay-webapp/backend/app/websocket/handlers.py
Cal Corum 9cae63ac43 CLAUDE: Implement Week 7 Task 7 - WebSocket manual outcome handlers
Complete manual outcome workflow for SBA and PD manual mode gameplay:

**WebSocket Event Handlers** (app/websocket/handlers.py):
- roll_dice: Server rolls dice, stores in state, broadcasts to players
- submit_manual_outcome: Validates and processes player submissions
- Events: dice_rolled, outcome_accepted, outcome_rejected, play_resolved

**Game Engine Integration** (app/core/game_engine.py):
- resolve_manual_play(): Processes manual outcomes with server dice
- Uses ab_roll for audit trail, player outcome for resolution
- Same orchestration as resolve_play() (save, update, advance inning)

**Data Model** (app/models/game_models.py):
- pending_manual_roll: Stores server dice between roll and submission

**Terminal Client** (terminal_client/):
- roll_dice command: Roll dice and display results
- manual_outcome command: Submit outcomes from physical cards
- Both integrated into REPL for testing

**Tests** (tests/unit/websocket/test_manual_outcome_handlers.py):
- 12 comprehensive tests covering all validation paths
- All tests passing (roll_dice: 4, submit_manual_outcome: 8)

**Key Decisions**:
- Server rolls dice for fairness (not players!)
- One-time roll usage (cleared after submission)
- Early validation (check pending roll before accepting)
- Field-level error messages for clear feedback

**Impact**:
- Complete manual mode workflow ready
- Frontend WebSocket integration supported
- Terminal testing commands available
- Audit trail with server-rolled dice maintained

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 22:51:31 -05:00

407 lines
14 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.validators import ValidationError as GameValidationError
from app.config.result_charts import PlayOutcome
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
)
# Broadcast play result to game room
await manager.broadcast_to_game(
str(game_id),
"play_resolved",
{
"game_id": str(game_id),
"play_number": state.play_count,
"outcome": outcome.value,
"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
}
)
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)}"}
)