From 43021b9c3c2804c0d6d6eee02daa8ebae6d0b8eb Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 26 Jan 2026 07:50:48 +0000 Subject: [PATCH] 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 --- .env.example | 12 + .gitignore | 63 ++ backend/Dockerfile | 22 + backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/admin.py | 296 ++++++++ backend/app/api/game.py | 119 ++++ backend/app/api/replay.py | 113 +++ backend/app/config.py | 47 ++ backend/app/main.py | 76 ++ backend/app/models/__init__.py | 7 + backend/app/models/admin.py | 15 + backend/app/models/base.py | 27 + backend/app/models/category.py | 18 + backend/app/models/game_event.py | 27 + backend/app/models/game_session.py | 24 + backend/app/models/question.py | 28 + backend/app/schemas/__init__.py | 17 + backend/app/schemas/admin.py | 31 + backend/app/schemas/game.py | 70 ++ backend/app/schemas/question.py | 61 ++ backend/app/services/__init__.py | 6 + backend/app/services/ai_generator.py | 97 +++ backend/app/services/ai_validator.py | 80 +++ backend/app/services/game_manager.py | 204 ++++++ backend/app/services/room_manager.py | 173 +++++ backend/app/sockets/__init__.py | 1 + backend/app/sockets/game_events.py | 312 +++++++++ backend/requirements.txt | 37 + docker-compose.yml | 63 ++ .../2026-01-26-webtriviasmulti-design.md | 659 ++++++++++++++++++ frontend/Dockerfile | 16 + frontend/index.html | 18 + frontend/package.json | 39 ++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 22 + frontend/src/hooks/useAchievements.ts | 165 +++++ frontend/src/hooks/useSocket.ts | 175 +++++ frontend/src/hooks/useSound.ts | 84 +++ frontend/src/index.css | 120 ++++ frontend/src/main.tsx | 16 + frontend/src/pages/Game.tsx | 335 +++++++++ frontend/src/pages/Home.tsx | 213 ++++++ frontend/src/pages/Lobby.tsx | 227 ++++++ frontend/src/pages/Replay.tsx | 247 +++++++ frontend/src/pages/Results.tsx | 210 ++++++ frontend/src/services/api.ts | 116 +++ frontend/src/services/socket.ts | 69 ++ frontend/src/stores/gameStore.ts | 104 +++ frontend/src/stores/soundStore.ts | 90 +++ frontend/src/stores/themeStore.ts | 140 ++++ frontend/src/themes/ThemeProvider.tsx | 65 ++ frontend/src/types/index.ts | 135 ++++ frontend/tailwind.config.js | 78 +++ frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 14 + 57 files changed, 5446 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/game.py create mode 100644 backend/app/api/replay.py create mode 100644 backend/app/config.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/admin.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/category.py create mode 100644 backend/app/models/game_event.py create mode 100644 backend/app/models/game_session.py create mode 100644 backend/app/models/question.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/app/schemas/game.py create mode 100644 backend/app/schemas/question.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/ai_generator.py create mode 100644 backend/app/services/ai_validator.py create mode 100644 backend/app/services/game_manager.py create mode 100644 backend/app/services/room_manager.py create mode 100644 backend/app/sockets/__init__.py create mode 100644 backend/app/sockets/game_events.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 docs/plans/2026-01-26-webtriviasmulti-design.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/hooks/useAchievements.ts create mode 100644 frontend/src/hooks/useSocket.ts create mode 100644 frontend/src/hooks/useSound.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Game.tsx create mode 100644 frontend/src/pages/Home.tsx create mode 100644 frontend/src/pages/Lobby.tsx create mode 100644 frontend/src/pages/Replay.tsx create mode 100644 frontend/src/pages/Results.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/socket.ts create mode 100644 frontend/src/stores/gameStore.ts create mode 100644 frontend/src/stores/soundStore.ts create mode 100644 frontend/src/stores/themeStore.ts create mode 100644 frontend/src/themes/ThemeProvider.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..576e130 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Backend +DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia +REDIS_URL=redis://redis:6379 +ANTHROPIC_API_KEY=sk-ant-your-api-key +JWT_SECRET=your-super-secret-jwt-key + +# Frontend +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000 + +# Cloudflare Tunnel +CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8da113f --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +ENV/ +.eggs/ +*.egg-info/ +*.egg + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build +dist/ +build/ +*.egg-info/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +.docker/ + +# Logs +logs/ +*.log + +# Database +*.db +*.sqlite3 + +# Coverage +htmlcov/ +.coverage +coverage.xml + +# Testing +.pytest_cache/ +.tox/ + +# Alembic +alembic/versions/*.pyc diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b9a2c6b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Expose port +EXPOSE 8000 + +# Run application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..489af58 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# WebTriviasMulti Backend diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..df5374a --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API routers diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..2018d32 --- /dev/null +++ b/backend/app/api/admin.py @@ -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 diff --git a/backend/app/api/game.py b/backend/app/api/game.py new file mode 100644 index 0000000..c9c98d0 --- /dev/null +++ b/backend/app/api/game.py @@ -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": "🎰"}, + ] diff --git a/backend/app/api/replay.py b/backend/app/api/replay.py new file mode 100644 index 0000000..4489f06 --- /dev/null +++ b/backend/app/api/replay.py @@ -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 + ] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..078ba39 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,47 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Database + database_url: str = "postgresql://trivia:trivia@localhost:5432/trivia" + + # Redis + redis_url: str = "redis://localhost:6379" + + # JWT + jwt_secret: str = "dev-secret-key-change-in-production" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 1440 # 24 hours + + # Anthropic + anthropic_api_key: str = "" + + # Game settings + default_times: dict = { + 1: 15, # 100 pts + 2: 20, # 200 pts + 3: 25, # 300 pts + 4: 35, # 400 pts + 5: 45, # 500 pts + } + + default_points: dict = { + 1: 100, + 2: 200, + 3: 300, + 4: 400, + 5: 500, + } + + steal_penalty_multiplier: float = 0.5 + steal_time_multiplier: float = 0.5 + + class Config: + env_file = ".env" + extra = "ignore" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..012304f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,76 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import socketio +from contextlib import asynccontextmanager + +from app.config import get_settings +from app.api import admin, game, replay +from app.sockets.game_events import register_socket_events + +settings = get_settings() + +# Socket.IO server +sio = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins="*", + logger=True, + engineio_logger=True +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + print("Starting WebTriviasMulti server...") + yield + # Shutdown + print("Shutting down WebTriviasMulti server...") + + +# FastAPI app +app = FastAPI( + title="WebTriviasMulti API", + description="API para el juego de trivia multiplayer", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) +app.include_router(game.router, prefix="/api/game", tags=["game"]) +app.include_router(replay.router, prefix="/api/replay", tags=["replay"]) + +# Register Socket.IO events +register_socket_events(sio) + +# Mount Socket.IO +socket_app = socketio.ASGIApp(sio, app) + + +@app.get("/") +async def root(): + return { + "message": "WebTriviasMulti API", + "version": "1.0.0", + "status": "running" + } + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + +# For running with uvicorn directly +if __name__ == "__main__": + import uvicorn + uvicorn.run("app.main:socket_app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..024050c --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +from app.models.category import Category +from app.models.question import Question +from app.models.game_session import GameSession +from app.models.game_event import GameEvent +from app.models.admin import Admin + +__all__ = ["Category", "Question", "GameSession", "GameEvent", "Admin"] diff --git a/backend/app/models/admin.py b/backend/app/models/admin.py new file mode 100644 index 0000000..31fb945 --- /dev/null +++ b/backend/app/models/admin.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.models.base import Base + + +class Admin(Base): + __tablename__ = "admins" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(100), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __repr__(self): + return f"" diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..f75d681 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from app.config import get_settings + +settings = get_settings() + +# Convert postgresql:// to postgresql+asyncpg:// +database_url = settings.database_url.replace( + "postgresql://", "postgresql+asyncpg://" +) + +engine = create_async_engine(database_url, echo=True) + +AsyncSessionLocal = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + +Base = declarative_base() + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..1d68571 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship +from app.models.base import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, unique=True) + icon = Column(String(50)) + color = Column(String(7)) # Hex color + + # Relationships + questions = relationship("Question", back_populates="category") + + def __repr__(self): + return f"" diff --git a/backend/app/models/game_event.py b/backend/app/models/game_event.py new file mode 100644 index 0000000..685e9c7 --- /dev/null +++ b/backend/app/models/game_event.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.models.base import Base + + +class GameEvent(Base): + __tablename__ = "game_events" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("game_sessions.id"), nullable=False) + event_type = Column(String(50), nullable=False) # question_selected, answer_submitted, steal_attempted, etc. + player_name = Column(String(100)) + team = Column(String(1)) # 'A' or 'B' + question_id = Column(Integer, ForeignKey("questions.id")) + answer_given = Column(Text) + was_correct = Column(Boolean) + was_steal = Column(Boolean, default=False) + points_earned = Column(Integer) + timestamp = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + session = relationship("GameSession", back_populates="events") + question = relationship("Question", back_populates="game_events") + + def __repr__(self): + return f"" diff --git a/backend/app/models/game_session.py b/backend/app/models/game_session.py new file mode 100644 index 0000000..b42837c --- /dev/null +++ b/backend/app/models/game_session.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, DateTime, ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.models.base import Base + + +class GameSession(Base): + __tablename__ = "game_sessions" + + id = Column(Integer, primary_key=True, index=True) + room_code = Column(String(6), unique=True, nullable=False, index=True) + status = Column(String(20), default="waiting") # waiting, playing, finished + team_a_score = Column(Integer, default=0) + team_b_score = Column(Integer, default=0) + current_team = Column(String(1)) # 'A' or 'B' + questions_used = Column(ARRAY(Integer), default=[]) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + finished_at = Column(DateTime(timezone=True)) + + # Relationships + events = relationship("GameEvent", back_populates="session") + + def __repr__(self): + return f"" diff --git a/backend/app/models/question.py b/backend/app/models/question.py new file mode 100644 index 0000000..1b007d8 --- /dev/null +++ b/backend/app/models/question.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.models.base import Base + + +class Question(Base): + __tablename__ = "questions" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) + question_text = Column(Text, nullable=False) + correct_answer = Column(String(500), nullable=False) + alt_answers = Column(ARRAY(String), default=[]) + difficulty = Column(Integer, nullable=False) # 1-5 + points = Column(Integer, nullable=False) + time_seconds = Column(Integer, nullable=False) + date_active = Column(Date, index=True) + status = Column(String(20), default="pending") # pending, approved, used + fun_fact = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + category = relationship("Category", back_populates="questions") + game_events = relationship("GameEvent", back_populates="question") + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..504d4e2 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,17 @@ +from app.schemas.question import QuestionCreate, QuestionUpdate, QuestionResponse +from app.schemas.game import ( + RoomCreate, + RoomJoin, + PlayerInfo, + GameState, + AnswerSubmit, + StealAttempt +) +from app.schemas.admin import AdminCreate, AdminLogin, Token + +__all__ = [ + "QuestionCreate", "QuestionUpdate", "QuestionResponse", + "RoomCreate", "RoomJoin", "PlayerInfo", "GameState", + "AnswerSubmit", "StealAttempt", + "AdminCreate", "AdminLogin", "Token" +] diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..208b7df --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from datetime import datetime + + +class AdminBase(BaseModel): + username: str + + +class AdminCreate(AdminBase): + password: str + + +class AdminLogin(AdminBase): + password: str + + +class AdminResponse(AdminBase): + id: int + created_at: datetime + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + username: str | None = None diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py new file mode 100644 index 0000000..778edea --- /dev/null +++ b/backend/app/schemas/game.py @@ -0,0 +1,70 @@ +from pydantic import BaseModel +from typing import Optional, List, Dict +from datetime import datetime + + +class PlayerInfo(BaseModel): + name: str + team: str # 'A' or 'B' + position: int + socket_id: Optional[str] = None + + +class RoomCreate(BaseModel): + player_name: str + + +class RoomJoin(BaseModel): + room_code: str + player_name: str + team: str # 'A' or 'B' + + +class TeamState(BaseModel): + players: List[PlayerInfo] + score: int + current_player_index: int + + +class QuestionState(BaseModel): + id: int + category_id: int + difficulty: int + points: int + answered: bool = False + + +class GameState(BaseModel): + room_code: str + status: str # waiting, playing, finished + team_a: TeamState + team_b: TeamState + current_team: Optional[str] = None + current_question: Optional[int] = None + can_steal: bool = False + board: Dict[int, List[QuestionState]] # category_id -> questions + timer_end: Optional[datetime] = None + + +class AnswerSubmit(BaseModel): + question_id: int + answer: str + + +class StealAttempt(BaseModel): + question_id: int + attempt: bool # True = try to steal, False = pass + answer: Optional[str] = None + + +class ChatMessage(BaseModel): + player_name: str + team: str + message: str + timestamp: datetime + + +class EmojiReaction(BaseModel): + player_name: str + team: str + emoji: str # One of: 👏 😮 😂 🔥 💀 🎉 😭 🤔 diff --git a/backend/app/schemas/question.py b/backend/app/schemas/question.py new file mode 100644 index 0000000..78288c6 --- /dev/null +++ b/backend/app/schemas/question.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import date, datetime + + +class QuestionBase(BaseModel): + question_text: str + correct_answer: str + alt_answers: List[str] = [] + difficulty: int + fun_fact: Optional[str] = None + + +class QuestionCreate(QuestionBase): + category_id: int + + +class QuestionUpdate(BaseModel): + question_text: Optional[str] = None + correct_answer: Optional[str] = None + alt_answers: Optional[List[str]] = None + difficulty: Optional[int] = None + fun_fact: Optional[str] = None + status: Optional[str] = None + date_active: Optional[date] = None + + +class QuestionResponse(QuestionBase): + id: int + category_id: int + points: int + time_seconds: int + date_active: Optional[date] + status: str + created_at: datetime + + class Config: + from_attributes = True + + +class QuestionForGame(BaseModel): + id: int + category_id: int + question_text: str + difficulty: int + points: int + time_seconds: int + + class Config: + from_attributes = True + + +class AIGenerateRequest(BaseModel): + category_id: int + difficulty: int + count: int = 5 + + +class AIValidateRequest(BaseModel): + question_id: int + player_answer: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..4d93b7d --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,6 @@ +from app.services.ai_validator import AIValidator +from app.services.ai_generator import AIGenerator +from app.services.game_manager import GameManager +from app.services.room_manager import RoomManager + +__all__ = ["AIValidator", "AIGenerator", "GameManager", "RoomManager"] diff --git a/backend/app/services/ai_generator.py b/backend/app/services/ai_generator.py new file mode 100644 index 0000000..12924c6 --- /dev/null +++ b/backend/app/services/ai_generator.py @@ -0,0 +1,97 @@ +import json +from anthropic import Anthropic +from app.config import get_settings + +settings = get_settings() + + +class AIGenerator: + def __init__(self): + self.client = Anthropic(api_key=settings.anthropic_api_key) + + async def generate_questions( + self, + category_name: str, + difficulty: int, + count: int = 5 + ) -> list[dict]: + """ + Generate trivia questions using Claude AI. + + Args: + category_name: Name of the category (e.g., "Nintendo", "Anime") + difficulty: 1-5 (1=very easy, 5=very hard) + count: Number of questions to generate + + Returns: + list[dict]: List of question objects + """ + difficulty_descriptions = { + 1: "muy fácil - conocimiento básico que la mayoría conoce", + 2: "fácil - conocimiento común entre fans casuales", + 3: "medio - requiere ser fan de la categoría", + 4: "difícil - conocimiento profundo del tema", + 5: "muy difícil - solo expertos conocerían esto" + } + + prompt = f"""Genera {count} preguntas de trivia para la categoría "{category_name}". +Dificultad: {difficulty} ({difficulty_descriptions.get(difficulty, 'medio')}) + +Requisitos: +- Las preguntas deben ser verificables y precisas +- Evitar ambigüedades +- Las respuestas deben ser específicas y concisas +- Incluir variaciones comunes de la respuesta +- Para gaming: referencias a juegos, personajes, mecánicas, fechas de lanzamiento +- Para anime: personajes, series, estudios, seiyuus +- Para música: artistas, canciones, álbumes, letras famosas +- Para películas: actores, directores, frases icónicas, premios +- Para libros: autores, obras, personajes literarios +- Para historia-cultura: eventos, fechas, personajes históricos, arte + +Formato JSON (array de objetos): +[ + {{ + "question": "texto de la pregunta", + "correct_answer": "respuesta principal", + "alt_answers": ["variación1", "variación2"], + "fun_fact": "dato curioso opcional sobre la respuesta" + }} +] + +Responde SOLO con el JSON, sin texto adicional.""" + + try: + message = self.client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=2000, + messages=[ + {"role": "user", "content": prompt} + ] + ) + + response_text = message.content[0].text.strip() + + # Parse JSON response + questions = json.loads(response_text) + + # Add metadata to each question + for q in questions: + q["difficulty"] = difficulty + q["points"] = settings.default_points.get(difficulty, 300) + q["time_seconds"] = settings.default_times.get(difficulty, 25) + + return questions + + except json.JSONDecodeError as e: + print(f"Error parsing AI response: {e}") + print(f"Response was: {response_text}") + return [] + + except Exception as e: + print(f"Error generating questions: {e}") + return [] + + +# Singleton instance +ai_generator = AIGenerator() diff --git a/backend/app/services/ai_validator.py b/backend/app/services/ai_validator.py new file mode 100644 index 0000000..0655822 --- /dev/null +++ b/backend/app/services/ai_validator.py @@ -0,0 +1,80 @@ +import json +from anthropic import Anthropic +from app.config import get_settings + +settings = get_settings() + + +class AIValidator: + def __init__(self): + self.client = Anthropic(api_key=settings.anthropic_api_key) + + async def validate_answer( + self, + question: str, + correct_answer: str, + alt_answers: list[str], + player_answer: str + ) -> dict: + """ + Validate if the player's answer is correct using Claude AI. + + Returns: + dict: {"valid": bool, "reason": str} + """ + prompt = f"""Eres un validador de trivia. Determina si la respuesta del jugador +es correcta comparándola con la respuesta oficial. + +Pregunta: {question} +Respuesta correcta: {correct_answer} +Respuestas alternativas válidas: {', '.join(alt_answers) if alt_answers else 'Ninguna'} +Respuesta del jugador: {player_answer} + +Considera válido si: +- Es sinónimo o variación de la respuesta correcta +- Tiene errores menores de ortografía +- Usa abreviaciones comunes (ej: "BOTW" = "Breath of the Wild") +- Es conceptualmente equivalente + +Responde SOLO con JSON: {{"valid": true/false, "reason": "breve explicación"}}""" + + try: + message = self.client.messages.create( + model="claude-3-haiku-20240307", + max_tokens=150, + messages=[ + {"role": "user", "content": prompt} + ] + ) + + response_text = message.content[0].text.strip() + + # Parse JSON response + result = json.loads(response_text) + return result + + except json.JSONDecodeError: + # If JSON parsing fails, try to extract the result + response_lower = response_text.lower() + if "true" in response_lower: + return {"valid": True, "reason": "Respuesta validada por IA"} + return {"valid": False, "reason": "No se pudo validar la respuesta"} + + except Exception as e: + print(f"Error validating answer: {e}") + # Fallback to exact match + player_lower = player_answer.lower().strip() + correct_lower = correct_answer.lower().strip() + + if player_lower == correct_lower: + return {"valid": True, "reason": "Coincidencia exacta"} + + for alt in alt_answers: + if player_lower == alt.lower().strip(): + return {"valid": True, "reason": "Coincide con respuesta alternativa"} + + return {"valid": False, "reason": "Respuesta incorrecta"} + + +# Singleton instance +ai_validator = AIValidator() diff --git a/backend/app/services/game_manager.py b/backend/app/services/game_manager.py new file mode 100644 index 0000000..f9a4806 --- /dev/null +++ b/backend/app/services/game_manager.py @@ -0,0 +1,204 @@ +from typing import Optional +from datetime import datetime, timedelta +from app.services.room_manager import room_manager +from app.services.ai_validator import ai_validator +from app.config import get_settings + +settings = get_settings() + + +class GameManager: + async def start_game(self, room_code: str, board: dict) -> Optional[dict]: + """ + Start a game in a room. + + Args: + room_code: The room code + board: Dict of category_id -> list of questions + + Returns: + Updated room state + """ + room = await room_manager.get_room(room_code) + if not room: + return None + + # Check minimum players + if not room["teams"]["A"] or not room["teams"]["B"]: + return None + + # Set up game state + room["status"] = "playing" + room["current_team"] = "A" + room["current_player_index"] = {"A": 0, "B": 0} + room["board"] = board + room["scores"] = {"A": 0, "B": 0} + + await room_manager.update_room(room_code, room) + return room + + async def select_question( + self, + room_code: str, + question_id: int, + category_id: int + ) -> Optional[dict]: + """Select a question from the board.""" + room = await room_manager.get_room(room_code) + if not room or room["status"] != "playing": + return None + + # Mark question as current + room["current_question"] = question_id + room["can_steal"] = False + + # Find and mark question on board + if str(category_id) in room["board"]: + for q in room["board"][str(category_id)]: + if q["id"] == question_id: + q["selected"] = True + break + + await room_manager.update_room(room_code, room) + return room + + async def submit_answer( + self, + room_code: str, + question: dict, + player_answer: str, + is_steal: bool = False + ) -> dict: + """ + Submit an answer for validation. + + Returns: + dict with validation result and updated game state + """ + room = await room_manager.get_room(room_code) + if not room: + return {"error": "Room not found"} + + # Validate answer with AI + result = await ai_validator.validate_answer( + question=question["question_text"], + correct_answer=question["correct_answer"], + alt_answers=question.get("alt_answers", []), + player_answer=player_answer + ) + + is_correct = result.get("valid", False) + points = question["points"] + + if is_correct: + # Award points + current_team = room["current_team"] + room["scores"][current_team] += points + + # Mark question as answered + category_id = str(question["category_id"]) + if category_id in room["board"]: + for q in room["board"][category_id]: + if q["id"] == question["id"]: + q["answered"] = True + break + + # Winner chooses next + room["current_question"] = None + room["can_steal"] = False + + # Advance player rotation + team_players = room["teams"][current_team] + room["current_player_index"][current_team] = ( + room["current_player_index"][current_team] + 1 + ) % len(team_players) + + else: + if is_steal: + # Failed steal - penalize + stealing_team = room["current_team"] + penalty = int(points * settings.steal_penalty_multiplier) + room["scores"][stealing_team] = max( + 0, room["scores"][stealing_team] - penalty + ) + + # Mark question as answered (nobody gets it) + category_id = str(question["category_id"]) + if category_id in room["board"]: + for q in room["board"][category_id]: + if q["id"] == question["id"]: + q["answered"] = True + break + + # Original team chooses next + room["current_team"] = "B" if stealing_team == "A" else "A" + room["current_question"] = None + room["can_steal"] = False + + else: + # Original team failed - enable steal + room["can_steal"] = True + # Switch to other team for potential steal + room["current_team"] = "B" if room["current_team"] == "A" else "A" + + # Check if game is over (all questions answered) + all_answered = all( + q["answered"] + for questions in room["board"].values() + for q in questions + ) + if all_answered: + room["status"] = "finished" + + await room_manager.update_room(room_code, room) + + return { + "valid": is_correct, + "reason": result.get("reason", ""), + "points_earned": points if is_correct else 0, + "room": room + } + + async def pass_steal(self, room_code: str, question_id: int) -> Optional[dict]: + """Pass on stealing opportunity.""" + room = await room_manager.get_room(room_code) + if not room: + return None + + # Mark question as answered + for category_id, questions in room["board"].items(): + for q in questions: + if q["id"] == question_id: + q["answered"] = True + break + + # Switch back to original team for next selection + room["current_team"] = "B" if room["current_team"] == "A" else "A" + room["current_question"] = None + room["can_steal"] = False + + await room_manager.update_room(room_code, room) + return room + + async def get_current_player(self, room: dict) -> Optional[dict]: + """Get the current player who should answer.""" + team = room["current_team"] + if not team: + return None + + players = room["teams"][team] + if not players: + return None + + index = room["current_player_index"][team] + return players[index % len(players)] + + def calculate_timer_end(self, time_seconds: int, is_steal: bool = False) -> datetime: + """Calculate when the timer should end.""" + if is_steal: + time_seconds = int(time_seconds * settings.steal_time_multiplier) + return datetime.utcnow() + timedelta(seconds=time_seconds) + + +# Singleton instance +game_manager = GameManager() diff --git a/backend/app/services/room_manager.py b/backend/app/services/room_manager.py new file mode 100644 index 0000000..bc73f3c --- /dev/null +++ b/backend/app/services/room_manager.py @@ -0,0 +1,173 @@ +import json +import random +import string +from typing import Optional +import redis.asyncio as redis +from app.config import get_settings + +settings = get_settings() + + +class RoomManager: + def __init__(self): + self.redis: Optional[redis.Redis] = None + + async def connect(self): + if not self.redis: + self.redis = await redis.from_url(settings.redis_url) + + async def disconnect(self): + if self.redis: + await self.redis.close() + + def _generate_room_code(self) -> str: + """Generate a 6-character room code.""" + return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + + async def create_room(self, player_name: str, socket_id: str) -> dict: + """Create a new game room.""" + await self.connect() + + # Generate unique room code + room_code = self._generate_room_code() + while await self.redis.exists(f"room:{room_code}"): + room_code = self._generate_room_code() + + # Create room state + room_state = { + "code": room_code, + "status": "waiting", + "host": player_name, + "teams": { + "A": [], + "B": [] + }, + "current_team": None, + "current_player_index": {"A": 0, "B": 0}, + "current_question": None, + "can_steal": False, + "scores": {"A": 0, "B": 0}, + "questions_used": [], + "board": {} + } + + # Save room state + await self.redis.setex( + f"room:{room_code}", + 3600 * 3, # 3 hours TTL + json.dumps(room_state) + ) + + # Add player to room + await self.add_player(room_code, player_name, "A", socket_id) + + return room_state + + async def get_room(self, room_code: str) -> Optional[dict]: + """Get room state by code.""" + await self.connect() + data = await self.redis.get(f"room:{room_code}") + if data: + return json.loads(data) + return None + + async def update_room(self, room_code: str, room_state: dict) -> bool: + """Update room state.""" + await self.connect() + await self.redis.setex( + f"room:{room_code}", + 3600 * 3, + json.dumps(room_state) + ) + return True + + async def add_player( + self, + room_code: str, + player_name: str, + team: str, + socket_id: str + ) -> Optional[dict]: + """Add a player to a room.""" + room = await self.get_room(room_code) + if not room: + return None + + # Check if team is full + if len(room["teams"][team]) >= 4: + return None + + # Check if name is taken + for t in ["A", "B"]: + for p in room["teams"][t]: + if p["name"].lower() == player_name.lower(): + return None + + # Add player + player = { + "name": player_name, + "team": team, + "position": len(room["teams"][team]), + "socket_id": socket_id + } + room["teams"][team].append(player) + + # Save player mapping + await self.redis.setex( + f"player:{socket_id}", + 3600 * 3, + json.dumps({"name": player_name, "room": room_code, "team": team}) + ) + + await self.update_room(room_code, room) + return room + + async def remove_player(self, socket_id: str) -> Optional[dict]: + """Remove a player from their room.""" + await self.connect() + + # Get player info + player_data = await self.redis.get(f"player:{socket_id}") + if not player_data: + return None + + player_info = json.loads(player_data) + room_code = player_info["room"] + team = player_info["team"] + + # Get room + room = await self.get_room(room_code) + if not room: + return None + + # Remove player from team + room["teams"][team] = [ + p for p in room["teams"][team] if p["socket_id"] != socket_id + ] + + # Update positions + for i, p in enumerate(room["teams"][team]): + p["position"] = i + + # Delete player mapping + await self.redis.delete(f"player:{socket_id}") + + # If room is empty, delete it + if not room["teams"]["A"] and not room["teams"]["B"]: + await self.redis.delete(f"room:{room_code}") + return None + + await self.update_room(room_code, room) + return room + + async def get_player(self, socket_id: str) -> Optional[dict]: + """Get player info by socket ID.""" + await self.connect() + data = await self.redis.get(f"player:{socket_id}") + if data: + return json.loads(data) + return None + + +# Singleton instance +room_manager = RoomManager() diff --git a/backend/app/sockets/__init__.py b/backend/app/sockets/__init__.py new file mode 100644 index 0000000..b3a4890 --- /dev/null +++ b/backend/app/sockets/__init__.py @@ -0,0 +1 @@ +# Socket.IO events diff --git a/backend/app/sockets/game_events.py b/backend/app/sockets/game_events.py new file mode 100644 index 0000000..92c7089 --- /dev/null +++ b/backend/app/sockets/game_events.py @@ -0,0 +1,312 @@ +import socketio +from datetime import datetime +from app.services.room_manager import room_manager +from app.services.game_manager import game_manager + + +def register_socket_events(sio: socketio.AsyncServer): + """Register all Socket.IO event handlers.""" + + @sio.event + async def connect(sid, environ): + print(f"Client connected: {sid}") + + @sio.event + async def disconnect(sid): + print(f"Client disconnected: {sid}") + # Remove player from room + room = await room_manager.remove_player(sid) + if room: + await sio.emit( + "player_left", + {"room": room}, + room=room["code"] + ) + + @sio.event + async def create_room(sid, data): + """Create a new game room.""" + player_name = data.get("player_name", "Player") + + room = await room_manager.create_room(player_name, sid) + + # Join socket room + sio.enter_room(sid, room["code"]) + + await sio.emit("room_created", {"room": room}, to=sid) + + @sio.event + async def join_room(sid, data): + """Join an existing room.""" + room_code = data.get("room_code", "").upper() + player_name = data.get("player_name", "Player") + team = data.get("team", "A") + + room = await room_manager.add_player(room_code, player_name, team, sid) + + if not room: + await sio.emit( + "error", + {"message": "Could not join room. It may be full or the name is taken."}, + to=sid + ) + return + + # Join socket room + sio.enter_room(sid, room_code) + + # Notify all players + await sio.emit("player_joined", {"room": room}, room=room_code) + + @sio.event + async def change_team(sid, data): + """Switch player to another team.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + new_team = data.get("team") + + room = await room_manager.get_room(room_code) + if not room or len(room["teams"][new_team]) >= 4: + await sio.emit( + "error", + {"message": "Cannot change team. It may be full."}, + to=sid + ) + return + + # Remove from current team + current_team = player["team"] + room["teams"][current_team] = [ + p for p in room["teams"][current_team] if p["socket_id"] != sid + ] + + # Add to new team + room["teams"][new_team].append({ + "name": player["name"], + "team": new_team, + "position": len(room["teams"][new_team]), + "socket_id": sid + }) + + await room_manager.update_room(room_code, room) + await sio.emit("team_changed", {"room": room}, room=room_code) + + @sio.event + async def start_game(sid, data): + """Start the game (host only).""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + room = await room_manager.get_room(room_code) + + if not room: + return + + # Check if player is host + if room["host"] != player["name"]: + await sio.emit( + "error", + {"message": "Only the host can start the game."}, + to=sid + ) + return + + # Check minimum players + if not room["teams"]["A"] or not room["teams"]["B"]: + await sio.emit( + "error", + {"message": "Both teams need at least one player."}, + to=sid + ) + return + + # Get board from data or generate + board = data.get("board", {}) + + updated_room = await game_manager.start_game(room_code, board) + + if updated_room: + await sio.emit("game_started", {"room": updated_room}, room=room_code) + + @sio.event + async def select_question(sid, data): + """Select a question from the board.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + question_id = data.get("question_id") + category_id = data.get("category_id") + + room = await game_manager.select_question(room_code, question_id, category_id) + + if room: + # Get current player info + current_player = await game_manager.get_current_player(room) + + await sio.emit( + "question_selected", + { + "room": room, + "question_id": question_id, + "current_player": current_player + }, + room=room_code + ) + + @sio.event + async def submit_answer(sid, data): + """Submit an answer to the current question.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + answer = data.get("answer", "") + question = data.get("question", {}) + is_steal = data.get("is_steal", False) + + result = await game_manager.submit_answer( + room_code, question, answer, is_steal + ) + + if "error" in result: + await sio.emit("error", {"message": result["error"]}, to=sid) + return + + await sio.emit( + "answer_result", + { + "player_name": player["name"], + "team": player["team"], + "answer": answer, + "valid": result["valid"], + "reason": result["reason"], + "points_earned": result["points_earned"], + "was_steal": is_steal, + "room": result["room"] + }, + room=room_code + ) + + @sio.event + async def steal_decision(sid, data): + """Decide whether to attempt stealing.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + attempt = data.get("attempt", False) + question_id = data.get("question_id") + + if not attempt: + # Pass on steal + room = await game_manager.pass_steal(room_code, question_id) + if room: + await sio.emit( + "steal_passed", + {"room": room, "team": player["team"]}, + room=room_code + ) + else: + # Will attempt steal - just notify, answer comes separately + room = await room_manager.get_room(room_code) + await sio.emit( + "steal_attempted", + { + "team": player["team"], + "player_name": player["name"], + "room": room + }, + room=room_code + ) + + @sio.event + async def chat_message(sid, data): + """Send a chat message to team.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + message = data.get("message", "")[:500] # Limit message length + + # Get all team members' socket IDs + room = await room_manager.get_room(room_code) + if not room: + return + + team_sockets = [ + p["socket_id"] for p in room["teams"][player["team"]] + ] + + # Send only to team members + for socket_id in team_sockets: + await sio.emit( + "chat_message", + { + "player_name": player["name"], + "team": player["team"], + "message": message, + "timestamp": datetime.utcnow().isoformat() + }, + to=socket_id + ) + + @sio.event + async def emoji_reaction(sid, data): + """Send an emoji reaction visible to all.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + emoji = data.get("emoji", "") + + # Validate emoji + allowed_emojis = ["👏", "😮", "😂", "🔥", "💀", "🎉", "😭", "🤔"] + if emoji not in allowed_emojis: + return + + await sio.emit( + "emoji_reaction", + { + "player_name": player["name"], + "team": player["team"], + "emoji": emoji + }, + room=room_code + ) + + @sio.event + async def timer_expired(sid, data): + """Handle timer expiration.""" + player = await room_manager.get_player(sid) + if not player: + return + + room_code = player["room"] + room = await room_manager.get_room(room_code) + + if not room: + return + + # Treat as wrong answer + if room["can_steal"]: + # Steal timer expired - pass + question_id = room["current_question"] + room = await game_manager.pass_steal(room_code, question_id) + await sio.emit("time_up", {"room": room, "was_steal": True}, room=room_code) + else: + # Answer timer expired - enable steal + room["can_steal"] = True + room["current_team"] = "B" if room["current_team"] == "A" else "A" + await room_manager.update_room(room_code, room) + await sio.emit("time_up", {"room": room, "was_steal": False}, room=room_code) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..887d94d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,37 @@ +# FastAPI +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# WebSockets +python-socketio==5.11.0 + +# Database +sqlalchemy==2.0.25 +asyncpg==0.29.0 +alembic==1.13.1 +psycopg2-binary==2.9.9 + +# Redis +redis==5.0.1 +aioredis==2.0.1 + +# Authentication +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 + +# AI +anthropic==0.18.1 + +# Utilities +pydantic==2.6.0 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 + +# Scheduler +apscheduler==3.10.4 + +# Testing +pytest==8.0.0 +pytest-asyncio==0.23.4 +httpx==0.26.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..09bda4e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + frontend: + build: ./frontend + ports: + - "3000:3000" + environment: + - VITE_API_URL=${VITE_API_URL:-http://localhost:8000} + - VITE_WS_URL=${VITE_WS_URL:-ws://localhost:8000} + depends_on: + - backend + volumes: + - ./frontend:/app + - /app/node_modules + + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=${DATABASE_URL:-postgresql://trivia:trivia@db:5432/trivia} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - JWT_SECRET=${JWT_SECRET:-dev-secret-key} + depends_on: + - db + - redis + volumes: + - ./backend:/app + + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=trivia + - POSTGRES_USER=trivia + - POSTGRES_PASSWORD=trivia + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + ports: + - "6379:6379" + + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + - frontend + - backend + profiles: + - production + +volumes: + postgres_data: + redis_data: diff --git a/docs/plans/2026-01-26-webtriviasmulti-design.md b/docs/plans/2026-01-26-webtriviasmulti-design.md new file mode 100644 index 0000000..8971a27 --- /dev/null +++ b/docs/plans/2026-01-26-webtriviasmulti-design.md @@ -0,0 +1,659 @@ +# WebTriviasMulti - Documento de Diseño + +**Fecha:** 2026-01-26 +**Versión:** 1.0 +**Estado:** Aprobado + +--- + +## 1. Visión General + +### 1.1 Descripción +WebTriviasMulti es una aplicación web de trivia multiplayer en tiempo real, inspirada en el formato de Jeopardy. Permite partidas entre 2 equipos de hasta 4 jugadores cada uno, con preguntas organizadas por categorías y niveles de dificultad. + +### 1.2 Características Principales +- Partidas en tiempo real con WebSockets +- 8 categorías temáticas: Nintendo, Xbox, PlayStation, Anime, Música, Películas, Libros, Historia-Cultura +- Tablero estilo Jeopardy (5 niveles de dificultad por categoría) +- Sistema de "robo" de puntos entre equipos +- Validación de respuestas mediante IA (Claude) +- Generación automática de preguntas con aprobación de administrador +- 5 temas visuales intercambiables +- Sistema de logros +- Replays de partidas +- Sonidos temáticos + +--- + +## 2. Mecánicas del Juego + +### 2.1 Flujo de Partida + +1. **Creación de sala**: Un jugador crea sala y recibe código de 6 caracteres +2. **Lobby**: Jugadores se unen con el código, eligen equipo (máx 4 por equipo), ingresan nombre +3. **Inicio**: El host inicia cuando hay al menos 1 jugador por equipo +4. **Tablero**: Se muestra el tablero con 8 categorías × 5 preguntas (100-500 pts) +5. **Turno**: El jugador en rotación del equipo activo selecciona una pregunta +6. **Respuesta**: Tiene X segundos (según dificultad) para escribir respuesta +7. **Validación IA**: Claude valida si la respuesta es correcta +8. **Robo opcional**: Si falla, equipo contrario decide si intenta robar +9. **Siguiente turno**: El equipo ganador elige siguiente pregunta +10. **Final**: Partida termina cuando se agotan las preguntas + +### 2.2 Sistema de Turnos +- **Rotación obligatoria**: Cada pregunta la responde un miembro diferente del equipo +- **El que acierta elige**: El equipo que responde correctamente (o roba) elige la siguiente pregunta + +### 2.3 Sistema de Puntos y Tiempo + +| Dificultad | Puntos | Tiempo | +|------------|--------|--------| +| 1 (Fácil) | 100 | 15 seg | +| 2 | 200 | 20 seg | +| 3 (Media) | 300 | 25 seg | +| 4 | 400 | 35 seg | +| 5 (Difícil)| 500 | 45 seg | + +### 2.4 Mecánica de Robo +- **Voluntario**: El equipo contrario decide si intenta robar o pasar +- **Penalización**: Si fallan el robo, pierden la mitad de los puntos de la pregunta +- **Tiempo reducido**: El equipo que roba tiene la mitad del tiempo original + +### 2.5 Formato de Respuestas +- Respuesta abierta (el jugador escribe libremente) +- Validación semántica con IA (acepta sinónimos, variaciones, errores de ortografía menores) + +--- + +## 3. Arquitectura Técnica + +### 3.1 Stack Tecnológico + +``` +┌─────────────────────────────────────────────────────────────┐ +│ FRONTEND (React) │ +│ - Vite + React 18 + TypeScript │ +│ - Tailwind CSS + Framer Motion (animaciones) │ +│ - Socket.io-client (tiempo real) │ +│ - Zustand (estado global) │ +│ - 5 temas visuales intercambiables │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ BACKEND (Python FastAPI) │ +│ - FastAPI + Uvicorn │ +│ - python-socketio (WebSockets) │ +│ - SQLAlchemy (ORM) │ +│ - Anthropic SDK (validación IA) │ +│ - APScheduler (tareas programadas) │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │PostgreSQL│ │ Redis │ │ Claude │ + │(datos) │ │ (estado) │ │ (IA) │ + └──────────┘ └──────────┘ └──────────┘ +``` + +### 3.2 Responsabilidades por Componente + +- **PostgreSQL**: Preguntas, categorías, historial de partidas, configuración, logros +- **Redis**: Estado de salas activas, turnos, temporizadores, sesiones WebSocket +- **Claude API**: Validar respuestas, generar preguntas nuevas + +--- + +## 4. Modelo de Datos + +### 4.1 PostgreSQL + +```sql +-- Categorías +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + icon VARCHAR(50), + color VARCHAR(7) +); + +-- Preguntas +CREATE TABLE questions ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES categories(id), + question_text TEXT NOT NULL, + correct_answer VARCHAR(500) NOT NULL, + alt_answers TEXT[], -- Respuestas alternativas válidas + difficulty INTEGER CHECK (difficulty BETWEEN 1 AND 5), + points INTEGER NOT NULL, + time_seconds INTEGER NOT NULL, + date_active DATE, + status VARCHAR(20) DEFAULT 'pending', -- pending, approved, used + fun_fact TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Sesiones de juego +CREATE TABLE game_sessions ( + id SERIAL PRIMARY KEY, + room_code VARCHAR(6) UNIQUE NOT NULL, + status VARCHAR(20) DEFAULT 'waiting', -- waiting, playing, finished + team_a_score INTEGER DEFAULT 0, + team_b_score INTEGER DEFAULT 0, + current_team VARCHAR(1), -- 'A' o 'B' + questions_used INTEGER[], + created_at TIMESTAMP DEFAULT NOW(), + finished_at TIMESTAMP +); + +-- Eventos de juego (para replays) +CREATE TABLE game_events ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES game_sessions(id), + event_type VARCHAR(50) NOT NULL, + player_name VARCHAR(100), + team VARCHAR(1), + question_id INTEGER REFERENCES questions(id), + answer_given TEXT, + was_correct BOOLEAN, + was_steal BOOLEAN DEFAULT FALSE, + points_earned INTEGER, + timestamp TIMESTAMP DEFAULT NOW() +); + +-- Administradores +CREATE TABLE admins ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 4.2 Redis (Estado Volátil) + +``` +room:{code} → { + players: [{name, team, position, socketId}], + teams: {A: [], B: []}, + currentTeam: 'A', + currentPlayerIndex: {A: 0, B: 0}, + status: 'waiting|playing|finished', + timer: timestamp, + canSteal: false, + currentQuestion: questionId +} + +player:{socket_id} → {name, room, team, position} +``` + +--- + +## 5. Sistema de IA + +### 5.1 Validación de Respuestas + +```python +VALIDATION_PROMPT = """ +Eres un validador de trivia. Determina si la respuesta del jugador +es correcta comparándola con la respuesta oficial. + +Pregunta: {question} +Respuesta correcta: {correct_answer} +Respuestas alternativas válidas: {alt_answers} +Respuesta del jugador: {player_answer} + +Considera válido si: +- Es sinónimo o variación de la respuesta correcta +- Tiene errores menores de ortografía +- Usa abreviaciones comunes (ej: "BOTW" = "Breath of the Wild") +- Es conceptualmente equivalente + +Responde SOLO con JSON: {"valid": true/false, "reason": "breve explicación"} +""" +``` + +### 5.2 Generación de Preguntas + +```python +GENERATION_PROMPT = """ +Genera 5 preguntas de trivia para la categoría {category}. +Dificultad: {difficulty} (1=muy fácil, 5=muy difícil) + +Formato JSON por pregunta: +{ + "question": "texto de la pregunta", + "correct_answer": "respuesta principal", + "alt_answers": ["variación1", "variación2"], + "fun_fact": "dato curioso opcional" +} + +Requisitos: +- Las preguntas deben ser verificables y precisas +- Evitar ambigüedades +- Ajustar complejidad según dificultad +- Para gaming: incluir referencias a juegos, personajes, mecánicas +- Para cultura: hechos históricos, arte, literatura +""" +``` + +### 5.3 Flujo de Aprobación + +1. Admin solicita generar preguntas desde panel +2. Claude genera batch de preguntas +3. Quedan en estado `pending` +4. Admin revisa, edita si necesario, aprueba o rechaza +5. Preguntas aprobadas se asignan a fecha futura + +--- + +## 6. Temas Visuales + +### 6.1 Temas Disponibles + +| Tema | Paleta Principal | Características | +|------|------------------|-----------------| +| **DRRR (Dollars)** | Negro, amarillo neón (#FFE135), cyan (#00FFFF) | Chat estilo Dollars, efectos glitch, tipografía urbana, bordes neón pulsantes | +| **Retro Arcade** | Púrpura (#9B59B6), rosa (#E91E63), cyan pixelado | Pixel art UI, tipografía 8-bit (Press Start 2P), scanlines, efectos CRT | +| **Moderno Minimalista** | Blanco (#FFFFFF), grises, acento azul (#3498DB) | Limpio, sombras suaves, tipografía sans-serif, transiciones elegantes | +| **Gaming RGB** | Negro (#0D0D0D) con gradientes RGB | Efectos de luz LED, gradientes animados, bordes brillantes, estilo "gamer" | +| **Anime Clásico 90s** | Pasteles, rosa (#FFB6C1), lavanda (#E6E6FA) | Estrellas brillantes, efectos sparkle, bordes redondeados, estilo shoujo | + +### 6.2 Implementación + +``` +/src/themes/ + ├── ThemeProvider.tsx # Context para tema activo + ├── index.ts # Exporta todos los temas + ├── drrr/ + │ ├── variables.css + │ ├── components.tsx + │ └── sounds/ + ├── retro-arcade/ + ├── minimal/ + ├── gaming-rgb/ + └── anime-90s/ +``` + +--- + +## 7. Sistema de Sonidos + +### 7.1 Eventos con Sonido + +| Evento | DRRR | Retro | Minimal | RGB | Anime 90s | +|--------|------|-------|---------|-----|-----------| +| Correcto | Glitch digital | 8-bit coin | Soft chime | Synth rise | Sparkle | +| Incorrecto | Static buzz | 8-bit fail | Low tone | Bass drop | Comedic | +| Robo | Suspense | Power-up | Click | Laser | Drama sting | +| Timer (tick) | Heartbeat | Beeps | Ticks | Pulse | Tension | +| Timer (urgente) | Fast heartbeat | Fast beeps | Fast ticks | Fast pulse | Panic | +| Victoria | Epic synth | Fanfare | Elegant | EDM drop | Anime victory | +| Derrota | Glitch fade | Game over | Soft close | Power down | Sad piano | +| Selección | Click neón | 8-bit select | Pop | RGB sweep | Cute pop | + +### 7.2 Almacenamiento +- Archivos en formato WebM/OGG para compatibilidad +- Precargados al seleccionar tema +- Volumen configurable por usuario + +--- + +## 8. Sistema de Logros + +### 8.1 Lista de Logros + +| ID | Nombre | Condición | Icono | +|----|--------|-----------|-------| +| 1 | Primera Victoria | Ganar tu primera partida | 🏆 | +| 2 | Racha de 3 | Responder 3 correctas seguidas | 🔥 | +| 3 | Racha de 5 | Responder 5 correctas seguidas | 🔥🔥 | +| 4 | Ladrón Novato | Primer robo exitoso | 🦝 | +| 5 | Ladrón Maestro | 5 robos exitosos en una partida | 🦝👑 | +| 6 | Especialista Nintendo | 10 correctas en Nintendo | 🍄 | +| 7 | Especialista Xbox | 10 correctas en Xbox | 🎮 | +| 8 | Especialista PlayStation | 10 correctas en PlayStation | 🎯 | +| 9 | Especialista Anime | 10 correctas en Anime | ⛩️ | +| 10 | Especialista Música | 10 correctas en Música | 🎵 | +| 11 | Especialista Películas | 10 correctas en Películas | 🎬 | +| 12 | Especialista Libros | 10 correctas en Libros | 📚 | +| 13 | Especialista Historia | 10 correctas en Historia-Cultura | 🏛️ | +| 14 | Invicto | Ganar sin fallar ninguna pregunta | ⭐ | +| 15 | Velocista | Responder correctamente en menos de 3 segundos | ⚡ | +| 16 | Comeback | Ganar estando 500+ puntos abajo | 🔄 | +| 17 | Dominio Total | Responder las 5 preguntas de una categoría correctamente | 👑 | +| 18 | Arriesgado | Responder correctamente 3 preguntas de 500 pts | 🎰 | + +### 8.2 Almacenamiento +- localStorage por navegador (sin cuentas) +- Estructura: `{odooId: {achievements: [...], stats: {...}}}` +- Mostrados al final de cada partida (nuevos desbloqueados) + +--- + +## 9. Sistema de Replays + +### 9.1 Datos Capturados + +Usando la tabla `game_events`, cada evento registra: +- Tipo de evento (question_selected, answer_submitted, steal_attempted, etc.) +- Jugador y equipo +- Pregunta seleccionada +- Respuesta dada +- Resultado (correcto/incorrecto) +- Puntos ganados/perdidos +- Timestamp preciso + +### 9.2 Reproducción + +1. Al finalizar partida, opción "Ver Replay" +2. Carga eventos ordenados por timestamp +3. Reproduce animación acelerada (x2, x4, x8) +4. Muestra: + - Tablero con preguntas revelándose + - Respuestas de jugadores + - Marcador actualizándose + - Momentos de robo +5. Controles: Play/Pause, velocidad, timeline scrubber + +### 9.3 Compartir +- Código único de replay (basado en session_id) +- URL compartible: `/replay/{code}` + +--- + +## 10. Panel de Administración + +### 10.1 Funcionalidades + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PANEL ADMINISTRADOR │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 📊 Dashboard │ +│ - Partidas activas en tiempo real │ +│ - Estadísticas del día │ +│ - Preguntas pendientes de aprobación │ +│ │ +│ ❓ Gestión de Preguntas │ +│ - CRUD de preguntas por categoría │ +│ - Generar con IA (botón por categoría/dificultad) │ +│ - Cola de aprobación │ +│ - Asignar a fechas │ +│ - Importar/exportar CSV │ +│ │ +│ 📅 Calendario │ +│ - Vista mensual de preguntas programadas │ +│ - Alertas de días sin contenido │ +│ │ +│ 🎮 Monitor │ +│ - Salas activas │ +│ - Cerrar salas problemáticas │ +│ │ +│ ⚙️ Configuración │ +│ - Tiempos y puntos por dificultad │ +│ - Penalización de robo │ +│ - API keys │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 10.2 Autenticación +- Login con usuario/contraseña +- JWT para sesiones +- Rutas protegidas `/admin/*` + +--- + +## 11. Comunicación en Partida + +### 11.1 Chat de Equipo +- Visible solo para miembros del mismo equipo +- Mensajes en tiempo real via WebSocket +- Historial durante la partida + +### 11.2 Reacciones Globales +- Emojis predefinidos que todos pueden ver +- Limitado para evitar spam (1 cada 3 segundos) +- Emojis disponibles: 👏 😮 😂 🔥 💀 🎉 😭 🤔 + +--- + +## 12. Estructura del Proyecto + +``` +WebTriviasMulti/ +├── backend/ +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py +│ │ ├── config.py +│ │ ├── models/ +│ │ │ ├── __init__.py +│ │ │ ├── category.py +│ │ │ ├── question.py +│ │ │ ├── game_session.py +│ │ │ ├── game_event.py +│ │ │ └── admin.py +│ │ ├── schemas/ +│ │ │ ├── __init__.py +│ │ │ ├── question.py +│ │ │ ├── game.py +│ │ │ └── admin.py +│ │ ├── services/ +│ │ │ ├── __init__.py +│ │ │ ├── ai_validator.py +│ │ │ ├── ai_generator.py +│ │ │ ├── game_manager.py +│ │ │ └── room_manager.py +│ │ ├── api/ +│ │ │ ├── __init__.py +│ │ │ ├── admin.py +│ │ │ ├── game.py +│ │ │ └── replay.py +│ │ └── sockets/ +│ │ ├── __init__.py +│ │ └── game_events.py +│ ├── requirements.txt +│ ├── Dockerfile +│ ├── alembic.ini +│ └── alembic/ +│ └── versions/ +│ +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── game/ +│ │ │ │ ├── Board.tsx +│ │ │ │ ├── QuestionCard.tsx +│ │ │ │ ├── Timer.tsx +│ │ │ │ ├── ScoreBoard.tsx +│ │ │ │ └── AnswerInput.tsx +│ │ │ ├── lobby/ +│ │ │ │ ├── CreateRoom.tsx +│ │ │ │ ├── JoinRoom.tsx +│ │ │ │ ├── TeamSelect.tsx +│ │ │ │ └── PlayerList.tsx +│ │ │ ├── chat/ +│ │ │ │ ├── TeamChat.tsx +│ │ │ │ └── EmojiReactions.tsx +│ │ │ ├── replay/ +│ │ │ │ ├── ReplayPlayer.tsx +│ │ │ │ └── ReplayControls.tsx +│ │ │ ├── achievements/ +│ │ │ │ ├── AchievementPopup.tsx +│ │ │ │ └── AchievementList.tsx +│ │ │ └── ui/ +│ │ │ ├── Button.tsx +│ │ │ ├── Modal.tsx +│ │ │ ├── Input.tsx +│ │ │ └── Toast.tsx +│ │ ├── themes/ +│ │ │ ├── ThemeProvider.tsx +│ │ │ ├── index.ts +│ │ │ ├── drrr/ +│ │ │ ├── retro-arcade/ +│ │ │ ├── minimal/ +│ │ │ ├── gaming-rgb/ +│ │ │ └── anime-90s/ +│ │ ├── hooks/ +│ │ │ ├── useSocket.ts +│ │ │ ├── useGame.ts +│ │ │ ├── useSound.ts +│ │ │ └── useAchievements.ts +│ │ ├── stores/ +│ │ │ ├── gameStore.ts +│ │ │ ├── themeStore.ts +│ │ │ └── soundStore.ts +│ │ ├── pages/ +│ │ │ ├── Home.tsx +│ │ │ ├── Lobby.tsx +│ │ │ ├── Game.tsx +│ │ │ ├── Replay.tsx +│ │ │ ├── Results.tsx +│ │ │ └── admin/ +│ │ │ ├── Dashboard.tsx +│ │ │ ├── Questions.tsx +│ │ │ ├── Calendar.tsx +│ │ │ ├── Monitor.tsx +│ │ │ └── Settings.tsx +│ │ ├── services/ +│ │ │ ├── socket.ts +│ │ │ └── api.ts +│ │ ├── types/ +│ │ │ └── index.ts +│ │ ├── App.tsx +│ │ └── main.tsx +│ ├── public/ +│ │ └── sounds/ +│ ├── package.json +│ ├── Dockerfile +│ ├── vite.config.ts +│ ├── tailwind.config.js +│ └── tsconfig.json +│ +├── docker-compose.yml +├── .env.example +├── .gitignore +├── README.md +└── docs/ + └── plans/ + └── 2026-01-26-webtriviasmulti-design.md +``` + +--- + +## 13. Despliegue + +### 13.1 Docker Compose + +```yaml +version: '3.8' + +services: + frontend: + build: ./frontend + ports: + - "3000:3000" + environment: + - VITE_API_URL=http://localhost:8000 + - VITE_WS_URL=ws://localhost:8000 + + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia + - REDIS_URL=redis://redis:6379 + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - JWT_SECRET=${JWT_SECRET} + depends_on: + - db + - redis + + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=trivia + - POSTGRES_USER=trivia + - POSTGRES_PASSWORD=trivia + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + - frontend + - backend + +volumes: + postgres_data: + redis_data: +``` + +### 13.2 Variables de Entorno + +```env +# Backend +DATABASE_URL=postgresql://trivia:trivia@db:5432/trivia +REDIS_URL=redis://redis:6379 +ANTHROPIC_API_KEY=sk-ant-... +JWT_SECRET=your-secret-key + +# Frontend +VITE_API_URL=https://trivia.tudominio.com/api +VITE_WS_URL=wss://trivia.tudominio.com + +# Cloudflare +CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token +``` + +--- + +## 14. Roadmap Futuro + +### Fase 3 - Competitivo +- Ranking global (requiere cuentas opcionales) +- Torneos programados +- Temporadas con recompensas + +### Fase 4 - Social +- Compartir resultados en redes +- Salas recurrentes +- Desafíos diarios + +### Fase 5 - Contenido +- Categorías rotativas por eventos +- Preguntas de la comunidad +- Modo "Experto" + +### Fase 6 - Técnico +- PWA instalable +- API pública +- Modo offline + +--- + +## 15. Referencias + +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [Socket.IO](https://socket.io/) +- [Anthropic Claude API](https://docs.anthropic.com/) +- [React](https://react.dev/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Framer Motion](https://www.framer.com/motion/) + +--- + +*Documento generado el 2026-01-26* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..bff51ca --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source +COPY . . + +# Expose port +EXPOSE 3000 + +# Development command +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4566993 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + WebTriviasMulti + + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..07bd19a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "webtriviasmulti-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.3", + "socket.io-client": "^4.7.4", + "zustand": "^4.5.0", + "framer-motion": "^11.0.3", + "howler": "^2.2.4", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.1" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@types/howler": "^2.2.11", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..d3e4557 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,22 @@ +import { Routes, Route } from 'react-router-dom' +import Home from './pages/Home' +import Lobby from './pages/Lobby' +import Game from './pages/Game' +import Results from './pages/Results' +import Replay from './pages/Replay' + +function App() { + return ( +
+ + } /> + } /> + } /> + } /> + } /> + +
+ ) +} + +export default App diff --git a/frontend/src/hooks/useAchievements.ts b/frontend/src/hooks/useAchievements.ts new file mode 100644 index 0000000..15af702 --- /dev/null +++ b/frontend/src/hooks/useAchievements.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect } from 'react' +import { useGameStore } from '../stores/gameStore' +import type { Achievement } from '../types' + +const STORAGE_KEY = 'trivia-achievements' + +// Achievement definitions +const achievementDefinitions: Achievement[] = [ + { 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: '🎰' }, +] + +export function useAchievements() { + const { achievements, setAchievements, unlockAchievement, stats } = useGameStore() + + // Load achievements from localStorage on mount + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + try { + const parsed = JSON.parse(stored) + // Merge with definitions to ensure all achievements exist + const merged = achievementDefinitions.map((def) => ({ + ...def, + unlocked: parsed.find((a: Achievement) => a.id === def.id)?.unlocked || false, + unlockedAt: parsed.find((a: Achievement) => a.id === def.id)?.unlockedAt, + })) + setAchievements(merged) + } catch { + setAchievements(achievementDefinitions) + } + } else { + setAchievements(achievementDefinitions) + } + }, [setAchievements]) + + // Save achievements to localStorage when they change + useEffect(() => { + if (achievements.length > 0) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(achievements)) + } + }, [achievements]) + + const checkAchievements = useCallback( + (context: { + won?: boolean + correctStreak?: number + stealSuccess?: boolean + categoryId?: number + answerTime?: number + deficit?: number + points?: number + neverFailed?: boolean + categoryComplete?: number + }) => { + const newUnlocks: number[] = [] + + // First Victory + if (context.won && !achievements.find((a) => a.id === 1)?.unlocked) { + unlockAchievement(1) + newUnlocks.push(1) + } + + // Streaks + if (context.correctStreak && context.correctStreak >= 3) { + if (!achievements.find((a) => a.id === 2)?.unlocked) { + unlockAchievement(2) + newUnlocks.push(2) + } + } + if (context.correctStreak && context.correctStreak >= 5) { + if (!achievements.find((a) => a.id === 3)?.unlocked) { + unlockAchievement(3) + newUnlocks.push(3) + } + } + + // Steals + if (context.stealSuccess) { + if (!achievements.find((a) => a.id === 4)?.unlocked) { + unlockAchievement(4) + newUnlocks.push(4) + } + if (stats.stealsSuccessful >= 5 && !achievements.find((a) => a.id === 5)?.unlocked) { + unlockAchievement(5) + newUnlocks.push(5) + } + } + + // Category specialists (6-13) + const categoryAchievementMap: Record = { + 1: 6, // Nintendo + 2: 7, // Xbox + 3: 8, // PlayStation + 4: 9, // Anime + 5: 10, // Música + 6: 11, // Películas + 7: 12, // Libros + 8: 13, // Historia-Cultura + } + if (context.categoryId) { + const achievementId = categoryAchievementMap[context.categoryId] + const categoryCount = stats.categoryCorrect[context.categoryId] || 0 + if (categoryCount >= 10 && achievementId && !achievements.find((a) => a.id === achievementId)?.unlocked) { + unlockAchievement(achievementId) + newUnlocks.push(achievementId) + } + } + + // Invicto + if (context.won && context.neverFailed && !achievements.find((a) => a.id === 14)?.unlocked) { + unlockAchievement(14) + newUnlocks.push(14) + } + + // Velocista + if (context.answerTime && context.answerTime < 3 && !achievements.find((a) => a.id === 15)?.unlocked) { + unlockAchievement(15) + newUnlocks.push(15) + } + + // Comeback + if (context.won && context.deficit && context.deficit >= 500 && !achievements.find((a) => a.id === 16)?.unlocked) { + unlockAchievement(16) + newUnlocks.push(16) + } + + // Dominio Total + if (context.categoryComplete && !achievements.find((a) => a.id === 17)?.unlocked) { + unlockAchievement(17) + newUnlocks.push(17) + } + + // Arriesgado + if (context.points === 500 && stats.fastAnswers >= 3 && !achievements.find((a) => a.id === 18)?.unlocked) { + unlockAchievement(18) + newUnlocks.push(18) + } + + return newUnlocks.map((id) => achievements.find((a) => a.id === id)!) + }, + [achievements, stats, unlockAchievement] + ) + + return { + achievements, + checkAchievements, + } +} diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts new file mode 100644 index 0000000..ad5b725 --- /dev/null +++ b/frontend/src/hooks/useSocket.ts @@ -0,0 +1,175 @@ +import { useEffect, useRef, useCallback } from 'react' +import { io, Socket } from 'socket.io-client' +import { useGameStore } from '../stores/gameStore' +import type { GameRoom, ChatMessage, AnswerResult } from '../types' + +const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000' + +export function useSocket() { + const socketRef = useRef(null) + const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd } = + useGameStore() + + useEffect(() => { + // Create socket connection + socketRef.current = io(SOCKET_URL, { + transports: ['websocket', 'polling'], + autoConnect: true, + }) + + const socket = socketRef.current + + // Connection events + socket.on('connect', () => { + console.log('Connected to server') + }) + + socket.on('disconnect', () => { + console.log('Disconnected from server') + }) + + socket.on('error', (data: { message: string }) => { + console.error('Socket error:', data.message) + }) + + // Room events + socket.on('room_created', (data: { room: GameRoom }) => { + setRoom(data.room) + }) + + socket.on('player_joined', (data: { room: GameRoom }) => { + setRoom(data.room) + }) + + socket.on('player_left', (data: { room: GameRoom }) => { + setRoom(data.room) + }) + + socket.on('team_changed', (data: { room: GameRoom }) => { + setRoom(data.room) + }) + + // Game events + socket.on('game_started', (data: { room: GameRoom }) => { + setRoom(data.room) + }) + + socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => { + setRoom(data.room) + // Fetch full question details + }) + + socket.on('answer_result', (data: AnswerResult) => { + setRoom(data.room) + if (!data.valid && !data.was_steal && data.room.can_steal) { + setShowStealPrompt(true) + } + }) + + socket.on('steal_attempted', (data: { room: GameRoom }) => { + setRoom(data.room) + setShowStealPrompt(false) + }) + + socket.on('steal_passed', (data: { room: GameRoom }) => { + setRoom(data.room) + setShowStealPrompt(false) + }) + + socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => { + setRoom(data.room) + if (!data.was_steal && data.room.can_steal) { + setShowStealPrompt(true) + } else { + setShowStealPrompt(false) + } + }) + + // Chat events + socket.on('chat_message', (data: ChatMessage) => { + addMessage(data) + }) + + socket.on('emoji_reaction', (data: { player_name: string; team: string; emoji: string }) => { + // Handle emoji reaction display + console.log(`${data.player_name} reacted with ${data.emoji}`) + }) + + return () => { + socket.disconnect() + } + }, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd]) + + // Socket methods + const createRoom = useCallback((playerName: string) => { + socketRef.current?.emit('create_room', { player_name: playerName }) + }, []) + + const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => { + socketRef.current?.emit('join_room', { + room_code: roomCode, + player_name: playerName, + team, + }) + }, []) + + const changeTeam = useCallback((team: 'A' | 'B') => { + socketRef.current?.emit('change_team', { team }) + }, []) + + const startGame = useCallback((board: Record) => { + socketRef.current?.emit('start_game', { board }) + }, []) + + const selectQuestion = useCallback((questionId: number, categoryId: number) => { + socketRef.current?.emit('select_question', { + question_id: questionId, + category_id: categoryId, + }) + }, []) + + const submitAnswer = useCallback( + (answer: string, question: Record, isSteal: boolean = false) => { + socketRef.current?.emit('submit_answer', { + answer, + question, + is_steal: isSteal, + }) + }, + [] + ) + + const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => { + socketRef.current?.emit('steal_decision', { + attempt, + question_id: questionId, + answer, + }) + }, []) + + const sendChatMessage = useCallback((message: string) => { + socketRef.current?.emit('chat_message', { message }) + }, []) + + const sendEmojiReaction = useCallback((emoji: string) => { + socketRef.current?.emit('emoji_reaction', { emoji }) + }, []) + + const notifyTimerExpired = useCallback(() => { + socketRef.current?.emit('timer_expired', {}) + }, []) + + return { + socket: socketRef.current, + createRoom, + joinRoom, + changeTeam, + startGame, + selectQuestion, + submitAnswer, + stealDecision, + sendChatMessage, + sendEmojiReaction, + notifyTimerExpired, + } +} diff --git a/frontend/src/hooks/useSound.ts b/frontend/src/hooks/useSound.ts new file mode 100644 index 0000000..d3bd2ad --- /dev/null +++ b/frontend/src/hooks/useSound.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef } from 'react' +import { Howl } from 'howler' +import { useSoundStore, soundPaths } from '../stores/soundStore' +import { useThemeStore } from '../stores/themeStore' +import type { ThemeName } from '../types' + +type SoundEffect = + | 'correct' + | 'incorrect' + | 'steal' + | 'timer_tick' + | 'timer_urgent' + | 'victory' + | 'defeat' + | 'select' + +export function useSound() { + const { volume, muted } = useSoundStore() + const { currentTheme } = useThemeStore() + const soundsRef = useRef>(new Map()) + + // Preload sounds for current theme + useEffect(() => { + const themeSounds = soundPaths[currentTheme] + if (!themeSounds) return + + // Clear old sounds + soundsRef.current.forEach((sound) => sound.unload()) + soundsRef.current.clear() + + // Load new sounds + Object.entries(themeSounds).forEach(([key, path]) => { + const sound = new Howl({ + src: [path], + volume: volume, + preload: true, + onloaderror: () => { + console.warn(`Failed to load sound: ${path}`) + }, + }) + soundsRef.current.set(key, sound) + }) + + return () => { + soundsRef.current.forEach((sound) => sound.unload()) + } + }, [currentTheme]) + + // Update volume when it changes + useEffect(() => { + soundsRef.current.forEach((sound) => { + sound.volume(volume) + }) + }, [volume]) + + const play = useCallback( + (effect: SoundEffect) => { + if (muted) return + + const sound = soundsRef.current.get(effect) + if (sound) { + sound.play() + } + }, + [muted] + ) + + const stop = useCallback((effect: SoundEffect) => { + const sound = soundsRef.current.get(effect) + if (sound) { + sound.stop() + } + }, []) + + const stopAll = useCallback(() => { + soundsRef.current.forEach((sound) => sound.stop()) + }, []) + + return { + play, + stop, + stopAll, + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b049e74 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,120 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base styles */ +:root { + --color-bg: #0a0a0a; + --color-primary: #FFE135; + --color-secondary: #00FFFF; + --color-accent: #FF00FF; + --color-text: #ffffff; + --color-text-muted: #888888; +} + +body { + margin: 0; + min-height: 100vh; + background-color: var(--color-bg); + color: var(--color-text); + font-family: 'Inter', sans-serif; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg); +} + +::-webkit-scrollbar-thumb { + background: var(--color-primary); + border-radius: 4px; +} + +/* Theme transitions */ +* { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; +} + +/* Utility classes */ +.text-shadow-neon { + text-shadow: 0 0 10px currentColor, 0 0 20px currentColor; +} + +.border-neon { + box-shadow: 0 0 5px currentColor, 0 0 10px currentColor, inset 0 0 5px currentColor; +} + +/* CRT scanline effect for retro theme */ +.crt-scanlines::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + transparent 50%, + rgba(0, 0, 0, 0.1) 50% + ); + background-size: 100% 4px; + pointer-events: none; +} + +/* Glitch effect for DRRR theme */ +.glitch-text { + position: relative; +} + +.glitch-text::before, +.glitch-text::after { + content: attr(data-text); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.glitch-text::before { + animation: glitch-1 0.3s infinite; + color: var(--color-secondary); + z-index: -1; +} + +.glitch-text::after { + animation: glitch-2 0.3s infinite; + color: var(--color-accent); + z-index: -2; +} + +@keyframes glitch-1 { + 0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); } + 20% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); } + 40% { clip-path: inset(40% 0 40% 0); transform: translate(2px, 2px); } + 60% { clip-path: inset(60% 0 20% 0); transform: translate(-2px, 2px); } + 80% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); } +} + +@keyframes glitch-2 { + 0%, 100% { clip-path: inset(0 0 0 0); transform: translate(0); } + 20% { clip-path: inset(60% 0 20% 0); transform: translate(2px, 2px); } + 40% { clip-path: inset(20% 0 60% 0); transform: translate(-2px, -2px); } + 60% { clip-path: inset(80% 0 0% 0); transform: translate(2px, -2px); } + 80% { clip-path: inset(40% 0 40% 0); transform: translate(-2px, 2px); } +} + +/* Sparkle effect for anime theme */ +.sparkle::before { + content: '✦'; + position: absolute; + animation: sparkle-float 2s infinite; +} + +@keyframes sparkle-float { + 0%, 100% { opacity: 0; transform: translateY(0) scale(0); } + 50% { opacity: 1; transform: translateY(-20px) scale(1); } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..0f1cdbd --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import { ThemeProvider } from './themes/ThemeProvider' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/Game.tsx b/frontend/src/pages/Game.tsx new file mode 100644 index 0000000..1547b39 --- /dev/null +++ b/frontend/src/pages/Game.tsx @@ -0,0 +1,335 @@ +import { useEffect, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { useSocket } from '../hooks/useSocket' +import { useSound } from '../hooks/useSound' +import { useGameStore } from '../stores/gameStore' +import { useThemeStyles } from '../themes/ThemeProvider' +import type { Question } from '../types' + +const categories = [ + { 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' }, +] + +export default function Game() { + const { roomCode } = useParams<{ roomCode: string }>() + const navigate = useNavigate() + const { selectQuestion, submitAnswer, stealDecision, sendEmojiReaction } = useSocket() + const { play } = useSound() + const { room, playerName, currentQuestion, showStealPrompt, setShowStealPrompt } = useGameStore() + const { config, styles } = useThemeStyles() + + const [answer, setAnswer] = useState('') + const [timeLeft, setTimeLeft] = useState(0) + const [showingQuestion, setShowingQuestion] = useState(false) + + // Redirect if game finished + useEffect(() => { + if (room?.status === 'finished') { + navigate(`/results/${room.code}`) + } + }, [room?.status, room?.code, navigate]) + + // Timer logic + useEffect(() => { + if (!currentQuestion || !showingQuestion) return + + setTimeLeft(currentQuestion.time_seconds) + const interval = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + clearInterval(interval) + return 0 + } + if (prev === 6) play('timer_urgent') + return prev - 1 + }) + }, 1000) + + return () => clearInterval(interval) + }, [currentQuestion, showingQuestion, play]) + + if (!room) { + return ( +
+

