"""WebSocket game server for real-time multiplayer gameplay. Integrates with SQLAlchemy database for game state persistence. """ import asyncio import json import logging from typing import Dict, Set, Optional from datetime import datetime from fastapi import WebSocket, WebSocketDisconnect from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.models.models import MTGOnlineGame, GamePlayer logger = logging.getLogger(__name__) # Protocol message types GAME_EVENT_JOIN = 1000 GAME_EVENT_LEAVE = 1001 GAME_EVENT_GAME_CLOSED = 1002 GAME_EVENT_GAME_HOST_CHANGED = 1003 GAME_EVENT_KICKED = 1004 GAME_EVENT_GAME_STATE_CHANGED = 1005 GAME_EVENT_PLAYER_PROPERTIES_CHANGED = 1007 GAME_EVENT_GAME_SAY = 1009 GAME_EVENT_CREATE_ARROW = 2000 GAME_EVENT_DELETE_ARROW = 2001 GAME_EVENT_CREATE_COUNTER = 2002 GAME_EVENT_SET_COUNTER = 2003 GAME_EVENT_DEL_COUNTER = 2004 GAME_EVENT_DRAW_CARDS = 2005 GAME_EVENT_REVEAL_CARDS = 2006 GAME_EVENT_SHUFFLE = 2007 GAME_EVENT_ROLL_DIE = 2008 GAME_EVENT_MOVE_CARD = 2009 GAME_EVENT_FLIP_CARD = 2010 GAME_EVENT_DESTROY_CARD = 2011 GAME_EVENT_ATTACH_CARD = 2012 GAME_EVENT_CREATE_TOKEN = 2013 GAME_EVENT_SET_CARD_ATTR = 2014 GAME_EVENT_SET_CARD_COUNTER = 2015 GAME_EVENT_SET_ACTIVE_PLAYER = 2016 GAME_EVENT_SET_ACTIVE_PHASE = 2017 GAME_EVENT_DUMP_ZONE = 2018 GAME_EVENT_CHANGE_ZONE_PROPERTIES = 2020 GAME_EVENT_REVERSE_TURN = 2021 GAME_EVENT_GAME_LOG_NOTICE = 2022 # Game command types GAME_COMMAND_KICK_FROM_GAME = 1000 GAME_COMMAND_LEAVE_GAME = 1001 GAME_COMMAND_GAME_SAY = 1002 GAME_COMMAND_SHUFFLE = 1003 GAME_COMMAND_MULLIGAN = 1004 GAME_COMMAND_ROLL_DIE = 1005 GAME_COMMAND_DRAW_CARDS = 1006 GAME_COMMAND_UNDO_DRAW = 1007 GAME_COMMAND_FLIP_CARD = 1008 GAME_COMMAND_ATTACH_CARD = 1009 GAME_COMMAND_CREATE_TOKEN = 1010 GAME_COMMAND_CREATE_ARROW = 1011 GAME_COMMAND_DELETE_ARROW = 1012 GAME_COMMAND_SET_CARD_ATTR = 1013 GAME_COMMAND_SET_CARD_COUNTER = 1014 GAME_COMMAND_INC_CARD_COUNTER = 1015 GAME_COMMAND_READY_START = 1016 GAME_COMMAND_CONCEDE = 1017 GAME_COMMAND_INC_COUNTER = 1018 GAME_COMMAND_CREATE_COUNTER = 1019 GAME_COMMAND_SET_COUNTER = 1020 GAME_COMMAND_DEL_COUNTER = 1021 GAME_COMMAND_NEXT_TURN = 1022 GAME_COMMAND_SET_ACTIVE_PHASE = 1023 GAME_COMMAND_DUMP_ZONE = 1024 GAME_COMMAND_REVEAL_CARDS = 1026 GAME_COMMAND_MOVE_CARD = 1027 GAME_COMMAND_SET_SIDEBOARD_PLAN = 1028 GAME_COMMAND_DECK_SELECT = 1029 GAME_COMMAND_SET_SIDEBOARD_LOCK = 1030 GAME_COMMAND_CHANGE_ZONE_PROPERTIES = 1031 GAME_COMMAND_UNCONCEDE = 1032 GAME_COMMAND_JUDGE = 1033 GAME_COMMAND_REVERSE_TURN = 1034 class GameRoom: """Manages a single game room with multiple connected players. Integrates with database for state persistence. """ def __init__(self, room_id: int, game_id: int): self.room_id = room_id self.game_id = game_id self.players: Dict[int, WebSocket] = {} # player_id -> websocket self.host_id: Optional[int] = None self.state: dict = { "started": False, "password_protected": False, "players_ready": set(), "turn": 1, "active_player": None, "phases": [], "zones": {}, } self.lock = asyncio.Lock() self.db_session: Optional[AsyncSession] = None async def initialize_db(self, db_session: AsyncSession): """Initialize database session and load game state.""" self.db_session = db_session await self._load_game_state() async def _load_game_state(self): """Load game state from database.""" if not self.db_session: return try: # Load game stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == self.game_id) result = await self.db_session.execute(stmt) game = result.scalar_one_or_none() if game: self.state["started"] = game.state == "IN_PROGRESS" self.state["password_protected"] = game.is_password_protected # Load players player_stmt = select(GamePlayer).where(GamePlayer.game_id == self.game_id) player_result = await self.db_session.execute(player_stmt) players = player_result.scalars().all() for player in players: if player.is_host: self.host_id = player.user_id except Exception as e: logger.error(f"Error loading game state: {e}") async def add_player(self, player_id: int, websocket: WebSocket): """Add a player to the game.""" async with self.lock: self.players[player_id] = websocket if self.host_id is None: self.host_id = player_id async def remove_player(self, player_id: int): """Remove a player from the game.""" async with self.lock: self.players.pop(player_id, None) if self.host_id == player_id and len(self.players) > 0: self.host_id = next(iter(self.players)) async def broadcast(self, message: dict): """Broadcast a message to all players.""" message_str = json.dumps(message) for player_id, websocket in list(self.players.items()): try: await websocket.send_text(message_str) except Exception as e: logger.error(f"Error sending to player {player_id}: {e}") async def send_to_player(self, player_id: int, message: dict): """Send a message to a specific player.""" if player_id in self.players: try: message_str = json.dumps(message) await self.players[player_id].send_text(message_str) except Exception as e: logger.error(f"Error sending to player {player_id}: {e}") def get_player_count(self) -> int: """Get number of connected players.""" return len(self.players) def is_full(self, max_players: int = 4) -> bool: """Check if game is full.""" return self.get_player_count() >= max_players async def update_game_state(self, state: str): """Update game state in database.""" if not self.db_session: return try: stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == self.game_id) result = await self.db_session.execute(stmt) game = result.scalar_one_or_none() if game: game.state = state if state == "IN_PROGRESS" and not game.started_at: game.started_at = datetime.now() elif state == "FINISHED": game.finished_at = datetime.now() await self.db_session.flush() logger.info(f"Game {self.game_id} state updated to {state}") except Exception as e: logger.error(f"Error updating game state: {e}") async def update_player_ready(self, player_id: int, is_ready: bool): """Update player ready status in database.""" if not self.db_session: return try: stmt = select(GamePlayer).where( GamePlayer.game_id == self.game_id, GamePlayer.user_id == player_id, ) result = await self.db_session.execute(stmt) player = result.scalar_one_or_none() if player: player.is_ready = is_ready await self.db_session.flush() if is_ready: self.state["players_ready"].add(player_id) else: self.state["players_ready"].discard(player_id) logger.info(f"Player {player_id} ready status updated to {is_ready}") except Exception as e: logger.error(f"Error updating player ready status: {e}") class GameServer: """Manages all active game rooms.""" def __init__(self): self.rooms: Dict[int, GameRoom] = {} # game_id -> GameRoom self.game_counter = 0 async def create_game(self, room_id: int, host_id: int) -> int: """Create a new game and return game ID.""" self.game_counter += 1 game_id = self.game_counter room = GameRoom(room_id, game_id) self.rooms[game_id] = room await room.add_player(host_id, None) # Host joins but websocket added later return game_id async def get_room(self, game_id: int) -> Optional[GameRoom]: """Get a game room by ID.""" return self.rooms.get(game_id) async def delete_room(self, game_id: int): """Delete a game room.""" room = self.rooms.pop(game_id, None) if room: # Close all player connections for player_id, websocket in list(room.players.items()): try: await websocket.close() except Exception: pass def get_active_games(self) -> list: """Get list of active game IDs.""" return list(self.rooms.keys()) # Global game server instance game_server = GameServer() async def game_websocket_endpoint(websocket: WebSocket, game_id: int): """WebSocket endpoint for game connections. Integrates with database for state persistence. """ room = await game_server.get_room(game_id) if not room: await websocket.close(code=4004, reason="Game not found") return # Wait for player ID from client player_id = None try: data = await websocket.receive_text() message = json.loads(data) if message.get("type") == "join": player_id = message.get("player_id") else: await websocket.close(code=4001, reason="Invalid join message") return except Exception as e: await websocket.close(code=4000, reason="Invalid message") return if not player_id: await websocket.close(code=4001, reason="Player ID required") return # Add player to room await room.add_player(player_id, websocket) # Send join confirmation await websocket.send_text(json.dumps({ "type": "joined", "player_id": player_id, "game_id": game_id, "state": room.state, })) # Broadcast join event to other players await room.broadcast({ "type": "game_event", "event": GAME_EVENT_JOIN, "player_id": player_id, }) # Handle messages try: while True: data = await websocket.receive_text() message = json.loads(data) if message.get("type") == "command": # Process game command await process_game_command(room, player_id, message) elif message.get("type") == "chat": # Broadcast chat message await room.broadcast({ "type": "game_event", "event": GAME_EVENT_GAME_SAY, "player_id": player_id, "message": message.get("message"), }) elif message.get("type") == "ping": # Respond to ping await room.send_to_player(player_id, { "type": "pong", "timestamp": datetime.now().isoformat(), }) except WebSocketDisconnect: # Player disconnected await room.remove_player(player_id) await room.broadcast({ "type": "game_event", "event": GAME_EVENT_LEAVE, "player_id": player_id, }) except Exception as e: logger.error(f"Error in game WebSocket: {e}") await room.remove_player(player_id) async def process_game_command(room: GameRoom, player_id: int, command: dict): """Process a game command from a player.""" cmd_type = command.get("type") # Validate command type if cmd_type not in [ GAME_COMMAND_KICK_FROM_GAME, GAME_COMMAND_LEAVE_GAME, GAME_COMMAND_GAME_SAY, GAME_COMMAND_SHUFFLE, GAME_COMMAND_MULLIGAN, GAME_COMMAND_ROLL_DIE, GAME_COMMAND_DRAW_CARDS, GAME_COMMAND_UNDO_DRAW, GAME_COMMAND_FLIP_CARD, GAME_COMMAND_ATTACH_CARD, GAME_COMMAND_CREATE_TOKEN, GAME_COMMAND_CREATE_ARROW, GAME_COMMAND_DELETE_ARROW, GAME_COMMAND_SET_CARD_ATTR, GAME_COMMAND_SET_CARD_COUNTER, GAME_COMMAND_INC_CARD_COUNTER, GAME_COMMAND_READY_START, GAME_COMMAND_CONCEDE, GAME_COMMAND_INC_COUNTER, GAME_COMMAND_CREATE_COUNTER, GAME_COMMAND_SET_COUNTER, GAME_COMMAND_DEL_COUNTER, GAME_COMMAND_NEXT_TURN, GAME_COMMAND_SET_ACTIVE_PHASE, GAME_COMMAND_DUMP_ZONE, GAME_COMMAND_REVEAL_CARDS, GAME_COMMAND_MOVE_CARD, GAME_COMMAND_SET_SIDEBOARD_PLAN, GAME_COMMAND_DECK_SELECT, GAME_COMMAND_SET_SIDEBOARD_LOCK, GAME_COMMAND_CHANGE_ZONE_PROPERTIES, GAME_COMMAND_UNCONCEDE, GAME_COMMAND_JUDGE, GAME_COMMAND_REVERSE_TURN, ]: await room.send_to_player(player_id, { "type": "error", "message": f"Invalid command type: {cmd_type}", }) return # Handle ready command specially if cmd_type == GAME_COMMAND_READY_START: is_ready = command.get("data", {}).get("is_ready", False) await room.update_player_ready(player_id, is_ready) # Broadcast command as game event await room.broadcast({ "type": "game_event", "event": cmd_type, "player_id": player_id, **command.get("data", {}), })