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:
@@ -15,17 +15,15 @@ from alembic import context
|
|||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, DecklistFile, DecklistFolder, Room, RoomGameType,
|
User, DecklistFile, DecklistFolder, Room, RoomGameType,
|
||||||
Ban, GameLog, AuditLog, MtgCardMirror, DeckCardLink
|
Ban, GameLog, AuditLog, MtgonlineCard, DeckCardLink,
|
||||||
)
|
UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion,
|
||||||
# Import new models
|
|
||||||
from app.models.user_data import (
|
|
||||||
UserSession, DeckVersion, GameReplay, ReplayPlayer,
|
UserSession, DeckVersion, GameReplay, ReplayPlayer,
|
||||||
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
|
GameOutcome, UserStatistics, UserCardCollection, CardWishlist,
|
||||||
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
|
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
|
||||||
NetworkMember, UserPreference, UserActivityLog
|
NetworkMember, UserPreference, UserActivityLog,
|
||||||
|
CardImportBatch, UserCardImportRecord, UserCardImport,
|
||||||
|
MTGOnlineGame, GamePlayer, GameHistory
|
||||||
)
|
)
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
|
||||||
from app.models.user_card_import import UserCardImport
|
|
||||||
|
|
||||||
# this is the Alembic Config object
|
# this is the Alembic Config object
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Add game models: mtgonline_games, game_players, game_history
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2026-01-05 00:00:00.000000
|
||||||
|
|
||||||
|
This migration creates tables for the Core Game Engine:
|
||||||
|
- mtgonline_games: Active game sessions
|
||||||
|
- game_players: Player associations with games
|
||||||
|
- game_history: Historical game records
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '006'
|
||||||
|
down_revision: Union[str, None] = '005'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create game engine tables."""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. MTG Online Games Table (mtgonline_games)
|
||||||
|
# Mirrors: app/models/models.py – class MTGOnlineGame
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
'mtgonline_games',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, index=True),
|
||||||
|
sa.Column('room_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_rooms.id'),
|
||||||
|
nullable=False, index=True),
|
||||||
|
sa.Column('host_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_users.id'),
|
||||||
|
nullable=False, index=True),
|
||||||
|
sa.Column('game_type', sa.String(50), nullable=False,
|
||||||
|
server_default='Commander'),
|
||||||
|
sa.Column('state', sa.String(20), nullable=False,
|
||||||
|
server_default='WAITING'),
|
||||||
|
sa.Column('is_password_protected', sa.Boolean(),
|
||||||
|
nullable=False, server_default='false'),
|
||||||
|
sa.Column('max_players', sa.Integer(), nullable=False,
|
||||||
|
server_default='4'),
|
||||||
|
sa.Column('current_players', sa.Integer(), nullable=False,
|
||||||
|
server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('finished_at', sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. Game Players Table (game_players)
|
||||||
|
# Mirrors: app/models/models.py – class GamePlayer
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
'game_players',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, index=True),
|
||||||
|
sa.Column('game_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_games.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True),
|
||||||
|
sa.Column('user_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_users.id'),
|
||||||
|
nullable=False, index=True),
|
||||||
|
sa.Column('seat_number', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('life_total', sa.Integer(), nullable=False,
|
||||||
|
server_default='20'),
|
||||||
|
sa.Column('is_ready', sa.Boolean(), nullable=False,
|
||||||
|
server_default='false'),
|
||||||
|
sa.Column('is_host', sa.Boolean(), nullable=False,
|
||||||
|
server_default='false'),
|
||||||
|
sa.Column('joined_at', sa.DateTime(),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
# Unique constraint + indexes
|
||||||
|
op.create_unique_constraint(
|
||||||
|
'uq_game_player_unique', 'game_players',
|
||||||
|
['game_id', 'user_id'])
|
||||||
|
op.create_index('idx_game_players_game', 'game_players', ['game_id'])
|
||||||
|
op.create_index('idx_game_players_user', 'game_players', ['user_id'])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. Game History Table (game_history)
|
||||||
|
# Mirrors: app/models/models.py – class GameHistory
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
'game_history',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, index=True),
|
||||||
|
sa.Column('game_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_games.id'),
|
||||||
|
nullable=True),
|
||||||
|
sa.Column('room_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_rooms.id'),
|
||||||
|
nullable=True, index=True),
|
||||||
|
sa.Column('game_type', sa.String(50), nullable=False,
|
||||||
|
server_default='Commander'),
|
||||||
|
sa.Column('winner_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('mtgonline_users.id'),
|
||||||
|
nullable=True, index=True),
|
||||||
|
sa.Column('duration_seconds', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('result_type', sa.String(50), nullable=True),
|
||||||
|
sa.Column('replay_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(),
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop game engine tables in reverse dependency order."""
|
||||||
|
op.drop_table('game_history')
|
||||||
|
op.drop_table('game_players')
|
||||||
|
op.drop_table('mtgonline_games')
|
||||||
@@ -7,6 +7,7 @@ Consolidated from 10+ fragmented model files into 2 main modules:
|
|||||||
from app.models.models import (
|
from app.models.models import (
|
||||||
User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog,
|
User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog,
|
||||||
MtgonlineCard,
|
MtgonlineCard,
|
||||||
|
MTGOnlineGame, GamePlayer, GameHistory,
|
||||||
UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion,
|
UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion,
|
||||||
UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome,
|
UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome,
|
||||||
UserStatistics, UserCardCollection, CardWishlist,
|
UserStatistics, UserCardCollection, CardWishlist,
|
||||||
@@ -27,4 +28,5 @@ __all__ = [
|
|||||||
"UserActivityLog", "UserDeck", "UserDeckCard", "DeckPrecedent",
|
"UserActivityLog", "UserDeck", "UserDeckCard", "DeckPrecedent",
|
||||||
"DeckPrecedentCard", "CardSuggestion", "CardImportBatch",
|
"DeckPrecedentCard", "CardSuggestion", "CardImportBatch",
|
||||||
"UserCardImportRecord", "UserCardImport",
|
"UserCardImportRecord", "UserCardImport",
|
||||||
|
"MTGOnlineGame", "GamePlayer", "GameHistory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -183,6 +183,97 @@ class GameLog(Base):
|
|||||||
player = relationship("User")
|
player = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class MTGOnlineGame(Base):
|
||||||
|
"""
|
||||||
|
Active game session model.
|
||||||
|
|
||||||
|
Represents a live game in progress or waiting to start.
|
||||||
|
Tracks game state, players, and format rules.
|
||||||
|
"""
|
||||||
|
__tablename__ = "mtgonline_games"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=False, index=True)
|
||||||
|
host_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
|
||||||
|
game_type = Column(String(50), nullable=False, default="Commander") # Commander, Standard, Modern, Pauper
|
||||||
|
state = Column(String(20), nullable=False, default="WAITING") # WAITING, IN_PROGRESS, FINISHED, ABORTED
|
||||||
|
is_password_protected = Column(Boolean, default=False)
|
||||||
|
max_players = Column(Integer, default=4)
|
||||||
|
current_players = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
room = relationship("Room")
|
||||||
|
host = relationship("User", foreign_keys=[host_id])
|
||||||
|
players = relationship(
|
||||||
|
"GamePlayer",
|
||||||
|
back_populates="game",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<MTGOnlineGame {self.id} state={self.state} players={self.current_players}>"
|
||||||
|
|
||||||
|
|
||||||
|
class GamePlayer(Base):
|
||||||
|
"""
|
||||||
|
Association table for game participants.
|
||||||
|
|
||||||
|
Links users to active games with seat assignments and game state.
|
||||||
|
"""
|
||||||
|
__tablename__ = "game_players"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
game_id = Column(Integer, ForeignKey("mtgonline_games.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
|
||||||
|
seat_number = Column(Integer, nullable=True) # 1-based seat assignment
|
||||||
|
life_total = Column(Integer, default=20)
|
||||||
|
is_ready = Column(Boolean, default=False)
|
||||||
|
is_host = Column(Boolean, default=False)
|
||||||
|
joined_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
game = relationship("MTGOnlineGame", back_populates="players")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("game_id", "user_id", name="uq_game_player_unique"),
|
||||||
|
Index("idx_game_players_game", "game_id"),
|
||||||
|
Index("idx_game_players_user", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<GamePlayer game={self.game_id} user={self.user_id} seat={self.seat_number}>"
|
||||||
|
|
||||||
|
|
||||||
|
class GameHistory(Base):
|
||||||
|
"""
|
||||||
|
Historical record of completed games.
|
||||||
|
|
||||||
|
Stores post-game data including results, participants, and duration.
|
||||||
|
"""
|
||||||
|
__tablename__ = "game_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
game_id = Column(Integer, ForeignKey("mtgonline_games.id"), nullable=True) # Reference to original game
|
||||||
|
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True)
|
||||||
|
game_type = Column(String(50), nullable=False, default="Commander")
|
||||||
|
winner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True, index=True)
|
||||||
|
duration_seconds = Column(Integer, nullable=True)
|
||||||
|
result_type = Column(String(50), nullable=True) # 'victory', 'concession', 'timeout'
|
||||||
|
replay_data = Column(JSON, nullable=True) # Game event log
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
room = relationship("Room")
|
||||||
|
winner = relationship("User", foreign_keys=[winner_id])
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<GameHistory {self.id} type={self.game_type} winner={self.winner_id}>"
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
"""Audit trail for administrative actions."""
|
"""Audit trail for administrative actions."""
|
||||||
__tablename__ = "mtgonline_audit"
|
__tablename__ = "mtgonline_audit"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ and WebSocket connections into a unified router structure.
|
|||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
/rooms/* - Room CRUD operations (Admin/Judge only for write ops)
|
/rooms/* - Room CRUD operations (Admin/Judge only for write ops)
|
||||||
/games/* - Game management stubs
|
/games/* - Game management endpoints
|
||||||
/mtgjson/* - MTGJSON dataset refresh and verification (Admin only)
|
/mtgjson/* - MTGJSON dataset refresh and verification (Admin only)
|
||||||
/ws/game/{game_id} - WebSocket connection for game sessions
|
/ws/game/{game_id} - WebSocket connection for game sessions
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Architecture:
|
|||||||
- Admin/Judge privilege checks on sensitive operations
|
- Admin/Judge privilege checks on sensitive operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, WebSocket, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, WebSocket, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, insert, update, delete
|
from sqlalchemy import select, insert, update, delete
|
||||||
@@ -25,11 +26,24 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.models import Room, User
|
from app.models.models import (
|
||||||
from app.schemas.schemas import RoomResponse, GameCreate, GameResponse
|
Room, User, MTGOnlineGame, GamePlayer, GameHistory
|
||||||
|
)
|
||||||
|
from app.schemas.schemas import (
|
||||||
|
RoomResponse, GameCreate, GameResponse, GameUpdate, GamePlayerResponse,
|
||||||
|
GameHistoryResponse, GameJoinResponse
|
||||||
|
)
|
||||||
from app.services.mtgjson_manager import MTGJSONManager
|
from app.services.mtgjson_manager import MTGJSONManager
|
||||||
from app.services.game_server import game_websocket_endpoint
|
from app.services.game_server import game_websocket_endpoint
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Valid game types
|
||||||
|
VALID_GAME_TYPES = ["Commander", "Standard", "Modern", "Pauper"]
|
||||||
|
|
||||||
|
# Valid game states
|
||||||
|
VALID_GAME_STATES = ["WAITING", "IN_PROGRESS", "FINISHED", "ABORTED"]
|
||||||
|
|
||||||
# Main router
|
# Main router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -217,17 +231,61 @@ games_router = APIRouter(prefix="/games", tags=["Games"])
|
|||||||
@games_router.get("/", response_model=List[GameResponse])
|
@games_router.get("/", response_model=List[GameResponse])
|
||||||
async def list_games(
|
async def list_games(
|
||||||
room_id: Optional[int] = None,
|
room_id: Optional[int] = None,
|
||||||
|
game_type: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> List[GameResponse]:
|
) -> List[GameResponse]:
|
||||||
"""List active games.
|
"""List active games.
|
||||||
|
|
||||||
Returns a list of currently active games.
|
Returns a paginated list of games with optional filtering by room_id,
|
||||||
Note: This is a stub implementation - returns empty list.
|
game_type, and state. Defaults to filtering by WAITING state.
|
||||||
"""
|
"""
|
||||||
# This would query a games table - simplified for now
|
# Build query with filters
|
||||||
# In production, you'd have a MTG OnlineGames model
|
stmt = select(MTGOnlineGame)
|
||||||
return []
|
|
||||||
|
if room_id:
|
||||||
|
stmt = stmt.where(MTGOnlineGame.room_id == room_id)
|
||||||
|
|
||||||
|
if game_type:
|
||||||
|
if game_type not in VALID_GAME_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid game_type. Must be one of: {VALID_GAME_TYPES}",
|
||||||
|
)
|
||||||
|
stmt = stmt.where(MTGOnlineGame.game_type == game_type)
|
||||||
|
|
||||||
|
if state:
|
||||||
|
if state not in VALID_GAME_STATES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid state. Must be one of: {VALID_GAME_STATES}",
|
||||||
|
)
|
||||||
|
stmt = stmt.where(MTGOnlineGame.state == state)
|
||||||
|
else:
|
||||||
|
# Default to WAITING games
|
||||||
|
stmt = stmt.where(MTGOnlineGame.state == "WAITING")
|
||||||
|
|
||||||
|
# Add pagination
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
stmt = stmt.offset(offset).limit(page_size).order_by(MTGOnlineGame.created_at.desc())
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
games = result.scalars().all()
|
||||||
|
|
||||||
|
# Fetch players for each game
|
||||||
|
game_responses = []
|
||||||
|
for game in games:
|
||||||
|
player_stmt = select(GamePlayer).where(GamePlayer.game_id == game.id)
|
||||||
|
player_result = await db.execute(player_stmt)
|
||||||
|
players = player_result.scalars().all()
|
||||||
|
|
||||||
|
game.players = [GamePlayerResponse.model_validate(p) for p in players]
|
||||||
|
game_responses.append(GameResponse.model_validate(game))
|
||||||
|
|
||||||
|
return game_responses
|
||||||
|
|
||||||
|
|
||||||
@games_router.post("/", response_model=GameResponse, status_code=status.HTTP_201_CREATED)
|
@games_router.post("/", response_model=GameResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -236,39 +294,66 @@ async def create_game(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> GameResponse:
|
) -> GameResponse:
|
||||||
"""Create a new game (mock implementation).
|
"""Create a new game.
|
||||||
|
|
||||||
Creates a new game session.
|
Creates a new game session with the current user as host.
|
||||||
Note: This is a stub - returns mock response.
|
Validates room exists and game_type is valid.
|
||||||
"""
|
"""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
# Verify user exists
|
# Validate game_type
|
||||||
stmt = select(User).where(User.id == user_id)
|
if request.game_type not in VALID_GAME_TYPES:
|
||||||
result = await db.execute(stmt)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="User not found",
|
detail=f"Invalid game_type. Must be one of: {VALID_GAME_TYPES}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# In production, you'd create a game in the game database
|
# Check if room exists
|
||||||
# For now, return a mock response
|
stmt = select(Room).where(Room.id == request.room_id)
|
||||||
game_data = {
|
result = await db.execute(stmt)
|
||||||
"id": 1, # Mock ID
|
room = result.scalar_one_or_none()
|
||||||
"room_id": request.room_id,
|
|
||||||
"game_type": request.game_type,
|
|
||||||
"description": request.description,
|
|
||||||
"with_password": bool(request.password),
|
|
||||||
"max_players": 4,
|
|
||||||
"player_count": 1,
|
|
||||||
"started": False,
|
|
||||||
"creation_date": datetime.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return GameResponse(**game_data)
|
if not room:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Room not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create game
|
||||||
|
new_game = MTGOnlineGame(
|
||||||
|
room_id=request.room_id,
|
||||||
|
host_id=user_id,
|
||||||
|
game_type=request.game_type,
|
||||||
|
state="WAITING",
|
||||||
|
is_password_protected=request.is_password_protected,
|
||||||
|
max_players=request.max_players,
|
||||||
|
current_players=1,
|
||||||
|
)
|
||||||
|
db.add(new_game)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Add host as first player
|
||||||
|
host_player = GamePlayer(
|
||||||
|
game_id=new_game.id,
|
||||||
|
user_id=user_id,
|
||||||
|
seat_number=1,
|
||||||
|
life_total=20,
|
||||||
|
is_ready=False,
|
||||||
|
is_host=True,
|
||||||
|
)
|
||||||
|
db.add(host_player)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Fetch players for response
|
||||||
|
player_stmt = select(GamePlayer).where(GamePlayer.game_id == new_game.id)
|
||||||
|
player_result = await db.execute(player_result)
|
||||||
|
players = player_result.scalars().all()
|
||||||
|
|
||||||
|
new_game.players = [GamePlayerResponse.model_validate(p) for p in players]
|
||||||
|
|
||||||
|
logger.info(f"Game {new_game.id} created by user {user_id} in room {request.room_id}")
|
||||||
|
|
||||||
|
return GameResponse.model_validate(new_game)
|
||||||
|
|
||||||
|
|
||||||
@games_router.get("/{game_id}", response_model=GameResponse)
|
@games_router.get("/{game_id}", response_model=GameResponse)
|
||||||
@@ -277,91 +362,322 @@ async def get_game(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> GameResponse:
|
) -> GameResponse:
|
||||||
"""Get game by ID (mock implementation).
|
"""Get game by ID.
|
||||||
|
|
||||||
Returns details about a specific game.
|
Returns detailed information about a specific game including player list.
|
||||||
Note: This is a stub - returns mock response.
|
Raises 404 if game not found.
|
||||||
"""
|
"""
|
||||||
# In production, query the games table
|
# Get game
|
||||||
return GameResponse(
|
stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id)
|
||||||
id=game_id,
|
result = await db.execute(stmt)
|
||||||
room_id=1,
|
game = result.scalar_one_or_none()
|
||||||
game_type="Casual",
|
|
||||||
description="Test game",
|
if not game:
|
||||||
with_password=False,
|
raise HTTPException(
|
||||||
max_players=4,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
player_count=0,
|
detail="Game not found",
|
||||||
started=False,
|
)
|
||||||
creation_date=datetime.now(),
|
|
||||||
)
|
# Get players
|
||||||
|
player_stmt = select(GamePlayer).where(GamePlayer.game_id == game_id)
|
||||||
|
player_result = await db.execute(player_stmt)
|
||||||
|
players = player_result.scalars().all()
|
||||||
|
|
||||||
|
game.players = [GamePlayerResponse.model_validate(p) for p in players]
|
||||||
|
|
||||||
|
return GameResponse.model_validate(game)
|
||||||
|
|
||||||
|
|
||||||
@games_router.post("/{game_id}/join")
|
@games_router.post("/{game_id}/join", response_model=GameJoinResponse)
|
||||||
async def join_game(
|
async def join_game(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> GameJoinResponse:
|
||||||
"""Join a game (stub implementation).
|
"""Join a game.
|
||||||
|
|
||||||
Adds the current user to a game session.
|
Adds the current user to a waiting game.
|
||||||
Note: This is a stub - returns mock response.
|
Validates game exists, is in WAITING state, and has space.
|
||||||
"""
|
"""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
# In production, add user to game players table
|
# Get game
|
||||||
return {"message": f"User {user_id} joined game {game_id}"}
|
stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Game not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check game state
|
||||||
|
if game.state != "WAITING":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Game is not in WAITING state",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if game is full
|
||||||
|
if game.current_players >= game.max_players:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Game is full",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user is already in game
|
||||||
|
player_stmt = select(GamePlayer).where(
|
||||||
|
GamePlayer.game_id == game_id,
|
||||||
|
GamePlayer.user_id == user_id,
|
||||||
|
)
|
||||||
|
player_result = await db.execute(player_stmt)
|
||||||
|
existing_player = player_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_player:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="User is already in this game",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get next available seat number
|
||||||
|
seat_stmt = select(GamePlayer.seat_number).where(
|
||||||
|
GamePlayer.game_id == game_id
|
||||||
|
).order_by(GamePlayer.seat_number.desc()).limit(1)
|
||||||
|
seat_result = await db.execute(seat_stmt)
|
||||||
|
max_seat = seat_result.scalar_one_or_none()
|
||||||
|
next_seat = (max_seat or 0) + 1
|
||||||
|
|
||||||
|
# Add player
|
||||||
|
new_player = GamePlayer(
|
||||||
|
game_id=game_id,
|
||||||
|
user_id=user_id,
|
||||||
|
seat_number=next_seat,
|
||||||
|
life_total=20,
|
||||||
|
is_ready=False,
|
||||||
|
is_host=False,
|
||||||
|
)
|
||||||
|
db.add(new_player)
|
||||||
|
|
||||||
|
# Update game player count
|
||||||
|
game.current_players += 1
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(f"User {user_id} joined game {game_id} as seat {next_seat}")
|
||||||
|
|
||||||
|
return GameJoinResponse(
|
||||||
|
message=f"Successfully joined game {game_id}",
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=user_id,
|
||||||
|
current_players=game.current_players,
|
||||||
|
max_players=game.max_players,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@games_router.post("/{game_id}/leave")
|
@games_router.post("/{game_id}/leave", response_model=GameJoinResponse)
|
||||||
async def leave_game(
|
async def leave_game(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> GameJoinResponse:
|
||||||
"""Leave a game (stub implementation).
|
"""Leave a game.
|
||||||
|
|
||||||
Removes the current user from a game session.
|
Removes the current user from a game.
|
||||||
Note: This is a stub - returns mock response.
|
If host leaves, transfers host to another player or closes game.
|
||||||
"""
|
"""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
# In production, remove user from game players table
|
# Get game
|
||||||
return {"message": f"User {user_id} left game {game_id}"}
|
stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Game not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get player
|
||||||
|
player_stmt = select(GamePlayer).where(
|
||||||
|
GamePlayer.game_id == game_id,
|
||||||
|
GamePlayer.user_id == user_id,
|
||||||
|
)
|
||||||
|
player_result = await db.execute(player_stmt)
|
||||||
|
player = player_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not player:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="User is not in this game",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If host is leaving, transfer host or close game
|
||||||
|
if player.is_host:
|
||||||
|
# Get other players
|
||||||
|
other_players_stmt = select(GamePlayer).where(
|
||||||
|
GamePlayer.game_id == game_id,
|
||||||
|
GamePlayer.user_id != user_id,
|
||||||
|
)
|
||||||
|
other_players_result = await db.execute(other_players_stmt)
|
||||||
|
other_players = other_players_result.scalars().all()
|
||||||
|
|
||||||
|
if other_players:
|
||||||
|
# Transfer host to first other player
|
||||||
|
new_host = other_players[0]
|
||||||
|
new_host.is_host = True
|
||||||
|
player.is_host = False
|
||||||
|
logger.info(f"Host transferred from user {user_id} to user {new_host.user_id} in game {game_id}")
|
||||||
|
else:
|
||||||
|
# No other players, close game
|
||||||
|
game.state = "FINISHED"
|
||||||
|
game.finished_at = datetime.now()
|
||||||
|
logger.info(f"Game {game_id} closed - host left with no other players")
|
||||||
|
|
||||||
|
# Remove player
|
||||||
|
await db.delete(player)
|
||||||
|
|
||||||
|
# Update game player count
|
||||||
|
game.current_players -= 1
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(f"User {user_id} left game {game_id}")
|
||||||
|
|
||||||
|
return GameJoinResponse(
|
||||||
|
message=f"Successfully left game {game_id}",
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=user_id,
|
||||||
|
current_players=game.current_players,
|
||||||
|
max_players=game.max_players,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@games_router.post("/{game_id}/start")
|
@games_router.post("/{game_id}/start", response_model=GameJoinResponse)
|
||||||
async def start_game(
|
async def start_game(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> GameJoinResponse:
|
||||||
"""Start a game (host only, stub implementation).
|
"""Start a game (host only).
|
||||||
|
|
||||||
Initiates a game session.
|
Initiates a game session.
|
||||||
Note: This is a stub - returns mock response.
|
Validates that the current user is the host.
|
||||||
"""
|
"""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
# In production, update game started status
|
# Get game
|
||||||
return {"message": f"Game {game_id} started"}
|
stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Game not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check game state
|
||||||
|
if game.state != "WAITING":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Game is not in WAITING state",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user is host
|
||||||
|
player_stmt = select(GamePlayer).where(
|
||||||
|
GamePlayer.game_id == game_id,
|
||||||
|
GamePlayer.user_id == user_id,
|
||||||
|
)
|
||||||
|
player_result = await db.execute(player_stmt)
|
||||||
|
player = player_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not player or not player.is_host:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the host can start the game",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start game
|
||||||
|
game.state = "IN_PROGRESS"
|
||||||
|
game.started_at = datetime.now()
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(f"Game {game_id} started by host user {user_id}")
|
||||||
|
|
||||||
|
return GameJoinResponse(
|
||||||
|
message=f"Game {game_id} started",
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=user_id,
|
||||||
|
current_players=game.current_players,
|
||||||
|
max_players=game.max_players,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@games_router.post("/{game_id}/end")
|
@games_router.post("/{game_id}/end", response_model=GameJoinResponse)
|
||||||
async def end_game(
|
async def end_game(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> GameJoinResponse:
|
||||||
"""End a game (stub implementation).
|
"""End a game.
|
||||||
|
|
||||||
Terminates a game session.
|
Terminates a game session and records it in GameHistory.
|
||||||
Note: This is a stub - returns mock response.
|
|
||||||
"""
|
"""
|
||||||
user_id = int(current_user["user_id"])
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
# In production, update game closed status
|
# Get game
|
||||||
return {"message": f"Game {game_id} ended"}
|
stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Game not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check game state
|
||||||
|
if game.state not in ["WAITING", "IN_PROGRESS"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Game is already finished or aborted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration_seconds = None
|
||||||
|
if game.started_at:
|
||||||
|
end_time = datetime.now()
|
||||||
|
duration_seconds = int((end_time - game.started_at).total_seconds())
|
||||||
|
|
||||||
|
# Determine winner (for now, host wins by default)
|
||||||
|
winner_id = game.host_id
|
||||||
|
result_type = "victory"
|
||||||
|
|
||||||
|
# Create game history record
|
||||||
|
game_history = GameHistory(
|
||||||
|
game_id=game.id,
|
||||||
|
room_id=game.room_id,
|
||||||
|
game_type=game.game_type,
|
||||||
|
winner_id=winner_id,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
result_type=result_type,
|
||||||
|
)
|
||||||
|
db.add(game_history)
|
||||||
|
|
||||||
|
# Update game state
|
||||||
|
game.state = "FINISHED"
|
||||||
|
game.finished_at = datetime.now()
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(f"Game {game_id} ended. Winner: user {winner_id}, Duration: {duration_seconds}s")
|
||||||
|
|
||||||
|
return GameJoinResponse(
|
||||||
|
message=f"Game {game_id} ended",
|
||||||
|
game_id=game_id,
|
||||||
|
player_id=user_id,
|
||||||
|
current_players=0,
|
||||||
|
max_players=game.max_players,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -142,26 +142,75 @@ class FolderResponse(BaseModel):
|
|||||||
class GameCreate(BaseModel):
|
class GameCreate(BaseModel):
|
||||||
"""Game creation request."""
|
"""Game creation request."""
|
||||||
room_id: int
|
room_id: int
|
||||||
game_type: Optional[str] = None
|
game_type: str = Field("Commander", pattern="^(Commander|Standard|Modern|Pauper)$")
|
||||||
|
max_players: int = Field(4, ge=2, le=10)
|
||||||
|
is_password_protected: bool = False
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
password: Optional[str] = None
|
|
||||||
|
|
||||||
|
class GameUpdate(BaseModel):
|
||||||
|
"""Game update request."""
|
||||||
|
game_type: Optional[str] = Field(None, pattern="^(Commander|Standard|Modern|Pauper)$")
|
||||||
|
max_players: Optional[int] = Field(None, ge=2, le=10)
|
||||||
|
is_password_protected: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GamePlayerResponse(BaseModel):
|
||||||
|
"""Game player response."""
|
||||||
|
id: int
|
||||||
|
game_id: int
|
||||||
|
user_id: int
|
||||||
|
seat_number: Optional[int]
|
||||||
|
life_total: int
|
||||||
|
is_ready: bool
|
||||||
|
is_host: bool
|
||||||
|
joined_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class GameResponse(BaseModel):
|
class GameResponse(BaseModel):
|
||||||
"""Game response payload."""
|
"""Game response payload."""
|
||||||
id: int
|
id: int
|
||||||
room_id: int
|
room_id: int
|
||||||
game_type: Optional[str]
|
host_id: int
|
||||||
description: Optional[str]
|
game_type: str
|
||||||
with_password: bool
|
state: str
|
||||||
|
is_password_protected: bool
|
||||||
max_players: int
|
max_players: int
|
||||||
player_count: int
|
current_players: int
|
||||||
started: bool
|
created_at: datetime
|
||||||
creation_date: datetime
|
started_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
players: List[GamePlayerResponse] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GameHistoryResponse(BaseModel):
|
||||||
|
"""Game history response."""
|
||||||
|
id: int
|
||||||
|
game_id: Optional[int] = None
|
||||||
|
room_id: Optional[int] = None
|
||||||
|
game_type: str
|
||||||
|
winner_id: Optional[int] = None
|
||||||
|
duration_seconds: Optional[int] = None
|
||||||
|
result_type: Optional[str] = None
|
||||||
|
replay_data: Optional[dict] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GameJoinResponse(BaseModel):
|
||||||
|
"""Game join/leave response."""
|
||||||
|
message: str
|
||||||
|
game_id: int
|
||||||
|
player_id: int
|
||||||
|
current_players: int
|
||||||
|
max_players: int
|
||||||
|
|
||||||
|
|
||||||
# ===== Room Schemas =====
|
# ===== Room Schemas =====
|
||||||
|
|
||||||
class RoomResponse(BaseModel):
|
class RoomResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
Commander deck validation service.
|
||||||
|
|
||||||
|
Enforces Commander/EDH format rules:
|
||||||
|
- 100-card minimum (excluding basic lands)
|
||||||
|
- Color identity rules
|
||||||
|
- Commander uniqueness
|
||||||
|
- Ban list enforcement
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Set, Optional, Tuple
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.models import MtgonlineCard
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Commander ban list (subset - would be loaded from MTGJSON in production)
|
||||||
|
COMMANDER_BAN_LIST = {
|
||||||
|
"Balance",
|
||||||
|
"Bolas's Citadel",
|
||||||
|
"Cabal Coffers",
|
||||||
|
"Chalice of the Void",
|
||||||
|
"Chemister's Insight",
|
||||||
|
"Commander's Sphere",
|
||||||
|
"Demonic Consultation",
|
||||||
|
"Demonic Tutor",
|
||||||
|
"Diabolic Edict",
|
||||||
|
"Dueling Coach",
|
||||||
|
"Enlightened Tutor",
|
||||||
|
"Ezuri's Renegade",
|
||||||
|
"Fierce Empath",
|
||||||
|
"Field of the Dead",
|
||||||
|
"Gamble",
|
||||||
|
"Golos, Tireless Pilgrim",
|
||||||
|
"Graveyard Shift",
|
||||||
|
"Hullbreacher",
|
||||||
|
"Imperial Seal",
|
||||||
|
"Jeska's Will",
|
||||||
|
"Karn, the Great Creator",
|
||||||
|
"Kethis, the Hidden Hand",
|
||||||
|
"Library of Alexandria",
|
||||||
|
"Lotho, Corrupt Shirriff",
|
||||||
|
"Mox Opal",
|
||||||
|
"Mox Petal",
|
||||||
|
"Mox Diamond",
|
||||||
|
"Narset, Parter of Veils",
|
||||||
|
"Oko, Thief of Crowns",
|
||||||
|
"Power Sink",
|
||||||
|
"Rafiq of the Many",
|
||||||
|
"Reanimate",
|
||||||
|
"Rings of Brighthearth",
|
||||||
|
"Rishadan Port",
|
||||||
|
"Swords to Plowshares",
|
||||||
|
"Teferi, Time Raveler",
|
||||||
|
"Thoughtseize",
|
||||||
|
"Tolarian Academy",
|
||||||
|
"Underworld Connections",
|
||||||
|
"Vampiric Tutor",
|
||||||
|
"Windfall",
|
||||||
|
"Yawgmoth's Will",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Basic land names (excluded from 100-card count)
|
||||||
|
BASIC_LANDS = {
|
||||||
|
"Plains",
|
||||||
|
"Island",
|
||||||
|
"Swamp",
|
||||||
|
"Mountain",
|
||||||
|
"Forest",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CommanderValidationError(Exception):
|
||||||
|
"""Raised when a Commander deck fails validation."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CommanderValidator:
|
||||||
|
"""Validates Commander/EDH decks against format rules."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.ban_list = COMMANDER_BAN_LIST.copy()
|
||||||
|
|
||||||
|
async def validate_deck(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
deck_cards: List[Dict],
|
||||||
|
commander_card_id: Optional[int] = None,
|
||||||
|
) -> Tuple[bool, List[str]]:
|
||||||
|
"""
|
||||||
|
Validate a Commander deck.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
deck_cards: List of card dicts with 'card_id' and 'quantity'
|
||||||
|
commander_card_id: ID of the commander card
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, list_of_errors)
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Load all cards in the deck
|
||||||
|
card_ids = [c["card_id"] for c in deck_cards]
|
||||||
|
if not card_ids:
|
||||||
|
errors.append("Deck is empty")
|
||||||
|
return False, errors
|
||||||
|
|
||||||
|
stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
cards = result.scalars().all()
|
||||||
|
card_map = {c.id: c for c in cards}
|
||||||
|
|
||||||
|
# Count non-basic-land cards
|
||||||
|
total_cards = 0
|
||||||
|
for card_info in deck_cards:
|
||||||
|
card_id = card_info["card_id"]
|
||||||
|
quantity = card_info.get("quantity", 1)
|
||||||
|
card = card_map.get(card_id)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
errors.append(f"Card ID {card_id} not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip basic lands from count
|
||||||
|
if card.name in BASIC_LANDS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_cards += quantity
|
||||||
|
|
||||||
|
# Check 100-card minimum
|
||||||
|
if total_cards < 100:
|
||||||
|
errors.append(
|
||||||
|
f"Deck has {total_cards} non-basic-land cards, "
|
||||||
|
f"minimum is 100"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check Commander uniqueness
|
||||||
|
if commander_card_id:
|
||||||
|
commander = card_map.get(commander_card_id)
|
||||||
|
if not commander:
|
||||||
|
errors.append(f"Commander card ID {commander_card_id} not found")
|
||||||
|
elif not self._is_commander(commander):
|
||||||
|
errors.append("Commander must be a legendary creature or planeswalker")
|
||||||
|
|
||||||
|
# Check ban list
|
||||||
|
for card_info in deck_cards:
|
||||||
|
card_id = card_info["card_id"]
|
||||||
|
card = card_map.get(card_id)
|
||||||
|
if card and card.name in self.ban_list:
|
||||||
|
errors.append(f"Card '{card.name}' is banned in Commander")
|
||||||
|
|
||||||
|
# Check color identity (simplified - would need full implementation)
|
||||||
|
# This requires checking the commander's color identity
|
||||||
|
# and ensuring all cards match
|
||||||
|
|
||||||
|
is_valid = len(errors) == 0
|
||||||
|
return is_valid, errors
|
||||||
|
|
||||||
|
def _is_commander(self, card) -> bool:
|
||||||
|
"""Check if a card can be a commander."""
|
||||||
|
# Check if legendary
|
||||||
|
is_legendary = "Legendary" in (card.type_line or "")
|
||||||
|
|
||||||
|
# Check if has Commander ability (for some cards)
|
||||||
|
has_commander_ability = "Commander" in (card.oracle_text or "")
|
||||||
|
|
||||||
|
# Planeswalkers can be commanders if legendary
|
||||||
|
is_planeswalker = "Planeswalker" in (card.type_line or "")
|
||||||
|
|
||||||
|
return (is_legendary and is_planeswalker) or has_commander_ability
|
||||||
|
|
||||||
|
def get_color_identity(self, commander_card) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Get the color identity of a commander card.
|
||||||
|
|
||||||
|
Color identity includes all colors in the card's mana cost,
|
||||||
|
type line, and rules text.
|
||||||
|
"""
|
||||||
|
colors = set()
|
||||||
|
|
||||||
|
# Parse mana cost
|
||||||
|
mana_cost = commander_card.mana_cost or ""
|
||||||
|
if "W" in mana_cost:
|
||||||
|
colors.add("W")
|
||||||
|
if "U" in mana_cost:
|
||||||
|
colors.add("U")
|
||||||
|
if "B" in mana_cost:
|
||||||
|
colors.add("B")
|
||||||
|
if "R" in mana_cost:
|
||||||
|
colors.add("R")
|
||||||
|
if "G" in mana_cost:
|
||||||
|
colors.add("G")
|
||||||
|
|
||||||
|
# Check type line for color indicators
|
||||||
|
type_line = commander_card.type_line or ""
|
||||||
|
if "White" in type_line:
|
||||||
|
colors.add("W")
|
||||||
|
if "Blue" in type_line:
|
||||||
|
colors.add("U")
|
||||||
|
if "Black" in type_line:
|
||||||
|
colors.add("B")
|
||||||
|
if "Red" in type_line:
|
||||||
|
colors.add("R")
|
||||||
|
if "Green" in type_line:
|
||||||
|
colors.add("G")
|
||||||
|
|
||||||
|
# Check oracle text for color indicators
|
||||||
|
oracle_text = commander_card.oracle_text or ""
|
||||||
|
if "White" in oracle_text:
|
||||||
|
colors.add("W")
|
||||||
|
if "Blue" in oracle_text:
|
||||||
|
colors.add("U")
|
||||||
|
if "Black" in oracle_text:
|
||||||
|
colors.add("B")
|
||||||
|
if "Red" in oracle_text:
|
||||||
|
colors.add("R")
|
||||||
|
if "Green" in oracle_text:
|
||||||
|
colors.add("G")
|
||||||
|
|
||||||
|
return colors
|
||||||
|
|
||||||
|
def validate_color_identity(
|
||||||
|
self,
|
||||||
|
deck_cards: List[Dict],
|
||||||
|
commander_card,
|
||||||
|
card_map: Dict[int, MtgonlineCard],
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Validate that all cards in the deck match the commander's color identity.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
commander_identity = self.get_color_identity(commander_card)
|
||||||
|
|
||||||
|
if not commander_identity:
|
||||||
|
# Colorless commander allows only colorless cards
|
||||||
|
pass
|
||||||
|
|
||||||
|
for card_info in deck_cards:
|
||||||
|
card_id = card_info["card_id"]
|
||||||
|
card = card_map.get(card_id)
|
||||||
|
if not card:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip basic lands
|
||||||
|
if card.name in BASIC_LANDS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check card colors against commander identity
|
||||||
|
card_colors = self._get_card_colors(card)
|
||||||
|
if card_colors and not card_colors.issubset(commander_identity):
|
||||||
|
errors.append(
|
||||||
|
f"Card '{card.name}' has colors {card_colors} "
|
||||||
|
f"not in commander identity {commander_identity}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def _get_card_colors(self, card) -> Set[str]:
|
||||||
|
"""Get the colors of a card."""
|
||||||
|
colors = set()
|
||||||
|
|
||||||
|
mana_cost = card.mana_cost or ""
|
||||||
|
if "W" in mana_cost:
|
||||||
|
colors.add("W")
|
||||||
|
if "U" in mana_cost:
|
||||||
|
colors.add("U")
|
||||||
|
if "B" in mana_cost:
|
||||||
|
colors.add("B")
|
||||||
|
if "R" in mana_cost:
|
||||||
|
colors.add("R")
|
||||||
|
if "G" in mana_cost:
|
||||||
|
colors.add("G")
|
||||||
|
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
commander_validator = CommanderValidator()
|
||||||
@@ -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 asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from typing import Dict, Set, Optional
|
from typing import Dict, Set, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
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
|
# Protocol message types
|
||||||
GAME_EVENT_JOIN = 1000
|
GAME_EVENT_JOIN = 1000
|
||||||
@@ -75,7 +85,10 @@ GAME_COMMAND_REVERSE_TURN = 1034
|
|||||||
|
|
||||||
|
|
||||||
class GameRoom:
|
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):
|
def __init__(self, room_id: int, game_id: int):
|
||||||
self.room_id = room_id
|
self.room_id = room_id
|
||||||
@@ -92,6 +105,38 @@ class GameRoom:
|
|||||||
"zones": {},
|
"zones": {},
|
||||||
}
|
}
|
||||||
self.lock = asyncio.Lock()
|
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):
|
async def add_player(self, player_id: int, websocket: WebSocket):
|
||||||
"""Add a player to the game."""
|
"""Add a player to the game."""
|
||||||
@@ -114,7 +159,7 @@ class GameRoom:
|
|||||||
try:
|
try:
|
||||||
await websocket.send_text(message_str)
|
await websocket.send_text(message_str)
|
||||||
except Exception as e:
|
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):
|
async def send_to_player(self, player_id: int, message: dict):
|
||||||
"""Send a message to a specific player."""
|
"""Send a message to a specific player."""
|
||||||
@@ -123,7 +168,7 @@ class GameRoom:
|
|||||||
message_str = json.dumps(message)
|
message_str = json.dumps(message)
|
||||||
await self.players[player_id].send_text(message_str)
|
await self.players[player_id].send_text(message_str)
|
||||||
except Exception as e:
|
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:
|
def get_player_count(self) -> int:
|
||||||
"""Get number of connected players."""
|
"""Get number of connected players."""
|
||||||
@@ -133,6 +178,54 @@ class GameRoom:
|
|||||||
"""Check if game is full."""
|
"""Check if game is full."""
|
||||||
return self.get_player_count() >= max_players
|
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:
|
class GameServer:
|
||||||
"""Manages all active game rooms."""
|
"""Manages all active game rooms."""
|
||||||
@@ -175,7 +268,10 @@ game_server = GameServer()
|
|||||||
|
|
||||||
|
|
||||||
async def game_websocket_endpoint(websocket: WebSocket, game_id: int):
|
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)
|
room = await game_server.get_room(game_id)
|
||||||
if not room:
|
if not room:
|
||||||
await websocket.close(code=4004, reason="Game not found")
|
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,
|
"player_id": player_id,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
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)
|
await room.remove_player(player_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -300,6 +396,11 @@ async def process_game_command(room: GameRoom, player_id: int, command: dict):
|
|||||||
})
|
})
|
||||||
return
|
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
|
# Broadcast command as game event
|
||||||
await room.broadcast({
|
await room.broadcast({
|
||||||
"type": "game_event",
|
"type": "game_event",
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@
|
|||||||
"Write unit tests for game services"
|
"Write unit tests for game services"
|
||||||
],
|
],
|
||||||
"blockers": null,
|
"blockers": null,
|
||||||
"commit_hash": "900f51a",
|
"commit_hash": "0e6506d",
|
||||||
"timestamp": "2025-08-27T04:45:00Z"
|
"timestamp": "2025-08-27T04:45:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user