Initial commit of mtgonline project
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""Card database service for importing and querying card data."""
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CardType(str, Enum):
|
||||
"""Card types from Magic: The Gathering."""
|
||||
CREATURES = "Creature"
|
||||
INSTANT = "Instant"
|
||||
SORCERY = "Sorcery"
|
||||
ENCHANTMENT = "Enchantment"
|
||||
ARTIFACT = "Artifact"
|
||||
PLANE = "Plane"
|
||||
PLANESWALKER = "Planeswalker"
|
||||
LAND = "Land"
|
||||
BATTLE = "Battle"
|
||||
|
||||
|
||||
class CardColor(str, Enum):
|
||||
"""Card colors."""
|
||||
WHITE = "W"
|
||||
BLUE = "U"
|
||||
BLACK = "B"
|
||||
RED = "R"
|
||||
GREEN = "G"
|
||||
COLORLESS = "C"
|
||||
MULTICOLOR = "M"
|
||||
SHARD = "S"
|
||||
WIDGET = "X"
|
||||
|
||||
|
||||
class CardRarity(str, Enum):
|
||||
"""Card rarities."""
|
||||
COMMON = "common"
|
||||
UNCOMMON = "uncommon"
|
||||
RARE = "rare"
|
||||
MYTHIC = "mythic"
|
||||
SPECIAL = "special"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardData:
|
||||
"""Card information from card database."""
|
||||
id: int
|
||||
name: str
|
||||
types: List[CardType]
|
||||
colors: List[CardColor]
|
||||
rarity: CardRarity
|
||||
set_code: str
|
||||
collector_number: str
|
||||
flavor_text: Optional[str] = None
|
||||
rules_text: Optional[str] = None
|
||||
power: Optional[str] = None
|
||||
toughness: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
provider_id: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.set_code}-{self.collector_number})"
|
||||
|
||||
|
||||
class CardDatabase:
|
||||
"""Card database service for importing and querying card data."""
|
||||
|
||||
def __init__(self):
|
||||
self.cards: Dict[int, CardData] = {}
|
||||
self._next_id = 1
|
||||
|
||||
async def import_from_mtjson(self, url: str = "https://mtjson.xyz/api/5.0.0/") -> List[CardData]:
|
||||
"""Import card data from MTJSON API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
imported_cards = []
|
||||
for card_data in data:
|
||||
card = self._parse_mtjson_card(card_data)
|
||||
self.cards[self._next_id] = card
|
||||
imported_cards.append(card)
|
||||
self._next_id += 1
|
||||
|
||||
return imported_cards
|
||||
|
||||
def _parse_mtjson_card(self, data: dict) -> CardData:
|
||||
"""Parse MTJSON card data into CardData."""
|
||||
card_id = self._next_id
|
||||
|
||||
# Extract types
|
||||
types = []
|
||||
if "types" in data:
|
||||
for type_str in data["types"]:
|
||||
try:
|
||||
types.append(CardType(type_str))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract colors
|
||||
colors = []
|
||||
if "colors" in data:
|
||||
for color_str in data["colors"]:
|
||||
try:
|
||||
colors.append(CardColor(color_str))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract rarity
|
||||
rarity = CardRarity.COMMON
|
||||
if "rarity" in data:
|
||||
try:
|
||||
rarity = CardRarity(data["rarity"].lower())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract set and collector number
|
||||
set_code = ""
|
||||
collector_number = ""
|
||||
if "set" in data:
|
||||
set_code = data["set"]
|
||||
if "collectorNumber" in data:
|
||||
collector_number = data["collectorNumber"]
|
||||
|
||||
# Extract image URL
|
||||
image_url = None
|
||||
if "imageUris" in data and "normal" in data["imageUris"]:
|
||||
image_url = data["imageUris"]["normal"]
|
||||
|
||||
return CardData(
|
||||
id=card_id,
|
||||
name=data.get("name", ""),
|
||||
types=types,
|
||||
colors=colors,
|
||||
rarity=rarity,
|
||||
set_code=set_code,
|
||||
collector_number=collector_number,
|
||||
flavor_text=data.get("flavorText"),
|
||||
rules_text=data.get("rulesText"),
|
||||
power=data.get("power"),
|
||||
toughness=data.get("toughness"),
|
||||
artist=data.get("artist"),
|
||||
image_url=image_url,
|
||||
provider_id=data.get("multiverseId"),
|
||||
)
|
||||
|
||||
def get_card_by_id(self, card_id: int) -> Optional[CardData]:
|
||||
"""Get card by ID."""
|
||||
return self.cards.get(card_id)
|
||||
|
||||
def get_card_by_name(self, name: str) -> List[CardData]:
|
||||
"""Get cards by name (case-insensitive)."""
|
||||
name_lower = name.lower()
|
||||
return [card for card in self.cards.values() if card.name.lower() == name_lower]
|
||||
|
||||
def search_cards(
|
||||
self,
|
||||
query: str = "",
|
||||
card_type: Optional[CardType] = None,
|
||||
color: Optional[CardColor] = None,
|
||||
rarity: Optional[CardRarity] = None,
|
||||
set_code: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> List[CardData]:
|
||||
"""Search cards with filters."""
|
||||
results = list(self.cards.values())
|
||||
|
||||
# Filter by query
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
results = [card for card in results if query_lower in card.name.lower()]
|
||||
|
||||
# Filter by type
|
||||
if card_type:
|
||||
results = [card for card in results if card_type in card.types]
|
||||
|
||||
# Filter by color
|
||||
if color:
|
||||
results = [card for card in results if color in card.colors]
|
||||
|
||||
# Filter by rarity
|
||||
if rarity:
|
||||
results = [card for card in results if card.rarity == rarity]
|
||||
|
||||
# Filter by set
|
||||
if set_code:
|
||||
results = [card for card in results if card.set_code == set_code.upper()]
|
||||
|
||||
return results[:limit]
|
||||
|
||||
def get_random_card(self) -> Optional[CardData]:
|
||||
"""Get a random card from the database."""
|
||||
import random
|
||||
if not self.cards:
|
||||
return None
|
||||
return random.choice(list(self.cards.values()))
|
||||
|
||||
def get_card_count(self) -> int:
|
||||
"""Get total number of cards in database."""
|
||||
return len(self.cards)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
card_database = CardDatabase()
|
||||
|
||||
|
||||
async def import_cards() -> List[CardData]:
|
||||
"""Import cards from MTJSON."""
|
||||
return await card_database.import_from_mtjson()
|
||||
|
||||
|
||||
def search_cards(**kwargs) -> List[CardData]:
|
||||
"""Search cards with filters."""
|
||||
return card_database.search_cards(**kwargs)
|
||||
|
||||
|
||||
def get_card_by_name(name: str) -> List[CardData]:
|
||||
"""Get cards by name."""
|
||||
return card_database.get_card_by_name(name)
|
||||
|
||||
|
||||
def get_card_by_id(card_id: int) -> Optional[CardData]:
|
||||
"""Get card by ID."""
|
||||
return card_database.get_card_by_id(card_id)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Deck list parsing and serialization utilities."""
|
||||
import re
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardInfo:
|
||||
"""Card information extracted from deck text."""
|
||||
count: int
|
||||
name: str
|
||||
set_code: Optional[str] = None
|
||||
collector_number: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.set_code and self.collector_number:
|
||||
return f"{self.count} {self.name} ({self.set_code}-{self.collector_number})"
|
||||
return f"{self.count} {self.name}"
|
||||
|
||||
|
||||
class DeckParser:
|
||||
"""Parse Cockatrice deck list formats."""
|
||||
|
||||
# Regex patterns for deck parsing
|
||||
CARD_LINE_RE = re.compile(r"^\s*[\w\[\(\{].*$")
|
||||
EMPTY_LINE_RE = re.compile(r"^\s*$")
|
||||
COMMENT_RE = re.compile(r"([\w\[\(\{].*$)")
|
||||
SB_MARK_RE = re.compile(r"^\s*sb:\s*(.+)", re.IGNORECASE)
|
||||
SB_COMMENT_RE = re.compile(r"^sideboard\s.*$", re.IGNORECASE)
|
||||
DECK_COMMENT_RE = re.compile(r"^((main)?deck(list)?|mainboard)\b", re.IGNORECASE)
|
||||
MULTIPLIER_RE = re.compile(r"^([xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))")
|
||||
HYPHEN_FORMAT_RE = re.compile(r"\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*)")
|
||||
REGULAR_FORMAT_RE = re.compile(r"\((\w{3,})\)\s+(\d+[^\w\s]*)")
|
||||
|
||||
def parse_plain_text(self, text: str) -> List[CardInfo]:
|
||||
"""Parse plain text deck format."""
|
||||
lines = text.strip().split('\n')
|
||||
max_line = len(lines)
|
||||
|
||||
# Find deck start (first card line)
|
||||
deck_start = -1
|
||||
for i, line in enumerate(lines):
|
||||
if self.CARD_LINE_RE.match(line):
|
||||
deck_start = i
|
||||
break
|
||||
|
||||
if deck_start == -1:
|
||||
return []
|
||||
|
||||
# Find sideboard start
|
||||
sBStart = -1
|
||||
for i in range(deck_start, max_line):
|
||||
if self.SB_MARK_RE.match(lines[i]):
|
||||
sBStart = i
|
||||
break
|
||||
elif self.SB_COMMENT_RE.match(lines[i]):
|
||||
sBStart = i
|
||||
break
|
||||
|
||||
if sBStart == -1:
|
||||
# Look for empty line after deck
|
||||
for i in range(deck_start + 1, max_line):
|
||||
if self.EMPTY_LINE_RE.match(lines[i]):
|
||||
# Check if there are cards after the empty line
|
||||
for j in range(i + 1, max_line):
|
||||
if self.CARD_LINE_RE.match(lines[j]):
|
||||
sBStart = i
|
||||
break
|
||||
if sBStart != -1:
|
||||
break
|
||||
|
||||
if sBStart == -1:
|
||||
sBStart = max_line
|
||||
|
||||
cards = []
|
||||
index = 0
|
||||
|
||||
# Skip comments
|
||||
while index < deck_start:
|
||||
if not self.EMPTY_LINE_RE.match(lines[index]):
|
||||
index += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Parse cards
|
||||
for i in range(index, sBStart):
|
||||
line = lines[i].strip()
|
||||
if not line or self.EMPTY_LINE_RE.match(line):
|
||||
continue
|
||||
|
||||
# Check for sideboard marker
|
||||
if self.SB_MARK_RE.match(line):
|
||||
match = self.SB_MARK_RE.match(line)
|
||||
card_name = match.group(1).strip() if match else line
|
||||
sideboard = True
|
||||
else:
|
||||
card_name = line
|
||||
sideboard = False
|
||||
|
||||
# Extract set code and collector number
|
||||
set_code = None
|
||||
collector_number = None
|
||||
match = self.HYPHEN_FORMAT_RE.search(card_name)
|
||||
if match:
|
||||
set_code = match.group(2).upper()
|
||||
collector_number = match.group(3)
|
||||
card_name = card_name[:match.start()].strip()
|
||||
else:
|
||||
match = self.REGULAR_FORMAT_RE.search(card_name)
|
||||
if match:
|
||||
set_code = match.group(1).upper()
|
||||
collector_number = match.group(2)
|
||||
card_name = card_name[:match.start()].strip()
|
||||
|
||||
# Extract count
|
||||
count = 1
|
||||
match = self.MULTIPLIER_RE.match(card_name)
|
||||
if match:
|
||||
count = int(match.group(2))
|
||||
card_name = match.group(3)
|
||||
|
||||
# Normalize card name
|
||||
card_name = card_name.strip()
|
||||
|
||||
if card_name:
|
||||
cards.append(CardInfo(
|
||||
count=count,
|
||||
name=card_name,
|
||||
set_code=set_code,
|
||||
collector_number=collector_number,
|
||||
))
|
||||
|
||||
return cards
|
||||
|
||||
def format_plain_text(self, cards: List[CardInfo], prefix_sideboard: bool = True) -> str:
|
||||
"""Format cards as plain text deck list."""
|
||||
lines = []
|
||||
for card in cards:
|
||||
prefix = "SB: " if prefix_sideboard and card.name.startswith("[SB]") else ""
|
||||
name = card.name
|
||||
if card.name.startswith("[SB]"):
|
||||
name = card.name[4:] # Remove [SB] prefix
|
||||
|
||||
if card.set_code and card.collector_number:
|
||||
lines.append(f"{prefix}{card.count} {name} ({card.set_code}-{card.collector_number})")
|
||||
else:
|
||||
lines.append(f"{prefix}{card.count} {name}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_native_xml(self, cards: List[CardInfo]) -> str:
|
||||
"""Convert cards to Cockatrice native XML format."""
|
||||
xml_lines = ['<cockatrice_deck version="1">']
|
||||
|
||||
# Group cards by zone (main/sideboard)
|
||||
main_cards = [c for c in cards if not c.name.startswith("[SB]")]
|
||||
side_cards = [c for c in cards if c.name.startswith("[SB]")]
|
||||
|
||||
if main_cards:
|
||||
xml_lines.append(' <zone name="main">')
|
||||
for card in main_cards:
|
||||
xml_lines.append(f' <card name="{card.name}" count="{card.count}" />')
|
||||
xml_lines.append(' </zone>')
|
||||
|
||||
if side_cards:
|
||||
xml_lines.append(' <zone name="sideboard">')
|
||||
for card in side_cards:
|
||||
clean_name = card.name[4:] # Remove [SB] prefix
|
||||
xml_lines.append(f' <card name="{clean_name}" count="{card.count}" />')
|
||||
xml_lines.append(' </zone>')
|
||||
|
||||
xml_lines.append('</cockatrice_deck>')
|
||||
return "\n".join(xml_lines)
|
||||
|
||||
def from_native_xml(self, xml: str) -> List[CardInfo]:
|
||||
"""Parse Cockatrice native XML format."""
|
||||
cards = []
|
||||
|
||||
# Simple XML parsing (in production, use proper XML parser)
|
||||
card_pattern = re.compile(r'<card\s+name="([^"]+)"\s+count="(\d+)"\s*/>')
|
||||
|
||||
for match in card_pattern.finditer(xml):
|
||||
name = match.group(1)
|
||||
count = int(match.group(2))
|
||||
cards.append(CardInfo(count=count, name=name))
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def parse_deck(text: str) -> List[CardInfo]:
|
||||
"""Parse a deck list from plain text or native XML."""
|
||||
parser = DeckParser()
|
||||
if text.strip().startswith("<cockatrice_deck"):
|
||||
return parser.from_native_xml(text)
|
||||
else:
|
||||
return parser.parse_plain_text(text)
|
||||
|
||||
|
||||
def format_deck(cards: List[CardInfo], format_type: str = "plain", prefix_sideboard: bool = True) -> str:
|
||||
"""Format cards to specified deck format."""
|
||||
parser = DeckParser()
|
||||
if format_type == "native":
|
||||
return parser.to_native_xml(cards)
|
||||
else:
|
||||
return parser.format_plain_text(cards, prefix_sideboard)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""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 websocket.send_text(json.dumps({
|
||||
"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 websocket.send_text(json.dumps({
|
||||
"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", {}),
|
||||
})
|
||||
Reference in New Issue
Block a user