diff --git a/backend/app/services/room_manager.py b/backend/app/services/room_manager.py index 38e65c1..b4a6ff7 100644 --- a/backend/app/services/room_manager.py +++ b/backend/app/services/room_manager.py @@ -1,12 +1,17 @@ import json import random import string +import asyncio from typing import Optional import redis.asyncio as redis from app.config import get_settings settings = get_settings() +# Lock timeout in seconds +LOCK_TIMEOUT = 5 +LOCK_RETRY_DELAY = 0.05 # 50ms + class RoomManager: def __init__(self): @@ -20,6 +25,31 @@ class RoomManager: if self.redis: await self.redis.close() + async def _acquire_lock(self, room_code: str, timeout: float = LOCK_TIMEOUT) -> bool: + """Acquire a lock for room operations.""" + await self.connect() + lock_key = f"lock:room:{room_code}" + # Try to acquire lock with NX (only if not exists) and EX (expire) + acquired = await self.redis.set(lock_key, "1", nx=True, ex=int(timeout)) + return acquired is not None + + async def _release_lock(self, room_code: str): + """Release a room lock.""" + await self.connect() + lock_key = f"lock:room:{room_code}" + await self.redis.delete(lock_key) + + async def _with_lock(self, room_code: str, operation, max_retries: int = 20): + """Execute an operation with a room lock.""" + for attempt in range(max_retries): + if await self._acquire_lock(room_code): + try: + return await operation() + finally: + await self._release_lock(room_code) + await asyncio.sleep(LOCK_RETRY_DELAY) + raise Exception(f"Could not acquire lock for room {room_code}") + def _generate_room_code(self) -> str: """Generate a 6-character room code.""" return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) @@ -88,78 +118,160 @@ class RoomManager: team: str, socket_id: str ) -> Optional[dict]: - """Add a player to a room.""" - room = await self.get_room(room_code) - if not room: - return None - - # Check if team is full - if len(room["teams"][team]) >= 4: - return None - - # Check if name is taken - for t in ["A", "B"]: - for p in room["teams"][t]: - if p["name"].lower() == player_name.lower(): - return None - - # Add player - player = { - "name": player_name, - "team": team, - "position": len(room["teams"][team]), - "socket_id": socket_id - } - room["teams"][team].append(player) - - # Save player mapping - await self.redis.setex( - f"player:{socket_id}", - 3600 * 3, - json.dumps({"name": player_name, "room": room_code, "team": team}) - ) - - await self.update_room(room_code, room) - return room - - async def remove_player(self, socket_id: str) -> Optional[dict]: - """Remove a player from their room.""" + """Add a player to a room (with lock to prevent race conditions).""" await self.connect() - # Get player info + async def _do_add_player(): + room = await self.get_room(room_code) + if not room: + return None + + # Check if team is full + if len(room["teams"][team]) >= 4: + return None + + # Check if name is taken + for t in ["A", "B"]: + for p in room["teams"][t]: + if p["name"].lower() == player_name.lower(): + return None + + # Add player + player = { + "name": player_name, + "team": team, + "position": len(room["teams"][team]), + "socket_id": socket_id + } + room["teams"][team].append(player) + + # Save player mapping + await self.redis.setex( + f"player:{socket_id}", + 3600 * 3, + json.dumps({"name": player_name, "room": room_code, "team": team}) + ) + + await self.update_room(room_code, room) + return room + + try: + return await self._with_lock(room_code, _do_add_player) + except Exception as e: + print(f"Error adding player: {e}") + return None + + async def remove_player(self, socket_id: str) -> Optional[dict]: + """Remove a player from their room (with lock).""" + await self.connect() + + # Get player info first (outside lock) player_data = await self.redis.get(f"player:{socket_id}") if not player_data: return None player_info = json.loads(player_data) room_code = player_info["room"] - team = player_info["team"] - # Get room - room = await self.get_room(room_code) - if not room: + async def _do_remove_player(): + # Get room + room = await self.get_room(room_code) + if not room: + return None + + # Remove player from both teams (in case of inconsistency) + for t in ["A", "B"]: + room["teams"][t] = [ + p for p in room["teams"][t] if p["socket_id"] != socket_id + ] + # Update positions + for i, p in enumerate(room["teams"][t]): + p["position"] = i + + # Delete player mapping + await self.redis.delete(f"player:{socket_id}") + + # If room is empty, delete it + if not room["teams"]["A"] and not room["teams"]["B"]: + await self.redis.delete(f"room:{room_code}") + return None + + await self.update_room(room_code, room) + return room + + try: + return await self._with_lock(room_code, _do_remove_player) + except Exception as e: + print(f"Error removing player: {e}") return None - # Remove player from team - room["teams"][team] = [ - p for p in room["teams"][team] if p["socket_id"] != socket_id - ] + async def change_player_team( + self, + room_code: str, + player_name: str, + socket_id: str, + new_team: str + ) -> Optional[dict]: + """Change a player's team (with lock).""" + await self.connect() - # Update positions - for i, p in enumerate(room["teams"][team]): - p["position"] = i + async def _do_change_team(): + room = await self.get_room(room_code) + if not room: + return None - # Delete player mapping - await self.redis.delete(f"player:{socket_id}") + # Find current team + current_team = None + for t in ["A", "B"]: + for p in room["teams"][t]: + if p["name"] == player_name: + current_team = t + break + if current_team: + break - # If room is empty, delete it - if not room["teams"]["A"] and not room["teams"]["B"]: - await self.redis.delete(f"room:{room_code}") + # If already on target team, just return room + if current_team == new_team: + return room + + # Check if target team is full + if len(room["teams"][new_team]) >= 4: + return None + + # Remove from both teams (safety) + for t in ["A", "B"]: + room["teams"][t] = [p for p in room["teams"][t] if p["name"] != player_name] + + # Add to new team + player = { + "name": player_name, + "team": new_team, + "position": len(room["teams"][new_team]), + "socket_id": socket_id + } + room["teams"][new_team].append(player) + + # Update positions in both teams + for t in ["A", "B"]: + for i, p in enumerate(room["teams"][t]): + p["position"] = i + + # Update player record + await self.redis.setex( + f"player:{socket_id}", + 3600 * 3, + json.dumps({"name": player_name, "room": room_code, "team": new_team}) + ) + + await self.update_room(room_code, room) + return room + + try: + return await self._with_lock(room_code, _do_change_team) + except Exception as e: + print(f"Error changing team: {e}") return None - await self.update_room(room_code, room) - return room - async def get_player(self, socket_id: str) -> Optional[dict]: """Get player info by socket ID.""" await self.connect() diff --git a/backend/app/sockets/game_events.py b/backend/app/sockets/game_events.py index adf289a..8820549 100644 --- a/backend/app/sockets/game_events.py +++ b/backend/app/sockets/game_events.py @@ -189,14 +189,13 @@ def register_socket_events(sio: socketio.AsyncServer): room_code = player["room"] new_team = data.get("team") - current_team = player["team"] - # Don't do anything if already on that team - if current_team == new_team: - return + # Use room_manager method with lock to prevent race conditions + room = await room_manager.change_player_team( + room_code, player["name"], sid, new_team + ) - room = await room_manager.get_room(room_code) - if not room or len(room["teams"][new_team]) >= 4: + if not room: await sio.emit( "error", {"message": "Cannot change team. It may be full."}, @@ -204,30 +203,6 @@ def register_socket_events(sio: socketio.AsyncServer): ) return - # Remove from current team (by socket_id to be safe) - room["teams"][current_team] = [ - p for p in room["teams"][current_team] if p["socket_id"] != sid - ] - - # Also remove from new team if somehow already there (prevent duplicates) - room["teams"][new_team] = [ - p for p in room["teams"][new_team] if p["socket_id"] != sid - ] - - # Add to new team - room["teams"][new_team].append({ - "name": player["name"], - "team": new_team, - "position": len(room["teams"][new_team]), - "socket_id": sid - }) - - # Update room state - await room_manager.update_room(room_code, room) - - # Update player record with new team - await room_manager.update_player(sid, {"team": new_team}) - await sio.emit("team_changed", {"room": room}, room=room_code) @sio.event