Files
mtgonline/backend/app/services/game_server.py
T
akadmin e9f5377e0a Phase 5: Complete Core Game Engine implementation
- Added MTGOnlineGame, GamePlayer, GameHistory models
- Implemented full game CRUD endpoints (create, list, get, join, leave, start, end)
- Integrated WebSocket game server with database persistence
- Added Commander deck validation service (100-card min, color identity, ban list)
- Created Alembic migration 006 for new game tables
- All game endpoints now database-backed (no more stubs)
2026-08-27 05:29:38 +00:00

411 lines
14 KiB
Python

"""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", {}),
})