310 lines
9.9 KiB
Python
310 lines
9.9 KiB
Python
"""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", {}),
|
|
})
|