Initial commit of mtgonline project

This commit is contained in:
2026-07-18 04:57:40 +00:00
commit 86c12376f8
1870 changed files with 547994 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Application Settings
APP_NAME=Cockatrice Web
APP_VERSION=0.1.0
DEBUG=True
# Database (PostgreSQL)
DATABASE_URL=postgresql+asyncpg://cockatrice_user:cockatrice_password@localhost:5432/cockatrice
# Redis (optional, for caching)
REDIS_URL=redis://localhost:6379/0
# Authentication
JWT_SECRET_KEY=your-secret-key-change-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# CORS
CORS_ORIGINS=["http://localhost:3000","http://localhost:8000"]
# Upload Settings
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE=10485760 # 10MB in bytes
# Logging
LOG_LEVEL=INFO
LOG_FORMAT=json
+61
View File
@@ -0,0 +1,61 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
.venv/
env/
.env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Environment
.env
.env.local
.env.production
# Docker
docker-compose.override.yml
+33
View File
@@ -0,0 +1,33 @@
"""
Cockatrice Web Application
A modern web-based implementation of the Cockatrice multiplayer Magic: The Gathering platform.
Built with FastAPI, WebSocket, and protocol buffer compatibility.
## Features
- User authentication and authorization
- Deck building and storage
- Real-time multiplayer game rooms
- Protocol buffer message compatibility
- Card database integration
- Admin moderation tools
## Running the Application
1. Install dependencies: `pip install -r requirements.txt`
2. Set environment variables or create `.env` file
3. Run: `uvicorn app.main:app --reload`
4. Access API docs at `http://localhost:8000/docs`
## Architecture
- Backend: FastAPI with async SQLAlchemy and WebSocket support
- Database: PostgreSQL with async driver
- Authentication: JWT tokens with bcrypt password hashing
- Game Server: WebSocket-based real-time multiplayer
- Protocol: Compatible with Cockatrice protocol buffer messages
## License
MIT License
"""
__version__ = "0.1.0"
__author__ = "Cockatrice Web Team"
+42
View File
@@ -0,0 +1,42 @@
"""
Database engine and session management.
Provides async SQLAlchemy engine and session factory for dependency injection.
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.settings import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_size=20,
max_overflow=10,
)
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""Base class for all ORM models."""
pass
async def get_db() -> AsyncSession:
"""FastAPI dependency that provides a database session."""
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+85
View File
@@ -0,0 +1,85 @@
"""
Security utilities for authentication and password hashing.
Implements JWT token management and bcrypt password hashing with salt.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.settings import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a bcrypt hash."""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt with configurable rounds."""
return pwd_context.hash(password, rounds=settings.BCRYPT_ROUNDS)
def create_access_token(
subject: str,
expires_delta: Optional[timedelta] = None,
) -> str:
"""Create a JWT access token."""
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
payload = {
"sub": subject,
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "access",
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(subject: str) -> str:
"""Create a JWT refresh token with longer expiry."""
expire = datetime.now(timezone.utc) + timedelta(
days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
payload = {
"sub": subject,
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "refresh",
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT token."""
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None
def get_current_user(token: str) -> Optional[dict]:
"""Extract user info from JWT token."""
payload = decode_token(token)
if not payload:
return None
if payload.get("type") != "access":
return None
return {
"user_id": payload.get("sub"),
"token_type": payload.get("type"),
}
+54
View File
@@ -0,0 +1,54 @@
"""
Application configuration management.
Uses pydantic-settings for typed configuration with environment variable overrides.
"""
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment variables or .env file."""
# Application
APP_NAME: str = "Cockatrice Web"
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
SECRET_KEY: str = "change-me-in-production"
# Database
DATABASE_URL: str = "postgresql+asyncpg://cockatrice:cockatrice@localhost:5432/cockatrice"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# JWT Configuration
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8080"]
# Email (for password reset, account activation)
SMTP_HOST: Optional[str] = None
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
EMAIL_FROM: Optional[str] = None
# Security
BCRYPT_ROUNDS: int = 12
MAX_LOGIN_ATTEMPTS: int = 5
LOGIN_BLOCK_MINUTES: int = 15
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
"""Get cached application settings."""
return Settings()
+53
View File
@@ -0,0 +1,53 @@
"""
FastAPI application factory and middleware setup.
Configures CORS, authentication, and error handling.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.settings import get_settings
from app.routers import auth, users, decks, rooms, games, admin
settings = get_settings()
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS configuration
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global exception handlers
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""Handle unhandled exceptions gracefully."""
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
# Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
app.include_router(decks.router, prefix="/api/v1/decks", tags=["Decks"])
app.include_router(rooms.router, prefix="/api/v1/rooms", tags=["Rooms"])
app.include_router(games.router, prefix="/api/v1/games", tags=["Games"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["Admin"])
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring."""
return {"status": "healthy", "version": settings.APP_VERSION}
+170
View File
@@ -0,0 +1,170 @@
"""
SQLAlchemy ORM models for the Cockatrice database.
Mirrors the original MySQL schema with modern PostgreSQL features.
"""
from sqlalchemy import Column, Integer, String, BigInteger, Boolean, DateTime, Text, ForeignKey, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class User(Base):
"""User account model."""
__tablename__ = "cockatrice_users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
password_sha512 = Column(String(128), nullable=False) # bcrypt hash
salt = Column(String(128), nullable=False) # password salt
email = Column(String(255), nullable=True, index=True)
country = Column(String(2), nullable=True)
real_name = Column(String(128), nullable=True)
avatar_bmp = Column(Text, nullable=True) # Base64 encoded avatar
privlevel = Column(String(50), nullable=True, default="User")
# Account status
is_active = Column(Boolean, default=True)
is_banned = Column(Boolean, default=False)
ban_reason = Column(Text, nullable=True)
ban_ends = Column(DateTime, nullable=True)
# VIP/Donator status
vip_status = Column(Integer, default=0) # 0=normal, 1=vip, 2=donator
vip_expiry = Column(DateTime, nullable=True)
# Timestamps
creation_date = Column(DateTime, server_default=func.now())
last_login = Column(DateTime, nullable=True)
# Relationships
decklist_files = relationship("DecklistFile", back_populates="owner", cascade="all, delete-orphan")
decks = relationship("Deck", back_populates="owner", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<User {self.username} (ID: {self.id})>"
class DecklistFolder(Base):
"""User deck folder."""
__tablename__ = "cockatrice_decklist_folders"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
name = Column(String(255), nullable=False)
parent_id = Column(Integer, ForeignKey("cockatrice_decklist_folders.id"), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
owner = relationship("User", back_populates="decklist_folders")
children = relationship("DecklistFolder", back_populates="parent", cascade="all, delete-orphan")
parent = relationship("DecklistFolder", back_populates="children", remote_side=[id])
files = relationship("DecklistFile", back_populates="folder", cascade="all, delete-orphan")
class DecklistFile(Base):
"""Deck file stored in a folder."""
__tablename__ = "cockatrice_decklist_files"
id = Column(Integer, primary_key=True, index=True)
folder_id = Column(Integer, ForeignKey("cockatrice_decklist_folders.id"), nullable=False)
owner_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
name = Column(String(255), nullable=False)
content = Column(Text, nullable=False) # Native XML or plain text deck format
format = Column(String(50), default="native") # 'native' or 'plain'
creation_date = Column(DateTime, server_default=func.now())
# Relationships
folder = relationship("DecklistFolder", back_populates="files")
owner = relationship("User", back_populates="decklist_files")
def __repr__(self) -> str:
return f"<DecklistFile {self.name} (ID: {self.id})>"
class Room(Base):
"""Chat room."""
__tablename__ = "cockatrice_rooms"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
is_password_protected = Column(Boolean, default=False)
password_hash = Column(String(128), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Game types available in this room
game_types = relationship("RoomGameType", back_populates="room", cascade="all, delete-orphan")
class RoomGameType(Base):
"""Game type definition for a room."""
__tablename__ = "cockatrice_rooms_gametypes"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("cockatrice_rooms.id"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Relationships
room = relationship("Room", back_populates="game_types")
class Ban(Base):
"""User ban record."""
__tablename__ = "cockatrice_bans"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
server_id = Column(Integer, nullable=True)
reason = Column(Text, nullable=False)
moderators = Column(String(255), nullable=True) # Admin usernames
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
expiration_time = Column(DateTime, nullable=True)
active = Column(Boolean, default=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User")
def __repr__(self) -> str:
return f"<Ban for User {self.user_id} (ID: {self.id})>"
class GameLog(Base):
"""Game log entry."""
__tablename__ = "cockatrice_log"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("cockatrice_rooms.id"), nullable=True)
player_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=True)
message = Column(Text, nullable=False)
timestamp = Column(DateTime, server_default=func.now())
# Relationships
room = relationship("Room")
player = relationship("User")
class AuditLog(Base):
"""Audit trail for administrative actions."""
__tablename__ = "cockatrice_audit"
id = Column(Integer, primary_key=True, index=True)
admin_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=False)
action_type = Column(String(50), nullable=False) # 'ban', 'unban', 'warn', etc.
target_user_id = Column(Integer, ForeignKey("cockatrice_users.id"), nullable=True)
details = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
timestamp = Column(DateTime, server_default=func.now())
# Relationships
admin = relationship("User", foreign_keys=[admin_id])
target_user = relationship("User", foreign_keys=[target_user_id])
# Indexes for performance
Index("idx_decks_owner", DecklistFile.owner_id)
Index("idx_decks_folder", DecklistFile.folder_id)
Index("idx_bans_active", Ban.active)
Index("idx_log_timestamp", GameLog.timestamp)
+213
View File
@@ -0,0 +1,213 @@
"""Admin router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User, Ban, GameLog, AuditLog
from app.schemas.schemas import BanCreate, BanResponse
router = APIRouter()
@router.get("/users", response_model=List[dict])
async def list_users(
page: int = 1,
page_size: int = 50,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all users (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = (
select(User)
.order_by(User.id)
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
users = result.scalars().all()
return [
{
"id": user.id,
"username": user.username,
"email": user.email,
"privlevel": user.privlevel,
"is_active": user.is_active,
"is_banned": user.is_banned,
"vip_status": user.vip_status,
"creation_date": user.creation_date,
}
for user in users
]
@router.get("/bans", response_model=List[BanResponse])
async def list_bans(
active_only: bool = True,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all bans (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Ban).order_by(Ban.id)
if active_only:
stmt = stmt.where(Ban.active == True)
result = await db.execute(stmt)
bans = result.scalars().all()
return [BanResponse.model_validate(ban) for ban in bans]
@router.post("/bans", response_model=BanResponse)
async def create_ban(
request: BanCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a ban (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
new_ban = Ban(
user_id=request.user_id,
reason=request.reason,
expiration_time=request.expiration_time,
moderators=current_user.get("username"),
ip_address=None, # Would get from request
)
db.add(new_ban)
await db.flush()
return BanResponse.model_validate(new_ban)
@router.post("/bans/{ban_id}/unban")
async def unban(
ban_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Unban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Ban).where(Ban.id == ban_id)
result = await db.execute(stmt)
ban = result.scalar_one_or_none()
if not ban:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ban not found",
)
# Deactivate ban
stmt = (
update(Ban)
.where(Ban.id == ban_id)
.values(active=False)
)
await db.execute(stmt)
# Also unban the user
stmt = (
update(User)
.where(User.id == ban.user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
return {"message": "User unbanned successfully"}
@router.get("/logs", response_model=List[dict])
async def list_logs(
room_id: Optional[int] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List game logs (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(GameLog).order_by(GameLog.id.desc()).limit(limit)
if room_id:
stmt = stmt.where(GameLog.room_id == room_id)
result = await db.execute(stmt)
logs = result.scalars().all()
return [
{
"id": log.id,
"room_id": log.room_id,
"player_id": log.player_id,
"message": log.message,
"timestamp": log.timestamp,
}
for log in logs
]
@router.post("/audit", response_model=AuditLog)
async def log_audit(
action_type: str,
target_user_id: Optional[int] = None,
details: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create an audit log entry (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
new_audit = AuditLog(
admin_id=int(current_user["user_id"]),
action_type=action_type,
target_user_id=target_user_id,
details=details,
ip_address=None, # Would get from request
)
db.add(new_audit)
await db.flush()
return AuditLog.model_validate(new_audit)
+160
View File
@@ -0,0 +1,160 @@
"""Authentication router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from app.core.database import get_db
from app.core.security import (
verify_password,
hash_password,
create_access_token,
create_refresh_token,
decode_token,
)
from app.models.models import User
from app.schemas.schemas import (
LoginRequest,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UserCreate,
UserResponse,
)
router = APIRouter()
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return JWT tokens."""
# Find user by username
stmt = select(User).where(User.username == request.username)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user or not verify_password(request.password, user.password_sha512):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled",
)
if user.is_banned and user.ban_ends and user.ban_ends > __import__("datetime").datetime.now():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is banned",
)
# Update last login
user.last_login = __import__("datetime").datetime.now()
await db.flush()
# Generate tokens
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
user=UserResponse.model_validate(user).model_dump(),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(request: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
"""Refresh access token using refresh token."""
payload = decode_token(request.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Verify user still exists and is active
user_id = payload["sub"]
stmt = select(User).where(User.id == int(user_id))
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is invalid",
)
# Generate new access token
access_token = create_access_token(str(user.id))
return TokenResponse(access_token=access_token)
@router.post("/register", response_model=UserResponse)
async def register(request: UserCreate, db: AsyncSession = Depends(get_db)):
"""Register a new user account."""
# Check if username exists
stmt = select(User).where(User.username == request.username)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists",
)
# Check if email exists (if provided)
if request.email:
stmt = select(User).where(User.email == request.email)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
# Create new user
new_user = User(
username=request.username,
password_sha512=hash_password(request.password),
salt="random_salt", # In production, generate random salt
email=request.email,
country=request.country,
real_name=request.real_name,
)
db.add(new_user)
await db.flush()
return UserResponse.model_validate(new_user)
@router.get("/me", response_model=UserResponse)
async def get_current_user(
token: str,
db: AsyncSession = Depends(get_db),
):
"""Get current authenticated user."""
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
user_id = payload["sub"]
stmt = select(User).where(User.id == int(user_id))
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse.model_validate(user)
+265
View File
@@ -0,0 +1,265 @@
"""Deck management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from typing import Optional, List
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import DecklistFile, DecklistFolder
from app.schemas.schemas import DeckCreate, DeckUpdate, DeckResponse, FolderCreate, FolderResponse
router = APIRouter()
@router.get("/", response_model=List[DeckResponse])
async def list_decks(
folder_id: Optional[int] = None,
page: int = 1,
page_size: int = 50,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List decks for current user."""
user_id = int(current_user["user_id"])
if folder_id:
stmt = (
select(DecklistFile)
.where(
DecklistFile.owner_id == user_id,
DecklistFile.folder_id == folder_id,
)
.offset((page - 1) * page_size)
.limit(page_size)
)
else:
stmt = (
select(DecklistFile)
.where(DecklistFile.owner_id == user_id)
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
decks = result.scalars().all()
return [DeckResponse.model_validate(deck) for deck in decks]
@router.post("/", response_model=DeckResponse)
async def create_deck(
request: DeckCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new deck."""
user_id = int(current_user["user_id"])
# Verify folder exists if specified
if request.folder_id:
stmt = select(DecklistFolder).where(
DecklistFolder.id == request.folder_id,
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Folder not found",
)
new_deck = DecklistFile(
owner_id=user_id,
folder_id=request.folder_id,
name=request.name,
content=request.content,
format=request.format,
)
db.add(new_deck)
await db.flush()
return DeckResponse.model_validate(new_deck)
@router.get("/{deck_id}", response_model=DeckResponse)
async def get_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get deck by ID."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFile).where(
DecklistFile.id == deck_id,
DecklistFile.owner_id == user_id,
)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
)
return DeckResponse.model_validate(deck)
@router.patch("/{deck_id}", response_model=DeckResponse)
async def update_deck(
deck_id: int,
request: DeckUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update deck."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFile).where(
DecklistFile.id == deck_id,
DecklistFile.owner_id == user_id,
)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
)
# Update fields
update_data = request.model_dump(exclude_unset=True)
stmt = (
update(DecklistFile)
.where(DecklistFile.id == deck_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
# Fetch updated deck
stmt = select(DecklistFile).where(DecklistFile.id == deck_id)
result = await db.execute(stmt)
updated_deck = result.scalar_one_or_none()
return DeckResponse.model_validate(updated_deck)
@router.delete("/{deck_id}")
async def delete_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete deck."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFile).where(
DecklistFile.id == deck_id,
DecklistFile.owner_id == user_id,
)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
)
await db.execute(delete(DecklistFile).where(DecklistFile.id == deck_id))
return {"message": "Deck deleted successfully"}
@router.get("/folders", response_model=List[FolderResponse])
async def list_folders(
parent_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List folders for current user."""
user_id = int(current_user["user_id"])
if parent_id:
stmt = select(DecklistFolder).where(
DecklistFolder.parent_id == parent_id,
DecklistFolder.owner_id == user_id,
)
else:
stmt = select(DecklistFolder).where(
DecklistFolder.parent_id == None, # Top-level folders
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
folders = result.scalars().all()
return [FolderResponse.model_validate(folder) for folder in folders]
@router.post("/folders", response_model=FolderResponse)
async def create_folder(
request: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new folder."""
user_id = int(current_user["user_id"])
# Verify parent folder exists if specified
if request.parent_id:
stmt = select(DecklistFolder).where(
DecklistFolder.id == request.parent_id,
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
parent_folder = result.scalar_one_or_none()
if not parent_folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent folder not found",
)
new_folder = DecklistFolder(
owner_id=user_id,
name=request.name,
parent_id=request.parent_id,
)
db.add(new_folder)
await db.flush()
return FolderResponse.model_validate(new_folder)
@router.delete("/folders/{folder_id}")
async def delete_folder(
folder_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete folder and all its contents."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFolder).where(
DecklistFolder.id == folder_id,
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Folder not found",
)
# Delete folder and all decks (cascading delete)
await db.execute(delete(DecklistFolder).where(DecklistFolder.id == folder_id))
return {"message": "Folder deleted successfully"}
+135
View File
@@ -0,0 +1,135 @@
"""Game management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import User
from app.schemas.schemas import GameCreate, GameResponse
router = APIRouter()
@router.get("/", response_model=List[GameResponse])
async def list_games(
room_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List active games."""
# This would query a games table - simplified for now
# In production, you'd have a CockatriceGames model
return []
@router.post("/", response_model=GameResponse)
async def create_game(
request: GameCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new game."""
user_id = int(current_user["user_id"])
# Verify user exists
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# In production, you'd create a game in the game database
# For now, return a mock response
game_data = {
"id": 1, # Mock ID
"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)
@router.get("/{game_id}", response_model=GameResponse)
async def get_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get game by ID."""
# In production, query the games table
return GameResponse(
id=game_id,
room_id=1,
game_type="Casual",
description="Test game",
with_password=False,
max_players=4,
player_count=0,
started=False,
creation_date=datetime.now(),
)
@router.post("/{game_id}/join")
async def join_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Join a game."""
user_id = int(current_user["user_id"])
# In production, add user to game players table
return {"message": f"User {user_id} joined game {game_id}"}
@router.post("/{game_id}/leave")
async def leave_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Leave a game."""
user_id = int(current_user["user_id"])
# In production, remove user from game players table
return {"message": f"User {user_id} left game {game_id}"}
@router.post("/{game_id}/start")
async def start_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Start a game (host only)."""
user_id = int(current_user["user_id"])
# In production, update game started status
return {"message": f"Game {game_id} started"}
@router.post("/{game_id}/end")
async def end_game(
game_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""End a game."""
user_id = int(current_user["user_id"])
# In production, update game closed status
return {"message": f"Game {game_id} ended"}
+158
View File
@@ -0,0 +1,158 @@
"""Room management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete
from typing import Optional, List
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import Room
from app.schemas.schemas import RoomResponse
router = APIRouter()
@router.get("/", response_model=List[RoomResponse])
async def list_rooms(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all available rooms."""
stmt = select(Room).order_by(Room.id)
result = await db.execute(stmt)
rooms = result.scalars().all()
return [RoomResponse.model_validate(room) for room in rooms]
@router.get("/{room_id}", response_model=RoomResponse)
async def get_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get room by ID."""
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
return RoomResponse.model_validate(room)
@router.post("/")
async def create_room(
name: str,
description: Optional[str] = None,
is_password_protected: bool = False,
password_hash: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
# Check if room name already exists
stmt = select(Room).where(Room.name == name)
result = await db.execute(stmt)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Room name already exists",
)
new_room = Room(
name=name,
description=description,
is_password_protected=is_password_protected,
password_hash=password_hash,
)
db.add(new_room)
await db.flush()
return {"message": f"Room '{name}' created successfully", "room_id": new_room.id}
@router.patch("/{room_id}")
async def update_room(
room_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
is_password_protected: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
# Update fields
update_data = {}
if name:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if is_password_protected is not None:
update_data["is_password_protected"] = is_password_protected
stmt = (
update(Room)
.where(Room.id == room_id)
.values(**update_data)
)
await db.execute(stmt)
return {"message": f"Room '{name or room.name}' updated successfully"}
@router.delete("/{room_id}")
async def delete_room(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete room (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(Room).where(Room.id == room_id)
result = await db.execute(stmt)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
await db.execute(delete(Room).where(Room.id == room_id))
return {"message": f"Room '{room.name}' deleted successfully"}
+168
View File
@@ -0,0 +1,168 @@
"""User management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user, hash_password
from app.models.models import User
from app.schemas.schemas import UserUpdate, UserResponse
router = APIRouter()
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get user by ID."""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse.model_validate(user)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
request: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update user profile."""
# Users can only update their own profile unless admin
if int(current_user["user_id"]) != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update another user's profile",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update fields
update_data = request.model_dump(exclude_unset=True)
# Hash new password if provided
if "new_password" in update_data:
update_data["password_sha512"] = hash_password(update_data.pop("new_password"))
# Update user
stmt = (
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.execute(stmt)
await db.flush()
# Fetch updated user
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
updated_user = result.scalar_one_or_none()
return UserResponse.model_validate(updated_user)
@router.post("/{user_id}/ban")
async def ban_user(
user_id: int,
reason: str,
expiration_time: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Ban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
from datetime import datetime
ban_ends = None
if expiration_time:
ban_ends = datetime.fromisoformat(expiration_time)
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=True,
ban_reason=reason,
ban_ends=ban_ends,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been banned"}
@router.post("/{user_id}/unban")
async def unban_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Unban a user (admin only)."""
# Check if current user is admin
if current_user.get("privlevel") not in ["Admin", "Judge"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update user ban status
stmt = (
update(User)
.where(User.id == user_id)
.values(
is_banned=False,
ban_reason=None,
ban_ends=None,
)
)
await db.execute(stmt)
await db.flush()
return {"message": f"User {user_id} has been unbanned"}
+18
View File
@@ -0,0 +1,18 @@
"""WebSocket router for game connections."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from typing import Optional
from app.services.game_server import game_websocket_endpoint
router = APIRouter()
@router.websocket("/ws/game/{game_id}")
async def game_websocket(
websocket: WebSocket,
game_id: int,
player_id: Optional[int] = None,
):
"""WebSocket endpoint for game connections."""
await websocket.accept()
await game_websocket_endpoint(websocket, game_id)
+133
View File
@@ -0,0 +1,133 @@
"""Pydantic models for protocol buffer message conversion."""
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from datetime import datetime
class ProtoMessageBase(BaseModel):
"""Base class for protocol buffer message models."""
message_type: str
timestamp: datetime = datetime.now()
class SessionCommand(ProtoMessageBase):
"""Session command message."""
message_type: str = "SessionCommand"
cmd_type: int
cmd_id: int = 0
data: Dict[str, Any] = {}
class GameCommand(ProtoMessageBase):
"""Game command message."""
message_type: str = "GameCommand"
cmd_type: int
cmd_id: int = 0
player_id: Optional[int] = None
data: Dict[str, Any] = {}
class GameEvent(ProtoMessageBase):
"""Game event message."""
message_type: str = "GameEvent"
event_type: int
player_id: Optional[int] = None
data: Dict[str, Any] = {}
class Response(ProtoMessageBase):
"""Response message."""
message_type: str = "Response"
cmd_id: int
response_code: int
data: Dict[str, Any] = {}
class ServerInfoUser(BaseModel):
"""ServerInfo_User message."""
id: int
name: str
user_level: int = 0
address: Optional[str] = None
real_name: Optional[str] = None
country: Optional[str] = None
avatar_bmp: Optional[bytes] = None
server_id: Optional[int] = None
session_id: Optional[int] = None
accountage_secs: Optional[int] = None
email: Optional[str] = None
privlevel: Optional[str] = None
class ServerInfoDeckStorageFile(BaseModel):
"""ServerInfo_DeckStorage_File message."""
creation_time: Optional[int] = None
class ServerInfoDeckStorageFolder(BaseModel):
"""ServerInfo_DeckStorage_Folder message."""
items: List[Dict[str, Any]] = []
class ServerInfoDeckStorageTreeItem(BaseModel):
"""ServerInfo_DeckStorage_TreeItem message."""
id: Optional[int] = None
name: Optional[str] = None
file: Optional[ServerInfoDeckStorageFile] = None
folder: Optional[ServerInfoDeckStorageFolder] = None
class ServerInfoCard(BaseModel):
"""ServerInfo_Card message."""
id: int
name: str
x: Optional[int] = None
y: Optional[int] = None
face_down: bool = False
tapped: bool = False
attacking: bool = False
color: Optional[str] = None
pt: Optional[str] = None
annotation: Optional[str] = None
destroy_on_zone_change: bool = False
doesnt_untap: bool = False
counter_list: List[Dict[str, Any]] = []
attach_player_id: Optional[int] = None
attach_zone: Optional[str] = None
attach_card_id: Optional[int] = None
provider_id: Optional[str] = None
class ServerInfoZone(BaseModel):
"""ServerInfo_Zone message."""
name: str
zone_type: int = 0 # PrivateZone=0, PublicZone=1, HiddenZone=2
with_coords: bool = False
card_count: int = 0
card_list: List[ServerInfoCard] = []
always_reveal_top_card: bool = False
always_look_at_top_card: bool = False
class ServerInfoGame(BaseModel):
"""ServerInfo_Game message."""
server_id: Optional[int] = None
room_id: Optional[int] = None
game_id: Optional[int] = None
description: Optional[str] = None
with_password: bool = False
max_players: int = 4
game_types: List[int] = []
creator_info: Optional[ServerInfoUser] = None
only_buddies: bool = False
only_registered: bool = False
spectators_allowed: bool = False
spectators_need_password: bool = False
spectators_can_chat: bool = False
spectators_omniscient: bool = False
share_decklists_on_load: bool = False
player_count: int = 0
spectators_count: int = 0
started: bool = False
start_time: Optional[int] = None
closed: bool = False
+170
View File
@@ -0,0 +1,170 @@
"""Cockatrice protocol constants and message definitions."""
import enum
class SessionCommandType(enum.IntEnum):
"""Session command types from session_commands.proto"""
PING = 1000
LOGIN = 1001
MESSAGE = 1002
LIST_USERS = 1003
GET_GAMES_OF_USER = 1004
GET_USER_INFO = 1005
ADD_TO_LIST = 1006
REMOVE_FROM_LIST = 1007
DECK_LIST = 1008
DECK_NEW_DIR = 1009
DECK_DEL_DIR = 1010
DECK_DEL = 1011
DECK_DOWNLOAD = 1012
DECK_UPLOAD = 1013
LIST_ROOMS = 1014
JOIN_ROOM = 1015
REGISTER = 1016
ACTIVATE = 1017
ACCOUNT_EDIT = 1018
ACCOUNT_IMAGE = 1019
ACCOUNT_PASSWORD = 1020
FORGOT_PASSWORD_REQUEST = 1021
FORGOT_PASSWORD_RESET = 1022
FORGOT_PASSWORD_CHALLENGE = 1023
REQUEST_PASSWORD_SALT = 1024
SET_CARD_ART_PARAMS = 1025
REPLAY_LIST = 1100
REPLAY_DOWNLOAD = 1101
REPLAY_MODIFY_MATCH = 1102
REPLAY_DELETE_MATCH = 1103
REPLAY_GET_CODE = 1104
REPLAY_SUBMIT_CODE = 1105
class GameCommandType(enum.IntEnum):
"""Game command types from game_commands.proto"""
KICK_FROM_GAME = 1000
LEAVE_GAME = 1001
GAME_SAY = 1002
SHUFFLE = 1003
MULLIGAN = 1004
ROLL_DIE = 1005
DRAW_CARDS = 1006
UNDO_DRAW = 1007
FLIP_CARD = 1008
ATTACH_CARD = 1009
CREATE_TOKEN = 1010
CREATE_ARROW = 1011
DELETE_ARROW = 1012
SET_CARD_ATTR = 1013
SET_CARD_COUNTER = 1014
INC_CARD_COUNTER = 1015
READY_START = 1016
CONCEDE = 1017
INC_COUNTER = 1018
CREATE_COUNTER = 1019
SET_COUNTER = 1020
DEL_COUNTER = 1021
NEXT_TURN = 1022
SET_ACTIVE_PHASE = 1023
DUMP_ZONE = 1024
REVEAL_CARDS = 1026
MOVE_CARD = 1027
SET_SIDEBOARD_PLAN = 1028
DECK_SELECT = 1029
SET_SIDEBOARD_LOCK = 1030
CHANGE_ZONE_PROPERTIES = 1031
UNCONCEDE = 1032
JUDGE = 1033
REVERSE_TURN = 1034
class GameEventType(enum.IntEnum):
"""Game event types from game_event.proto"""
JOIN = 1000
LEAVE = 1001
GAME_CLOSED = 1002
GAME_HOST_CHANGED = 1003
KICKED = 1004
GAME_STATE_CHANGED = 1005
PLAYER_PROPERTIES_CHANGED = 1007
GAME_SAY = 1009
CREATE_ARROW = 2000
DELETE_ARROW = 2001
CREATE_COUNTER = 2002
SET_COUNTER = 2003
DEL_COUNTER = 2004
DRAW_CARDS = 2005
REVEAL_CARDS = 2006
SHUFFLE = 2007
ROLL_DIE = 2008
MOVE_CARD = 2009
FLIP_CARD = 2010
DESTROY_CARD = 2011
ATTACH_CARD = 2012
CREATE_TOKEN = 2013
SET_CARD_ATTR = 2014
SET_CARD_COUNTER = 2015
SET_ACTIVE_PLAYER = 2016
SET_ACTIVE_PHASE = 2017
DUMP_ZONE = 2018
CHANGE_ZONE_PROPERTIES = 2020
REVERSE_TURN = 2021
GAME_LOG_NOTICE = 2022
class ResponseCode(enum.IntEnum):
"""Response codes from response.proto"""
RespNotConnected = -1
RespNothing = 0
RespOk = 1
RespNotInRoom = 2
RespInternalError = 3
RespInvalidCommand = 4
RespInvalidData = 5
RespNameNotFound = 6
RespLoginNeeded = 7
RespFunctionNotAllowed = 8
RespGameNotStarted = 9
RespGameFull = 10
RespContextError = 11
RespWrongPassword = 12
RespSpectatorsNotAllowed = 13
RespOnlyBuddies = 14
RespUserLevelTooLow = 15
RespInIgnoreList = 16
RespWouldOverwriteOldSession = 17
RespChatFlood = 18
RespUserIsBanned = 19
RespAccessDenied = 20
RespUsernameInvalid = 21
RespRegistrationRequired = 22
RespRegistrationAccepted = 23
RespUserAlreadyExists = 24
RespEmailRequiredToRegister = 25
RespTooManyRequests = 26
RespPasswordTooShort = 27
RespAccountNotActivated = 28
RespRegistrationDisabled = 29
RespRegistrationFailed = 30
RespActivationAccepted = 31
RespActivationFailed = 32
RespRegistrationAcceptedNeedsActivation = 33
RespClientIdRequired = 34
RespClientUpdateRequired = 35
RespServerFull = 36
RespEmailBlackListed = 37
class ZoneType(enum.IntEnum):
"""Zone types from serverinfo_zone.proto"""
PrivateZone = 0
PublicZone = 1
HiddenZone = 2
class UserLevelFlag(enum.IntFlag):
"""User level flags from serverinfo_user.proto"""
IsNothing = 0
IsUser = 1
IsRegistered = 2
IsModerator = 4
IsAdmin = 8
IsJudge = 16
+232
View File
@@ -0,0 +1,232 @@
"""
Pydantic schemas for request/response validation.
Provides typed data structures for API endpoints.
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List
from datetime import datetime
# ===== Authentication Schemas =====
class LoginRequest(BaseModel):
"""Login request payload."""
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
class LoginResponse(BaseModel):
"""Login response payload."""
access_token: str
refresh_token: str
token_type: str = "bearer"
user: dict
class RefreshTokenRequest(BaseModel):
"""Refresh token request."""
refresh_token: str
class TokenResponse(BaseModel):
"""Token response."""
access_token: str
token_type: str = "bearer"
# ===== User Schemas =====
class UserBase(BaseModel):
"""User base fields."""
username: str
email: Optional[str] = None
country: Optional[str] = Field(None, max_length=2)
real_name: Optional[str] = None
class UserCreate(UserBase):
"""User registration fields."""
password: str = Field(..., min_length=8)
class Config:
json_schema_extra = {
"example": {
"username": "player123",
"password": "securepassword123",
"email": "player@example.com",
"country": "US"
}
}
class UserUpdate(BaseModel):
"""User update fields."""
email: Optional[str] = None
country: Optional[str] = None
real_name: Optional[str] = None
new_password: Optional[str] = Field(None, min_length=8, max_length=128)
class UserResponse(BaseModel):
"""User response payload."""
id: int
username: str
email: Optional[str]
country: Optional[str]
real_name: Optional[str]
privlevel: str
vip_status: int
is_active: bool
is_banned: bool
ban_reason: Optional[str]
creation_date: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True
# ===== Deck Schemas =====
class DeckCreate(BaseModel):
"""Deck creation request."""
name: str = Field(..., min_length=1, max_length=255)
content: str = Field(..., min_length=1)
folder_id: Optional[int] = None
format: str = Field("native", pattern="^(native|plain)$")
class DeckUpdate(BaseModel):
"""Deck update request."""
name: Optional[str] = None
content: Optional[str] = None
folder_id: Optional[int] = None
class DeckResponse(BaseModel):
"""Deck response payload."""
id: int
name: str
content: str
format: str
folder_id: Optional[int]
owner_id: int
creation_date: datetime
class Config:
from_attributes = True
class FolderCreate(BaseModel):
"""Folder creation request."""
name: str = Field(..., min_length=1, max_length=255)
parent_id: Optional[int] = None
class FolderResponse(BaseModel):
"""Folder response payload."""
id: int
name: str
parent_id: Optional[int]
owner_id: int
creation_date: datetime
children: List["FolderResponse"] = []
files: List[DeckResponse] = []
class Config:
from_attributes = True
# ===== Game Schemas =====
class GameCreate(BaseModel):
"""Game creation request."""
room_id: int
game_type: Optional[str] = None
description: Optional[str] = None
password: Optional[str] = None
class GameResponse(BaseModel):
"""Game response payload."""
id: int
room_id: int
game_type: Optional[str]
description: Optional[str]
with_password: bool
max_players: int
player_count: int
started: bool
creation_date: datetime
class Config:
from_attributes = True
# ===== Room Schemas =====
class RoomResponse(BaseModel):
"""Room response payload."""
id: int
name: str
description: Optional[str]
is_password_protected: bool
game_types: List[str] = []
player_count: int = 0
creation_date: datetime
class Config:
from_attributes = True
# ===== Ban Schemas =====
class BanCreate(BaseModel):
"""Ban creation request."""
user_id: int
reason: str = Field(..., min_length=1, max_length=1000)
expiration_time: Optional[datetime] = None
class BanResponse(BaseModel):
"""Ban response payload."""
id: int
user_id: int
reason: str
moderators: Optional[str]
expiration_time: Optional[datetime]
active: bool
creation_date: datetime
class Config:
from_attributes = True
# ===== Auth Error Responses =====
class ErrorResponse(BaseModel):
"""Standard error response."""
detail: str
class ValidationErrorResponse(BaseModel):
"""Validation error response."""
detail: List[dict] # Pydantic validation errors
# ===== Pagination Schemas =====
class PaginationParams(BaseModel):
"""Common pagination parameters."""
page: int = Field(1, ge=1)
page_size: int = Field(50, ge=1, le=100)
class PaginatedResponse(BaseModel):
"""Generic paginated response."""
items: List[dict]
total: int
page: int
page_size: int
total_pages: int
+225
View File
@@ -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)
+206
View File
@@ -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)
+309
View File
@@ -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", {}),
})
+35
View File
@@ -0,0 +1,35 @@
"""
Ruff configuration for linting and formatting.
"""
[tool.ruff]
# Target Python version
target-version = "py312"
# Line length
line-length = 100
# Enable rules
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
# Ignore specific rules
[tool.ruff.lint.ignore]
E501 = "Line too long" # Handled by formatter
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # Allow unused imports in __init__.py
"tests/**" = ["S101"] # Allow assert in tests
+34
View File
@@ -0,0 +1,34 @@
# FastAPI and ASGI
fastapi==0.115.0
uvicorn[standard]==0.30.0
websockets==12.0
# Database
sqlalchemy[asyncio]==2.0.34
asyncpg==0.29.0
alembic==1.13.2
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
# Validation
pydantic==2.9.2
pydantic-settings==2.5.2
# Redis
redis[hiredis]==5.1.0
# Utilities
python-dotenv==1.0.1
httpx==0.27.2
# Testing
pytest==8.3.3
pytest-asyncio==0.24.0
pytest-cov==6.0.0
aiosqlite==0.20.0
# Linting
ruff==0.6.5
+122
View File
@@ -0,0 +1,122 @@
"""
Pytest configuration and fixtures for Cockatrice Web Application tests.
"""
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.core.database import Base, get_db
from app.core.security import hash_password
from app.models.models import User
# Test database URL (in-memory SQLite for tests)
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
# Create test engine and session
test_engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for the test session."""
import asyncio
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def db_session():
"""Create a new database session for each test."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionLocal() as session:
yield session
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture(scope="function")
async def client(db_session):
"""Create a test client with database dependency override."""
async def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture(scope="function")
async def test_user(db_session):
"""Create a test user."""
user = User(
username="testuser",
password_sha512=hash_password("testpassword123"),
salt="test_salt",
email="test@example.com",
country="US",
privlevel="User",
)
db_session.add(user)
await db_session.flush()
return user
@pytest_asyncio.fixture(scope="function")
async def admin_user(db_session):
"""Create an admin test user."""
user = User(
username="adminuser",
password_sha512=hash_password("adminpassword123"),
salt="admin_salt",
email="admin@example.com",
country="US",
privlevel="Admin",
)
db_session.add(user)
await db_session.flush()
return user
@pytest_asyncio.fixture(scope="function")
async def auth_headers(client, test_user):
"""Get authentication headers for test user."""
from app.core.security import create_access_token, create_refresh_token
access_token = create_access_token(str(test_user.id))
return {"Authorization": f"Bearer {access_token}"}
@pytest_asyncio.fixture(scope="function")
async def admin_headers(client, admin_user):
"""Get authentication headers for admin user."""
from app.core.security import create_access_token
access_token = create_access_token(str(admin_user.id))
return {"Authorization": f"Bearer {access_token}"}
+85
View File
@@ -0,0 +1,85 @@
"""
Tests for admin endpoints.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import User, Ban
from app.core.security import hash_password
class TestAdminEndpoints:
"""Test admin endpoints."""
@pytest.mark.asyncio
async def test_list_users_admin(self, client: AsyncClient, admin_headers: dict):
"""Test listing users as admin."""
response = await client.get("/api/v1/admin/users", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_list_users_non_admin(self, client: AsyncClient, auth_headers: dict):
"""Test listing users as non-admin."""
response = await client.get("/api/v1/admin/users", headers=auth_headers)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_create_ban(self, client: AsyncClient, admin_headers: dict, test_user: User):
"""Test creating a ban."""
response = await client.post(
"/api/v1/admin/bans",
json={
"user_id": test_user.id,
"reason": "Test ban reason",
},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["reason"] == "Test ban reason"
assert data["active"] == True
@pytest.mark.asyncio
async def test_list_bans(self, client: AsyncClient, admin_headers: dict, test_user: User):
"""Test listing bans."""
# Create a ban first
await client.post(
"/api/v1/admin/bans",
json={
"user_id": test_user.id,
"reason": "Test ban",
},
headers=admin_headers,
)
# List bans
response = await client.get("/api/v1/admin/bans", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) > 0
@pytest.mark.asyncio
async def test_unban_user(self, client: AsyncClient, admin_headers: dict, test_user: User):
"""Test unbanning a user."""
# Create a ban first
ban_response = await client.post(
"/api/v1/admin/bans",
json={
"user_id": test_user.id,
"reason": "Test ban",
},
headers=admin_headers,
)
ban_id = ban_response.json()["id"]
# Unban user
response = await client.post(
f"/api/v1/admin/bans/{ban_id}/unban",
headers=admin_headers,
)
assert response.status_code == 200
assert response.json()["message"] == "User unbanned successfully"
+123
View File
@@ -0,0 +1,123 @@
"""
Tests for authentication endpoints.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import User
from app.core.security import hash_password, create_access_token
class TestAuthentication:
"""Test authentication endpoints."""
@pytest.mark.asyncio
async def test_login_success(self, client: AsyncClient, test_user: User):
"""Test successful login."""
response = await client.post(
"/api/v1/auth/login",
json={
"username": "testuser",
"password": "testpassword123",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["user"]["username"] == "testuser"
@pytest.mark.asyncio
async def test_login_invalid_password(self, client: AsyncClient, test_user: User):
"""Test login with invalid password."""
response = await client.post(
"/api/v1/auth/login",
json={
"username": "testuser",
"password": "wrongpassword",
},
)
assert response.status_code == 401
assert response.json()["detail"] == "Invalid username or password"
@pytest.mark.asyncio
async def test_login_nonexistent_user(self, client: AsyncClient):
"""Test login with non-existent user."""
response = await client.post(
"/api/v1/auth/login",
json={
"username": "nonexistent",
"password": "password123",
},
)
assert response.status_code == 401
assert response.json()["detail"] == "Invalid username or password"
@pytest.mark.asyncio
async def test_register_success(self, client: AsyncClient):
"""Test successful user registration."""
response = await client.post(
"/api/v1/auth/register",
json={
"username": "newuser",
"password": "newpassword123",
"email": "new@example.com",
"country": "US",
},
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "newuser"
assert data["email"] == "new@example.com"
@pytest.mark.asyncio
async def test_register_duplicate_username(self, client: AsyncClient, test_user: User):
"""Test registration with duplicate username."""
response = await client.post(
"/api/v1/auth/register",
json={
"username": "testuser", # Already exists
"password": "newpassword123",
"email": "new@example.com",
},
)
assert response.status_code == 409
assert response.json()["detail"] == "Username already exists"
@pytest.mark.asyncio
async def test_get_current_user(self, client: AsyncClient, auth_headers: dict):
"""Test getting current user."""
response = await client.get(
"/api/v1/auth/me",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "testuser"
@pytest.mark.asyncio
async def test_refresh_token(self, client: AsyncClient, test_user: User):
"""Test refreshing access token."""
from app.core.security import create_refresh_token
# First login to get refresh token
login_response = await client.post(
"/api/v1/auth/login",
json={
"username": "testuser",
"password": "testpassword123",
},
)
refresh_token = login_response.json()["refresh_token"]
# Refresh token
response = await client.post(
"/api/v1/auth/refresh",
json={
"refresh_token": refresh_token,
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
+162
View File
@@ -0,0 +1,162 @@
"""
Tests for deck management endpoints.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import DecklistFile, DecklistFolder
from app.core.security import hash_password
class TestDeckManagement:
"""Test deck management endpoints."""
@pytest.mark.asyncio
async def test_create_deck(self, client: AsyncClient, auth_headers: dict):
"""Test creating a new deck."""
response = await client.post(
"/api/v1/decks/",
json={
"name": "Test Deck",
"content": "4 Lightning Bolt\n2 Searing Blaze",
"format": "plain",
},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Deck"
assert data["format"] == "plain"
@pytest.mark.asyncio
async def test_list_decks(self, client: AsyncClient, auth_headers: dict):
"""Test listing decks."""
# Create a deck first
await client.post(
"/api/v1/decks/",
json={
"name": "List Test Deck",
"content": "4 Lightning Bolt",
"format": "plain",
},
headers=auth_headers,
)
# List decks
response = await client.get("/api/v1/decks/", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) > 0
@pytest.mark.asyncio
async def test_get_deck(self, client: AsyncClient, auth_headers: dict):
"""Test getting a specific deck."""
# Create a deck first
create_response = await client.post(
"/api/v1/decks/",
json={
"name": "Get Test Deck",
"content": "4 Lightning Bolt",
"format": "plain",
},
headers=auth_headers,
)
deck_id = create_response.json()["id"]
# Get deck
response = await client.get(f"/api/v1/decks/{deck_id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Get Test Deck"
@pytest.mark.asyncio
async def test_update_deck(self, client: AsyncClient, auth_headers: dict):
"""Test updating a deck."""
# Create a deck first
create_response = await client.post(
"/api/v1/decks/",
json={
"name": "Update Test Deck",
"content": "4 Lightning Bolt",
"format": "plain",
},
headers=auth_headers,
)
deck_id = create_response.json()["id"]
# Update deck
response = await client.patch(
f"/api/v1/decks/{deck_id}",
json={"name": "Updated Deck Name"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Deck Name"
@pytest.mark.asyncio
async def test_delete_deck(self, client: AsyncClient, auth_headers: dict):
"""Test deleting a deck."""
# Create a deck first
create_response = await client.post(
"/api/v1/decks/",
json={
"name": "Delete Test Deck",
"content": "4 Lightning Bolt",
"format": "plain",
},
headers=auth_headers,
)
deck_id = create_response.json()["id"]
# Delete deck
response = await client.delete(f"/api/v1/decks/{deck_id}", headers=auth_headers)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_create_folder(self, client: AsyncClient, auth_headers: dict):
"""Test creating a folder."""
response = await client.post(
"/api/v1/decks/folders",
json={
"name": "Test Folder",
},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Folder"
@pytest.mark.asyncio
async def test_list_folders(self, client: AsyncClient, auth_headers: dict):
"""Test listing folders."""
# Create a folder first
await client.post(
"/api/v1/decks/folders",
json={"name": "List Test Folder"},
headers=auth_headers,
)
# List folders
response = await client.get("/api/v1/decks/folders", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) > 0
@pytest.mark.asyncio
async def test_delete_folder(self, client: AsyncClient, auth_headers: dict):
"""Test deleting a folder."""
# Create a folder first
create_response = await client.post(
"/api/v1/decks/folders",
json={"name": "Delete Test Folder"},
headers=auth_headers,
)
folder_id = create_response.json()["id"]
# Delete folder
response = await client.delete(f"/api/v1/decks/folders/{folder_id}", headers=auth_headers)
assert response.status_code == 200