Cargando...

+
+ ) + } + + const myTeam = room.teams.A.find(p => p.name === playerName) ? 'A' : 'B' + const isMyTurn = room.current_team === myTeam + const currentPlayer = isMyTurn + ? room.teams[myTeam][room.current_player_index[myTeam]] + : null + const amICurrentPlayer = currentPlayer?.name === playerName + + const handleSelectQuestion = (question: Question, categoryId: number) => { + if (!amICurrentPlayer || question.answered) return + play('select') + selectQuestion(question.id, categoryId) + setShowingQuestion(true) + } + + const handleSubmitAnswer = () => { + if (!currentQuestion || !answer.trim()) return + submitAnswer(answer, currentQuestion as Record, room.can_steal) + setAnswer('') + setShowingQuestion(false) + } + + const handleStealDecision = (attempt: boolean) => { + if (!currentQuestion) return + if (attempt) { + setShowingQuestion(true) + } else { + stealDecision(false, currentQuestion.id) + } + setShowStealPrompt(false) + } + + const emojis = ['👏', '😮', '😂', '🔥', '💀', '🎉', '😭', '🤔'] + + return ( +
+
+ {/* Scoreboard */} +
+
+
Equipo A
+
+ {room.scores.A} +
+
+ +
+
+ Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'} +
+ {amICurrentPlayer && ( +
+ ¡Tu turno! +
+ )} +
+ +
+
Equipo B
+
+ {room.scores.B} +
+
+
+ + {/* Game Board */} +
+ {/* Category Headers */} + {categories.map((cat) => ( +
+
{cat.icon}
+
{cat.name}
+
+ ))} + + {/* Questions Grid */} + {[1, 2, 3, 4, 5].map((difficulty) => + categories.map((cat) => { + const questions = room.board[String(cat.id)] || [] + const question = questions.find(q => q.difficulty === difficulty) + const isAnswered = question?.answered + + return ( + question && handleSelectQuestion(question, cat.id)} + disabled={isAnswered || !amICurrentPlayer} + className={`p-4 rounded transition-all ${ + isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70' + }`} + style={{ + backgroundColor: isAnswered ? config.colors.bg : cat.color + '40', + border: `2px solid ${cat.color}`, + }} + > + + {difficulty * 100} + + + ) + }) + )} +
+ + {/* Question Modal */} + + {showingQuestion && currentQuestion && ( + + + {/* Timer */} +
+ + {currentQuestion.points} puntos + +
5 ? config.colors.primary : undefined }} + > + {timeLeft}s +
+
+ + {/* Question */} +

