Initial commit of mtgonline project
This commit is contained in:
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Card database service for importing and querying card data."""
|
||||
import httpx
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CardType(str, Enum):
|
||||
"""Card types from Magic: The Gathering."""
|
||||
CREATURES = "Creature"
|
||||
INSTANT = "Instant"
|
||||
SORCERY = "Sorcery"
|
||||
ENCHANTMENT = "Enchantment"
|
||||
ARTIFACT = "Artifact"
|
||||
PLANE = "Plane"
|
||||
PLANESWALKER = "Planeswalker"
|
||||
LAND = "Land"
|
||||
BATTLE = "Battle"
|
||||
|
||||
|
||||
class CardColor(str, Enum):
|
||||
"""Card colors."""
|
||||
WHITE = "W"
|
||||
BLUE = "U"
|
||||
BLACK = "B"
|
||||
RED = "R"
|
||||
GREEN = "G"
|
||||
COLORLESS = "C"
|
||||
MULTICOLOR = "M"
|
||||
SHARD = "S"
|
||||
WIDGET = "X"
|
||||
|
||||
|
||||
class CardRarity(str, Enum):
|
||||
"""Card rarities."""
|
||||
COMMON = "common"
|
||||
UNCOMMON = "uncommon"
|
||||
RARE = "rare"
|
||||
MYTHIC = "mythic"
|
||||
SPECIAL = "special"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardData:
|
||||
"""Card information from card database."""
|
||||
id: int
|
||||
name: str
|
||||
types: List[CardType]
|
||||
colors: List[CardColor]
|
||||
rarity: CardRarity
|
||||
set_code: str
|
||||
collector_number: str
|
||||
flavor_text: Optional[str] = None
|
||||
rules_text: Optional[str] = None
|
||||
power: Optional[str] = None
|
||||
toughness: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
provider_id: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.set_code}-{self.collector_number})"
|
||||
|
||||
|
||||
class CardDatabase:
|
||||
"""Card database service for importing and querying card data."""
|
||||
|
||||
def __init__(self):
|
||||
self.cards: Dict[int, CardData] = {}
|
||||
self._next_id = 1
|
||||
|
||||
async def import_from_mtjson(self, url: str = "https://mtjson.xyz/api/5.0.0/") -> List[CardData]:
|
||||
"""Import card data from MTJSON API."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
imported_cards = []
|
||||
for card_data in data:
|
||||
card = self._parse_mtjson_card(card_data)
|
||||
self.cards[self._next_id] = card
|
||||
imported_cards.append(card)
|
||||
self._next_id += 1
|
||||
|
||||
return imported_cards
|
||||
|
||||
def _parse_mtjson_card(self, data: dict) -> CardData:
|
||||
"""Parse MTJSON card data into CardData."""
|
||||
card_id = self._next_id
|
||||
|
||||
# Extract types
|
||||
types = []
|
||||
if "types" in data:
|
||||
for type_str in data["types"]:
|
||||
try:
|
||||
types.append(CardType(type_str))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract colors
|
||||
colors = []
|
||||
if "colors" in data:
|
||||
for color_str in data["colors"]:
|
||||
try:
|
||||
colors.append(CardColor(color_str))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract rarity
|
||||
rarity = CardRarity.COMMON
|
||||
if "rarity" in data:
|
||||
try:
|
||||
rarity = CardRarity(data["rarity"].lower())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract set and collector number
|
||||
set_code = ""
|
||||
collector_number = ""
|
||||
if "set" in data:
|
||||
set_code = data["set"]
|
||||
if "collectorNumber" in data:
|
||||
collector_number = data["collectorNumber"]
|
||||
|
||||
# Extract image URL
|
||||
image_url = None
|
||||
if "imageUris" in data and "normal" in data["imageUris"]:
|
||||
image_url = data["imageUris"]["normal"]
|
||||
|
||||
return CardData(
|
||||
id=card_id,
|
||||
name=data.get("name", ""),
|
||||
types=types,
|
||||
colors=colors,
|
||||
rarity=rarity,
|
||||
set_code=set_code,
|
||||
collector_number=collector_number,
|
||||
flavor_text=data.get("flavorText"),
|
||||
rules_text=data.get("rulesText"),
|
||||
power=data.get("power"),
|
||||
toughness=data.get("toughness"),
|
||||
artist=data.get("artist"),
|
||||
image_url=image_url,
|
||||
provider_id=data.get("multiverseId"),
|
||||
)
|
||||
|
||||
def get_card_by_id(self, card_id: int) -> Optional[CardData]:
|
||||
"""Get card by ID."""
|
||||
return self.cards.get(card_id)
|
||||
|
||||
def get_card_by_name(self, name: str) -> List[CardData]:
|
||||
"""Get cards by name (case-insensitive)."""
|
||||
name_lower = name.lower()
|
||||
return [card for card in self.cards.values() if card.name.lower() == name_lower]
|
||||
|
||||
def search_cards(
|
||||
self,
|
||||
query: str = "",
|
||||
card_type: Optional[CardType] = None,
|
||||
color: Optional[CardColor] = None,
|
||||
rarity: Optional[CardRarity] = None,
|
||||
set_code: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> List[CardData]:
|
||||
"""Search cards with filters."""
|
||||
results = list(self.cards.values())
|
||||
|
||||
# Filter by query
|
||||
if query:
|
||||
query_lower = query.lower()
|
||||
results = [card for card in results if query_lower in card.name.lower()]
|
||||
|
||||
# Filter by type
|
||||
if card_type:
|
||||
results = [card for card in results if card_type in card.types]
|
||||
|
||||
# Filter by color
|
||||
if color:
|
||||
results = [card for card in results if color in card.colors]
|
||||
|
||||
# Filter by rarity
|
||||
if rarity:
|
||||
results = [card for card in results if card.rarity == rarity]
|
||||
|
||||
# Filter by set
|
||||
if set_code:
|
||||
results = [card for card in results if card.set_code == set_code.upper()]
|
||||
|
||||
return results[:limit]
|
||||
|
||||
def get_random_card(self) -> Optional[CardData]:
|
||||
"""Get a random card from the database."""
|
||||
import random
|
||||
if not self.cards:
|
||||
return None
|
||||
return random.choice(list(self.cards.values()))
|
||||
|
||||
def get_card_count(self) -> int:
|
||||
"""Get total number of cards in database."""
|
||||
return len(self.cards)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
card_database = CardDatabase()
|
||||
|
||||
|
||||
async def import_cards() -> List[CardData]:
|
||||
"""Import cards from MTJSON."""
|
||||
return await card_database.import_from_mtjson()
|
||||
|
||||
|
||||
def search_cards(**kwargs) -> List[CardData]:
|
||||
"""Search cards with filters."""
|
||||
return card_database.search_cards(**kwargs)
|
||||
|
||||
|
||||
def get_card_by_name(name: str) -> List[CardData]:
|
||||
"""Get cards by name."""
|
||||
return card_database.get_card_by_name(name)
|
||||
|
||||
|
||||
def get_card_by_id(card_id: int) -> Optional[CardData]:
|
||||
"""Get card by ID."""
|
||||
return card_database.get_card_by_id(card_id)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Deck list parsing and serialization utilities."""
|
||||
import re
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardInfo:
|
||||
"""Card information extracted from deck text."""
|
||||
count: int
|
||||
name: str
|
||||
set_code: Optional[str] = None
|
||||
collector_number: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.set_code and self.collector_number:
|
||||
return f"{self.count} {self.name} ({self.set_code}-{self.collector_number})"
|
||||
return f"{self.count} {self.name}"
|
||||
|
||||
|
||||
class DeckParser:
|
||||
"""Parse Cockatrice deck list formats."""
|
||||
|
||||
# Regex patterns for deck parsing
|
||||
CARD_LINE_RE = re.compile(r"^\s*[\w\[\(\{].*$")
|
||||
EMPTY_LINE_RE = re.compile(r"^\s*$")
|
||||
COMMENT_RE = re.compile(r"([\w\[\(\{].*$)")
|
||||
SB_MARK_RE = re.compile(r"^\s*sb:\s*(.+)", re.IGNORECASE)
|
||||
SB_COMMENT_RE = re.compile(r"^sideboard\s.*$", re.IGNORECASE)
|
||||
DECK_COMMENT_RE = re.compile(r"^((main)?deck(list)?|mainboard)\b", re.IGNORECASE)
|
||||
MULTIPLIER_RE = re.compile(r"^([xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))")
|
||||
HYPHEN_FORMAT_RE = re.compile(r"\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*)")
|
||||
REGULAR_FORMAT_RE = re.compile(r"\((\w{3,})\)\s+(\d+[^\w\s]*)")
|
||||
|
||||
def parse_plain_text(self, text: str) -> List[CardInfo]:
|
||||
"""Parse plain text deck format."""
|
||||
lines = text.strip().split('\n')
|
||||
max_line = len(lines)
|
||||
|
||||
# Find deck start (first card line)
|
||||
deck_start = -1
|
||||
for i, line in enumerate(lines):
|
||||
if self.CARD_LINE_RE.match(line):
|
||||
deck_start = i
|
||||
break
|
||||
|
||||
if deck_start == -1:
|
||||
return []
|
||||
|
||||
# Find sideboard start
|
||||
sBStart = -1
|
||||
for i in range(deck_start, max_line):
|
||||
if self.SB_MARK_RE.match(lines[i]):
|
||||
sBStart = i
|
||||
break
|
||||
elif self.SB_COMMENT_RE.match(lines[i]):
|
||||
sBStart = i
|
||||
break
|
||||
|
||||
if sBStart == -1:
|
||||
# Look for empty line after deck
|
||||
for i in range(deck_start + 1, max_line):
|
||||
if self.EMPTY_LINE_RE.match(lines[i]):
|
||||
# Check if there are cards after the empty line
|
||||
for j in range(i + 1, max_line):
|
||||
if self.CARD_LINE_RE.match(lines[j]):
|
||||
sBStart = i
|
||||
break
|
||||
if sBStart != -1:
|
||||
break
|
||||
|
||||
if sBStart == -1:
|
||||
sBStart = max_line
|
||||
|
||||
cards = []
|
||||
index = 0
|
||||
|
||||
# Skip comments
|
||||
while index < deck_start:
|
||||
if not self.EMPTY_LINE_RE.match(lines[index]):
|
||||
index += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Parse cards
|
||||
for i in range(index, sBStart):
|
||||
line = lines[i].strip()
|
||||
if not line or self.EMPTY_LINE_RE.match(line):
|
||||
continue
|
||||
|
||||
# Check for sideboard marker
|
||||
if self.SB_MARK_RE.match(line):
|
||||
match = self.SB_MARK_RE.match(line)
|
||||
card_name = match.group(1).strip() if match else line
|
||||
sideboard = True
|
||||
else:
|
||||
card_name = line
|
||||
sideboard = False
|
||||
|
||||
# Extract set code and collector number
|
||||
set_code = None
|
||||
collector_number = None
|
||||
match = self.HYPHEN_FORMAT_RE.search(card_name)
|
||||
if match:
|
||||
set_code = match.group(2).upper()
|
||||
collector_number = match.group(3)
|
||||
card_name = card_name[:match.start()].strip()
|
||||
else:
|
||||
match = self.REGULAR_FORMAT_RE.search(card_name)
|
||||
if match:
|
||||
set_code = match.group(1).upper()
|
||||
collector_number = match.group(2)
|
||||
card_name = card_name[:match.start()].strip()
|
||||
|
||||
# Extract count
|
||||
count = 1
|
||||
match = self.MULTIPLIER_RE.match(card_name)
|
||||
if match:
|
||||
count = int(match.group(2))
|
||||
card_name = match.group(3)
|
||||
|
||||
# Normalize card name
|
||||
card_name = card_name.strip()
|
||||
|
||||
if card_name:
|
||||
cards.append(CardInfo(
|
||||
count=count,
|
||||
name=card_name,
|
||||
set_code=set_code,
|
||||
collector_number=collector_number,
|
||||
))
|
||||
|
||||
return cards
|
||||
|
||||
def format_plain_text(self, cards: List[CardInfo], prefix_sideboard: bool = True) -> str:
|
||||
"""Format cards as plain text deck list."""
|
||||
lines = []
|
||||
for card in cards:
|
||||
prefix = "SB: " if prefix_sideboard and card.name.startswith("[SB]") else ""
|
||||
name = card.name
|
||||
if card.name.startswith("[SB]"):
|
||||
name = card.name[4:] # Remove [SB] prefix
|
||||
|
||||
if card.set_code and card.collector_number:
|
||||
lines.append(f"{prefix}{card.count} {name} ({card.set_code}-{card.collector_number})")
|
||||
else:
|
||||
lines.append(f"{prefix}{card.count} {name}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_native_xml(self, cards: List[CardInfo]) -> str:
|
||||
"""Convert cards to Cockatrice native XML format."""
|
||||
xml_lines = ['<cockatrice_deck version="1">']
|
||||
|
||||
# Group cards by zone (main/sideboard)
|
||||
main_cards = [c for c in cards if not c.name.startswith("[SB]")]
|
||||
side_cards = [c for c in cards if c.name.startswith("[SB]")]
|
||||
|
||||
if main_cards:
|
||||
xml_lines.append(' <zone name="main">')
|
||||
for card in main_cards:
|
||||
xml_lines.append(f' <card name="{card.name}" count="{card.count}" />')
|
||||
xml_lines.append(' </zone>')
|
||||
|
||||
if side_cards:
|
||||
xml_lines.append(' <zone name="sideboard">')
|
||||
for card in side_cards:
|
||||
clean_name = card.name[4:] # Remove [SB] prefix
|
||||
xml_lines.append(f' <card name="{clean_name}" count="{card.count}" />')
|
||||
xml_lines.append(' </zone>')
|
||||
|
||||
xml_lines.append('</cockatrice_deck>')
|
||||
return "\n".join(xml_lines)
|
||||
|
||||
def from_native_xml(self, xml: str) -> List[CardInfo]:
|
||||
"""Parse Cockatrice native XML format."""
|
||||
cards = []
|
||||
|
||||
# Simple XML parsing (in production, use proper XML parser)
|
||||
card_pattern = re.compile(r'<card\s+name="([^"]+)"\s+count="(\d+)"\s*/>')
|
||||
|
||||
for match in card_pattern.finditer(xml):
|
||||
name = match.group(1)
|
||||
count = int(match.group(2))
|
||||
cards.append(CardInfo(count=count, name=name))
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def parse_deck(text: str) -> List[CardInfo]:
|
||||
"""Parse a deck list from plain text or native XML."""
|
||||
parser = DeckParser()
|
||||
if text.strip().startswith("<cockatrice_deck"):
|
||||
return parser.from_native_xml(text)
|
||||
else:
|
||||
return parser.parse_plain_text(text)
|
||||
|
||||
|
||||
def format_deck(cards: List[CardInfo], format_type: str = "plain", prefix_sideboard: bool = True) -> str:
|
||||
"""Format cards to specified deck format."""
|
||||
parser = DeckParser()
|
||||
if format_type == "native":
|
||||
return parser.to_native_xml(cards)
|
||||
else:
|
||||
return parser.format_plain_text(cards, prefix_sideboard)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""WebSocket game server for real-time multiplayer gameplay."""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, Set, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
# Protocol message types
|
||||
GAME_EVENT_JOIN = 1000
|
||||
GAME_EVENT_LEAVE = 1001
|
||||
GAME_EVENT_GAME_CLOSED = 1002
|
||||
GAME_EVENT_GAME_HOST_CHANGED = 1003
|
||||
GAME_EVENT_KICKED = 1004
|
||||
GAME_EVENT_GAME_STATE_CHANGED = 1005
|
||||
GAME_EVENT_PLAYER_PROPERTIES_CHANGED = 1007
|
||||
GAME_EVENT_GAME_SAY = 1009
|
||||
GAME_EVENT_CREATE_ARROW = 2000
|
||||
GAME_EVENT_DELETE_ARROW = 2001
|
||||
GAME_EVENT_CREATE_COUNTER = 2002
|
||||
GAME_EVENT_SET_COUNTER = 2003
|
||||
GAME_EVENT_DEL_COUNTER = 2004
|
||||
GAME_EVENT_DRAW_CARDS = 2005
|
||||
GAME_EVENT_REVEAL_CARDS = 2006
|
||||
GAME_EVENT_SHUFFLE = 2007
|
||||
GAME_EVENT_ROLL_DIE = 2008
|
||||
GAME_EVENT_MOVE_CARD = 2009
|
||||
GAME_EVENT_FLIP_CARD = 2010
|
||||
GAME_EVENT_DESTROY_CARD = 2011
|
||||
GAME_EVENT_ATTACH_CARD = 2012
|
||||
GAME_EVENT_CREATE_TOKEN = 2013
|
||||
GAME_EVENT_SET_CARD_ATTR = 2014
|
||||
GAME_EVENT_SET_CARD_COUNTER = 2015
|
||||
GAME_EVENT_SET_ACTIVE_PLAYER = 2016
|
||||
GAME_EVENT_SET_ACTIVE_PHASE = 2017
|
||||
GAME_EVENT_DUMP_ZONE = 2018
|
||||
GAME_EVENT_CHANGE_ZONE_PROPERTIES = 2020
|
||||
GAME_EVENT_REVERSE_TURN = 2021
|
||||
GAME_EVENT_GAME_LOG_NOTICE = 2022
|
||||
|
||||
# Game command types
|
||||
GAME_COMMAND_KICK_FROM_GAME = 1000
|
||||
GAME_COMMAND_LEAVE_GAME = 1001
|
||||
GAME_COMMAND_GAME_SAY = 1002
|
||||
GAME_COMMAND_SHUFFLE = 1003
|
||||
GAME_COMMAND_MULLIGAN = 1004
|
||||
GAME_COMMAND_ROLL_DIE = 1005
|
||||
GAME_COMMAND_DRAW_CARDS = 1006
|
||||
GAME_COMMAND_UNDO_DRAW = 1007
|
||||
GAME_COMMAND_FLIP_CARD = 1008
|
||||
GAME_COMMAND_ATTACH_CARD = 1009
|
||||
GAME_COMMAND_CREATE_TOKEN = 1010
|
||||
GAME_COMMAND_CREATE_ARROW = 1011
|
||||
GAME_COMMAND_DELETE_ARROW = 1012
|
||||
GAME_COMMAND_SET_CARD_ATTR = 1013
|
||||
GAME_COMMAND_SET_CARD_COUNTER = 1014
|
||||
GAME_COMMAND_INC_CARD_COUNTER = 1015
|
||||
GAME_COMMAND_READY_START = 1016
|
||||
GAME_COMMAND_CONCEDE = 1017
|
||||
GAME_COMMAND_INC_COUNTER = 1018
|
||||
GAME_COMMAND_CREATE_COUNTER = 1019
|
||||
GAME_COMMAND_SET_COUNTER = 1020
|
||||
GAME_COMMAND_DEL_COUNTER = 1021
|
||||
GAME_COMMAND_NEXT_TURN = 1022
|
||||
GAME_COMMAND_SET_ACTIVE_PHASE = 1023
|
||||
GAME_COMMAND_DUMP_ZONE = 1024
|
||||
GAME_COMMAND_REVEAL_CARDS = 1026
|
||||
GAME_COMMAND_MOVE_CARD = 1027
|
||||
GAME_COMMAND_SET_SIDEBOARD_PLAN = 1028
|
||||
GAME_COMMAND_DECK_SELECT = 1029
|
||||
GAME_COMMAND_SET_SIDEBOARD_LOCK = 1030
|
||||
GAME_COMMAND_CHANGE_ZONE_PROPERTIES = 1031
|
||||
GAME_COMMAND_UNCONCEDE = 1032
|
||||
GAME_COMMAND_JUDGE = 1033
|
||||
GAME_COMMAND_REVERSE_TURN = 1034
|
||||
|
||||
|
||||
class GameRoom:
|
||||
"""Manages a single game room with multiple connected players."""
|
||||
|
||||
def __init__(self, room_id: int, game_id: int):
|
||||
self.room_id = room_id
|
||||
self.game_id = game_id
|
||||
self.players: Dict[int, WebSocket] = {} # player_id -> websocket
|
||||
self.host_id: Optional[int] = None
|
||||
self.state: dict = {
|
||||
"started": False,
|
||||
"password_protected": False,
|
||||
"players_ready": set(),
|
||||
"turn": 1,
|
||||
"active_player": None,
|
||||
"phases": [],
|
||||
"zones": {},
|
||||
}
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def add_player(self, player_id: int, websocket: WebSocket):
|
||||
"""Add a player to the game."""
|
||||
async with self.lock:
|
||||
self.players[player_id] = websocket
|
||||
if self.host_id is None:
|
||||
self.host_id = player_id
|
||||
|
||||
async def remove_player(self, player_id: int):
|
||||
"""Remove a player from the game."""
|
||||
async with self.lock:
|
||||
self.players.pop(player_id, None)
|
||||
if self.host_id == player_id and len(self.players) > 0:
|
||||
self.host_id = next(iter(self.players))
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast a message to all players."""
|
||||
message_str = json.dumps(message)
|
||||
for player_id, websocket in list(self.players.items()):
|
||||
try:
|
||||
await websocket.send_text(message_str)
|
||||
except Exception as e:
|
||||
print(f"Error sending to player {player_id}: {e}")
|
||||
|
||||
async def send_to_player(self, player_id: int, message: dict):
|
||||
"""Send a message to a specific player."""
|
||||
if player_id in self.players:
|
||||
try:
|
||||
message_str = json.dumps(message)
|
||||
await self.players[player_id].send_text(message_str)
|
||||
except Exception as e:
|
||||
print(f"Error sending to player {player_id}: {e}")
|
||||
|
||||
def get_player_count(self) -> int:
|
||||
"""Get number of connected players."""
|
||||
return len(self.players)
|
||||
|
||||
def is_full(self, max_players: int = 4) -> bool:
|
||||
"""Check if game is full."""
|
||||
return self.get_player_count() >= max_players
|
||||
|
||||
|
||||
class GameServer:
|
||||
"""Manages all active game rooms."""
|
||||
|
||||
def __init__(self):
|
||||
self.rooms: Dict[int, GameRoom] = {} # game_id -> GameRoom
|
||||
self.game_counter = 0
|
||||
|
||||
async def create_game(self, room_id: int, host_id: int) -> int:
|
||||
"""Create a new game and return game ID."""
|
||||
self.game_counter += 1
|
||||
game_id = self.game_counter
|
||||
room = GameRoom(room_id, game_id)
|
||||
self.rooms[game_id] = room
|
||||
await room.add_player(host_id, None) # Host joins but websocket added later
|
||||
return game_id
|
||||
|
||||
async def get_room(self, game_id: int) -> Optional[GameRoom]:
|
||||
"""Get a game room by ID."""
|
||||
return self.rooms.get(game_id)
|
||||
|
||||
async def delete_room(self, game_id: int):
|
||||
"""Delete a game room."""
|
||||
room = self.rooms.pop(game_id, None)
|
||||
if room:
|
||||
# Close all player connections
|
||||
for player_id, websocket in list(room.players.items()):
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_active_games(self) -> list:
|
||||
"""Get list of active game IDs."""
|
||||
return list(self.rooms.keys())
|
||||
|
||||
|
||||
# Global game server instance
|
||||
game_server = GameServer()
|
||||
|
||||
|
||||
async def game_websocket_endpoint(websocket: WebSocket, game_id: int):
|
||||
"""WebSocket endpoint for game connections."""
|
||||
room = await game_server.get_room(game_id)
|
||||
if not room:
|
||||
await websocket.close(code=4004, reason="Game not found")
|
||||
return
|
||||
|
||||
# Wait for player ID from client
|
||||
player_id = None
|
||||
try:
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
if message.get("type") == "join":
|
||||
player_id = message.get("player_id")
|
||||
else:
|
||||
await websocket.close(code=4001, reason="Invalid join message")
|
||||
return
|
||||
except Exception as e:
|
||||
await websocket.close(code=4000, reason="Invalid message")
|
||||
return
|
||||
|
||||
if not player_id:
|
||||
await websocket.close(code=4001, reason="Player ID required")
|
||||
return
|
||||
|
||||
# Add player to room
|
||||
await room.add_player(player_id, websocket)
|
||||
|
||||
# Send join confirmation
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "joined",
|
||||
"player_id": player_id,
|
||||
"game_id": game_id,
|
||||
"state": room.state,
|
||||
}))
|
||||
|
||||
# Broadcast join event to other players
|
||||
await room.broadcast({
|
||||
"type": "game_event",
|
||||
"event": GAME_EVENT_JOIN,
|
||||
"player_id": player_id,
|
||||
})
|
||||
|
||||
# Handle messages
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
|
||||
if message.get("type") == "command":
|
||||
# Process game command
|
||||
await process_game_command(room, player_id, message)
|
||||
elif message.get("type") == "chat":
|
||||
# Broadcast chat message
|
||||
await room.broadcast({
|
||||
"type": "game_event",
|
||||
"event": GAME_EVENT_GAME_SAY,
|
||||
"player_id": player_id,
|
||||
"message": message.get("message"),
|
||||
})
|
||||
elif message.get("type") == "ping":
|
||||
# Respond to ping
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "pong",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}))
|
||||
except WebSocketDisconnect:
|
||||
# Player disconnected
|
||||
await room.remove_player(player_id)
|
||||
await room.broadcast({
|
||||
"type": "game_event",
|
||||
"event": GAME_EVENT_LEAVE,
|
||||
"player_id": player_id,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error in game WebSocket: {e}")
|
||||
await room.remove_player(player_id)
|
||||
|
||||
|
||||
async def process_game_command(room: GameRoom, player_id: int, command: dict):
|
||||
"""Process a game command from a player."""
|
||||
cmd_type = command.get("type")
|
||||
|
||||
# Validate command type
|
||||
if cmd_type not in [
|
||||
GAME_COMMAND_KICK_FROM_GAME,
|
||||
GAME_COMMAND_LEAVE_GAME,
|
||||
GAME_COMMAND_GAME_SAY,
|
||||
GAME_COMMAND_SHUFFLE,
|
||||
GAME_COMMAND_MULLIGAN,
|
||||
GAME_COMMAND_ROLL_DIE,
|
||||
GAME_COMMAND_DRAW_CARDS,
|
||||
GAME_COMMAND_UNDO_DRAW,
|
||||
GAME_COMMAND_FLIP_CARD,
|
||||
GAME_COMMAND_ATTACH_CARD,
|
||||
GAME_COMMAND_CREATE_TOKEN,
|
||||
GAME_COMMAND_CREATE_ARROW,
|
||||
GAME_COMMAND_DELETE_ARROW,
|
||||
GAME_COMMAND_SET_CARD_ATTR,
|
||||
GAME_COMMAND_SET_CARD_COUNTER,
|
||||
GAME_COMMAND_INC_CARD_COUNTER,
|
||||
GAME_COMMAND_READY_START,
|
||||
GAME_COMMAND_CONCEDE,
|
||||
GAME_COMMAND_INC_COUNTER,
|
||||
GAME_COMMAND_CREATE_COUNTER,
|
||||
GAME_COMMAND_SET_COUNTER,
|
||||
GAME_COMMAND_DEL_COUNTER,
|
||||
GAME_COMMAND_NEXT_TURN,
|
||||
GAME_COMMAND_SET_ACTIVE_PHASE,
|
||||
GAME_COMMAND_DUMP_ZONE,
|
||||
GAME_COMMAND_REVEAL_CARDS,
|
||||
GAME_COMMAND_MOVE_CARD,
|
||||
GAME_COMMAND_SET_SIDEBOARD_PLAN,
|
||||
GAME_COMMAND_DECK_SELECT,
|
||||
GAME_COMMAND_SET_SIDEBOARD_LOCK,
|
||||
GAME_COMMAND_CHANGE_ZONE_PROPERTIES,
|
||||
GAME_COMMAND_UNCONCEDE,
|
||||
GAME_COMMAND_JUDGE,
|
||||
GAME_COMMAND_REVERSE_TURN,
|
||||
]:
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "error",
|
||||
"message": f"Invalid command type: {cmd_type}",
|
||||
}))
|
||||
return
|
||||
|
||||
# Broadcast command as game event
|
||||
await room.broadcast({
|
||||
"type": "game_event",
|
||||
"event": cmd_type,
|
||||
"player_id": player_id,
|
||||
**command.get("data", {}),
|
||||
})
|
||||
Reference in New Issue
Block a user