feat: Initial project structure for WebTriviasMulti

- Backend: FastAPI + Python-SocketIO + SQLAlchemy
  - Models for categories, questions, game sessions, events
  - AI services for answer validation and question generation (Claude)
  - Room management with Redis
  - Game logic with stealing mechanics
  - Admin API for question management

- Frontend: React + Vite + TypeScript + Tailwind
  - 5 visual themes (DRRR, Retro, Minimal, RGB, Anime 90s)
  - Real-time game with Socket.IO
  - Achievement system
  - Replay functionality
  - Sound effects per theme

- Docker Compose for deployment
- Design documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 07:50:48 +00:00
commit 43021b9c3c
57 changed files with 5446 additions and 0 deletions

View File

@@ -0,0 +1 @@
# API routers

296
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,296 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from typing import List
from app.models.base import get_db
from app.models.admin import Admin
from app.models.question import Question
from app.models.category import Category
from app.schemas.admin import AdminCreate, Token, TokenData
from app.schemas.question import (
QuestionCreate, QuestionUpdate, QuestionResponse,
AIGenerateRequest
)
from app.services.ai_generator import ai_generator
from app.config import get_settings
router = APIRouter()
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_expire_minutes)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
async def get_current_admin(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> Admin:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(Admin).where(Admin.username == username))
admin = result.scalar_one_or_none()
if admin is None:
raise credentials_exception
return admin
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Admin).where(Admin.username == form_data.username)
)
admin = result.scalar_one_or_none()
if not admin or not verify_password(form_data.password, admin.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": admin.username})
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/register", response_model=Token)
async def register_admin(
admin_data: AdminCreate,
db: AsyncSession = Depends(get_db)
):
# Check if admin exists
result = await db.execute(
select(Admin).where(Admin.username == admin_data.username)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Create admin
admin = Admin(
username=admin_data.username,
password_hash=get_password_hash(admin_data.password)
)
db.add(admin)
await db.commit()
access_token = create_access_token(data={"sub": admin.username})
return {"access_token": access_token, "token_type": "bearer"}
# Question Management
@router.get("/questions", response_model=List[QuestionResponse])
async def get_questions(
category_id: int = None,
status: str = None,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
query = select(Question)
if category_id:
query = query.where(Question.category_id == category_id)
if status:
query = query.where(Question.status == status)
result = await db.execute(query.order_by(Question.created_at.desc()))
return result.scalars().all()
@router.post("/questions", response_model=QuestionResponse)
async def create_question(
question_data: QuestionCreate,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
question = Question(
**question_data.model_dump(),
points=settings.default_points.get(question_data.difficulty, 300),
time_seconds=settings.default_times.get(question_data.difficulty, 25)
)
db.add(question)
await db.commit()
await db.refresh(question)
return question
@router.put("/questions/{question_id}", response_model=QuestionResponse)
async def update_question(
question_id: int,
question_data: QuestionUpdate,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
for key, value in question_data.model_dump(exclude_unset=True).items():
setattr(question, key, value)
await db.commit()
await db.refresh(question)
return question
@router.delete("/questions/{question_id}")
async def delete_question(
question_id: int,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
await db.delete(question)
await db.commit()
return {"status": "deleted"}
@router.post("/questions/generate")
async def generate_questions(
request: AIGenerateRequest,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
# Get category name
result = await db.execute(
select(Category).where(Category.id == request.category_id)
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# Generate questions with AI
generated = await ai_generator.generate_questions(
category_name=category.name,
difficulty=request.difficulty,
count=request.count
)
# Save to database as pending
questions = []
for q_data in generated:
question = Question(
category_id=request.category_id,
question_text=q_data["question"],
correct_answer=q_data["correct_answer"],
alt_answers=q_data.get("alt_answers", []),
difficulty=q_data["difficulty"],
points=q_data["points"],
time_seconds=q_data["time_seconds"],
fun_fact=q_data.get("fun_fact"),
status="pending"
)
db.add(question)
questions.append(question)
await db.commit()
return {
"generated": len(questions),
"questions": [q.id for q in questions]
}
@router.post("/questions/{question_id}/approve")
async def approve_question(
question_id: int,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
question.status = "approved"
await db.commit()
return {"status": "approved"}
@router.post("/questions/{question_id}/reject")
async def reject_question(
question_id: int,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Question).where(Question.id == question_id))
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
await db.delete(question)
await db.commit()
return {"status": "rejected"}
# Categories
@router.get("/categories")
async def get_categories(
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
result = await db.execute(select(Category))
return result.scalars().all()
@router.post("/categories")
async def create_category(
name: str,
icon: str = None,
color: str = None,
db: AsyncSession = Depends(get_db),
admin: Admin = Depends(get_current_admin)
):
category = Category(name=name, icon=icon, color=color)
db.add(category)
await db.commit()
await db.refresh(category)
return category

119
backend/app/api/game.py Normal file
View File

@@ -0,0 +1,119 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import date
from typing import Dict, List
from app.models.base import get_db
from app.models.question import Question
from app.models.category import Category
from app.schemas.game import RoomCreate, RoomJoin, GameState
router = APIRouter()
@router.get("/categories")
async def get_game_categories():
"""Get all categories for the game board."""
# Return hardcoded categories for now
# In production, these would come from the database
return [
{"id": 1, "name": "Nintendo", "icon": "🍄", "color": "#E60012"},
{"id": 2, "name": "Xbox", "icon": "🎮", "color": "#107C10"},
{"id": 3, "name": "PlayStation", "icon": "🎯", "color": "#003791"},
{"id": 4, "name": "Anime", "icon": "⛩️", "color": "#FF6B9D"},
{"id": 5, "name": "Música", "icon": "🎵", "color": "#1DB954"},
{"id": 6, "name": "Películas", "icon": "🎬", "color": "#F5C518"},
{"id": 7, "name": "Libros", "icon": "📚", "color": "#8B4513"},
{"id": 8, "name": "Historia-Cultura", "icon": "🏛️", "color": "#6B5B95"},
]
@router.get("/board/{room_code}")
async def get_game_board(room_code: str):
"""
Get the game board with questions for today.
Returns questions grouped by category.
"""
from app.services.room_manager import room_manager
room = await room_manager.get_room(room_code)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# If board already exists in room, return it
if room.get("board"):
return room["board"]
# Otherwise, this would load from database
# For now, return empty board structure
return {}
@router.get("/today-questions")
async def get_today_questions():
"""
Get all approved questions for today, grouped by category and difficulty.
This is used to build the game board.
"""
# This would query the database for questions with date_active = today
# For now, return sample structure
return {
"date": str(date.today()),
"categories": {
"1": { # Nintendo
"name": "Nintendo",
"questions": [
{"difficulty": 1, "id": 1, "points": 100},
{"difficulty": 2, "id": 2, "points": 200},
{"difficulty": 3, "id": 3, "points": 300},
{"difficulty": 4, "id": 4, "points": 400},
{"difficulty": 5, "id": 5, "points": 500},
]
}
# ... other categories
}
}
@router.get("/question/{question_id}")
async def get_question(question_id: int):
"""
Get a specific question (without the answer).
Used when a player selects a question.
"""
# This would query the database
# For now, return sample
return {
"id": question_id,
"question_text": "¿En qué año se lanzó la primera consola Nintendo Entertainment System (NES) en Japón?",
"difficulty": 3,
"points": 300,
"time_seconds": 25,
"category_id": 1
}
@router.get("/achievements")
async def get_achievements():
"""Get list of all available achievements."""
return [
{"id": 1, "name": "Primera Victoria", "description": "Ganar tu primera partida", "icon": "🏆"},
{"id": 2, "name": "Racha de 3", "description": "Responder 3 correctas seguidas", "icon": "🔥"},
{"id": 3, "name": "Racha de 5", "description": "Responder 5 correctas seguidas", "icon": "🔥🔥"},
{"id": 4, "name": "Ladrón Novato", "description": "Primer robo exitoso", "icon": "🦝"},
{"id": 5, "name": "Ladrón Maestro", "description": "5 robos exitosos en una partida", "icon": "🦝👑"},
{"id": 6, "name": "Especialista Nintendo", "description": "10 correctas en Nintendo", "icon": "🍄"},
{"id": 7, "name": "Especialista Xbox", "description": "10 correctas en Xbox", "icon": "🎮"},
{"id": 8, "name": "Especialista PlayStation", "description": "10 correctas en PlayStation", "icon": "🎯"},
{"id": 9, "name": "Especialista Anime", "description": "10 correctas en Anime", "icon": "⛩️"},
{"id": 10, "name": "Especialista Música", "description": "10 correctas en Música", "icon": "🎵"},
{"id": 11, "name": "Especialista Películas", "description": "10 correctas en Películas", "icon": "🎬"},
{"id": 12, "name": "Especialista Libros", "description": "10 correctas en Libros", "icon": "📚"},
{"id": 13, "name": "Especialista Historia", "description": "10 correctas en Historia-Cultura", "icon": "🏛️"},
{"id": 14, "name": "Invicto", "description": "Ganar sin fallar ninguna pregunta", "icon": ""},
{"id": 15, "name": "Velocista", "description": "Responder correctamente en menos de 3 segundos", "icon": ""},
{"id": 16, "name": "Comeback", "description": "Ganar estando 500+ puntos abajo", "icon": "🔄"},
{"id": 17, "name": "Dominio Total", "description": "Responder las 5 preguntas de una categoría", "icon": "👑"},
{"id": 18, "name": "Arriesgado", "description": "Responder correctamente 3 preguntas de 500 pts", "icon": "🎰"},
]

113
backend/app/api/replay.py Normal file
View File

@@ -0,0 +1,113 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.models.base import get_db
from app.models.game_session import GameSession
from app.models.game_event import GameEvent
router = APIRouter()
@router.get("/{session_id}")
async def get_replay(
session_id: int,
db: AsyncSession = Depends(get_db)
):
"""
Get replay data for a game session.
Returns all events in chronological order.
"""
# Get session
result = await db.execute(
select(GameSession).where(GameSession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Get all events
events_result = await db.execute(
select(GameEvent)
.where(GameEvent.session_id == session_id)
.order_by(GameEvent.timestamp)
)
events = events_result.scalars().all()
return {
"session": {
"id": session.id,
"room_code": session.room_code,
"team_a_score": session.team_a_score,
"team_b_score": session.team_b_score,
"status": session.status,
"created_at": session.created_at,
"finished_at": session.finished_at
},
"events": [
{
"id": e.id,
"event_type": e.event_type,
"player_name": e.player_name,
"team": e.team,
"question_id": e.question_id,
"answer_given": e.answer_given,
"was_correct": e.was_correct,
"was_steal": e.was_steal,
"points_earned": e.points_earned,
"timestamp": e.timestamp
}
for e in events
]
}
@router.get("/code/{room_code}")
async def get_replay_by_code(
room_code: str,
db: AsyncSession = Depends(get_db)
):
"""
Get replay data by room code.
"""
result = await db.execute(
select(GameSession).where(GameSession.room_code == room_code)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return await get_replay(session.id, db)
@router.get("/")
async def list_replays(
limit: int = 20,
offset: int = 0,
db: AsyncSession = Depends(get_db)
):
"""
List recent finished game sessions.
"""
result = await db.execute(
select(GameSession)
.where(GameSession.status == "finished")
.order_by(GameSession.finished_at.desc())
.offset(offset)
.limit(limit)
)
sessions = result.scalars().all()
return [
{
"id": s.id,
"room_code": s.room_code,
"team_a_score": s.team_a_score,
"team_b_score": s.team_b_score,
"finished_at": s.finished_at
}
for s in sessions
]