+ {currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'} +

+ + {/* Answer Input */} + {amICurrentPlayer && ( +
+ setAnswer(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()} + placeholder="Escribe tu respuesta..." + autoFocus + className="w-full px-4 py-3 rounded-lg bg-transparent outline-none text-lg" + style={{ + border: `2px solid ${config.colors.primary}`, + color: config.colors.text, + }} + /> + +
+ )} + + {!amICurrentPlayer && ( +

+ Esperando respuesta de {currentPlayer?.name}... +

+ )} +
+
+ )} +
+ + {/* Steal Prompt */} + + {showStealPrompt && room.current_team === myTeam && ( + + +

+ ¡Oportunidad de Robo! +

+

+ El equipo contrario falló. ¿Quieres intentar robar los puntos? +
+ Advertencia: Si fallas, perderás puntos +

+
+ + +
+
+
+ )} +
+ + {/* Emoji Reactions */} +
+ {emojis.map((emoji) => ( + + ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..17b4615 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useSocket } from '../hooks/useSocket' +import { useGameStore } from '../stores/gameStore' +import { useThemeStore, themes } from '../stores/themeStore' +import { useThemeStyles } from '../themes/ThemeProvider' +import type { ThemeName } from '../types' + +export default function Home() { + const [playerName, setPlayerName] = useState('') + const [roomCode, setRoomCode] = useState('') + const [mode, setMode] = useState<'select' | 'create' | 'join'>('select') + const [error, setError] = useState('') + + const navigate = useNavigate() + const { createRoom, joinRoom } = useSocket() + const { setPlayerName: storeSetPlayerName, room } = useGameStore() + const { currentTheme, setTheme } = useThemeStore() + const { config, styles } = useThemeStyles() + + // Navigate when room is created/joined + if (room) { + navigate(`/lobby/${room.code}`) + } + + const handleCreateRoom = () => { + if (!playerName.trim()) { + setError('Ingresa tu nombre') + return + } + storeSetPlayerName(playerName.trim()) + createRoom(playerName.trim()) + } + + const handleJoinRoom = () => { + if (!playerName.trim()) { + setError('Ingresa tu nombre') + return + } + if (!roomCode.trim() || roomCode.length !== 6) { + setError('Ingresa un código de sala válido (6 caracteres)') + return + } + storeSetPlayerName(playerName.trim()) + joinRoom(roomCode.toUpperCase(), playerName.trim(), 'A') + } + + return ( +
+ +

+ WebTriviasMulti +

+

Trivia multiplayer en tiempo real

+
+ + {/* Theme Selector */} + + {(Object.keys(themes) as ThemeName[]).map((themeName) => ( + + ))} + + + + {mode === 'select' ? ( +
+ + +
+ ) : ( +
+ + +
+ + setPlayerName(e.target.value)} + placeholder="Ingresa tu nombre" + maxLength={20} + className="w-full px-4 py-2 rounded-lg bg-transparent outline-none" + style={{ + border: `1px solid ${config.colors.primary}`, + color: config.colors.text, + }} + /> +
+ + {mode === 'join' && ( +
+ + setRoomCode(e.target.value.toUpperCase())} + placeholder="ABCD12" + maxLength={6} + className="w-full px-4 py-2 rounded-lg bg-transparent outline-none uppercase tracking-widest text-center text-xl" + style={{ + border: `1px solid ${config.colors.primary}`, + color: config.colors.text, + }} + /> +
+ )} + + {error && ( +

{error}

+ )} + + +
+ )} +
+ + + 8 categorías • 2 equipos • Preguntas diarias + +
+ ) +} diff --git a/frontend/src/pages/Lobby.tsx b/frontend/src/pages/Lobby.tsx new file mode 100644 index 0000000..5eb4116 --- /dev/null +++ b/frontend/src/pages/Lobby.tsx @@ -0,0 +1,227 @@ +import { useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useSocket } from '../hooks/useSocket' +import { useGameStore } from '../stores/gameStore' +import { useThemeStyles } from '../themes/ThemeProvider' + +export default function Lobby() { + const { roomCode } = useParams<{ roomCode: string }>() + const navigate = useNavigate() + const { changeTeam, startGame } = useSocket() + const { room, playerName } = useGameStore() + const { config, styles } = useThemeStyles() + + // Redirect if no room + useEffect(() => { + if (!room && !roomCode) { + navigate('/') + } + }, [room, roomCode, navigate]) + + // Navigate to game when started + useEffect(() => { + if (room?.status === 'playing') { + navigate(`/game/${room.code}`) + } + }, [room?.status, room?.code, navigate]) + + if (!room) { + return ( +
+

Cargando...

+
+ ) + } + + const isHost = room.host === playerName + const canStart = room.teams.A.length > 0 && room.teams.B.length > 0 + + const handleStartGame = () => { + // In production, fetch today's questions and build board + const sampleBoard = { + '1': [ + { id: 1, category_id: 1, difficulty: 1, points: 100, time_seconds: 15, answered: false }, + { id: 2, category_id: 1, difficulty: 2, points: 200, time_seconds: 20, answered: false }, + { id: 3, category_id: 1, difficulty: 3, points: 300, time_seconds: 25, answered: false }, + { id: 4, category_id: 1, difficulty: 4, points: 400, time_seconds: 35, answered: false }, + { id: 5, category_id: 1, difficulty: 5, points: 500, time_seconds: 45, answered: false }, + ], + // Add more categories... + } + startGame(sampleBoard) + } + + return ( +
+
+ {/* Header */} + +

+ Sala de Espera +

+
+ {room.code} +
+

+ Comparte este código con tus amigos +

+
+ + {/* Teams */} +
+ {/* Team A */} + +

+ Equipo A +

+
+ {room.teams.A.map((player, index) => ( +
+ {player.name} + {player.name === room.host && ( + + Host + + )} +
+ ))} + {room.teams.A.length < 4 && ( + + )} +
+
+ + {/* Team B */} + +

+ Equipo B +

+
+ {room.teams.B.map((player, index) => ( +
+ {player.name} + {player.name === room.host && ( + + Host + + )} +
+ ))} + {room.teams.B.length < 4 && ( + + )} +
+
+
+ + {/* Start Button */} + {isHost && ( + + + {!canStart && ( +

+ Ambos equipos necesitan al menos un jugador +

+ )} +
+ )} + + {!isHost && ( + + Esperando a que el host inicie la partida... + + )} +
+
+ ) +} diff --git a/frontend/src/pages/Replay.tsx b/frontend/src/pages/Replay.tsx new file mode 100644 index 0000000..a302799 --- /dev/null +++ b/frontend/src/pages/Replay.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from 'react' +import { useParams, Link } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useThemeStyles } from '../themes/ThemeProvider' +import type { ReplayData, GameEvent } from '../types' + +export default function Replay() { + const { sessionId } = useParams<{ sessionId: string }>() + const { config, styles } = useThemeStyles() + + const [replayData, setReplayData] = useState(null) + const [currentEventIndex, setCurrentEventIndex] = useState(0) + const [isPlaying, setIsPlaying] = useState(false) + const [speed, setSpeed] = useState(1) + const [loading, setLoading] = useState(true) + + // Fetch replay data + useEffect(() => { + const fetchReplay = async () => { + try { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const response = await fetch(`${apiUrl}/api/replay/code/${sessionId}`) + if (response.ok) { + const data = await response.json() + setReplayData(data) + } + } catch (error) { + console.error('Failed to fetch replay:', error) + } finally { + setLoading(false) + } + } + + fetchReplay() + }, [sessionId]) + + // Playback logic + useEffect(() => { + if (!isPlaying || !replayData) return + + const interval = setInterval(() => { + setCurrentEventIndex((prev) => { + if (prev >= replayData.events.length - 1) { + setIsPlaying(false) + return prev + } + return prev + 1 + }) + }, 1000 / speed) + + return () => clearInterval(interval) + }, [isPlaying, speed, replayData]) + + const currentEvents = replayData?.events.slice(0, currentEventIndex + 1) || [] + const currentScores = currentEvents.reduce( + (acc, event) => { + if (event.was_correct && event.points_earned) { + acc[event.team] += event.points_earned + } else if (!event.was_correct && event.was_steal && event.points_earned) { + acc[event.team] -= Math.abs(event.points_earned) + } + return acc + }, + { A: 0, B: 0 } + ) + + if (loading) { + return ( +
+

Cargando replay...

+
+ ) + } + + if (!replayData) { + return ( +
+

No se encontró el replay

+ + Volver al inicio + +
+ ) + } + + return ( +
+
+ {/* Header */} +
+ + ← Volver + +

+ Replay: {replayData.session.room_code} +

+
+ {new Date(replayData.session.created_at).toLocaleDateString()} +
+
+ + {/* Scores */} +
+
+
Equipo A
+
+ {currentScores.A} +
+
+
+
Equipo B
+
+ {currentScores.B} +
+
+
+ + {/* Playback Controls */} +
+ + + + +
+ Velocidad: + {[1, 2, 4].map((s) => ( + + ))} +
+
+ + {/* Timeline */} +
+ setCurrentEventIndex(Number(e.target.value))} + className="w-full" + /> +
+ Evento {currentEventIndex + 1} + de {replayData.events.length} +
+
+ + {/* Events List */} +
+
+ {replayData.events.map((event, index) => ( + +
+
+ + {event.team} + + {event.player_name} +
+
+ {event.was_correct ? ( + ✓ +{event.points_earned} + ) : ( + ✗ {event.was_steal ? `-${Math.abs(event.points_earned || 0)}` : ''} + )} +
+
+ {event.answer_given && ( +
+ Respuesta: "{event.answer_given}" +
+ )} +
+ ))} +
+
+ + {/* Final Scores */} +
+
Resultado Final
+
+ {replayData.session.team_a_score} + - + {replayData.session.team_b_score} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Results.tsx b/frontend/src/pages/Results.tsx new file mode 100644 index 0000000..69e1f00 --- /dev/null +++ b/frontend/src/pages/Results.tsx @@ -0,0 +1,210 @@ +import { useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useSound } from '../hooks/useSound' +import { useAchievements } from '../hooks/useAchievements' +import { useGameStore } from '../stores/gameStore' +import { useThemeStyles } from '../themes/ThemeProvider' + +export default function Results() { + const { roomCode } = useParams<{ roomCode: string }>() + const navigate = useNavigate() + const { play } = useSound() + const { achievements } = useAchievements() + const { room, playerName, resetGame } = useGameStore() + const { config, styles } = useThemeStyles() + + const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B' + const won = room ? room.scores[myTeam] > room.scores[myTeam === 'A' ? 'B' : 'A'] : false + const tied = room ? room.scores.A === room.scores.B : false + + // Play victory/defeat sound + useEffect(() => { + if (won) { + play('victory') + } else if (!tied) { + play('defeat') + } + }, [won, tied, play]) + + if (!room) { + return ( +
+

No hay resultados disponibles

+
+ ) + } + + const winnerTeam = room.scores.A > room.scores.B ? 'A' : room.scores.B > room.scores.A ? 'B' : null + const newAchievements = achievements.filter(a => a.unlocked && a.unlockedAt) + + const handlePlayAgain = () => { + resetGame() + navigate('/') + } + + return ( +
+ + {/* Result Header */} + + {tied ? ( +

+ ¡EMPATE! +

+ ) : won ? ( + <> +

+ ¡VICTORIA! +

+

+ Tu equipo ha ganado +

+ + ) : ( + <> +

+ DERROTA +

+

+ Mejor suerte la próxima vez +

+ + )} +
+ + {/* Scores */} + +
+
Equipo A
+
+ {room.scores.A} +
+
+ {room.teams.A.map(p => p.name).join(', ')} +
+
+ +
+ VS +
+ +
+
Equipo B
+
+ {room.scores.B} +
+
+ {room.teams.B.map(p => p.name).join(', ')} +
+
+
+ + {/* New Achievements */} + {newAchievements.length > 0 && ( + +

+ ¡Nuevos Logros Desbloqueados! +

+
+ {newAchievements.map((achievement) => ( + +
{achievement.icon}
+
+ {achievement.name} +
+
+ {achievement.description} +
+
+ ))} +
+
+ )} + + {/* Actions */} + + + + + Ver Replay + + +
+
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..2d996ac --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,116 @@ +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + +class ApiService { + private baseUrl: string + + constructor() { + this.baseUrl = API_URL + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${endpoint}` + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`) + } + + return response.json() + } + + // Game endpoints + async getCategories() { + return this.request< + Array<{ id: number; name: string; icon: string; color: string }> + >('/api/game/categories') + } + + async getTodayQuestions() { + return this.request<{ + date: string + categories: Record< + string, + { + name: string + questions: Array<{ + difficulty: number + id: number + points: number + }> + } + > + }>('/api/game/today-questions') + } + + async getQuestion(questionId: number) { + return this.request<{ + id: number + question_text: string + difficulty: number + points: number + time_seconds: number + category_id: number + }>(`/api/game/question/${questionId}`) + } + + async getAchievements() { + return this.request< + Array<{ + id: number + name: string + description: string + icon: string + }> + >('/api/game/achievements') + } + + // Replay endpoints + async getReplay(sessionId: string) { + return this.request<{ + session: { + id: number + room_code: string + team_a_score: number + team_b_score: number + status: string + created_at: string + finished_at: string + } + events: Array<{ + id: number + event_type: string + player_name: string + team: 'A' | 'B' + question_id: number + answer_given: string + was_correct: boolean + was_steal: boolean + points_earned: number + timestamp: string + }> + }>(`/api/replay/code/${sessionId}`) + } + + async listReplays(limit = 20, offset = 0) { + return this.request< + Array<{ + id: number + room_code: string + team_a_score: number + team_b_score: number + finished_at: string + }> + >(`/api/replay?limit=${limit}&offset=${offset}`) + } +} + +export const api = new ApiService() diff --git a/frontend/src/services/socket.ts b/frontend/src/services/socket.ts new file mode 100644 index 0000000..e3dd66b --- /dev/null +++ b/frontend/src/services/socket.ts @@ -0,0 +1,69 @@ +import { io, Socket } from 'socket.io-client' + +const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000' + +class SocketService { + private socket: Socket | null = null + private listeners: Map void>> = new Map() + + connect(): Socket { + if (!this.socket) { + this.socket = io(SOCKET_URL, { + transports: ['websocket', 'polling'], + autoConnect: true, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + }) + + this.socket.on('connect', () => { + console.log('Socket connected:', this.socket?.id) + }) + + this.socket.on('disconnect', (reason) => { + console.log('Socket disconnected:', reason) + }) + + this.socket.on('error', (error) => { + console.error('Socket error:', error) + }) + } + + return this.socket + } + + disconnect(): void { + if (this.socket) { + this.socket.disconnect() + this.socket = null + } + } + + on(event: string, callback: (data: unknown) => void): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event)!.add(callback) + + this.socket?.on(event, callback) + } + + off(event: string, callback: (data: unknown) => void): void { + this.listeners.get(event)?.delete(callback) + this.socket?.off(event, callback) + } + + emit(event: string, data?: unknown): void { + this.socket?.emit(event, data) + } + + get connected(): boolean { + return this.socket?.connected ?? false + } + + get id(): string | undefined { + return this.socket?.id + } +} + +export const socketService = new SocketService() diff --git a/frontend/src/stores/gameStore.ts b/frontend/src/stores/gameStore.ts new file mode 100644 index 0000000..9a7fefa --- /dev/null +++ b/frontend/src/stores/gameStore.ts @@ -0,0 +1,104 @@ +import { create } from 'zustand' +import type { GameRoom, Player, Question, ChatMessage, Achievement } from '../types' + +interface GameState { + // Room state + room: GameRoom | null + setRoom: (room: GameRoom | null) => void + + // Player info + playerName: string + setPlayerName: (name: string) => void + + // Current question + currentQuestion: Question | null + setCurrentQuestion: (question: Question | null) => void + + // Timer + timerEnd: Date | null + setTimerEnd: (end: Date | null) => void + + // Chat messages + messages: ChatMessage[] + addMessage: (message: ChatMessage) => void + clearMessages: () => void + + // Achievements + achievements: Achievement[] + setAchievements: (achievements: Achievement[]) => void + unlockAchievement: (id: number) => void + + // Game stats (for achievements tracking) + stats: { + correctStreak: number + stealsAttempted: number + stealsSuccessful: number + categoryCorrect: Record + fastAnswers: number + maxDeficit: number + } + updateStats: (updates: Partial) => void + resetStats: () => void + + // UI state + showStealPrompt: boolean + setShowStealPrompt: (show: boolean) => void + + // Reset + resetGame: () => void +} + +const initialStats = { + correctStreak: 0, + stealsAttempted: 0, + stealsSuccessful: 0, + categoryCorrect: {}, + fastAnswers: 0, + maxDeficit: 0, +} + +export const useGameStore = create((set) => ({ + room: null, + setRoom: (room) => set({ room }), + + playerName: '', + setPlayerName: (playerName) => set({ playerName }), + + currentQuestion: null, + setCurrentQuestion: (currentQuestion) => set({ currentQuestion }), + + timerEnd: null, + setTimerEnd: (timerEnd) => set({ timerEnd }), + + messages: [], + addMessage: (message) => + set((state) => ({ messages: [...state.messages, message].slice(-100) })), + clearMessages: () => set({ messages: [] }), + + achievements: [], + setAchievements: (achievements) => set({ achievements }), + unlockAchievement: (id) => + set((state) => ({ + achievements: state.achievements.map((a) => + a.id === id ? { ...a, unlocked: true, unlockedAt: new Date().toISOString() } : a + ), + })), + + stats: initialStats, + updateStats: (updates) => + set((state) => ({ stats: { ...state.stats, ...updates } })), + resetStats: () => set({ stats: initialStats }), + + showStealPrompt: false, + setShowStealPrompt: (showStealPrompt) => set({ showStealPrompt }), + + resetGame: () => + set({ + room: null, + currentQuestion: null, + timerEnd: null, + messages: [], + stats: initialStats, + showStealPrompt: false, + }), +})) diff --git a/frontend/src/stores/soundStore.ts b/frontend/src/stores/soundStore.ts new file mode 100644 index 0000000..d343fe3 --- /dev/null +++ b/frontend/src/stores/soundStore.ts @@ -0,0 +1,90 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { ThemeName } from '../types' + +type SoundEffect = + | 'correct' + | 'incorrect' + | 'steal' + | 'timer_tick' + | 'timer_urgent' + | 'victory' + | 'defeat' + | 'select' + +interface SoundState { + volume: number + muted: boolean + setVolume: (volume: number) => void + setMuted: (muted: boolean) => void + toggleMute: () => void +} + +export const useSoundStore = create()( + persist( + (set) => ({ + volume: 0.7, + muted: false, + setVolume: (volume) => set({ volume }), + setMuted: (muted) => set({ muted }), + toggleMute: () => set((state) => ({ muted: !state.muted })), + }), + { + name: 'trivia-sound', + } + ) +) + +// Sound file paths per theme +export const soundPaths: Record> = { + drrr: { + correct: '/sounds/drrr/correct.mp3', + incorrect: '/sounds/drrr/incorrect.mp3', + steal: '/sounds/drrr/steal.mp3', + timer_tick: '/sounds/drrr/tick.mp3', + timer_urgent: '/sounds/drrr/urgent.mp3', + victory: '/sounds/drrr/victory.mp3', + defeat: '/sounds/drrr/defeat.mp3', + select: '/sounds/drrr/select.mp3', + }, + retro: { + correct: '/sounds/retro/correct.mp3', + incorrect: '/sounds/retro/incorrect.mp3', + steal: '/sounds/retro/steal.mp3', + timer_tick: '/sounds/retro/tick.mp3', + timer_urgent: '/sounds/retro/urgent.mp3', + victory: '/sounds/retro/victory.mp3', + defeat: '/sounds/retro/defeat.mp3', + select: '/sounds/retro/select.mp3', + }, + minimal: { + correct: '/sounds/minimal/correct.mp3', + incorrect: '/sounds/minimal/incorrect.mp3', + steal: '/sounds/minimal/steal.mp3', + timer_tick: '/sounds/minimal/tick.mp3', + timer_urgent: '/sounds/minimal/urgent.mp3', + victory: '/sounds/minimal/victory.mp3', + defeat: '/sounds/minimal/defeat.mp3', + select: '/sounds/minimal/select.mp3', + }, + rgb: { + correct: '/sounds/rgb/correct.mp3', + incorrect: '/sounds/rgb/incorrect.mp3', + steal: '/sounds/rgb/steal.mp3', + timer_tick: '/sounds/rgb/tick.mp3', + timer_urgent: '/sounds/rgb/urgent.mp3', + victory: '/sounds/rgb/victory.mp3', + defeat: '/sounds/rgb/defeat.mp3', + select: '/sounds/rgb/select.mp3', + }, + anime: { + correct: '/sounds/anime/correct.mp3', + incorrect: '/sounds/anime/incorrect.mp3', + steal: '/sounds/anime/steal.mp3', + timer_tick: '/sounds/anime/tick.mp3', + timer_urgent: '/sounds/anime/urgent.mp3', + victory: '/sounds/anime/victory.mp3', + defeat: '/sounds/anime/defeat.mp3', + select: '/sounds/anime/select.mp3', + }, +} diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts new file mode 100644 index 0000000..474a43c --- /dev/null +++ b/frontend/src/stores/themeStore.ts @@ -0,0 +1,140 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { ThemeName, ThemeConfig } from '../types' + +export const themes: Record = { + drrr: { + name: 'drrr', + displayName: 'DRRR (Dollars)', + colors: { + bg: '#0a0a0a', + primary: '#FFE135', + secondary: '#00FFFF', + accent: '#FF00FF', + text: '#ffffff', + textMuted: '#888888', + }, + fonts: { + heading: 'Bebas Neue, sans-serif', + body: 'Inter, sans-serif', + }, + effects: { + glow: true, + scanlines: false, + glitch: true, + sparkles: false, + rgbShift: false, + }, + }, + retro: { + name: 'retro', + displayName: 'Retro Arcade', + colors: { + bg: '#1a1a2e', + primary: '#9B59B6', + secondary: '#E91E63', + accent: '#00FFFF', + text: '#ffffff', + textMuted: '#aaaaaa', + }, + fonts: { + heading: '"Press Start 2P", cursive', + body: '"Press Start 2P", cursive', + }, + effects: { + glow: true, + scanlines: true, + glitch: false, + sparkles: false, + rgbShift: false, + }, + }, + minimal: { + name: 'minimal', + displayName: 'Moderno Minimalista', + colors: { + bg: '#ffffff', + primary: '#3498DB', + secondary: '#2ECC71', + accent: '#E74C3C', + text: '#2c3e50', + textMuted: '#7f8c8d', + }, + fonts: { + heading: 'Inter, sans-serif', + body: 'Inter, sans-serif', + }, + effects: { + glow: false, + scanlines: false, + glitch: false, + sparkles: false, + rgbShift: false, + }, + }, + rgb: { + name: 'rgb', + displayName: 'Gaming RGB', + colors: { + bg: '#0D0D0D', + primary: '#FF0080', + secondary: '#00FF80', + accent: '#8000FF', + text: '#ffffff', + textMuted: '#666666', + }, + fonts: { + heading: 'Inter, sans-serif', + body: 'Inter, sans-serif', + }, + effects: { + glow: true, + scanlines: false, + glitch: false, + sparkles: false, + rgbShift: true, + }, + }, + anime: { + name: 'anime', + displayName: 'Anime Clásico 90s', + colors: { + bg: '#FFF5F5', + primary: '#FFB6C1', + secondary: '#E6E6FA', + accent: '#FF69B4', + text: '#4a4a4a', + textMuted: '#888888', + }, + fonts: { + heading: 'Inter, sans-serif', + body: 'Inter, sans-serif', + }, + effects: { + glow: false, + scanlines: false, + glitch: false, + sparkles: true, + rgbShift: false, + }, + }, +} + +interface ThemeState { + currentTheme: ThemeName + setTheme: (theme: ThemeName) => void + getThemeConfig: () => ThemeConfig +} + +export const useThemeStore = create()( + persist( + (set, get) => ({ + currentTheme: 'drrr', + setTheme: (currentTheme) => set({ currentTheme }), + getThemeConfig: () => themes[get().currentTheme], + }), + { + name: 'trivia-theme', + } + ) +) diff --git a/frontend/src/themes/ThemeProvider.tsx b/frontend/src/themes/ThemeProvider.tsx new file mode 100644 index 0000000..32b135b --- /dev/null +++ b/frontend/src/themes/ThemeProvider.tsx @@ -0,0 +1,65 @@ +import { useEffect, type ReactNode } from 'react' +import { useThemeStore, themes } from '../stores/themeStore' + +interface ThemeProviderProps { + children: ReactNode +} + +export function ThemeProvider({ children }: ThemeProviderProps) { + const { currentTheme } = useThemeStore() + const theme = themes[currentTheme] + + useEffect(() => { + // Apply CSS variables + const root = document.documentElement + root.style.setProperty('--color-bg', theme.colors.bg) + root.style.setProperty('--color-primary', theme.colors.primary) + root.style.setProperty('--color-secondary', theme.colors.secondary) + root.style.setProperty('--color-accent', theme.colors.accent) + root.style.setProperty('--color-text', theme.colors.text) + root.style.setProperty('--color-text-muted', theme.colors.textMuted) + root.style.setProperty('--font-heading', theme.fonts.heading) + root.style.setProperty('--font-body', theme.fonts.body) + + // Apply body background + document.body.style.backgroundColor = theme.colors.bg + document.body.style.color = theme.colors.text + + // Add theme class to body + document.body.className = `theme-${currentTheme}` + }, [currentTheme, theme]) + + return <>{children} +} + +// Theme-aware component wrapper +export function useThemeStyles() { + const { currentTheme, getThemeConfig } = useThemeStore() + const config = getThemeConfig() + + return { + theme: currentTheme, + config, + styles: { + // Background styles + bgPrimary: { backgroundColor: config.colors.bg }, + bgSecondary: { backgroundColor: config.colors.primary + '20' }, + + // Text styles + textPrimary: { color: config.colors.text }, + textSecondary: { color: config.colors.textMuted }, + textAccent: { color: config.colors.primary }, + + // Border styles + borderPrimary: { borderColor: config.colors.primary }, + borderSecondary: { borderColor: config.colors.secondary }, + + // Effect classes + glowEffect: config.effects.glow ? 'text-shadow-neon' : '', + scanlineEffect: config.effects.scanlines ? 'crt-scanlines' : '', + glitchEffect: config.effects.glitch ? 'glitch-text' : '', + sparkleEffect: config.effects.sparkles ? 'sparkle' : '', + rgbEffect: config.effects.rgbShift ? 'animate-rgb-shift' : '', + }, + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..f23f053 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,135 @@ +// Game Types + +export interface Player { + name: string + team: 'A' | 'B' + position: number + socket_id?: string +} + +export interface TeamState { + players: Player[] + score: number + currentPlayerIndex: number +} + +export interface Question { + id: number + category_id: number + question_text?: string + difficulty: number + points: number + time_seconds: number + answered?: boolean + selected?: boolean +} + +export interface Category { + id: number + name: string + icon: string + color: string +} + +export interface GameRoom { + code: string + status: 'waiting' | 'playing' | 'finished' + host: string + teams: { + A: Player[] + B: Player[] + } + current_team: 'A' | 'B' | null + current_player_index: { A: number; B: number } + current_question: number | null + can_steal: boolean + scores: { A: number; B: number } + board: Record +} + +export interface ChatMessage { + player_name: string + team: 'A' | 'B' + message: string + timestamp: string +} + +export interface EmojiReaction { + player_name: string + team: 'A' | 'B' + emoji: string +} + +export interface Achievement { + id: number + name: string + description: string + icon: string + unlocked?: boolean + unlockedAt?: string +} + +export interface AnswerResult { + player_name: string + team: 'A' | 'B' + answer: string + valid: boolean + reason: string + points_earned: number + was_steal: boolean + room: GameRoom +} + +export interface GameEvent { + id: number + event_type: string + player_name: string + team: 'A' | 'B' + question_id: number + answer_given: string + was_correct: boolean + was_steal: boolean + points_earned: number + timestamp: string +} + +export interface ReplayData { + session: { + id: number + room_code: string + team_a_score: number + team_b_score: number + status: string + created_at: string + finished_at: string + } + events: GameEvent[] +} + +// Theme Types + +export type ThemeName = 'drrr' | 'retro' | 'minimal' | 'rgb' | 'anime' + +export interface ThemeConfig { + name: ThemeName + displayName: string + colors: { + bg: string + primary: string + secondary: string + accent: string + text: string + textMuted: string + } + fonts: { + heading: string + body: string + } + effects: { + glow: boolean + scanlines: boolean + glitch: boolean + sparkles: boolean + rgbShift: boolean + } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..297f30a --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,78 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + // DRRR Theme + drrr: { + bg: '#0a0a0a', + primary: '#FFE135', + secondary: '#00FFFF', + accent: '#FF00FF', + }, + // Retro Arcade Theme + retro: { + bg: '#1a1a2e', + primary: '#9B59B6', + secondary: '#E91E63', + accent: '#00FFFF', + }, + // Gaming RGB Theme + rgb: { + bg: '#0D0D0D', + primary: '#FF0000', + secondary: '#00FF00', + accent: '#0000FF', + }, + // Anime 90s Theme + anime: { + bg: '#FFF5F5', + primary: '#FFB6C1', + secondary: '#E6E6FA', + accent: '#FF69B4', + }, + }, + fontFamily: { + 'pixel': ['"Press Start 2P"', 'cursive'], + 'urban': ['Bebas Neue', 'sans-serif'], + }, + animation: { + 'glitch': 'glitch 0.3s infinite', + 'pulse-neon': 'pulse-neon 2s infinite', + 'scanline': 'scanline 8s linear infinite', + 'sparkle': 'sparkle 1.5s infinite', + 'rgb-shift': 'rgb-shift 3s infinite', + }, + keyframes: { + glitch: { + '0%, 100%': { transform: 'translate(0)' }, + '20%': { transform: 'translate(-2px, 2px)' }, + '40%': { transform: 'translate(-2px, -2px)' }, + '60%': { transform: 'translate(2px, 2px)' }, + '80%': { transform: 'translate(2px, -2px)' }, + }, + 'pulse-neon': { + '0%, 100%': { boxShadow: '0 0 5px currentColor, 0 0 10px currentColor' }, + '50%': { boxShadow: '0 0 20px currentColor, 0 0 30px currentColor' }, + }, + scanline: { + '0%': { transform: 'translateY(-100%)' }, + '100%': { transform: 'translateY(100%)' }, + }, + sparkle: { + '0%, 100%': { opacity: 1, transform: 'scale(1)' }, + '50%': { opacity: 0.5, transform: 'scale(0.8)' }, + }, + 'rgb-shift': { + '0%': { filter: 'hue-rotate(0deg)' }, + '100%': { filter: 'hue-rotate(360deg)' }, + }, + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5413626 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..505001b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + host: true + }, + build: { + outDir: 'dist', + sourcemap: true + } +})