CLAUDE: Add Undo Last Play feature for game rollback
- Added rollback_play WebSocket handler (handlers.py:1632) - Accepts game_id and num_plays (default: 1) - Validates game state and play count - Broadcasts play_rolled_back and game_state_update events - Full error handling with rate limiting - Added undoLastPlay action to useGameActions composable - Emits rollback_play event to backend - Added Undo button to game page ([id].vue) - Amber floating action button with undo arrow icon - Positioned above substitutions button - Only visible when game is active and has plays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c27a652e54
commit
920d1c599c
@ -1,16 +1,24 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import ValidationError
|
||||
from socketio import AsyncServer
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.config.result_charts import PlayOutcome
|
||||
from app.core.dice import dice_system
|
||||
from app.core.exceptions import (
|
||||
DatabaseError,
|
||||
GameNotFoundError,
|
||||
InvalidGameStateError,
|
||||
)
|
||||
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.middleware.rate_limit import rate_limiter
|
||||
from app.models.game_models import ManualOutcomeSubmission
|
||||
from app.services.lineup_service import lineup_service
|
||||
from app.utils.auth import verify_token
|
||||
@ -61,24 +69,45 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
logger.warning(f"Connection {sid} rejected: invalid token")
|
||||
return False
|
||||
|
||||
await manager.connect(sid, user_id)
|
||||
# Extract IP address for logging
|
||||
ip_address = environ.get("REMOTE_ADDR")
|
||||
|
||||
await manager.connect(sid, user_id, ip_address=ip_address)
|
||||
await sio.emit("connected", {"user_id": user_id}, room=sid)
|
||||
|
||||
logger.info(f"Connection {sid} accepted for user {user_id}")
|
||||
return True
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
# Token parsing or missing data error
|
||||
logger.warning(f"Connection {sid} auth error: {e}")
|
||||
return False
|
||||
except (ConnectionError, OSError) as e:
|
||||
# Network/socket error during connection
|
||||
logger.error(f"Connection {sid} network error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Connection error: {e}")
|
||||
# Unexpected error - log and reject connection
|
||||
logger.error(f"Connection {sid} unexpected error: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@sio.event
|
||||
async def disconnect(sid):
|
||||
"""Handle disconnection"""
|
||||
# Clean up rate limiter buckets for this connection
|
||||
rate_limiter.remove_connection(sid)
|
||||
await manager.disconnect(sid)
|
||||
|
||||
@sio.event
|
||||
async def join_game(sid, data):
|
||||
"""Handle join game request"""
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
game_id = data.get("game_id")
|
||||
role = data.get("role", "player")
|
||||
@ -94,9 +123,14 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
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)})
|
||||
except (ValueError, TypeError) as e:
|
||||
# Invalid data format
|
||||
logger.warning(f"Join game validation error for {sid}: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": "Invalid game data"})
|
||||
except (ConnectionError, OSError) as e:
|
||||
# Network error during room join
|
||||
logger.error(f"Join game network error for {sid}: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": "Connection error"})
|
||||
|
||||
@sio.event
|
||||
async def leave_game(sid, data):
|
||||
@ -106,12 +140,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
if game_id:
|
||||
await manager.leave_game(sid, game_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Leave game error: {e}")
|
||||
except (ValueError, TypeError) as e:
|
||||
# Invalid data - log but don't error to client (leave is cleanup)
|
||||
logger.warning(f"Leave game data error for {sid}: {e}")
|
||||
except (ConnectionError, OSError) as e:
|
||||
# Network error - log but don't propagate (connection may already be gone)
|
||||
logger.debug(f"Leave game network error for {sid}: {e}")
|
||||
|
||||
@sio.event
|
||||
async def heartbeat(sid):
|
||||
"""Handle heartbeat ping"""
|
||||
"""
|
||||
Handle client-initiated heartbeat ping.
|
||||
|
||||
Updates session activity timestamp to prevent expiration.
|
||||
Socket.io has its own ping/pong mechanism, but clients can
|
||||
send explicit heartbeats for application-level keepalive.
|
||||
"""
|
||||
await manager.update_activity(sid)
|
||||
await sio.emit("heartbeat_ack", {}, room=sid)
|
||||
|
||||
@sio.event
|
||||
@ -121,6 +166,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
|
||||
Recovers game from database if not in memory.
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
game_id_str = data.get("game_id")
|
||||
if not game_id_str:
|
||||
@ -155,9 +210,15 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
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)
|
||||
except GameNotFoundError as e:
|
||||
logger.warning(f"Game not found: {e.game_id}")
|
||||
await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error fetching game state: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"})
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in request_game_state: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": "Invalid request data"})
|
||||
|
||||
@sio.event
|
||||
async def roll_dice(sid, data):
|
||||
@ -174,6 +235,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
dice_rolled: Broadcast to game room with dice results
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -189,6 +260,15 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for rolls
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "roll"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many roll requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# Acquire lock before modifying state
|
||||
async with state_manager.game_lock(game_id):
|
||||
# Get game state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
@ -216,7 +296,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
state.pending_manual_roll = ab_roll
|
||||
state_manager.update_state(game_id, state)
|
||||
|
||||
# Broadcast dice results to all players in game
|
||||
# Broadcast dice results to all players in game (outside lock)
|
||||
await manager.broadcast_to_game(
|
||||
str(game_id),
|
||||
"dice_rolled",
|
||||
@ -236,10 +316,29 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Roll dice error: {e}", exc_info=True)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Lock timeout while rolling dice for game {game_id}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Failed to roll dice: {str(e)}"}
|
||||
sid, "error", {"message": "Server busy - please try again"}
|
||||
)
|
||||
except GameValidationError as e:
|
||||
logger.warning(f"Validation error in roll_dice: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error during roll_dice: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in roll_dice: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid request data"}
|
||||
)
|
||||
except Exception as e:
|
||||
# Unexpected error - ensure we log and report
|
||||
logger.error(f"Unexpected error in roll_dice: {e}", exc_info=True)
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "An unexpected error occurred"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -261,6 +360,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
outcome_rejected: To requester if validation fails
|
||||
error: To requester if processing fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -290,6 +399,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for decisions
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "decision"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many outcome submissions. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# TODO: Verify user is active batter or authorized to submit
|
||||
# user_id = manager.user_sessions.get(sid)
|
||||
|
||||
@ -333,6 +449,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
# game state) is handled in PlayResolver, not here. This layer only validates
|
||||
# basic input format and type checking.
|
||||
|
||||
# Acquire lock for state modifications
|
||||
async with state_manager.game_lock(game_id):
|
||||
# Re-fetch state inside lock to ensure consistency
|
||||
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
|
||||
|
||||
# Check for pending roll BEFORE accepting outcome
|
||||
if not state.pending_manual_roll:
|
||||
await manager.emit_to_user(
|
||||
@ -371,7 +497,36 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
# Clear pending roll only AFTER successful validation (one-time use)
|
||||
state.pending_manual_roll = None
|
||||
state_manager.update_state(game_id, state)
|
||||
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 DatabaseError as e:
|
||||
# Database error during play resolution
|
||||
logger.error(f"Database error resolving manual play: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error during play resolution - please retry"}
|
||||
)
|
||||
return
|
||||
except SQLAlchemyError as e:
|
||||
# SQLAlchemy error during play resolution
|
||||
logger.error(f"SQLAlchemy error resolving manual play: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
return
|
||||
|
||||
# Broadcasting happens outside lock to avoid holding it too long
|
||||
# Confirm acceptance to submitter AFTER successful validation
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
@ -442,34 +597,25 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
logger.debug(f"Broadcast updated game state after play resolution")
|
||||
|
||||
except GameValidationError as e:
|
||||
# Game engine validation error (e.g., missing hit location)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Lock timeout while submitting manual outcome for game {game_id}")
|
||||
await manager.emit_to_user(
|
||||
sid, "outcome_rejected", {"message": str(e), "field": "validation"}
|
||||
sid, "error", {"message": "Server busy - please try again"}
|
||||
)
|
||||
logger.warning(f"Manual play validation failed: {e}")
|
||||
return
|
||||
|
||||
except ValueError as e:
|
||||
# Business logic validation error from PlayResolver
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error in submit_manual_outcome: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "outcome_rejected", {"message": str(e), "field": "validation"}
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
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)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"SQLAlchemy error in submit_manual_outcome: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Failed to resolve play: {str(e)}"}
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Submit manual outcome error: {e}", exc_info=True)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in submit_manual_outcome: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Failed to process outcome: {str(e)}"}
|
||||
sid, "error", {"message": "Invalid outcome data"}
|
||||
)
|
||||
|
||||
# ===== SUBSTITUTION EVENTS =====
|
||||
@ -494,6 +640,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
substitution_error: To requester if validation fails
|
||||
error: To requester if processing fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -515,6 +671,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for substitutions
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "substitution"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many substitution requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# Get game state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
@ -563,6 +726,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
f"Replacing {player_out_lineup_id} with card {player_in_card_id}"
|
||||
)
|
||||
|
||||
# Acquire lock before substitution to prevent concurrent lineup modifications
|
||||
async with state_manager.game_lock(game_id):
|
||||
# Create SubstitutionManager instance
|
||||
db_ops = DatabaseOperations()
|
||||
sub_manager = SubstitutionManager(db_ops)
|
||||
@ -575,6 +740,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
# Broadcasting happens outside lock
|
||||
if result.success:
|
||||
# Broadcast to all clients in game
|
||||
await manager.broadcast_to_game(
|
||||
@ -622,10 +788,20 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
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)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Lock timeout while processing pinch hitter for game {game_id}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Failed to process pinch hitter: {str(e)}"}
|
||||
sid, "error", {"message": "Server busy - please try again"}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in pinch hitter: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in pinch hitter request: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid substitution data"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -650,6 +826,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
substitution_error: To requester if validation fails
|
||||
error: To requester if processing fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -671,6 +857,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for substitutions
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "substitution"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many substitution requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# Get game state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
@ -728,6 +921,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
f"Replacing {player_out_lineup_id} with card {player_in_card_id} at {new_position}"
|
||||
)
|
||||
|
||||
# Acquire lock before substitution to prevent concurrent lineup modifications
|
||||
async with state_manager.game_lock(game_id):
|
||||
# Create SubstitutionManager instance
|
||||
db_ops = DatabaseOperations()
|
||||
sub_manager = SubstitutionManager(db_ops)
|
||||
@ -741,6 +936,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
# Broadcasting happens outside lock
|
||||
if result.success:
|
||||
# Broadcast to all clients in game
|
||||
await manager.broadcast_to_game(
|
||||
@ -788,12 +984,20 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
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)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Lock timeout while processing defensive replacement for game {game_id}")
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
"error",
|
||||
{"message": f"Failed to process defensive replacement: {str(e)}"},
|
||||
sid, "error", {"message": "Server busy - please try again"}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in defensive replacement: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in defensive replacement request: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid substitution data"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -816,6 +1020,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
substitution_error: To requester if validation fails
|
||||
error: To requester if processing fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -837,6 +1051,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for substitutions
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "substitution"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many substitution requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# Get game state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
@ -885,6 +1106,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
f"Replacing {player_out_lineup_id} with card {player_in_card_id}"
|
||||
)
|
||||
|
||||
# Acquire lock before substitution to prevent concurrent lineup modifications
|
||||
async with state_manager.game_lock(game_id):
|
||||
# Create SubstitutionManager instance
|
||||
db_ops = DatabaseOperations()
|
||||
sub_manager = SubstitutionManager(db_ops)
|
||||
@ -897,6 +1120,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
# Broadcasting happens outside lock
|
||||
if result.success:
|
||||
# Broadcast to all clients in game
|
||||
await manager.broadcast_to_game(
|
||||
@ -944,12 +1168,20 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
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)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Lock timeout while processing pitching change for game {game_id}")
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
"error",
|
||||
{"message": f"Failed to process pitching change: {str(e)}"},
|
||||
sid, "error", {"message": "Server busy - please try again"}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in pitching change: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in pitching change request: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid substitution data"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -968,6 +1200,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
lineup_data: To requester with active lineup
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -1074,10 +1316,15 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
{"message": f"Lineup not found for team {team_id}"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get lineup error: {e}", exc_info=True)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in get_lineup: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Failed to get lineup: {str(e)}"}
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in get_lineup request: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid lineup request"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -1096,6 +1343,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
defensive_decision_submitted: To requester and broadcast to game room
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -1111,6 +1368,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for decisions
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "decision"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many decision requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# Get game state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
@ -1164,12 +1428,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Submit defensive decision error: {e}", exc_info=True)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Validation error in defensive decision: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
"error",
|
||||
{"message": f"Failed to submit defensive decision: {str(e)}"},
|
||||
sid, "error", {"message": "Invalid decision data"}
|
||||
)
|
||||
except GameValidationError as e:
|
||||
logger.warning(f"Game validation error in defensive decision: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in defensive decision: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in defensive decision: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid decision format"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -1188,6 +1463,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
|
||||
Session 2 Update (2025-01-14): Replaced approach with action field. Stealing is now an action choice.
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -1203,6 +1488,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for decisions
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "decision"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many decision requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# Get game state
|
||||
state = state_manager.get_state(game_id)
|
||||
if not state:
|
||||
@ -1244,12 +1536,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Submit offensive decision error: {e}", exc_info=True)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Validation error in offensive decision: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid,
|
||||
"error",
|
||||
{"message": f"Failed to submit offensive decision: {str(e)}"},
|
||||
sid, "error", {"message": "Invalid decision data"}
|
||||
)
|
||||
except GameValidationError as e:
|
||||
logger.warning(f"Game validation error in offensive decision: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in offensive decision: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in offensive decision: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid decision format"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
@ -1264,6 +1567,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
box_score_data: To requester with box score
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract and validate game_id
|
||||
game_id_str = data.get("game_id")
|
||||
@ -1305,8 +1618,132 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get box score error: {e}", exc_info=True)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in get_box_score: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": f"Failed to get box score: {str(e)}"}
|
||||
sid, "error", {"message": "Database error fetching box score - please retry"}
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid data in get_box_score request: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid box score request"}
|
||||
)
|
||||
|
||||
@sio.event
|
||||
async def rollback_play(sid, data):
|
||||
"""
|
||||
Roll back the last N plays.
|
||||
|
||||
Deletes plays from the database and reconstructs game state by replaying
|
||||
remaining plays. Also removes any substitutions that occurred during the
|
||||
rolled-back plays.
|
||||
|
||||
Event data:
|
||||
game_id: UUID of the game
|
||||
num_plays: int - Number of plays to roll back (default: 1)
|
||||
|
||||
Emits:
|
||||
play_rolled_back: Broadcast to game room with new state
|
||||
game_state_update: Broadcast to game room with updated state
|
||||
error: To requester if validation fails
|
||||
"""
|
||||
# Update activity timestamp
|
||||
await manager.update_activity(sid)
|
||||
|
||||
# Rate limit check - connection level
|
||||
if not await rate_limiter.check_websocket_limit(sid):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
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 num_plays (default to 1)
|
||||
num_plays = data.get("num_plays", 1)
|
||||
if not isinstance(num_plays, int) or num_plays < 1:
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "num_plays must be a positive integer"}
|
||||
)
|
||||
return
|
||||
|
||||
# Rate limit check - game level for rollback (same as decisions)
|
||||
if not await rate_limiter.check_game_limit(str(game_id), "decision"):
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Too many requests. Please wait.", "code": "GAME_RATE_LIMITED"}
|
||||
)
|
||||
return
|
||||
|
||||
# TODO: Verify user is authorized (game manager/owner only)
|
||||
# user_id = manager.user_sessions.get(sid)
|
||||
|
||||
logger.info(f"Rollback request for game {game_id}: {num_plays} play(s)")
|
||||
|
||||
# Acquire lock before modifying state
|
||||
async with state_manager.game_lock(game_id):
|
||||
# Execute rollback
|
||||
new_state = await game_engine.rollback_plays(game_id, num_plays)
|
||||
|
||||
# Broadcast rollback notification to all players in game (outside lock)
|
||||
await manager.broadcast_to_game(
|
||||
str(game_id),
|
||||
"play_rolled_back",
|
||||
{
|
||||
"game_id": str(game_id),
|
||||
"num_plays": num_plays,
|
||||
"new_play_count": new_state.play_count,
|
||||
"inning": new_state.inning,
|
||||
"half": new_state.half,
|
||||
"message": f"Rolled back {num_plays} play(s)",
|
||||
},
|
||||
)
|
||||
|
||||
# Broadcast updated game state to all players
|
||||
await manager.broadcast_to_game(
|
||||
str(game_id),
|
||||
"game_state_update",
|
||||
new_state.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Rollback successful for game {game_id}: "
|
||||
f"Now at play {new_state.play_count}, inning {new_state.inning} {new_state.half}"
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# Validation errors from game_engine.rollback_plays
|
||||
logger.warning(f"Rollback validation error for game {game_id}: {e}")
|
||||
await manager.emit_to_user(sid, "error", {"message": str(e)})
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Lock timeout while rolling back game {game_id}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Server busy - please try again"}
|
||||
)
|
||||
except DatabaseError as e:
|
||||
logger.error(f"Database error in rollback_play: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error in rollback_play: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Database error - please retry"}
|
||||
)
|
||||
except (TypeError, AttributeError) as e:
|
||||
logger.warning(f"Invalid data in rollback_play request: {e}")
|
||||
await manager.emit_to_user(
|
||||
sid, "error", {"message": "Invalid rollback request"}
|
||||
)
|
||||
|
||||
@ -246,6 +246,27 @@ export function useGameActions(gameId?: string) {
|
||||
uiStore.showInfo('Requesting pitching change...', 3000)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Undo/Rollback Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Undo the last N plays
|
||||
* Rolls back plays from the database and reconstructs game state
|
||||
*/
|
||||
function undoLastPlay(numPlays: number = 1) {
|
||||
if (!validateConnection()) return
|
||||
|
||||
console.log('[GameActions] Undoing last', numPlays, 'play(s)')
|
||||
|
||||
socket.value!.emit('rollback_play', {
|
||||
game_id: currentGameId.value!,
|
||||
num_plays: numPlays,
|
||||
})
|
||||
|
||||
uiStore.showInfo(`Undoing ${numPlays} play(s)...`, 3000)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Request Actions
|
||||
// ============================================================================
|
||||
@ -314,6 +335,9 @@ export function useGameActions(gameId?: string) {
|
||||
requestDefensiveReplacement,
|
||||
requestPitchingChange,
|
||||
|
||||
// Undo/Rollback
|
||||
undoLastPlay,
|
||||
|
||||
// Data requests
|
||||
getLineup,
|
||||
getBoxScore,
|
||||
|
||||
@ -238,10 +238,25 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Floating Action Button for Substitutions -->
|
||||
<!-- Floating Action Buttons -->
|
||||
<div class="fixed bottom-6 right-6 flex flex-col gap-3 z-40">
|
||||
<!-- Undo Last Play Button -->
|
||||
<button
|
||||
v-if="canUndo"
|
||||
class="w-14 h-14 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||
aria-label="Undo Last Play"
|
||||
title="Undo Last Play"
|
||||
@click="handleUndoLastPlay"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Substitutions Button -->
|
||||
<button
|
||||
v-if="canMakeSubstitutions"
|
||||
class="fixed bottom-6 right-6 w-16 h-16 bg-primary hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center z-40 transition-all hover:scale-110"
|
||||
class="w-16 h-16 bg-primary hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||
aria-label="Open Substitutions"
|
||||
@click="showSubstitutions = true"
|
||||
>
|
||||
@ -250,6 +265,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -290,6 +306,9 @@ const { socket, isConnected, connectionError, connect } = useWebSocket()
|
||||
// useGameActions will create its own computed internally if needed
|
||||
const actions = useGameActions(route.params.id as string)
|
||||
|
||||
// Destructure undoLastPlay for the undo button
|
||||
const { undoLastPlay } = actions
|
||||
|
||||
// Game state from store
|
||||
const gameState = computed(() => gameStore.gameState)
|
||||
const playHistory = computed(() => gameStore.playHistory)
|
||||
@ -395,6 +414,11 @@ const canMakeSubstitutions = computed(() => {
|
||||
return gameState.value?.status === 'active' && isMyTurn.value
|
||||
})
|
||||
|
||||
const canUndo = computed(() => {
|
||||
// Can only undo if game is active and there are plays to undo
|
||||
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
|
||||
})
|
||||
|
||||
// Lineup helpers for substitutions
|
||||
const currentLineup = computed(() => {
|
||||
if (!myTeamId.value) return []
|
||||
@ -543,6 +567,12 @@ const handleSubstitutionCancel = () => {
|
||||
showSubstitutions.value = false
|
||||
}
|
||||
|
||||
// Undo handler
|
||||
const handleUndoLastPlay = () => {
|
||||
console.log('[Game Page] Undoing last play')
|
||||
undoLastPlay(1)
|
||||
}
|
||||
|
||||
// Measure ScoreBoard height dynamically
|
||||
const updateScoreBoardHeight = () => {
|
||||
if (scoreBoardRef.value) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user