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)
This commit is contained in:
2026-08-27 05:29:38 +00:00
parent 0e6506d97f
commit e9f5377e0a
9 changed files with 1057 additions and 102 deletions
+107 -6
View File
@@ -1,9 +1,19 @@
"""WebSocket game server for real-time multiplayer gameplay."""
"""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
@@ -75,7 +85,10 @@ GAME_COMMAND_REVERSE_TURN = 1034
class GameRoom:
"""Manages a single game room with multiple connected players."""
"""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
@@ -92,6 +105,38 @@ class GameRoom:
"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."""
@@ -114,7 +159,7 @@ class GameRoom:
try:
await websocket.send_text(message_str)
except Exception as e:
print(f"Error sending to player {player_id}: {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."""
@@ -123,7 +168,7 @@ class GameRoom:
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}")
logger.error(f"Error sending to player {player_id}: {e}")
def get_player_count(self) -> int:
"""Get number of connected players."""
@@ -132,6 +177,54 @@ class GameRoom:
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:
@@ -175,7 +268,10 @@ game_server = GameServer()
async def game_websocket_endpoint(websocket: WebSocket, game_id: int):
"""WebSocket endpoint for game connections."""
"""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")
@@ -249,7 +345,7 @@ async def game_websocket_endpoint(websocket: WebSocket, game_id: int):
"player_id": player_id,
})
except Exception as e:
print(f"Error in game WebSocket: {e}")
logger.error(f"Error in game WebSocket: {e}")
await room.remove_player(player_id)
@@ -300,6 +396,11 @@ async def process_game_command(room: GameRoom, player_id: int, command: dict):
})
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",