"""WebSocket game server for real-time multiplayer gameplay.""" import asyncio import json from typing import Dict, Set, Optional from datetime import datetime from fastapi import WebSocket, WebSocketDisconnect # 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.""" 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() 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: print(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: print(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 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.""" 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: print(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 # Broadcast command as game event await room.broadcast({ "type": "game_event", "event": cmd_type, "player_id": player_id, **command.get("data", {}), })