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 import logging
from uuid import UUID from uuid import UUID
from pydantic import ValidationError from pydantic import ValidationError
from socketio import AsyncServer from socketio import AsyncServer
from sqlalchemy.exc import SQLAlchemyError
from app.config.result_charts import PlayOutcome from app.config.result_charts import PlayOutcome
from app.core.dice import dice_system 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.game_engine import game_engine
from app.core.state_manager import state_manager from app.core.state_manager import state_manager
from app.core.substitution_manager import SubstitutionManager from app.core.substitution_manager import SubstitutionManager
from app.core.validators import ValidationError as GameValidationError from app.core.validators import ValidationError as GameValidationError
from app.database.operations import DatabaseOperations from app.database.operations import DatabaseOperations
from app.middleware.rate_limit import rate_limiter
from app.models.game_models import ManualOutcomeSubmission from app.models.game_models import ManualOutcomeSubmission
from app.services.lineup_service import lineup_service from app.services.lineup_service import lineup_service
from app.utils.auth import verify_token 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") logger.warning(f"Connection {sid} rejected: invalid token")
return False 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) await sio.emit("connected", {"user_id": user_id}, room=sid)
logger.info(f"Connection {sid} accepted for user {user_id}") logger.info(f"Connection {sid} accepted for user {user_id}")
return True 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: 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 return False
@sio.event @sio.event
async def disconnect(sid): async def disconnect(sid):
"""Handle disconnection""" """Handle disconnection"""
# Clean up rate limiter buckets for this connection
rate_limiter.remove_connection(sid)
await manager.disconnect(sid) await manager.disconnect(sid)
@sio.event @sio.event
async def join_game(sid, data): async def join_game(sid, data):
"""Handle join game request""" """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: try:
game_id = data.get("game_id") game_id = data.get("game_id")
role = data.get("role", "player") 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} sid, "game_joined", {"game_id": game_id, "role": role}
) )
except Exception as e: except (ValueError, TypeError) as e:
logger.error(f"Join game error: {e}") # Invalid data format
await manager.emit_to_user(sid, "error", {"message": str(e)}) 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 @sio.event
async def leave_game(sid, data): async def leave_game(sid, data):
@ -106,12 +140,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
if game_id: if game_id:
await manager.leave_game(sid, game_id) await manager.leave_game(sid, game_id)
except Exception as e: except (ValueError, TypeError) as e:
logger.error(f"Leave game error: {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 @sio.event
async def heartbeat(sid): 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) await sio.emit("heartbeat_ack", {}, room=sid)
@sio.event @sio.event
@ -121,6 +166,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
Recovers game from database if not in memory. 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: try:
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
if not game_id_str: 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") logger.warning(f"Game {game_id} not found in memory or database")
except Exception as e: except GameNotFoundError as e:
logger.error(f"Request game state error: {e}", exc_info=True) logger.warning(f"Game not found: {e.game_id}")
await manager.emit_to_user(sid, "error", {"message": str(e)}) 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 @sio.event
async def roll_dice(sid, data): 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 dice_rolled: Broadcast to game room with dice results
error: To requester if validation fails 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -189,6 +260,15 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # Get game state
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if not state: if not state:
@ -216,7 +296,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
state.pending_manual_roll = ab_roll state.pending_manual_roll = ab_roll
state_manager.update_state(game_id, state) 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( await manager.broadcast_to_game(
str(game_id), str(game_id),
"dice_rolled", "dice_rolled",
@ -236,10 +316,29 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
}, },
) )
except Exception as e: except asyncio.TimeoutError:
logger.error(f"Roll dice error: {e}", exc_info=True) logger.error(f"Lock timeout while rolling dice for game {game_id}")
await manager.emit_to_user( 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 @sio.event
@ -261,6 +360,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
outcome_rejected: To requester if validation fails outcome_rejected: To requester if validation fails
error: To requester if processing 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -290,6 +399,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # TODO: Verify user is active batter or authorized to submit
# user_id = manager.user_sessions.get(sid) # 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 # game state) is handled in PlayResolver, not here. This layer only validates
# basic input format and type checking. # 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 # Check for pending roll BEFORE accepting outcome
if not state.pending_manual_roll: if not state.pending_manual_roll:
await manager.emit_to_user( 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) # Clear pending roll only AFTER successful validation (one-time use)
state.pending_manual_roll = None state.pending_manual_roll = None
state_manager.update_state(game_id, state) 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 # Confirm acceptance to submitter AFTER successful validation
await manager.emit_to_user( await manager.emit_to_user(
sid, sid,
@ -442,34 +597,25 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
logger.debug(f"Broadcast updated game state after play resolution") logger.debug(f"Broadcast updated game state after play resolution")
except GameValidationError as e: except asyncio.TimeoutError:
# Game engine validation error (e.g., missing hit location) logger.error(f"Lock timeout while submitting manual outcome for game {game_id}")
await manager.emit_to_user( 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}") except DatabaseError as e:
return logger.error(f"Database error in submit_manual_outcome: {e}")
except ValueError as e:
# Business logic validation error from PlayResolver
await manager.emit_to_user( 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}") except SQLAlchemyError as e:
return logger.error(f"SQLAlchemy error in submit_manual_outcome: {e}")
except Exception as e:
# Unexpected error during resolution
logger.error(f"Error resolving manual play: {e}", exc_info=True)
await manager.emit_to_user( await manager.emit_to_user(
sid, "error", {"message": f"Failed to resolve play: {str(e)}"} sid, "error", {"message": "Database error - please retry"}
) )
return except (ValueError, TypeError) as e:
logger.warning(f"Invalid data in submit_manual_outcome: {e}")
except Exception as e:
logger.error(f"Submit manual outcome error: {e}", exc_info=True)
await manager.emit_to_user( await manager.emit_to_user(
sid, "error", {"message": f"Failed to process outcome: {str(e)}"} sid, "error", {"message": "Invalid outcome data"}
) )
# ===== SUBSTITUTION EVENTS ===== # ===== SUBSTITUTION EVENTS =====
@ -494,6 +640,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
substitution_error: To requester if validation fails substitution_error: To requester if validation fails
error: To requester if processing 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -515,6 +671,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # Get game state
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if not state: 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}" 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 # Create SubstitutionManager instance
db_ops = DatabaseOperations() db_ops = DatabaseOperations()
sub_manager = SubstitutionManager(db_ops) sub_manager = SubstitutionManager(db_ops)
@ -575,6 +740,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
team_id=team_id, team_id=team_id,
) )
# Broadcasting happens outside lock
if result.success: if result.success:
# Broadcast to all clients in game # Broadcast to all clients in game
await manager.broadcast_to_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}" f"Pinch hitter failed for game {game_id}: {result.error_message}"
) )
except Exception as e: except asyncio.TimeoutError:
logger.error(f"Pinch hitter request error: {e}", exc_info=True) logger.error(f"Lock timeout while processing pinch hitter for game {game_id}")
await manager.emit_to_user( 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 @sio.event
@ -650,6 +826,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
substitution_error: To requester if validation fails substitution_error: To requester if validation fails
error: To requester if processing 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -671,6 +857,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # Get game state
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if not state: 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}" 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 # Create SubstitutionManager instance
db_ops = DatabaseOperations() db_ops = DatabaseOperations()
sub_manager = SubstitutionManager(db_ops) sub_manager = SubstitutionManager(db_ops)
@ -741,6 +936,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
team_id=team_id, team_id=team_id,
) )
# Broadcasting happens outside lock
if result.success: if result.success:
# Broadcast to all clients in game # Broadcast to all clients in game
await manager.broadcast_to_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}" f"Defensive replacement failed for game {game_id}: {result.error_message}"
) )
except Exception as e: except asyncio.TimeoutError:
logger.error(f"Defensive replacement request error: {e}", exc_info=True) logger.error(f"Lock timeout while processing defensive replacement for game {game_id}")
await manager.emit_to_user( await manager.emit_to_user(
sid, sid, "error", {"message": "Server busy - please try again"}
"error", )
{"message": f"Failed to process defensive replacement: {str(e)}"}, 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 @sio.event
@ -816,6 +1020,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
substitution_error: To requester if validation fails substitution_error: To requester if validation fails
error: To requester if processing 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -837,6 +1051,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # Get game state
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if not state: 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}" 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 # Create SubstitutionManager instance
db_ops = DatabaseOperations() db_ops = DatabaseOperations()
sub_manager = SubstitutionManager(db_ops) sub_manager = SubstitutionManager(db_ops)
@ -897,6 +1120,7 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
team_id=team_id, team_id=team_id,
) )
# Broadcasting happens outside lock
if result.success: if result.success:
# Broadcast to all clients in game # Broadcast to all clients in game
await manager.broadcast_to_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}" f"Pitching change failed for game {game_id}: {result.error_message}"
) )
except Exception as e: except asyncio.TimeoutError:
logger.error(f"Pitching change request error: {e}", exc_info=True) logger.error(f"Lock timeout while processing pitching change for game {game_id}")
await manager.emit_to_user( await manager.emit_to_user(
sid, sid, "error", {"message": "Server busy - please try again"}
"error", )
{"message": f"Failed to process pitching change: {str(e)}"}, 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 @sio.event
@ -968,6 +1200,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
lineup_data: To requester with active lineup lineup_data: To requester with active lineup
error: To requester if validation fails 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("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}"}, {"message": f"Lineup not found for team {team_id}"},
) )
except Exception as e: except SQLAlchemyError as e:
logger.error(f"Get lineup error: {e}", exc_info=True) logger.error(f"Database error in get_lineup: {e}")
await manager.emit_to_user( 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 @sio.event
@ -1096,6 +1343,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
defensive_decision_submitted: To requester and broadcast to game room defensive_decision_submitted: To requester and broadcast to game room
error: To requester if validation fails 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -1111,6 +1368,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # Get game state
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if not state: if not state:
@ -1164,12 +1428,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
}, },
) )
except Exception as e: except ValidationError as e:
logger.error(f"Submit defensive decision error: {e}", exc_info=True) logger.warning(f"Validation error in defensive decision: {e}")
await manager.emit_to_user( await manager.emit_to_user(
sid, sid, "error", {"message": "Invalid decision data"}
"error", )
{"message": f"Failed to submit defensive decision: {str(e)}"}, 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 @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. 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("game_id") game_id_str = data.get("game_id")
@ -1203,6 +1488,13 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
return 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 # Get game state
state = state_manager.get_state(game_id) state = state_manager.get_state(game_id)
if not state: if not state:
@ -1244,12 +1536,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
}, },
) )
except Exception as e: except ValidationError as e:
logger.error(f"Submit offensive decision error: {e}", exc_info=True) logger.warning(f"Validation error in offensive decision: {e}")
await manager.emit_to_user( await manager.emit_to_user(
sid, sid, "error", {"message": "Invalid decision data"}
"error", )
{"message": f"Failed to submit offensive decision: {str(e)}"}, 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 @sio.event
@ -1264,6 +1567,16 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
box_score_data: To requester with box score box_score_data: To requester with box score
error: To requester if validation fails 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: try:
# Extract and validate game_id # Extract and validate game_id
game_id_str = data.get("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: except SQLAlchemyError as e:
logger.error(f"Get box score error: {e}", exc_info=True) logger.error(f"Database error in get_box_score: {e}")
await manager.emit_to_user( 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) 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 // Data Request Actions
// ============================================================================ // ============================================================================
@ -314,6 +335,9 @@ export function useGameActions(gameId?: string) {
requestDefensiveReplacement, requestDefensiveReplacement,
requestPitchingChange, requestPitchingChange,
// Undo/Rollback
undoLastPlay,
// Data requests // Data requests
getLineup, getLineup,
getBoxScore, getBoxScore,

View File

@ -238,10 +238,25 @@
</div> </div>
</Teleport> </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 <button
v-if="canMakeSubstitutions" 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" aria-label="Open Substitutions"
@click="showSubstitutions = true" @click="showSubstitutions = true"
> >
@ -250,6 +265,7 @@
</svg> </svg>
</button> </button>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -290,6 +306,9 @@ const { socket, isConnected, connectionError, connect } = useWebSocket()
// useGameActions will create its own computed internally if needed // useGameActions will create its own computed internally if needed
const actions = useGameActions(route.params.id as string) const actions = useGameActions(route.params.id as string)
// Destructure undoLastPlay for the undo button
const { undoLastPlay } = actions
// Game state from store // Game state from store
const gameState = computed(() => gameStore.gameState) const gameState = computed(() => gameStore.gameState)
const playHistory = computed(() => gameStore.playHistory) const playHistory = computed(() => gameStore.playHistory)
@ -395,6 +414,11 @@ const canMakeSubstitutions = computed(() => {
return gameState.value?.status === 'active' && isMyTurn.value 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 // Lineup helpers for substitutions
const currentLineup = computed(() => { const currentLineup = computed(() => {
if (!myTeamId.value) return [] if (!myTeamId.value) return []
@ -543,6 +567,12 @@ const handleSubstitutionCancel = () => {
showSubstitutions.value = false showSubstitutions.value = false
} }
// Undo handler
const handleUndoLastPlay = () => {
console.log('[Game Page] Undoing last play')
undoLastPlay(1)
}
// Measure ScoreBoard height dynamically // Measure ScoreBoard height dynamically
const updateScoreBoardHeight = () => { const updateScoreBoardHeight = () => {
if (scoreBoardRef.value) { if (scoreBoardRef.value) {