Compare commits

..

8 Commits

Author SHA1 Message Date
1e1daf94f6 feat: generación automática de preguntas diarias
- Script generate_daily_questions.py: genera 5 preguntas por categoría/dificultad
- Usa Claude API para generar preguntas en español
- Cron job configurado para medianoche (0 0 * * *)
- 14 categorías × 5 dificultades × 5 preguntas = 350 preguntas/día
- Evita duplicados verificando preguntas existentes

fix: rotación de jugadores en robo fallido/pasado

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:57:01 +00:00
2d4330ef74 fix: rotación de jugadores en robo fallido/pasado
- Cuando un equipo falla el robo, avanza su índice de jugador
- Cuando un equipo pasa el robo, también avanza su índice
- El turno vuelve correctamente al equipo original

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:40:20 +00:00
be5b1775a0 feat: sistema de 2 rondas con puntos dobles
Ronda 1: 5 categorías con puntos normales (100-500)
Ronda 2: 5 categorías diferentes con puntos x2 (200-1000)

Backend:
- question_service: soporta excluir categorías y multiplicador de puntos
- game_manager: trackea current_round, start_round_2() carga nuevo tablero
- game_events: emite round_started al completar ronda 1

Frontend:
- useSocket: escucha evento round_started
- Game.tsx: muestra indicador de ronda actual
- types: GameRoom incluye current_round

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:28:28 +00:00
e017c5804c fix: condición de carrera al unirse múltiples jugadores
- Agrega sistema de locks en Redis para operaciones de sala
- add_player, remove_player y change_team ahora son atómicos
- Previene sobrescritura de estado cuando jugadores se unen simultáneamente
- Nuevo método change_player_team con lock integrado

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:21:41 +00:00
e0106502b1 fix: persistencia de resultados del juego
- Guarda gameResult en localStorage al terminar partida
- Results.tsx recupera resultados de localStorage o del room
- Expira después de 1 hora
- Resuelve "No hay resultados disponibles" tras recargar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 02:07:03 +00:00
112f489e40 feat: reconexión de sesión + 6 nuevas categorías + corrección de bugs
- Añade sistema de reconexión tras refresh/cierre del navegador
  - Persistencia de sesión en localStorage (3h TTL)
  - Banner de reconexión en Home
  - Evento rejoin_room en backend

- Nuevas categorías: Series TV, Marvel/DC, Disney, Memes, Pokémon, Mitología

- Correcciones de bugs:
  - Fix: juego bloqueado al fallar robo (steal decision)
  - Fix: jugador duplicado al cambiar de equipo
  - Fix: rotación incorrecta de turno tras fallo

- Config: soporte para Cloudflare tunnel (allowedHosts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 01:53:32 +00:00
6248037b47 docs: añade documentación completa del proyecto
- README.md: descripción general, stack, instalación rápida
- docs/API.md: referencia completa de API REST y WebSocket
- docs/ARCHITECTURE.md: arquitectura del sistema con diagramas
- docs/INSTALLATION.md: guía detallada de instalación
- backend/.env.example: plantilla de configuración

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 23:50:34 +00:00
ab201e113a feat: 5 categorías rotativas por partida + pool de 200 preguntas + mejoras UI
Cambios principales:
- Tablero ahora muestra 5 categorías aleatorias (de 8 disponibles)
- Pool de 200 preguntas (8 cats × 5 diffs × 5 opciones)
- Preguntas rotan aleatoriamente entre partidas
- Diseño mejorado estilo Jeopardy con efectos visuales
- Socket singleton para conexión persistente
- Nuevos sonidos: game_start, player_join, question_reveal, hover, countdown
- Control de volumen vertical
- Barra de progreso del timer en modal de preguntas
- Animaciones mejoradas con Framer Motion

Backend:
- question_service: selección aleatoria de 5 categorías
- room_manager: fix retorno de create_room
- game_events: carga board desde DB, await en enter_room

Frontend:
- Game.tsx: tablero dinámico, efectos hover, mejor scoreboard
- useSocket: singleton service, eventos con sonidos
- SoundControl: slider vertical
- soundStore: 5 nuevos efectos de sonido

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 23:44:55 +00:00
21 changed files with 3410 additions and 373 deletions

344
README.md Normal file
View File

@@ -0,0 +1,344 @@
# Trivy - Trivia Multiplayer en Tiempo Real
<p align="center">
<img src="docs/logo.png" alt="Trivy Logo" width="200">
</p>
**Trivy** es un juego de trivia multijugador en tiempo real inspirado en Jeopardy. Dos equipos compiten respondiendo preguntas de diferentes categorías, con mecánicas de robo y validación de respuestas por IA.
## Características
- **Multijugador en tiempo real** - Hasta 8 jugadores (2 equipos × 4 jugadores)
- **8 categorías temáticas** - Nintendo, Xbox, PlayStation, Anime, Música, Películas, Libros, Historia
- **5 categorías por partida** - Rotación aleatoria entre partidas
- **Pool de 200 preguntas** - 5 opciones por cada categoría/dificultad
- **Validación de respuestas por IA** - Claude API para respuestas flexibles
- **Sistema de robo** - Oportunidad de robar puntos cuando el equipo contrario falla
- **Múltiples temas visuales** - DRRR, Retro Arcade, Minimal, Gaming RGB, Anime 90s
- **Efectos de sonido** - Sonidos personalizados por tema
- **Chat de equipo** - Comunicación privada entre compañeros
- **Reacciones emoji** - Expresiones en tiempo real
- **Panel de administración** - Gestión de preguntas, categorías y monitoreo
## Stack Tecnológico
### Backend
- **FastAPI** - Framework web async de alto rendimiento
- **Python-SocketIO** - WebSockets para comunicación en tiempo real
- **PostgreSQL** - Base de datos principal
- **Redis** - Estado de salas y sesiones en tiempo real
- **SQLAlchemy** - ORM async
- **Anthropic Claude API** - Validación inteligente de respuestas
### Frontend
- **React 18** - UI declarativa
- **TypeScript** - Tipado estático
- **Vite** - Build tool rápido
- **Zustand** - Estado global ligero
- **Framer Motion** - Animaciones fluidas
- **Tailwind CSS** - Estilos utilitarios
- **Socket.IO Client** - Comunicación WebSocket
- **Howler.js** - Gestión de audio
## Requisitos
- Python 3.11+
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
- API Key de Anthropic (Claude)
## Instalación
### 1. Clonar repositorio
```bash
git clone https://gitea.local/frank/Trivy.git
cd Trivy
```
### 2. Configurar Backend
```bash
cd backend
# Crear entorno virtual
python -m venv venv
source venv/bin/activate # Linux/Mac
# o: venv\Scripts\activate # Windows
# Instalar dependencias
pip install -r requirements.txt
# Configurar variables de entorno
cp .env.example .env
# Editar .env con tus credenciales
```
### 3. Configurar Frontend
```bash
cd frontend
# Instalar dependencias
npm install
# Configurar variables de entorno
cp .env.example .env
# Editar .env con la URL del backend
```
### 4. Base de datos
```bash
# Con Docker
docker-compose up -d db redis
# O configurar PostgreSQL y Redis manualmente
# Crear base de datos: trivy
```
### 5. Migraciones
```bash
cd backend
alembic upgrade head
```
### 6. Datos iniciales
```bash
# Crear usuario admin
python -c "
from app.models.base import get_async_session
from app.models.admin import Admin
import asyncio
async def create_admin():
async with get_async_session()() as db:
admin = Admin(username='admin')
admin.set_password('admin123')
db.add(admin)
await db.commit()
asyncio.run(create_admin())
"
# Crear categorías y preguntas iniciales
python scripts/seed_data.py
```
## Ejecución
### Desarrollo
```bash
# Terminal 1 - Backend
cd backend
source venv/bin/activate
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000 --reload
# Terminal 2 - Frontend
cd frontend
npm run dev -- --host
```
### Producción
```bash
# Backend con Gunicorn
gunicorn app.main:socket_app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
# Frontend build
cd frontend
npm run build
# Servir dist/ con nginx o similar
```
## Configuración
### Variables de entorno - Backend (.env)
```env
# Base de datos
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/trivy
# Redis
REDIS_URL=redis://localhost:6379
# Anthropic API
ANTHROPIC_API_KEY=sk-ant-...
# Configuración del juego
STEAL_PENALTY_MULTIPLIER=0.5
STEAL_TIME_MULTIPLIER=0.5
ROOM_TTL_HOURS=3
```
### Variables de entorno - Frontend (.env)
```env
VITE_API_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
```
## Arquitectura
```
Trivy/
├── backend/
│ ├── app/
│ │ ├── api/ # Endpoints REST
│ │ │ ├── admin.py # Panel de administración
│ │ │ ├── auth.py # Autenticación
│ │ │ └── game.py # Datos del juego
│ │ ├── models/ # Modelos SQLAlchemy
│ │ ├── schemas/ # Schemas Pydantic
│ │ ├── services/ # Lógica de negocio
│ │ │ ├── room_manager.py # Gestión de salas (Redis)
│ │ │ ├── game_manager.py # Lógica del juego
│ │ │ ├── ai_validator.py # Validación con Claude
│ │ │ └── question_service.py # Carga de preguntas
│ │ ├── sockets/ # Eventos WebSocket
│ │ │ └── game_events.py
│ │ ├── config.py # Configuración
│ │ └── main.py # Aplicación ASGI
│ ├── alembic/ # Migraciones
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── components/ # Componentes React
│ │ │ ├── chat/ # Chat y reacciones
│ │ │ ├── game/ # Componentes del juego
│ │ │ ├── lobby/ # Sala de espera
│ │ │ └── ui/ # Componentes UI genéricos
│ │ ├── hooks/ # Custom hooks
│ │ │ ├── useSocket.ts
│ │ │ └── useSound.ts
│ │ ├── pages/ # Páginas/rutas
│ │ ├── services/ # Servicios (socket)
│ │ ├── stores/ # Estado Zustand
│ │ ├── themes/ # Temas visuales
│ │ └── types/ # Tipos TypeScript
│ ├── public/
│ │ └── sounds/ # Archivos de audio
│ └── package.json
└── docs/ # Documentación
```
## Flujo del Juego
```
1. CREAR SALA
Host → create_room → Código de 6 caracteres
2. UNIRSE
Jugadores → join_room(código) → Asignación a equipo A/B
3. INICIAR
Host → start_game → Backend carga 5 categorías aleatorias
4. TURNO
Jugador actual → Selecciona pregunta del tablero
5. RESPONDER
30 segundos para escribir respuesta
Claude valida si es correcta
6. ROBO (si falla)
Equipo contrario puede intentar robar
Si falla el robo, pierde puntos
7. SIGUIENTE TURNO
Rotación de jugadores y equipos
8. FIN
Cuando todas las preguntas están contestadas
Equipo con más puntos gana
```
## API REST
### Autenticación
- `POST /api/auth/login` - Login admin
- `POST /api/auth/logout` - Logout
### Juego
- `GET /api/game/categories` - Lista de categorías
- `GET /api/game/today-questions` - Preguntas del día
### Admin
- `GET /api/admin/questions` - Listar preguntas
- `POST /api/admin/questions` - Crear pregunta
- `PUT /api/admin/questions/{id}` - Actualizar pregunta
- `DELETE /api/admin/questions/{id}` - Eliminar pregunta
- `GET /api/admin/categories` - Listar categorías
- `GET /api/admin/rooms/active` - Salas activas
## Eventos WebSocket
### Cliente → Servidor
- `create_room` - Crear nueva sala
- `join_room` - Unirse a sala existente
- `start_game` - Iniciar partida (solo host)
- `select_question` - Seleccionar pregunta
- `submit_answer` - Enviar respuesta
- `steal_decision` - Decidir si robar
- `chat_message` - Mensaje de chat
- `team_message` - Mensaje de equipo
- `send_reaction` - Enviar emoji
### Servidor → Cliente
- `room_created` - Sala creada
- `player_joined` - Jugador se unió
- `game_started` - Juego iniciado
- `question_selected` - Pregunta seleccionada
- `answer_result` - Resultado de respuesta
- `steal_prompt` - Oportunidad de robo
- `game_finished` - Juego terminado
- `error` - Error
## Temas Disponibles
| Tema | Descripción |
|------|-------------|
| DRRR | Inspirado en Durarara!! - Oscuro con acentos amarillos |
| Retro Arcade | Estética arcade 80s - Neón sobre negro |
| Minimal | Diseño limpio y moderno - Blanco con acentos |
| Gaming RGB | Gradientes RGB animados - Estilo gamer |
| Anime 90s | Colores pasteles - Nostalgia anime |
## Categorías
1. **Nintendo** 🍄 - Mario, Zelda, Pokémon, etc.
2. **Xbox** 🎮 - Halo, Forza, Game Pass, etc.
3. **PlayStation** 🎯 - God of War, Uncharted, etc.
4. **Anime** ⛩️ - Naruto, One Piece, Dragon Ball, etc.
5. **Música** 🎵 - Artistas, géneros, historia musical
6. **Películas** 🎬 - Cine clásico y moderno
7. **Libros** 📚 - Literatura universal
8. **Historia** 🏛️ - Historia y cultura general
## Contribuir
1. Fork el repositorio
2. Crear rama feature (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -m 'feat: descripción'`)
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
## Licencia
MIT License - ver [LICENSE](LICENSE)
## Créditos
Desarrollado por Frank con asistencia de Claude (Anthropic)
---
<p align="center">
<strong>¡Que gane el mejor equipo!</strong> 🏆
</p>

45
backend/.env.example Normal file
View File

@@ -0,0 +1,45 @@
# ===========================================
# Trivy Backend - Configuration
# ===========================================
# Database
DATABASE_URL=postgresql+asyncpg://trivy:trivy@localhost:5432/trivy
# Redis
REDIS_URL=redis://localhost:6379
# Anthropic API (Required for answer validation)
ANTHROPIC_API_KEY=sk-ant-api03-YOUR_API_KEY_HERE
# ===========================================
# Game Settings
# ===========================================
# Penalty multiplier when failing a steal (0.5 = lose 50% of question points)
STEAL_PENALTY_MULTIPLIER=0.5
# Time multiplier for steal attempts (0.5 = half the original time)
STEAL_TIME_MULTIPLIER=0.5
# How long rooms stay in Redis (hours)
ROOM_TTL_HOURS=3
# ===========================================
# Security
# ===========================================
# CORS origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Secret key for sessions (generate with: openssl rand -hex 32)
SECRET_KEY=your-secret-key-here
# ===========================================
# Optional
# ===========================================
# Log level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# Enable SQL echo (for debugging)
SQL_ECHO=false

View File

@@ -37,6 +37,8 @@ class GameManager:
room["current_player_index"] = {"A": 0, "B": 0} room["current_player_index"] = {"A": 0, "B": 0}
room["board"] = board room["board"] = board
room["scores"] = {"A": 0, "B": 0} room["scores"] = {"A": 0, "B": 0}
room["current_round"] = 1
room["round1_categories"] = [int(cat_id) for cat_id in board.keys()]
await room_manager.update_room(room_code, room) await room_manager.update_room(room_code, room)
return room return room
@@ -134,6 +136,12 @@ class GameManager:
q["answered"] = True q["answered"] = True
break break
# Advance stealing team's player index (they had their turn)
team_players = room["teams"][stealing_team]
room["current_player_index"][stealing_team] = (
room["current_player_index"][stealing_team] + 1
) % len(team_players)
# Original team chooses next # Original team chooses next
room["current_team"] = "B" if stealing_team == "A" else "A" room["current_team"] = "B" if stealing_team == "A" else "A"
room["current_question"] = None room["current_question"] = None
@@ -141,18 +149,32 @@ class GameManager:
else: else:
# Original team failed - enable steal # Original team failed - enable steal
failed_team = room["current_team"]
room["can_steal"] = True 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) # Advance failed team's player index (they had their turn)
team_players = room["teams"][failed_team]
room["current_player_index"][failed_team] = (
room["current_player_index"][failed_team] + 1
) % len(team_players)
# Switch to other team for potential steal
room["current_team"] = "B" if failed_team == "A" else "A"
# Check if round is over (all questions answered)
all_answered = all( all_answered = all(
q["answered"] q["answered"]
for questions in room["board"].values() for questions in room["board"].values()
for q in questions for q in questions
) )
if all_answered: if all_answered:
room["status"] = "finished" current_round = room.get("current_round", 1)
if current_round == 1:
# Round 1 finished - need to start round 2
room["round_finished"] = True
else:
# Round 2 finished - game over
room["status"] = "finished"
await room_manager.update_room(room_code, room) await room_manager.update_room(room_code, room)
@@ -176,11 +198,31 @@ class GameManager:
q["answered"] = True q["answered"] = True
break break
# The team that passed on steal - advance their player index
passing_team = room["current_team"]
team_players = room["teams"][passing_team]
room["current_player_index"][passing_team] = (
room["current_player_index"][passing_team] + 1
) % len(team_players)
# Switch back to original team for next selection # Switch back to original team for next selection
room["current_team"] = "B" if room["current_team"] == "A" else "A" room["current_team"] = "B" if passing_team == "A" else "A"
room["current_question"] = None room["current_question"] = None
room["can_steal"] = False room["can_steal"] = False
# Check if round is over
all_answered = all(
q["answered"]
for questions in room["board"].values()
for q in questions
)
if all_answered:
current_round = room.get("current_round", 1)
if current_round == 1:
room["round_finished"] = True
else:
room["status"] = "finished"
await room_manager.update_room(room_code, room) await room_manager.update_room(room_code, room)
return room return room
@@ -197,6 +239,45 @@ class GameManager:
index = room["current_player_index"][team] index = room["current_player_index"][team]
return players[index % len(players)] return players[index % len(players)]
async def start_round_2(
self,
db: AsyncSession,
room_code: str
) -> Optional[dict]:
"""
Start round 2 with different categories and double points.
"""
room = await room_manager.get_room(room_code)
if not room:
return None
# Get categories used in round 1
round1_categories = room.get("round1_categories", [])
# Get new board excluding round 1 categories, with 2x points
new_board = await question_service.get_board_for_game(
db,
exclude_categories=round1_categories,
point_multiplier=2
)
if not new_board:
# Not enough categories for round 2 - end game
room["status"] = "finished"
await room_manager.update_room(room_code, room)
return room
# Update room for round 2
room["board"] = new_board
room["current_round"] = 2
room["round_finished"] = False
room["current_question"] = None
room["can_steal"] = False
# Keep current_team - winner of last question picks first
await room_manager.update_room(room_code, room)
return room
def calculate_timer_end(self, time_seconds: int, is_steal: bool = False) -> datetime: def calculate_timer_end(self, time_seconds: int, is_steal: bool = False) -> datetime:
"""Calculate when the timer should end.""" """Calculate when the timer should end."""
if is_steal: if is_steal:

View File

@@ -1,11 +1,15 @@
from typing import Optional, List, Dict from typing import Optional, List, Dict
from datetime import date from datetime import date
from sqlalchemy import select, and_ import random
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.question import Question from app.models.question import Question
from app.models.category import Category from app.models.category import Category
# Number of categories per game
CATEGORIES_PER_GAME = 5
class QuestionService: class QuestionService:
async def get_daily_questions( async def get_daily_questions(
@@ -61,19 +65,72 @@ class QuestionService:
async def get_board_for_game( async def get_board_for_game(
self, self,
db: AsyncSession, db: AsyncSession,
target_date: Optional[date] = None target_date: Optional[date] = None,
exclude_categories: Optional[List[int]] = None,
point_multiplier: int = 1
) -> Dict[str, List[dict]]: ) -> Dict[str, List[dict]]:
""" """
Genera el tablero 8×5 para el juego. Genera el tablero 5×5 para el juego.
Si no hay suficientes preguntas, retorna lo disponible. Selecciona 5 categorías aleatorias y 1 pregunta por dificultad.
Args:
db: Database session
target_date: Date for questions (default: today)
exclude_categories: Category IDs to exclude (for round 2)
point_multiplier: Multiply points by this value (for round 2)
Returns: Returns:
Dict con category_id como string (para JSON) -> lista de preguntas Dict con category_id como string (para JSON) -> lista de preguntas
""" """
board = await self.get_daily_questions(db, target_date) full_board = await self.get_daily_questions(db, target_date)
# Convertir keys a string para JSON if not full_board:
return {str(k): v for k, v in board.items()} return {}
# Get available category IDs that have questions
available_categories = list(full_board.keys())
# Exclude categories from previous round
if exclude_categories:
available_categories = [
c for c in available_categories if c not in exclude_categories
]
if not available_categories:
return {}
# Select random categories (up to CATEGORIES_PER_GAME)
num_categories = min(CATEGORIES_PER_GAME, len(available_categories))
selected_categories = random.sample(available_categories, num_categories)
# Build the game board with selected categories
game_board: Dict[str, List[dict]] = {}
for cat_id in selected_categories:
questions_by_difficulty: Dict[int, List[dict]] = {}
# Group questions by difficulty
for q in full_board[cat_id]:
diff = q["difficulty"]
if diff not in questions_by_difficulty:
questions_by_difficulty[diff] = []
questions_by_difficulty[diff].append(q)
# Select one random question per difficulty
selected_questions = []
for difficulty in range(1, 6): # 1-5
if difficulty in questions_by_difficulty:
questions = questions_by_difficulty[difficulty]
selected_q = random.choice(questions).copy()
# Apply point multiplier for round 2
if point_multiplier > 1:
selected_q["points"] = selected_q["points"] * point_multiplier
selected_questions.append(selected_q)
if selected_questions:
game_board[str(cat_id)] = selected_questions
return game_board
async def get_question_by_id( async def get_question_by_id(
self, self,

View File

@@ -1,12 +1,17 @@
import json import json
import random import random
import string import string
import asyncio
from typing import Optional from typing import Optional
import redis.asyncio as redis import redis.asyncio as redis
from app.config import get_settings from app.config import get_settings
settings = get_settings() settings = get_settings()
# Lock timeout in seconds
LOCK_TIMEOUT = 5
LOCK_RETRY_DELAY = 0.05 # 50ms
class RoomManager: class RoomManager:
def __init__(self): def __init__(self):
@@ -20,6 +25,31 @@ class RoomManager:
if self.redis: if self.redis:
await self.redis.close() await self.redis.close()
async def _acquire_lock(self, room_code: str, timeout: float = LOCK_TIMEOUT) -> bool:
"""Acquire a lock for room operations."""
await self.connect()
lock_key = f"lock:room:{room_code}"
# Try to acquire lock with NX (only if not exists) and EX (expire)
acquired = await self.redis.set(lock_key, "1", nx=True, ex=int(timeout))
return acquired is not None
async def _release_lock(self, room_code: str):
"""Release a room lock."""
await self.connect()
lock_key = f"lock:room:{room_code}"
await self.redis.delete(lock_key)
async def _with_lock(self, room_code: str, operation, max_retries: int = 20):
"""Execute an operation with a room lock."""
for attempt in range(max_retries):
if await self._acquire_lock(room_code):
try:
return await operation()
finally:
await self._release_lock(room_code)
await asyncio.sleep(LOCK_RETRY_DELAY)
raise Exception(f"Could not acquire lock for room {room_code}")
def _generate_room_code(self) -> str: def _generate_room_code(self) -> str:
"""Generate a 6-character room code.""" """Generate a 6-character room code."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
@@ -59,9 +89,9 @@ class RoomManager:
) )
# Add player to room # Add player to room
await self.add_player(room_code, player_name, "A", socket_id) room = await self.add_player(room_code, player_name, "A", socket_id)
return room_state return room
async def get_room(self, room_code: str) -> Optional[dict]: async def get_room(self, room_code: str) -> Optional[dict]:
"""Get room state by code.""" """Get room state by code."""
@@ -88,78 +118,160 @@ class RoomManager:
team: str, team: str,
socket_id: str socket_id: str
) -> Optional[dict]: ) -> Optional[dict]:
"""Add a player to a room.""" """Add a player to a room (with lock to prevent race conditions)."""
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() await self.connect()
# Get player info async def _do_add_player():
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
try:
return await self._with_lock(room_code, _do_add_player)
except Exception as e:
print(f"Error adding player: {e}")
return None
async def remove_player(self, socket_id: str) -> Optional[dict]:
"""Remove a player from their room (with lock)."""
await self.connect()
# Get player info first (outside lock)
player_data = await self.redis.get(f"player:{socket_id}") player_data = await self.redis.get(f"player:{socket_id}")
if not player_data: if not player_data:
return None return None
player_info = json.loads(player_data) player_info = json.loads(player_data)
room_code = player_info["room"] room_code = player_info["room"]
team = player_info["team"]
# Get room async def _do_remove_player():
room = await self.get_room(room_code) # Get room
if not room: room = await self.get_room(room_code)
if not room:
return None
# Remove player from both teams (in case of inconsistency)
for t in ["A", "B"]:
room["teams"][t] = [
p for p in room["teams"][t] if p["socket_id"] != socket_id
]
# Update positions
for i, p in enumerate(room["teams"][t]):
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
try:
return await self._with_lock(room_code, _do_remove_player)
except Exception as e:
print(f"Error removing player: {e}")
return None return None
# Remove player from team async def change_player_team(
room["teams"][team] = [ self,
p for p in room["teams"][team] if p["socket_id"] != socket_id room_code: str,
] player_name: str,
socket_id: str,
new_team: str
) -> Optional[dict]:
"""Change a player's team (with lock)."""
await self.connect()
# Update positions async def _do_change_team():
for i, p in enumerate(room["teams"][team]): room = await self.get_room(room_code)
p["position"] = i if not room:
return None
# Delete player mapping # Find current team
await self.redis.delete(f"player:{socket_id}") current_team = None
for t in ["A", "B"]:
for p in room["teams"][t]:
if p["name"] == player_name:
current_team = t
break
if current_team:
break
# If room is empty, delete it # If already on target team, just return room
if not room["teams"]["A"] and not room["teams"]["B"]: if current_team == new_team:
await self.redis.delete(f"room:{room_code}") return room
# Check if target team is full
if len(room["teams"][new_team]) >= 4:
return None
# Remove from both teams (safety)
for t in ["A", "B"]:
room["teams"][t] = [p for p in room["teams"][t] if p["name"] != player_name]
# Add to new team
player = {
"name": player_name,
"team": new_team,
"position": len(room["teams"][new_team]),
"socket_id": socket_id
}
room["teams"][new_team].append(player)
# Update positions in both teams
for t in ["A", "B"]:
for i, p in enumerate(room["teams"][t]):
p["position"] = i
# Update player record
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps({"name": player_name, "room": room_code, "team": new_team})
)
await self.update_room(room_code, room)
return room
try:
return await self._with_lock(room_code, _do_change_team)
except Exception as e:
print(f"Error changing team: {e}")
return None return None
await self.update_room(room_code, room)
return room
async def get_player(self, socket_id: str) -> Optional[dict]: async def get_player(self, socket_id: str) -> Optional[dict]:
"""Get player info by socket ID.""" """Get player info by socket ID."""
await self.connect() await self.connect()
@@ -168,6 +280,23 @@ class RoomManager:
return json.loads(data) return json.loads(data)
return None return None
async def update_player(self, socket_id: str, updates: dict) -> Optional[dict]:
"""Update player info."""
await self.connect()
data = await self.redis.get(f"player:{socket_id}")
if not data:
return None
player = json.loads(data)
player.update(updates)
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps(player)
)
return player
async def get_player_stats(self, room_code: str, player_name: str) -> Optional[dict]: async def get_player_stats(self, room_code: str, player_name: str) -> Optional[dict]:
"""Obtiene stats de un jugador.""" """Obtiene stats de un jugador."""
await self.connect() await self.connect()

View File

@@ -1,5 +1,6 @@
import socketio import socketio
import time import time
import json
from datetime import datetime from datetime import datetime
from app.services.room_manager import room_manager from app.services.room_manager import room_manager
from app.services.game_manager import game_manager from app.services.game_manager import game_manager
@@ -50,7 +51,7 @@ def register_socket_events(sio: socketio.AsyncServer):
await room_manager.init_player_stats(room["code"], player_name) await room_manager.init_player_stats(room["code"], player_name)
# Join socket room # Join socket room
sio.enter_room(sid, room["code"]) await sio.enter_room(sid, room["code"])
await sio.emit("room_created", {"room": room}, to=sid) await sio.emit("room_created", {"room": room}, to=sid)
@@ -75,11 +76,110 @@ def register_socket_events(sio: socketio.AsyncServer):
await room_manager.init_player_stats(room_code, player_name) await room_manager.init_player_stats(room_code, player_name)
# Join socket room # Join socket room
sio.enter_room(sid, room_code) await sio.enter_room(sid, room_code)
# Notify all players # Notify all players
await sio.emit("player_joined", {"room": room}, room=room_code) await sio.emit("player_joined", {"room": room}, room=room_code)
@sio.event
async def rejoin_room(sid, data):
"""Rejoin an existing room after disconnect/refresh."""
room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "")
team = data.get("team", "A")
if not room_code or not player_name:
await sio.emit(
"rejoin_failed",
{"message": "Missing room code or player name"},
to=sid
)
return
room = await room_manager.get_room(room_code)
if not room:
await sio.emit(
"rejoin_failed",
{"message": "Room not found or expired"},
to=sid
)
return
# Check if player was in this room (by name)
player_found = False
player_team = None
for t in ["A", "B"]:
for i, p in enumerate(room["teams"][t]):
if p["name"] == player_name:
# Update socket_id for this player
room["teams"][t][i]["socket_id"] = sid
player_found = True
player_team = t
break
if player_found:
break
if not player_found:
# Player not found, try to add them back to their preferred team
if len(room["teams"][team]) >= 4:
# Try other team
other_team = "B" if team == "A" else "A"
if len(room["teams"][other_team]) >= 4:
await sio.emit(
"rejoin_failed",
{"message": "Room is full"},
to=sid
)
return
team = other_team
room["teams"][team].append({
"name": player_name,
"team": team,
"position": len(room["teams"][team]),
"socket_id": sid
})
player_team = team
# Update room and player records
await room_manager.update_room(room_code, room)
await room_manager.update_player(sid, {
"name": player_name,
"room": room_code,
"team": player_team
})
# Also set new player record if it doesn't exist
existing = await room_manager.get_player(sid)
if not existing:
await room_manager.redis.setex(
f"player:{sid}",
3600 * 3,
json.dumps({"name": player_name, "room": room_code, "team": player_team})
)
# Join socket room
await sio.enter_room(sid, room_code)
# Send current game state to rejoining player
await sio.emit(
"rejoin_success",
{
"room": room,
"player_name": player_name,
"team": player_team
},
to=sid
)
# Notify others that player reconnected
await sio.emit(
"player_reconnected",
{"player_name": player_name, "team": player_team, "room": room},
room=room_code,
skip_sid=sid
)
@sio.event @sio.event
async def change_team(sid, data): async def change_team(sid, data):
"""Switch player to another team.""" """Switch player to another team."""
@@ -90,8 +190,12 @@ def register_socket_events(sio: socketio.AsyncServer):
room_code = player["room"] room_code = player["room"]
new_team = data.get("team") new_team = data.get("team")
room = await room_manager.get_room(room_code) # Use room_manager method with lock to prevent race conditions
if not room or len(room["teams"][new_team]) >= 4: room = await room_manager.change_player_team(
room_code, player["name"], sid, new_team
)
if not room:
await sio.emit( await sio.emit(
"error", "error",
{"message": "Cannot change team. It may be full."}, {"message": "Cannot change team. It may be full."},
@@ -99,21 +203,6 @@ def register_socket_events(sio: socketio.AsyncServer):
) )
return 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) await sio.emit("team_changed", {"room": room}, room=room_code)
@sio.event @sio.event
@@ -147,13 +236,18 @@ def register_socket_events(sio: socketio.AsyncServer):
) )
return return
# Get board from data or generate # Load board from database and start game
board = data.get("board", {}) async with await get_db_session() as db:
updated_room = await game_manager.start_game_with_db(db, room_code)
updated_room = await game_manager.start_game(room_code, board)
if updated_room: if updated_room:
await sio.emit("game_started", {"room": updated_room}, room=room_code) await sio.emit("game_started", {"room": updated_room}, room=room_code)
else:
await sio.emit(
"error",
{"message": "No hay preguntas disponibles para hoy. Contacta al administrador."},
to=sid
)
@sio.event @sio.event
async def select_question(sid, data): async def select_question(sid, data):
@@ -264,9 +358,28 @@ def register_socket_events(sio: socketio.AsyncServer):
points_earned=result["points_earned"] points_earned=result["points_earned"]
) )
# Verificar si el juego termino (todas las preguntas respondidas) # Verificar si terminó la ronda o el juego
if room_data.get("status") == "finished": if room_data.get("round_finished"):
# Disparar finalizacion automatica # Ronda 1 terminada - iniciar ronda 2
async with await get_db_session() as db:
new_room = await game_manager.start_round_2(db, room_code)
if new_room:
if new_room.get("status") == "finished":
# No hay suficientes categorías para ronda 2
await finish_game_internal(room_code)
else:
# Emitir evento de nueva ronda
await sio.emit(
"round_started",
{
"room": new_room,
"round": 2,
"message": "¡Ronda 2! Puntos dobles"
},
room=room_code
)
elif room_data.get("status") == "finished":
# Juego terminado
await finish_game_internal(room_code) await finish_game_internal(room_code)
@sio.event @sio.event
@@ -301,6 +414,26 @@ def register_socket_events(sio: socketio.AsyncServer):
team=player["team"], team=player["team"],
question_id=question_id question_id=question_id
) )
# Verificar si terminó la ronda o el juego
if room.get("round_finished"):
async with await get_db_session() as db:
new_room = await game_manager.start_round_2(db, room_code)
if new_room:
if new_room.get("status") == "finished":
await finish_game_internal(room_code)
else:
await sio.emit(
"round_started",
{
"room": new_room,
"round": 2,
"message": "¡Ronda 2! Puntos dobles"
},
room=room_code
)
elif room.get("status") == "finished":
await finish_game_internal(room_code)
else: else:
# Will attempt steal - just notify, answer comes separately # Will attempt steal - just notify, answer comes separately
room = await room_manager.get_room(room_code) room = await room_manager.get_room(room_code)

View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Cron wrapper para generar preguntas diarias
# Ejecutar a medianoche: 0 0 * * * /root/Trivy/backend/scripts/cron_generate_questions.sh
set -e
SCRIPT_DIR="/root/Trivy/backend"
VENV_PATH="$SCRIPT_DIR/venv/bin/python3"
SCRIPT_PATH="$SCRIPT_DIR/scripts/generate_daily_questions.py"
LOG_FILE="/var/log/trivy-questions.log"
# Load environment variables
if [ -f "$SCRIPT_DIR/.env" ]; then
export $(grep -v '^#' "$SCRIPT_DIR/.env" | xargs)
fi
# Timestamp
echo "========================================" >> "$LOG_FILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Iniciando generación de preguntas" >> "$LOG_FILE"
# Run the script
cd "$SCRIPT_DIR"
$VENV_PATH "$SCRIPT_PATH" >> "$LOG_FILE" 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Generación completada exitosamente" >> "$LOG_FILE"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: La generación falló con código $EXIT_CODE" >> "$LOG_FILE"
fi
echo "" >> "$LOG_FILE"
exit $EXIT_CODE

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
Script para generar preguntas diarias automáticamente usando Claude API.
Ejecutar con cron a medianoche para generar preguntas del día siguiente.
"""
import asyncio
import sys
import os
from datetime import date, timedelta
from typing import List, Dict
import json
# Add backend to path
sys.path.insert(0, '/root/Trivy/backend')
import anthropic
from sqlalchemy import select
from app.models.base import get_async_session
from app.models.category import Category
from app.models.question import Question
# Configuration
QUESTIONS_PER_DIFFICULTY = 5 # 5 preguntas por cada dificultad
DIFFICULTIES = [1, 2, 3, 4, 5]
POINTS_MAP = {1: 100, 2: 200, 3: 300, 4: 400, 5: 500}
TIME_MAP = {1: 30, 2: 30, 3: 25, 4: 20, 5: 15}
# Get API key from environment
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
def get_difficulty_description(difficulty: int) -> str:
"""Get description for difficulty level."""
descriptions = {
1: "muy fácil, conocimiento básico que casi todos saben",
2: "fácil, conocimiento común para fans casuales",
3: "moderada, requiere conocimiento intermedio del tema",
4: "difícil, requiere conocimiento profundo del tema",
5: "muy difícil, solo expertos o super fans sabrían la respuesta"
}
return descriptions.get(difficulty, "moderada")
async def generate_questions_for_category(
client: anthropic.Anthropic,
category: Dict,
difficulty: int,
target_date: date,
existing_questions: List[str]
) -> List[Dict]:
"""Generate questions for a specific category and difficulty using Claude."""
difficulty_desc = get_difficulty_description(difficulty)
points = POINTS_MAP[difficulty]
time_seconds = TIME_MAP[difficulty]
# Build prompt
prompt = f"""Genera exactamente {QUESTIONS_PER_DIFFICULTY} preguntas de trivia sobre "{category['name']}" con dificultad {difficulty} ({difficulty_desc}).
REGLAS IMPORTANTES:
1. Las preguntas deben ser en español
2. Las respuestas deben ser cortas (1-4 palabras idealmente)
3. Incluye respuestas alternativas válidas cuando aplique
4. NO repitas estas preguntas existentes: {json.dumps(existing_questions[:20], ensure_ascii=False) if existing_questions else "ninguna"}
5. Cada pregunta debe tener un dato curioso relacionado
6. Las preguntas deben ser verificables y tener una respuesta objetiva correcta
Responde SOLO con un JSON array válido con esta estructura exacta:
[
{{
"question_text": "¿Pregunta aquí?",
"correct_answer": "Respuesta correcta",
"alt_answers": ["alternativa1", "alternativa2"],
"fun_fact": "Dato curioso relacionado con la pregunta"
}}
]
Genera exactamente {QUESTIONS_PER_DIFFICULTY} preguntas diferentes y variadas sobre {category['name']}."""
try:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}]
)
# Extract JSON from response
response_text = response.content[0].text.strip()
# Try to find JSON array in response
start_idx = response_text.find('[')
end_idx = response_text.rfind(']') + 1
if start_idx == -1 or end_idx == 0:
print(f" ERROR: No se encontró JSON válido para {category['name']} dificultad {difficulty}")
return []
json_str = response_text[start_idx:end_idx]
questions_data = json.loads(json_str)
# Format questions for database
formatted_questions = []
for q in questions_data:
formatted_questions.append({
"category_id": category['id'],
"question_text": q["question_text"],
"correct_answer": q["correct_answer"],
"alt_answers": q.get("alt_answers", []),
"difficulty": difficulty,
"points": points,
"time_seconds": time_seconds,
"date_active": target_date,
"status": "approved",
"fun_fact": q.get("fun_fact", "")
})
return formatted_questions
except json.JSONDecodeError as e:
print(f" ERROR JSON para {category['name']} dificultad {difficulty}: {e}")
return []
except Exception as e:
print(f" ERROR generando para {category['name']} dificultad {difficulty}: {e}")
return []
async def get_existing_questions(db, category_id: int) -> List[str]:
"""Get existing question texts to avoid duplicates."""
result = await db.execute(
select(Question.question_text).where(Question.category_id == category_id)
)
return [row[0] for row in result.fetchall()]
async def generate_daily_questions(target_date: date = None):
"""Main function to generate all daily questions."""
if not ANTHROPIC_API_KEY:
print("ERROR: ANTHROPIC_API_KEY no está configurada")
sys.exit(1)
if target_date is None:
# Generate for tomorrow by default
target_date = date.today() + timedelta(days=1)
print(f"=== Generando preguntas para {target_date} ===")
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
AsyncSessionLocal = get_async_session()
async with AsyncSessionLocal() as db:
# Get all categories
result = await db.execute(select(Category))
categories = result.scalars().all()
if not categories:
print("ERROR: No hay categorías en la base de datos")
return
print(f"Categorías encontradas: {len(categories)}")
total_generated = 0
for category in categories:
cat_dict = {"id": category.id, "name": category.name}
print(f"\n📁 Categoría: {category.name}")
# Get existing questions to avoid duplicates
existing = await get_existing_questions(db, category.id)
for difficulty in DIFFICULTIES:
print(f" Dificultad {difficulty}...", end=" ", flush=True)
questions = await generate_questions_for_category(
client, cat_dict, difficulty, target_date, existing
)
if questions:
# Insert into database
for q_data in questions:
question = Question(**q_data)
db.add(question)
existing.append(q_data["question_text"])
print(f"{len(questions)} preguntas")
total_generated += len(questions)
else:
print("✗ Error")
# Small delay to avoid rate limiting
await asyncio.sleep(1)
await db.commit()
print(f"\n=== COMPLETADO ===")
print(f"Total de preguntas generadas: {total_generated}")
print(f"Fecha activa: {target_date}")
async def check_existing_questions(target_date: date = None):
"""Check if questions already exist for target date."""
if target_date is None:
target_date = date.today() + timedelta(days=1)
AsyncSessionLocal = get_async_session()
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Question).where(Question.date_active == target_date)
)
existing = result.scalars().all()
return len(existing)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Generar preguntas diarias para Trivy")
parser.add_argument(
"--date",
type=str,
help="Fecha objetivo (YYYY-MM-DD). Default: mañana"
)
parser.add_argument(
"--force",
action="store_true",
help="Generar aunque ya existan preguntas para esa fecha"
)
parser.add_argument(
"--check",
action="store_true",
help="Solo verificar si existen preguntas"
)
args = parser.parse_args()
target_date = None
if args.date:
target_date = date.fromisoformat(args.date)
if args.check:
count = asyncio.run(check_existing_questions(target_date))
check_date = target_date or (date.today() + timedelta(days=1))
print(f"Preguntas para {check_date}: {count}")
sys.exit(0)
# Check if questions already exist
if not args.force:
count = asyncio.run(check_existing_questions(target_date))
if count > 0:
check_date = target_date or (date.today() + timedelta(days=1))
print(f"Ya existen {count} preguntas para {check_date}")
print("Usa --force para regenerar")
sys.exit(0)
asyncio.run(generate_daily_questions(target_date))

492
docs/API.md Normal file
View File

@@ -0,0 +1,492 @@
# API Reference - Trivy
## Base URL
```
http://localhost:8000/api
```
## Autenticación
El panel de administración usa autenticación básica HTTP.
```bash
# Ejemplo con curl
curl -u admin:admin123 http://localhost:8000/api/admin/questions
```
---
## Endpoints REST
### Game
#### GET /game/categories
Obtiene todas las categorías disponibles.
**Response:**
```json
[
{
"id": 1,
"name": "Nintendo",
"icon": "🍄",
"color": "#E60012"
},
{
"id": 2,
"name": "Xbox",
"icon": "🎮",
"color": "#107C10"
}
]
```
#### GET /game/today-questions
Obtiene las preguntas activas para hoy (sin respuestas).
**Response:**
```json
{
"1": [
{
"id": 1,
"category_id": 1,
"question_text": "¿En qué año se lanzó la NES?",
"difficulty": 1,
"points": 100,
"time_seconds": 15
}
]
}
```
---
### Admin - Preguntas
#### GET /admin/questions
Lista todas las preguntas.
**Query Parameters:**
- `status` (optional): `pending`, `approved`, `used`
- `category_id` (optional): ID de categoría
**Response:**
```json
[
{
"id": 1,
"category_id": 1,
"question_text": "¿En qué año se lanzó la NES?",
"correct_answer": "1983",
"alt_answers": ["1984"],
"difficulty": 1,
"points": 100,
"time_seconds": 15,
"date_active": "2024-01-26",
"status": "approved",
"fun_fact": "La NES salvó la industria del videojuego",
"created_at": "2024-01-25T10:00:00Z"
}
]
```
#### POST /admin/questions
Crea una nueva pregunta.
**Request Body:**
```json
{
"category_id": 1,
"question_text": "¿Cuál es el nombre del fontanero de Nintendo?",
"correct_answer": "Mario",
"alt_answers": ["Super Mario"],
"difficulty": 1,
"points": 100,
"time_seconds": 15,
"date_active": "2024-01-26",
"status": "approved",
"fun_fact": "Mario apareció por primera vez en Donkey Kong"
}
```
**Response:** `201 Created`
#### PUT /admin/questions/{id}
Actualiza una pregunta existente.
#### DELETE /admin/questions/{id}
Elimina una pregunta.
---
### Admin - Categorías
#### GET /admin/categories
Lista todas las categorías.
#### POST /admin/categories
Crea una nueva categoría.
**Request Body:**
```json
{
"name": "Ciencia",
"icon": "🔬",
"color": "#00BCD4"
}
```
---
### Admin - Salas
#### GET /admin/rooms/active
Obtiene las salas activas en Redis.
**Response:**
```json
[
{
"code": "ABC123",
"status": "playing",
"host": "Player1",
"teams": {
"A": [{"name": "Player1", "team": "A"}],
"B": [{"name": "Player2", "team": "B"}]
},
"scores": {"A": 500, "B": 300},
"current_team": "A"
}
]
```
---
## WebSocket Events
### Conexión
```javascript
import { io } from 'socket.io-client';
const socket = io('http://localhost:8000', {
transports: ['websocket', 'polling']
});
```
---
### Eventos Cliente → Servidor
#### create_room
Crea una nueva sala de juego.
```javascript
socket.emit('create_room', {
player_name: 'MiNombre'
});
```
#### join_room
Unirse a una sala existente.
```javascript
socket.emit('join_room', {
room_code: 'ABC123',
player_name: 'MiNombre',
team: 'A' // o 'B'
});
```
#### change_team
Cambiar de equipo en el lobby.
```javascript
socket.emit('change_team', {
team: 'B'
});
```
#### start_game
Iniciar el juego (solo host).
```javascript
socket.emit('start_game', {});
```
#### select_question
Seleccionar una pregunta del tablero.
```javascript
socket.emit('select_question', {
question_id: 1,
category_id: 1
});
```
#### submit_answer
Enviar respuesta a la pregunta actual.
```javascript
socket.emit('submit_answer', {
answer: 'Mi respuesta',
question: { /* objeto pregunta */ },
is_steal: false
});
```
#### steal_decision
Decidir si intentar robar.
```javascript
socket.emit('steal_decision', {
attempt: true, // o false para pasar
question_id: 1,
answer: 'Mi respuesta' // solo si attempt=true
});
```
#### chat_message
Enviar mensaje al chat general.
```javascript
socket.emit('chat_message', {
message: 'Hola a todos!'
});
```
#### team_message
Enviar mensaje al chat de equipo.
```javascript
socket.emit('team_message', {
room_code: 'ABC123',
team: 'A',
player_name: 'MiNombre',
message: 'Mensaje privado del equipo'
});
```
#### send_reaction
Enviar reacción emoji.
```javascript
socket.emit('send_reaction', {
emoji: '🎉',
room_code: 'ABC123',
player_name: 'MiNombre'
});
```
---
### Eventos Servidor → Cliente
#### room_created
Confirmación de sala creada.
```javascript
socket.on('room_created', (data) => {
console.log(data.room.code); // 'ABC123'
});
```
**Payload:**
```json
{
"room": {
"code": "ABC123",
"status": "waiting",
"host": "Player1",
"teams": {"A": [...], "B": []},
"scores": {"A": 0, "B": 0}
}
}
```
#### player_joined
Un jugador se unió a la sala.
```javascript
socket.on('player_joined', (data) => {
console.log('Nuevo jugador:', data.room.teams);
});
```
#### player_left
Un jugador abandonó la sala.
```javascript
socket.on('player_left', (data) => {
console.log('Jugador salió');
});
```
#### game_started
El juego ha comenzado.
```javascript
socket.on('game_started', (data) => {
// data.room.board contiene el tablero con las preguntas
console.log('Categorías:', Object.keys(data.room.board));
});
```
**Payload:**
```json
{
"room": {
"status": "playing",
"board": {
"1": [
{
"id": 1,
"question_text": "...",
"difficulty": 1,
"points": 100,
"answered": false
}
]
},
"current_team": "A",
"current_player_index": {"A": 0, "B": 0}
}
}
```
#### question_selected
Se seleccionó una pregunta.
```javascript
socket.on('question_selected', (data) => {
console.log('Pregunta:', data.question_id);
});
```
#### answer_result
Resultado de una respuesta.
```javascript
socket.on('answer_result', (data) => {
if (data.valid) {
console.log('¡Correcto! +', data.points_earned);
} else {
console.log('Incorrecto:', data.reason);
}
});
```
**Payload:**
```json
{
"valid": true,
"reason": "Respuesta correcta",
"points_earned": 100,
"was_steal": false,
"room": { /* estado actualizado */ }
}
```
#### steal_prompt
Oportunidad de robo disponible.
```javascript
socket.on('steal_prompt', (data) => {
console.log('¡Puedes robar!');
});
```
#### game_finished
El juego ha terminado.
```javascript
socket.on('game_finished', (data) => {
console.log('Ganador:', data.winner);
console.log('Puntuación final:', data.room.scores);
});
```
#### new_reaction
Nueva reacción de un jugador.
```javascript
socket.on('new_reaction', (data) => {
console.log(data.player_name, 'reaccionó con', data.emoji);
});
```
#### team_message
Mensaje de chat de equipo.
```javascript
socket.on('team_message', (data) => {
console.log(`[Equipo] ${data.player_name}: ${data.message}`);
});
```
#### error
Error del servidor.
```javascript
socket.on('error', (data) => {
console.error('Error:', data.message);
});
```
---
## Códigos de Error
| Código | Mensaje | Descripción |
|--------|---------|-------------|
| `room_not_found` | Room not found | La sala no existe |
| `room_full` | Room is full | La sala está llena |
| `not_host` | Only the host can start | Solo el host puede iniciar |
| `need_players` | Both teams need players | Ambos equipos necesitan jugadores |
| `not_your_turn` | Not your turn | No es tu turno |
| `question_answered` | Question already answered | Pregunta ya contestada |
---
## Rate Limiting
- API REST: 100 requests/minuto por IP
- WebSocket: Sin límite específico (controlado por lógica de juego)
## Timeouts
- Preguntas: 15-35 segundos según dificultad
- Robo: 50% del tiempo original
- Sala inactiva: 3 horas (configurable)

374
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,374 @@
# Arquitectura de Trivy
## Visión General
Trivy utiliza una arquitectura de microservicios con comunicación en tiempo real mediante WebSockets.
```
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENTES │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Browser │ │ Browser │ │ Browser │ │ Browser │ │
│ │ React App│ │ React App│ │ React App│ │ React App│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └─────────────┴──────┬──────┴─────────────┘ │
│ │ │
│ WebSocket/HTTP │
└────────────────────────────┼─────────────────────────────────────────┘
┌────────────────────────────┼─────────────────────────────────────────┐
│ BACKEND │
│ │ │
│ ┌─────────────────────────▼─────────────────────────────┐ │
│ │ FastAPI + Socket.IO │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ REST API │ │ WebSocket │ │ ASGI │ │ │
│ │ │ Endpoints │ │ Events │ │ Middleware │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │ │
│ └─────────┼────────────────┼────────────────────────────┘ │
│ │ │ │
│ ┌─────────▼────────────────▼────────────────────────────┐ │
│ │ SERVICES │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Room │ │ Game │ │ AI │ │ │
│ │ │ Manager │ │ Manager │ │ Validator │ │ │
│ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │
│ └────────┼───────────────┼───────────────┼──────────────┘ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Redis │ │ PostgreSQL│ │ Claude │ │
│ │ (State) │ │ (Data) │ │ API │ │
│ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
## Componentes
### Frontend (React + TypeScript)
```
frontend/src/
├── components/ # Componentes reutilizables
│ ├── chat/ # EmojiReactions, ReactionOverlay, TeamChat
│ ├── game/ # Componentes del tablero
│ ├── lobby/ # Sala de espera
│ └── ui/ # SoundControl, botones, inputs
├── hooks/ # Custom hooks
│ ├── useSocket.ts # Gestión de WebSocket
│ └── useSound.ts # Gestión de audio
├── pages/ # Rutas principales
│ ├── Home.tsx # Crear/unir sala
│ ├── Lobby.tsx # Sala de espera
│ ├── Game.tsx # Tablero de juego
│ ├── Results.tsx # Resultados finales
│ └── admin/ # Panel de administración
├── services/
│ └── socket.ts # Singleton de Socket.IO
├── stores/ # Estado global (Zustand)
│ ├── gameStore.ts # Estado del juego
│ ├── themeStore.ts # Tema visual
│ └── soundStore.ts # Configuración de audio
├── themes/ # Definiciones de temas
└── types/ # Tipos TypeScript
```
### Backend (FastAPI + Python)
```
backend/app/
├── api/ # Endpoints REST
│ ├── admin.py # CRUD de preguntas/categorías
│ ├── auth.py # Autenticación admin
│ └── game.py # Datos públicos del juego
├── models/ # Modelos SQLAlchemy
│ ├── question.py # Preguntas
│ ├── category.py # Categorías
│ ├── admin.py # Administradores
│ └── game_session.py # Sesiones de juego
├── schemas/ # Schemas Pydantic
├── services/ # Lógica de negocio
│ ├── room_manager.py # Gestión de salas (Redis)
│ ├── game_manager.py # Lógica del juego
│ ├── question_service.py # Carga de preguntas
│ └── ai_validator.py # Validación con Claude
├── sockets/ # Eventos WebSocket
│ └── game_events.py # Todos los eventos del juego
├── config.py # Configuración
└── main.py # Punto de entrada ASGI
```
## Flujo de Datos
### 1. Creación de Sala
```
Cliente Backend Redis
│ │ │
│──── create_room ────────>│ │
│ │ │
│ │──── SETEX room:CODE ────>│
│ │ │
│ │<─── OK ─────────────────│
│ │ │
│<─── room_created ────────│ │
│ │ │
```
### 2. Unirse a Sala
```
Cliente Backend Redis
│ │ │
│──── join_room ──────────>│ │
│ │ │
│ │──── GET room:CODE ──────>│
│ │<─── room_data ──────────│
│ │ │
│ │──── SETEX (updated) ────>│
│ │ │
│<─── player_joined ───────│ │
│ │ │
│ │──── emit to room ───────>│ (otros clientes)
```
### 3. Inicio del Juego
```
Cliente Backend PostgreSQL
│ │ │
│──── start_game ─────────>│ │
│ │ │
│ │──── SELECT questions ───>│
│ │ (5 random cats) │
│ │<─── questions ──────────│
│ │ │
│ │──── SELECT 1 per diff ──>│
│ │<─── board data ─────────│
│ │ │
│<─── game_started ────────│ │
│ (with board) │ │
```
### 4. Responder Pregunta
```
Cliente Backend Claude API
│ │ │
│──── submit_answer ──────>│ │
│ │ │
│ │──── validate_answer ────>│
│ │ (question, answer) │
│ │ │
│ │<─── {valid, reason} ────│
│ │ │
│<─── answer_result ───────│ │
│ │ │
```
## Estado del Juego
### Redis (Estado en Tiempo Real)
```json
{
"room:ABC123": {
"code": "ABC123",
"status": "playing",
"host": "Player1",
"teams": {
"A": [{"name": "Player1", "team": "A", "socket_id": "..."}],
"B": [{"name": "Player2", "team": "B", "socket_id": "..."}]
},
"current_team": "A",
"current_player_index": {"A": 0, "B": 0},
"current_question": null,
"can_steal": false,
"scores": {"A": 500, "B": 300},
"board": {
"1": [/* preguntas categoría 1 */],
"3": [/* preguntas categoría 3 */]
}
},
"player:socket_id": {
"room": "ABC123",
"name": "Player1",
"team": "A"
}
}
```
### PostgreSQL (Datos Persistentes)
```sql
-- Categorías
categories (id, name, icon, color)
-- Preguntas (pool de 200)
questions (
id, category_id, question_text, correct_answer,
alt_answers[], difficulty, points, time_seconds,
date_active, status, fun_fact
)
-- Sesiones de juego (historial)
game_sessions (
id, room_code, status,
team_a_score, team_b_score,
questions_used[], created_at, finished_at
)
-- Eventos de juego (analytics)
game_events (
id, session_id, event_type, player_name,
question_id, data, created_at
)
-- Administradores
admins (id, username, password_hash)
```
## Validación de Respuestas con IA
El sistema usa Claude para validación flexible de respuestas:
```python
# ai_validator.py
async def validate_answer(
question: str,
correct_answer: str,
alt_answers: list,
player_answer: str
) -> dict:
prompt = f"""
Pregunta: {question}
Respuesta correcta: {correct_answer}
Respuestas alternativas: {alt_answers}
Respuesta del jugador: {player_answer}
¿La respuesta del jugador es correcta?
Considera sinónimos, abreviaciones, errores menores de ortografía.
Responde en JSON: {{"valid": bool, "reason": "explicación"}}
"""
response = await anthropic.messages.create(
model="claude-3-haiku-20240307",
messages=[{"role": "user", "content": prompt}]
)
return json.loads(response.content[0].text)
```
## Selección de Preguntas
Cada partida selecciona aleatoriamente:
1. **5 categorías** de las 8 disponibles
2. **1 pregunta por dificultad** de las 5 opciones disponibles
```python
# question_service.py
async def get_board_for_game(db, target_date):
# 1. Obtener todas las preguntas del día
full_board = await get_daily_questions(db, target_date)
# 2. Seleccionar 5 categorías aleatorias
selected_cats = random.sample(list(full_board.keys()), 5)
# 3. Para cada categoría, seleccionar 1 pregunta por dificultad
game_board = {}
for cat_id in selected_cats:
questions_by_diff = group_by_difficulty(full_board[cat_id])
selected = []
for diff in range(1, 6):
if diff in questions_by_diff:
selected.append(random.choice(questions_by_diff[diff]))
game_board[cat_id] = selected
return game_board
```
## Temas y Sonidos
### Estructura de Temas
```typescript
// themes/drrr.ts
export const drrrTheme: ThemeConfig = {
name: 'drrr',
displayName: 'DRRR Style',
colors: {
primary: '#FFD93D',
secondary: '#6BCB77',
accent: '#FF6B6B',
bg: '#1a1a2e',
text: '#EAEAEA',
textMuted: '#888888'
},
fonts: {
heading: '"Press Start 2P", monospace',
body: 'Inter, sans-serif'
}
}
```
### Estructura de Sonidos
```
public/sounds/
├── drrr/
│ ├── correct.mp3
│ ├── incorrect.mp3
│ ├── select.mp3
│ └── ...
├── retro/
├── minimal/
├── rgb/
└── anime/
```
Cada tema tiene sus propios archivos de audio, con fallback a sonidos generados por Web Audio API si no existen.
## Escalabilidad
### Horizontal Scaling
```
┌─────────────┐
│ NGINX │
│ (LB) │
└──────┬──────┘
┌─────────────────┼─────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Backend │ │ Backend │ │ Backend │
│ Node 1 │ │ Node 2 │ │ Node 3 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└────────────────┼────────────────┘
┌───────────┴───────────┐
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ Redis │ │ PostgreSQL│
│ Cluster │ │ Primary │
└───────────┘ └───────────┘
```
### Consideraciones
1. **Socket.IO con Redis Adapter** - Para sincronizar eventos entre múltiples instancias
2. **Sticky Sessions** - Para mantener conexiones WebSocket
3. **PostgreSQL Read Replicas** - Para queries de lectura
4. **Redis Cluster** - Para alta disponibilidad del estado
## Seguridad
1. **CORS** - Configurado para dominios permitidos
2. **Rate Limiting** - En endpoints REST
3. **Input Validation** - Pydantic schemas
4. **SQL Injection** - Prevenido por SQLAlchemy ORM
5. **XSS** - React escapa contenido por defecto
6. **WebSocket Auth** - Validación de sala/jugador en cada evento

469
docs/INSTALLATION.md Normal file
View File

@@ -0,0 +1,469 @@
# Guía de Instalación - Trivy
## Requisitos Previos
### Software Requerido
| Software | Versión Mínima | Notas |
|----------|----------------|-------|
| Python | 3.11+ | Con pip |
| Node.js | 18+ | Con npm |
| PostgreSQL | 15+ | O Docker |
| Redis | 7+ | O Docker |
| Git | 2.0+ | Para clonar |
### API Keys
- **Anthropic Claude API** - Obtener en https://console.anthropic.com
## Opción 1: Instalación con Docker (Recomendado)
### 1. Clonar repositorio
```bash
git clone https://gitea.local/frank/Trivy.git
cd Trivy
```
### 2. Configurar variables de entorno
```bash
# Backend
cp backend/.env.example backend/.env
# Editar backend/.env:
DATABASE_URL=postgresql+asyncpg://trivy:trivy@db:5432/trivy
REDIS_URL=redis://redis:6379
ANTHROPIC_API_KEY=sk-ant-api03-YOUR_KEY_HERE
# Frontend
cp frontend/.env.example frontend/.env
# Editar frontend/.env:
VITE_API_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
```
### 3. Iniciar con Docker Compose
```bash
docker-compose up -d
```
Esto iniciará:
- PostgreSQL en puerto 5432
- Redis en puerto 6379
- Backend en puerto 8000
- Frontend en puerto 3000
### 4. Ejecutar migraciones
```bash
docker-compose exec backend alembic upgrade head
```
### 5. Crear datos iniciales
```bash
docker-compose exec backend python scripts/seed_data.py
```
### 6. Acceder
- Frontend: http://localhost:3000
- Admin: http://localhost:3000/admin (admin/admin123)
- API: http://localhost:8000/docs
---
## Opción 2: Instalación Manual
### 1. Clonar repositorio
```bash
git clone https://gitea.local/frank/Trivy.git
cd Trivy
```
### 2. Configurar PostgreSQL
```bash
# Crear usuario y base de datos
sudo -u postgres psql
CREATE USER trivy WITH PASSWORD 'trivy';
CREATE DATABASE trivy OWNER trivy;
GRANT ALL PRIVILEGES ON DATABASE trivy TO trivy;
\q
```
### 3. Configurar Redis
```bash
# Ubuntu/Debian
sudo apt install redis-server
sudo systemctl start redis
sudo systemctl enable redis
# Verificar
redis-cli ping
# Respuesta: PONG
```
### 4. Configurar Backend
```bash
cd backend
# Crear entorno virtual
python3 -m venv venv
source venv/bin/activate
# Instalar dependencias
pip install -r requirements.txt
# Configurar variables de entorno
cp .env.example .env
```
Editar `backend/.env`:
```env
# Base de datos
DATABASE_URL=postgresql+asyncpg://trivy:trivy@localhost:5432/trivy
# Redis
REDIS_URL=redis://localhost:6379
# Anthropic API
ANTHROPIC_API_KEY=sk-ant-api03-YOUR_KEY_HERE
# Configuración del juego
STEAL_PENALTY_MULTIPLIER=0.5
STEAL_TIME_MULTIPLIER=0.5
ROOM_TTL_HOURS=3
# CORS (separar con comas)
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
```
### 5. Ejecutar migraciones
```bash
cd backend
source venv/bin/activate
alembic upgrade head
```
### 6. Crear usuario admin
```bash
python -c "
import asyncio
from app.models.base import get_async_session
from app.models.admin import Admin
async def create_admin():
AsyncSession = get_async_session()
async with AsyncSession() as db:
admin = Admin(username='admin')
admin.set_password('admin123')
db.add(admin)
await db.commit()
print('Admin creado: admin/admin123')
asyncio.run(create_admin())
"
```
### 7. Crear categorías
```bash
python -c "
import asyncio
from app.models.base import get_async_session
from app.models.category import Category
categories = [
('Nintendo', '🍄', '#E60012'),
('Xbox', '🎮', '#107C10'),
('PlayStation', '🎯', '#003791'),
('Anime', '⛩️', '#FF6B9D'),
('Música', '🎵', '#1DB954'),
('Películas', '🎬', '#F5C518'),
('Libros', '📚', '#8B4513'),
('Historia', '🏛️', '#6B5B95'),
]
async def create_categories():
AsyncSession = get_async_session()
async with AsyncSession() as db:
for name, icon, color in categories:
cat = Category(name=name, icon=icon, color=color)
db.add(cat)
await db.commit()
print(f'{len(categories)} categorías creadas')
asyncio.run(create_categories())
"
```
### 8. Crear preguntas (ejecutar script completo)
```bash
python scripts/seed_questions.py
```
### 9. Configurar Frontend
```bash
cd frontend
# Instalar dependencias
npm install
# Configurar variables de entorno
cp .env.example .env
```
Editar `frontend/.env`:
```env
VITE_API_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
```
### 10. Iniciar servicios
**Terminal 1 - Backend:**
```bash
cd backend
source venv/bin/activate
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000 --reload
```
**Terminal 2 - Frontend:**
```bash
cd frontend
npm run dev -- --host
```
### 11. Acceder
- Frontend: http://localhost:3000
- Admin: http://localhost:3000/admin
- API Docs: http://localhost:8000/docs
---
## Configuración para Red Local
Para permitir acceso desde otros dispositivos en la red:
### 1. Obtener IP local
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
# Ejemplo: 192.168.1.100
```
### 2. Actualizar configuración
**backend/.env:**
```env
CORS_ORIGINS=http://localhost:3000,http://192.168.1.100:3000
```
**frontend/.env:**
```env
VITE_API_URL=http://192.168.1.100:8000
VITE_WS_URL=http://192.168.1.100:8000
```
### 3. Iniciar con host 0.0.0.0
```bash
# Backend
uvicorn app.main:socket_app --host 0.0.0.0 --port 8000
# Frontend
npm run dev -- --host
```
### 4. Acceder desde otros dispositivos
- http://192.168.1.100:3000
---
## Producción
### Backend con Gunicorn
```bash
pip install gunicorn
gunicorn app.main:socket_app \
-w 4 \
-k uvicorn.workers.UvicornWorker \
-b 0.0.0.0:8000 \
--access-logfile - \
--error-logfile -
```
### Frontend Build
```bash
cd frontend
npm run build
# Servir con cualquier servidor estático
# Ejemplo con serve:
npm install -g serve
serve -s dist -l 3000
```
### Nginx como Reverse Proxy
```nginx
# /etc/nginx/sites-available/trivy
server {
listen 80;
server_name trivy.example.com;
# Frontend
location / {
root /var/www/trivy/frontend/dist;
try_files $uri $uri/ /index.html;
}
# API
location /api {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket
location /socket.io {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
### Systemd Service
```ini
# /etc/systemd/system/trivy-backend.service
[Unit]
Description=Trivy Backend
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/trivy/backend
Environment=PATH=/var/www/trivy/backend/venv/bin
ExecStart=/var/www/trivy/backend/venv/bin/gunicorn app.main:socket_app -w 4 -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable trivy-backend
sudo systemctl start trivy-backend
```
---
## Solución de Problemas
### Error: "Could not connect to Redis"
```bash
# Verificar Redis
redis-cli ping
# Si no responde, iniciar Redis
sudo systemctl start redis
```
### Error: "Database connection failed"
```bash
# Verificar PostgreSQL
sudo systemctl status postgresql
# Verificar credenciales
psql -U trivy -d trivy -h localhost
```
### Error: "CORS blocked"
Verificar que la URL del frontend esté en `CORS_ORIGINS` del backend.
### WebSocket no conecta
1. Verificar que `VITE_WS_URL` use `http://` (no `ws://`)
2. Verificar que el backend use `socket_app` (no `app`)
3. Verificar firewall/puertos
### Preguntas no cargan
```bash
# Verificar preguntas en BD
cd backend
source venv/bin/activate
python -c "
import asyncio
from sqlalchemy import select
from app.models.base import get_async_session
from app.models.question import Question
from datetime import date
async def check():
AsyncSession = get_async_session()
async with AsyncSession() as db:
result = await db.execute(
select(Question).where(Question.date_active == date.today())
)
qs = result.scalars().all()
print(f'Preguntas para hoy: {len(qs)}')
asyncio.run(check())
"
```
### Sonidos no funcionan
Los sonidos requieren archivos MP3 en `/public/sounds/{tema}/`. Si no existen, el sistema usa fallbacks generados por Web Audio API (tonos simples).
---
## Actualizaciones
```bash
cd Trivy
git pull origin main
# Backend
cd backend
source venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
# Frontend
cd frontend
npm install
npm run build
# Reiniciar servicios
sudo systemctl restart trivy-backend
```

View File

@@ -7,15 +7,12 @@ import { useThemeStyles } from '../../themes/ThemeProvider'
interface SoundControlProps { interface SoundControlProps {
/** Compact mode shows just the icon, expanded shows slider */ /** Compact mode shows just the icon, expanded shows slider */
compact?: boolean compact?: boolean
/** Position for the popup menu when in compact mode */
popupPosition?: 'top' | 'bottom' | 'left' | 'right'
/** Custom class name */ /** Custom class name */
className?: string className?: string
} }
export default function SoundControl({ export default function SoundControl({
compact = false, compact = false,
popupPosition = 'top',
className = '', className = '',
}: SoundControlProps) { }: SoundControlProps) {
const { volume, muted, setVolume, toggleMute } = useSoundStore() const { volume, muted, setVolume, toggleMute } = useSoundStore()
@@ -84,19 +81,6 @@ export default function SoundControl({
) )
} }
const getPopupStyles = () => {
switch (popupPosition) {
case 'top':
return 'bottom-full mb-2 left-1/2 -translate-x-1/2'
case 'bottom':
return 'top-full mt-2 left-1/2 -translate-x-1/2'
case 'left':
return 'right-full mr-2 top-1/2 -translate-y-1/2'
case 'right':
return 'left-full ml-2 top-1/2 -translate-y-1/2'
}
}
if (!compact) { if (!compact) {
return ( return (
<div <div
@@ -158,32 +142,37 @@ export default function SoundControl({
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className={`absolute ${getPopupStyles()} p-3 rounded-lg shadow-lg z-50`} className="absolute top-full mt-2 right-0 p-3 rounded-lg shadow-lg z-50"
style={{ style={{
backgroundColor: config.colors.bg, backgroundColor: config.colors.bg,
border: `1px solid ${config.colors.primary}`, border: `1px solid ${config.colors.primary}`,
}} }}
> >
<div className="flex flex-col items-center gap-2 min-w-[120px]"> <div className="flex flex-col items-center gap-2">
<button <button
onClick={handleToggleMute} onClick={handleToggleMute}
className="p-2 rounded-lg transition-colors hover:opacity-80" className="p-1 rounded-lg transition-colors hover:opacity-80"
style={{ color: config.colors.primary }} style={{ color: config.colors.primary }}
> >
{getVolumeIcon()} {getVolumeIcon()}
</button> </button>
<input {/* Vertical slider container */}
type="range" <div className="relative h-24 w-6 flex items-center justify-center">
min="0" <input
max="1" type="range"
step="0.05" min="0"
value={muted ? 0 : volume} max="1"
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))} step="0.05"
className="w-full h-2 rounded-lg appearance-none cursor-pointer" value={muted ? 0 : volume}
style={{ onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
background: `linear-gradient(to right, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`, className="h-20 w-2 rounded-lg appearance-none cursor-pointer"
}} style={{
/> writingMode: 'vertical-lr',
direction: 'rtl',
background: `linear-gradient(to top, ${config.colors.primary} 0%, ${config.colors.primary} ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 ${(muted ? 0 : volume) * 100}%, ${config.colors.text}30 100%)`,
}}
/>
</div>
<span <span
className="text-xs" className="text-xs"
style={{ color: config.colors.textMuted }} style={{ color: config.colors.textMuted }}

View File

@@ -1,14 +1,12 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { io, Socket } from 'socket.io-client' import { useGameStore, saveSession, clearSession, saveGameResult } from '../stores/gameStore'
import { useGameStore } from '../stores/gameStore'
import { soundPlayer } from './useSound' import { soundPlayer } from './useSound'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import { useSoundStore } from '../stores/soundStore' import { useSoundStore } from '../stores/soundStore'
import { socketService } from '../services/socket'
import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types' import type { GameRoom, ChatMessage, AnswerResult, Achievement } from '../types'
import type { Reaction } from '../stores/gameStore' import type { Reaction } from '../stores/gameStore'
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
// Team message type // Team message type
export interface TeamMessage { export interface TeamMessage {
player_name: string player_name: string
@@ -18,8 +16,7 @@ export interface TeamMessage {
} }
export function useSocket() { export function useSocket() {
const socketRef = useRef<Socket | null>(null) const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setGameResult, addReaction, addTeamMessage } =
const { setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage } =
useGameStore() useGameStore()
// Initialize sound player with current theme // Initialize sound player with current theme
@@ -27,23 +24,16 @@ export function useSocket() {
soundPlayer.loadTheme(currentTheme) soundPlayer.loadTheme(currentTheme)
useEffect(() => { useEffect(() => {
// Create socket connection // Get singleton socket connection
socketRef.current = io(SOCKET_URL, { const socket = socketService.connect()
transports: ['websocket', 'polling'],
autoConnect: true,
})
const socket = socketRef.current // Only set up listeners once globally
if (socketService.isInitialized) {
// Connection events return // No cleanup - socket persists
socket.on('connect', () => { }
console.log('Connected to server') socketService.setInitialized()
})
socket.on('disconnect', () => {
console.log('Disconnected from server')
})
// Error handler
socket.on('error', (data: { message: string }) => { socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message) console.error('Socket error:', data.message)
}) })
@@ -55,6 +45,9 @@ export function useSocket() {
socket.on('player_joined', (data: { room: GameRoom }) => { socket.on('player_joined', (data: { room: GameRoom }) => {
setRoom(data.room) setRoom(data.room)
// Play sound when a player joins
const volume = useSoundStore.getState().volume
soundPlayer.play('player_join', volume)
}) })
socket.on('player_left', (data: { room: GameRoom }) => { socket.on('player_left', (data: { room: GameRoom }) => {
@@ -65,14 +58,52 @@ export function useSocket() {
setRoom(data.room) setRoom(data.room)
}) })
// Reconnection events
socket.on('rejoin_success', (data: { room: GameRoom; player_name: string; team: 'A' | 'B' }) => {
console.log('Rejoin successful:', data.player_name)
setRoom(data.room)
useGameStore.getState().setPlayerName(data.player_name)
// Update saved session with possibly new team
saveSession(data.room.code, data.player_name, data.team)
})
socket.on('rejoin_failed', (data: { message: string }) => {
console.log('Rejoin failed:', data.message)
clearSession()
})
socket.on('player_reconnected', (data: { player_name: string; team: string; room: GameRoom }) => {
console.log('Player reconnected:', data.player_name)
setRoom(data.room)
})
// Game events // Game events
socket.on('game_started', (data: { room: GameRoom }) => { socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room) setRoom(data.room)
// Play game start sound
const volume = useSoundStore.getState().volume
soundPlayer.play('game_start', volume)
})
socket.on('round_started', (data: { room: GameRoom; round: number; message: string }) => {
setRoom(data.room)
setCurrentQuestion(null)
// Play sound for new round
const volume = useSoundStore.getState().volume
soundPlayer.play('game_start', volume)
}) })
socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => { socket.on('question_selected', (data: { room: GameRoom; question_id: number }) => {
setRoom(data.room) setRoom(data.room)
// Fetch full question details // Find the question in the board and set it as current
const questionId = data.question_id
for (const categoryQuestions of Object.values(data.room.board || {})) {
const question = (categoryQuestions as Array<{ id: number }>).find(q => q.id === questionId)
if (question) {
setCurrentQuestion(question as unknown as import('../types').Question)
break
}
}
}) })
socket.on('answer_result', (data: AnswerResult) => { socket.on('answer_result', (data: AnswerResult) => {
@@ -82,12 +113,17 @@ export function useSocket() {
const volume = useSoundStore.getState().volume const volume = useSoundStore.getState().volume
if (data.valid) { if (data.valid) {
soundPlayer.play('correct', volume) soundPlayer.play('correct', volume)
// Clear current question after correct answer
setCurrentQuestion(null)
} else { } else {
soundPlayer.play('incorrect', volume) soundPlayer.play('incorrect', volume)
} }
if (!data.valid && !data.was_steal && data.room.can_steal) { if (!data.valid && !data.was_steal && data.room.can_steal) {
setShowStealPrompt(true) setShowStealPrompt(true)
} else if (data.was_steal) {
// Clear question after steal attempt (success or fail)
setCurrentQuestion(null)
} }
}) })
@@ -103,6 +139,7 @@ export function useSocket() {
socket.on('steal_passed', (data: { room: GameRoom }) => { socket.on('steal_passed', (data: { room: GameRoom }) => {
setRoom(data.room) setRoom(data.room)
setShowStealPrompt(false) setShowStealPrompt(false)
setCurrentQuestion(null)
}) })
socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => { socket.on('time_up', (data: { room: GameRoom; was_steal: boolean }) => {
@@ -111,6 +148,7 @@ export function useSocket() {
setShowStealPrompt(true) setShowStealPrompt(true)
} else { } else {
setShowStealPrompt(false) setShowStealPrompt(false)
setCurrentQuestion(null)
} }
}) })
@@ -163,7 +201,7 @@ export function useSocket() {
soundPlayer.play('defeat', volume) soundPlayer.play('defeat', volume)
} }
setGameResult({ const gameResultData = {
winner: data.winner, winner: data.winner,
finalScores: data.final_scores, finalScores: data.final_scores,
replayCode: data.replay_code, replayCode: data.replay_code,
@@ -172,21 +210,27 @@ export function useSocket() {
team: a.team, team: a.team,
achievement: a.achievement as Achievement achievement: a.achievement as Achievement
})) }))
}
setGameResult(gameResultData)
// Persist game result to localStorage
saveGameResult({
...gameResultData,
roomCode: data.room.code
}) })
}) })
return () => { // No cleanup - socket connection persists across components
socket.disconnect() }, [])
}
}, [setRoom, addMessage, setShowStealPrompt, setCurrentQuestion, setTimerEnd, setGameResult, addReaction, addTeamMessage])
// Socket methods // Socket methods - use singleton service
const createRoom = useCallback((playerName: string) => { const createRoom = useCallback((playerName: string) => {
socketRef.current?.emit('create_room', { player_name: playerName }) socketService.emit('create_room', { player_name: playerName })
}, []) }, [])
const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => { const joinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketRef.current?.emit('join_room', { socketService.emit('join_room', {
room_code: roomCode, room_code: roomCode,
player_name: playerName, player_name: playerName,
team, team,
@@ -194,15 +238,15 @@ export function useSocket() {
}, []) }, [])
const changeTeam = useCallback((team: 'A' | 'B') => { const changeTeam = useCallback((team: 'A' | 'B') => {
socketRef.current?.emit('change_team', { team }) socketService.emit('change_team', { team })
}, []) }, [])
const startGame = useCallback((board: Record<string, unknown>) => { const startGame = useCallback((board: Record<string, unknown>) => {
socketRef.current?.emit('start_game', { board }) socketService.emit('start_game', { board })
}, []) }, [])
const selectQuestion = useCallback((questionId: number, categoryId: number) => { const selectQuestion = useCallback((questionId: number, categoryId: number) => {
socketRef.current?.emit('select_question', { socketService.emit('select_question', {
question_id: questionId, question_id: questionId,
category_id: categoryId, category_id: categoryId,
}) })
@@ -210,7 +254,7 @@ export function useSocket() {
const submitAnswer = useCallback( const submitAnswer = useCallback(
(answer: string, question: Record<string, unknown>, isSteal: boolean = false) => { (answer: string, question: Record<string, unknown>, isSteal: boolean = false) => {
socketRef.current?.emit('submit_answer', { socketService.emit('submit_answer', {
answer, answer,
question, question,
is_steal: isSteal, is_steal: isSteal,
@@ -220,7 +264,7 @@ export function useSocket() {
) )
const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => { const stealDecision = useCallback((attempt: boolean, questionId: number, answer?: string) => {
socketRef.current?.emit('steal_decision', { socketService.emit('steal_decision', {
attempt, attempt,
question_id: questionId, question_id: questionId,
answer, answer,
@@ -228,15 +272,15 @@ export function useSocket() {
}, []) }, [])
const sendChatMessage = useCallback((message: string) => { const sendChatMessage = useCallback((message: string) => {
socketRef.current?.emit('chat_message', { message }) socketService.emit('chat_message', { message })
}, []) }, [])
const sendEmojiReaction = useCallback((emoji: string) => { const sendEmojiReaction = useCallback((emoji: string) => {
socketRef.current?.emit('emoji_reaction', { emoji }) socketService.emit('emoji_reaction', { emoji })
}, []) }, [])
const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => { const sendReaction = useCallback((emoji: string, roomCode?: string, playerName?: string) => {
socketRef.current?.emit('send_reaction', { socketService.emit('send_reaction', {
emoji, emoji,
room_code: roomCode, room_code: roomCode,
player_name: playerName, player_name: playerName,
@@ -245,7 +289,7 @@ export function useSocket() {
const sendTeamMessage = useCallback( const sendTeamMessage = useCallback(
(message: string, roomCode: string, team: 'A' | 'B', playerName: string) => { (message: string, roomCode: string, team: 'A' | 'B', playerName: string) => {
socketRef.current?.emit('team_message', { socketService.emit('team_message', {
room_code: roomCode, room_code: roomCode,
team, team,
player_name: playerName, player_name: playerName,
@@ -256,13 +300,22 @@ export function useSocket() {
) )
const notifyTimerExpired = useCallback(() => { const notifyTimerExpired = useCallback(() => {
socketRef.current?.emit('timer_expired', {}) socketService.emit('timer_expired', {})
}, [])
const rejoinRoom = useCallback((roomCode: string, playerName: string, team: 'A' | 'B') => {
socketService.emit('rejoin_room', {
room_code: roomCode,
player_name: playerName,
team,
})
}, []) }, [])
return { return {
socket: socketRef.current, socket: socketService.connect(),
createRoom, createRoom,
joinRoom, joinRoom,
rejoinRoom,
changeTeam, changeTeam,
startGame, startGame,
selectQuestion, selectQuestion,

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useSocket } from '../hooks/useSocket' import { useSocket } from '../hooks/useSocket'
@@ -11,16 +11,23 @@ import TeamChat from '../components/chat/TeamChat'
import SoundControl from '../components/ui/SoundControl' import SoundControl from '../components/ui/SoundControl'
import type { Question } from '../types' import type { Question } from '../types'
const categories = [ // All available categories with their styling
{ id: 1, name: 'Nintendo', icon: '🍄', color: '#E60012' }, const allCategories: Record<number, { name: string; icon: string; color: string }> = {
{ id: 2, name: 'Xbox', icon: '🎮', color: '#107C10' }, 1: { name: 'Nintendo', icon: '🍄', color: '#E60012' },
{ id: 3, name: 'PlayStation', icon: '🎯', color: '#003791' }, 2: { name: 'Xbox', icon: '🎮', color: '#107C10' },
{ id: 4, name: 'Anime', icon: '⛩️', color: '#FF6B9D' }, 3: { name: 'PlayStation', icon: '🎯', color: '#003791' },
{ id: 5, name: 'Música', icon: '🎵', color: '#1DB954' }, 4: { name: 'Anime', icon: '⛩️', color: '#FF6B9D' },
{ id: 6, name: 'Películas', icon: '🎬', color: '#F5C518' }, 5: { name: 'Música', icon: '🎵', color: '#1DB954' },
{ id: 7, name: 'Libros', icon: '📚', color: '#8B4513' }, 6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
{ id: 8, name: 'Historia-Cultura', icon: '🏛️', color: '#6B5B95' }, 7: { name: 'Libros', icon: '📚', color: '#8B4513' },
] 8: { name: 'Historia', icon: '🏛️', color: '#6B5B95' },
9: { name: 'Series de TV', icon: '📺', color: '#E50914' },
10: { name: 'Marvel/DC', icon: '🦸', color: '#ED1D24' },
11: { name: 'Disney', icon: '🏰', color: '#113CCF' },
12: { name: 'Memes', icon: '🐸', color: '#7CFC00' },
13: { name: 'Pokémon', icon: '🔴', color: '#FFCB05' },
14: { name: 'Mitología', icon: '⚡', color: '#9B59B6' },
}
export default function Game() { export default function Game() {
useParams<{ roomCode: string }>() useParams<{ roomCode: string }>()
@@ -32,7 +39,7 @@ export default function Game() {
const [answer, setAnswer] = useState('') const [answer, setAnswer] = useState('')
const [timeLeft, setTimeLeft] = useState(0) const [timeLeft, setTimeLeft] = useState(0)
const [showingQuestion, setShowingQuestion] = useState(false) const [hoveredCell, setHoveredCell] = useState<string | null>(null)
// Redirect if game finished // Redirect if game finished
useEffect(() => { useEffect(() => {
@@ -41,9 +48,16 @@ export default function Game() {
} }
}, [room?.status, room?.code, navigate]) }, [room?.status, room?.code, navigate])
// Play sound when question is revealed
useEffect(() => {
if (currentQuestion) {
play('question_reveal')
}
}, [currentQuestion, play])
// Timer logic with sound effects // Timer logic with sound effects
useEffect(() => { useEffect(() => {
if (!currentQuestion || !showingQuestion) return if (!currentQuestion) return
setTimeLeft(currentQuestion.time_seconds) setTimeLeft(currentQuestion.time_seconds)
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -52,11 +66,11 @@ export default function Game() {
clearInterval(interval) clearInterval(interval)
return 0 return 0
} }
// Play urgent sound when time is running low (5 seconds or less) if (prev <= 4 && prev > 1) {
if (prev <= 6 && prev > 1) { play('countdown')
} else if (prev <= 6 && prev > 4) {
play('timer_urgent') play('timer_urgent')
} else if (prev > 6) { } else if (prev > 6) {
// Play tick sound for normal countdown
play('timer_tick') play('timer_tick')
} }
return prev - 1 return prev - 1
@@ -64,12 +78,25 @@ export default function Game() {
}, 1000) }, 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [currentQuestion, showingQuestion, play]) }, [currentQuestion, play])
// Hover sound handler
const handleCellHover = useCallback((cellId: string, canSelect: boolean) => {
if (canSelect && hoveredCell !== cellId) {
setHoveredCell(cellId)
play('hover')
}
}, [hoveredCell, play])
if (!room) { if (!room) {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}> <div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>Cargando...</p> <motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-8 h-8 border-4 rounded-full"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
</div> </div>
) )
} }
@@ -85,164 +112,398 @@ export default function Game() {
if (!amICurrentPlayer || question.answered) return if (!amICurrentPlayer || question.answered) return
play('select') play('select')
selectQuestion(question.id, categoryId) selectQuestion(question.id, categoryId)
setShowingQuestion(true)
} }
const handleSubmitAnswer = () => { const handleSubmitAnswer = () => {
if (!currentQuestion || !answer.trim()) return if (!currentQuestion || !answer.trim()) return
submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal) submitAnswer(answer, currentQuestion as unknown as Record<string, unknown>, room.can_steal)
setAnswer('') setAnswer('')
setShowingQuestion(false)
} }
const handleStealDecision = (attempt: boolean) => { const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return if (!currentQuestion) return
if (attempt) { if (attempt) {
setShowingQuestion(true) // Notify server that we're attempting to steal
stealDecision(true, currentQuestion.id)
// Keep the question modal open for the steal attempt
// The modal is already controlled by currentQuestion state
} else { } else {
// Pass on steal
stealDecision(false, currentQuestion.id) stealDecision(false, currentQuestion.id)
} }
setShowStealPrompt(false) setShowStealPrompt(false)
} }
// Handler for sending team messages
const handleSendTeamMessage = (message: string) => { const handleSendTeamMessage = (message: string) => {
if (room && playerName && myTeam) { if (room && playerName && myTeam) {
sendTeamMessage(message, room.code, myTeam, playerName) sendTeamMessage(message, room.code, myTeam, playerName)
} }
} }
// Determine if the game is active (playing status)
const isGameActive = room.status === 'playing' const isGameActive = room.status === 'playing'
const timerProgress = currentQuestion ? (timeLeft / currentQuestion.time_seconds) * 100 : 100
// Get active categories from the board (dynamic based on what backend sends)
const activeCategories = useMemo(() => {
if (!room.board) return []
return Object.keys(room.board).map(id => ({
id: parseInt(id),
...allCategories[parseInt(id)] || { name: `Cat ${id}`, icon: '❓', color: '#666' }
}))
}, [room.board])
const numCategories = activeCategories.length || 5
return ( return (
<div className="min-h-screen p-4" style={styles.bgPrimary}> <div className="min-h-screen p-2 md:p-4 overflow-hidden" style={styles.bgPrimary}>
<div className="max-w-6xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Scoreboard */} {/* Header with Room Code and Round */}
<div className="flex justify-between items-center mb-6"> <div className="text-center mb-4">
<div <motion.h1
className="text-center px-6 py-2 rounded-lg" initial={{ y: -20, opacity: 0 }}
style={{ animate={{ y: 0, opacity: 1 }}
backgroundColor: config.colors.primary + '20', className="text-2xl md:text-3xl font-bold tracking-wider"
border: `2px solid ${config.colors.primary}`, style={{ color: config.colors.primary, fontFamily: config.fonts.heading }}
}}
> >
<div className="text-sm" style={styles.textSecondary}>Equipo A</div> TRIVY
<div className="text-3xl font-bold" style={{ color: config.colors.primary }}> </motion.h1>
{room.scores.A} <div className="flex items-center justify-center gap-3 text-xs" style={{ color: config.colors.textMuted }}>
</div> <span className="opacity-60">Sala: {room.code}</span>
</div> <span className="opacity-40">|</span>
<motion.span
<div className="text-center"> key={room.current_round}
<div className="text-sm" style={styles.textSecondary}> initial={{ scale: 1.5, color: config.colors.accent }}
Turno de {room.current_team === 'A' ? 'Equipo A' : 'Equipo B'} animate={{ scale: 1, color: config.colors.textMuted }}
</div> className="font-bold"
{amICurrentPlayer && ( style={{
<div className="text-lg font-bold" style={{ color: config.colors.accent }}> color: room.current_round === 2 ? config.colors.accent : config.colors.textMuted
¡Tu turno! }}
</div> >
)} Ronda {room.current_round || 1}
</div> {room.current_round === 2 && ' (x2)'}
</motion.span>
<div
className="text-center px-6 py-2 rounded-lg"
style={{
backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`,
}}
>
<div className="text-sm" style={styles.textSecondary}>Equipo B</div>
<div className="text-3xl font-bold" style={{ color: config.colors.secondary }}>
{room.scores.B}
</div>
</div> </div>
</div> </div>
{/* Game Board */} {/* Scoreboard */}
<div className="grid grid-cols-8 gap-2 mb-6"> <div className="flex justify-between items-stretch gap-4 mb-4">
{/* Category Headers */} {/* Team A Score */}
{categories.map((cat) => ( <motion.div
<div initial={{ x: -50, opacity: 0 }}
key={cat.id} animate={{ x: 0, opacity: 1 }}
className="text-center p-2 rounded-t-lg" className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
style={{ backgroundColor: cat.color }} room.current_team === 'A' ? 'ring-2 ring-offset-2' : ''
> }`}
<div className="text-2xl">{cat.icon}</div> style={{
<div className="text-xs text-white font-bold truncate">{cat.name}</div> background: `linear-gradient(135deg, ${config.colors.primary}30 0%, ${config.colors.primary}10 100%)`,
border: `3px solid ${config.colors.primary}`,
boxShadow: room.current_team === 'A' ? `0 0 20px ${config.colors.primary}50` : 'none',
'--tw-ring-color': config.colors.primary,
'--tw-ring-offset-color': config.colors.bg,
} as React.CSSProperties}
>
{room.current_team === 'A' && (
<motion.div
className="absolute inset-0"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ background: `radial-gradient(circle, ${config.colors.primary}20 0%, transparent 70%)` }}
/>
)}
<div className="relative z-10">
<div className="text-xs md:text-sm font-medium opacity-80" style={{ color: config.colors.primary }}>
EQUIPO A
</div>
<motion.div
key={room.scores.A}
initial={{ scale: 1.3 }}
animate={{ scale: 1 }}
className="text-3xl md:text-5xl font-black"
style={{ color: config.colors.primary, textShadow: `0 0 10px ${config.colors.primary}50` }}
>
{room.scores.A}
</motion.div>
<div className="text-xs opacity-60 mt-1" style={{ color: config.colors.textMuted }}>
{room.teams.A.map(p => p.name).join(', ')}
</div>
</div> </div>
))} </motion.div>
{/* Turn Indicator */}
<div className="flex flex-col items-center justify-center px-2">
<motion.div
animate={amICurrentPlayer ? { scale: [1, 1.1, 1] } : {}}
transition={{ duration: 0.5, repeat: amICurrentPlayer ? Infinity : 0 }}
className="text-center"
>
<div className="text-xs opacity-60 mb-1" style={{ color: config.colors.textMuted }}>TURNO</div>
<div
className="text-lg md:text-xl font-bold px-3 py-1 rounded-full"
style={{
backgroundColor: room.current_team === 'A' ? config.colors.primary : config.colors.secondary,
color: config.colors.bg
}}
>
{room.current_team}
</div>
{amICurrentPlayer && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs mt-1 font-bold"
style={{ color: config.colors.accent }}
>
¡TU TURNO!
</motion.div>
)}
</motion.div>
</div>
{/* Team B Score */}
<motion.div
initial={{ x: 50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
className={`flex-1 text-center p-3 md:p-4 rounded-xl relative overflow-hidden ${
room.current_team === 'B' ? 'ring-2 ring-offset-2' : ''
}`}
style={{
background: `linear-gradient(135deg, ${config.colors.secondary}30 0%, ${config.colors.secondary}10 100%)`,
border: `3px solid ${config.colors.secondary}`,
boxShadow: room.current_team === 'B' ? `0 0 20px ${config.colors.secondary}50` : 'none',
'--tw-ring-color': config.colors.secondary,
'--tw-ring-offset-color': config.colors.bg,
} as React.CSSProperties}
>
{room.current_team === 'B' && (
<motion.div
className="absolute inset-0"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ background: `radial-gradient(circle, ${config.colors.secondary}20 0%, transparent 70%)` }}
/>
)}
<div className="relative z-10">
<div className="text-xs md:text-sm font-medium opacity-80" style={{ color: config.colors.secondary }}>
EQUIPO B
</div>
<motion.div
key={room.scores.B}
initial={{ scale: 1.3 }}
animate={{ scale: 1 }}
className="text-3xl md:text-5xl font-black"
style={{ color: config.colors.secondary, textShadow: `0 0 10px ${config.colors.secondary}50` }}
>
{room.scores.B}
</motion.div>
<div className="text-xs opacity-60 mt-1" style={{ color: config.colors.textMuted }}>
{room.teams.B.map(p => p.name).join(', ')}
</div>
</div>
</motion.div>
</div>
{/* Game Board - Jeopardy Style */}
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="rounded-xl overflow-hidden mb-16"
style={{
border: `3px solid ${config.colors.primary}40`,
boxShadow: `0 10px 40px ${config.colors.primary}20`
}}
>
{/* Category Headers */}
<div
className="grid"
style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
>
{activeCategories.map((cat, index) => (
<motion.div
key={cat.id}
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="text-center p-2 md:p-4 relative overflow-hidden"
style={{
background: `linear-gradient(180deg, ${cat.color} 0%, ${cat.color}CC 100%)`,
borderRight: index < numCategories - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none'
}}
>
<div className="text-2xl md:text-4xl mb-1 drop-shadow-lg">{cat.icon}</div>
<div className="text-xs md:text-sm text-white font-bold uppercase tracking-wide truncate drop-shadow">
{cat.name}
</div>
</motion.div>
))}
</div>
{/* Questions Grid */} {/* Questions Grid */}
{[1, 2, 3, 4, 5].map((difficulty) => {[1, 2, 3, 4, 5].map((difficulty, rowIndex) => (
categories.map((cat) => { <div
const questions = room.board[String(cat.id)] || [] key={difficulty}
const question = questions.find(q => q.difficulty === difficulty) className="grid"
const isAnswered = question?.answered style={{ gridTemplateColumns: `repeat(${numCategories}, minmax(0, 1fr))` }}
>
{activeCategories.map((cat, colIndex) => {
const questions = room.board[String(cat.id)] || []
const question = questions.find(q => q.difficulty === difficulty)
const isAnswered = question?.answered
const cellId = `${cat.id}-${difficulty}`
const canSelect = !isAnswered && amICurrentPlayer && question
return ( return (
<motion.button <motion.button
key={`${cat.id}-${difficulty}`} key={cellId}
whileHover={!isAnswered && amICurrentPlayer ? { scale: 1.05 } : {}} initial={{ scale: 0, opacity: 0 }}
whileTap={!isAnswered && amICurrentPlayer ? { scale: 0.95 } : {}} animate={{ scale: 1, opacity: 1 }}
onClick={() => question && handleSelectQuestion(question, cat.id)} transition={{ delay: (rowIndex * 8 + colIndex) * 0.02 + 0.3 }}
disabled={isAnswered || !amICurrentPlayer} whileHover={canSelect ? {
className={`p-4 rounded transition-all ${ scale: 1.08,
isAnswered ? 'opacity-30' : amICurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed opacity-70' zIndex: 10,
}`} boxShadow: `0 0 25px ${cat.color}80`
style={{ } : {}}
backgroundColor: isAnswered ? config.colors.bg : cat.color + '40', whileTap={canSelect ? { scale: 0.95 } : {}}
border: `2px solid ${cat.color}`, onClick={() => question && handleSelectQuestion(question, cat.id)}
}} onMouseEnter={() => handleCellHover(cellId, !!canSelect)}
> onMouseLeave={() => setHoveredCell(null)}
<span className="text-xl font-bold" style={{ color: config.colors.text }}> disabled={isAnswered || !amICurrentPlayer}
{difficulty * 100} className={`relative aspect-[4/3] md:aspect-square flex items-center justify-center transition-all duration-200 ${
</span> isAnswered
</motion.button> ? 'cursor-default'
) : canSelect
}) ? 'cursor-pointer'
)} : 'cursor-not-allowed'
</div> }`}
style={{
background: isAnswered
? `linear-gradient(135deg, ${config.colors.bg} 0%, ${config.colors.bg}90 100%)`
: `linear-gradient(135deg, ${cat.color}50 0%, ${cat.color}30 100%)`,
borderRight: colIndex < numCategories - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
borderBottom: '1px solid rgba(255,255,255,0.1)',
opacity: isAnswered ? 0.3 : (!amICurrentPlayer ? 0.6 : 1),
}}
>
{/* Glow effect on hover */}
{canSelect && hoveredCell === cellId && (
<motion.div
layoutId="cellHighlight"
className="absolute inset-0 pointer-events-none"
style={{
background: `radial-gradient(circle, ${cat.color}40 0%, transparent 70%)`,
}}
/>
)}
{/* Points display */}
<span
className={`text-lg md:text-2xl lg:text-3xl font-black relative z-10 transition-all ${
isAnswered ? 'line-through opacity-40' : ''
}`}
style={{
color: isAnswered ? config.colors.textMuted : '#FFD700',
textShadow: isAnswered ? 'none' : '2px 2px 4px rgba(0,0,0,0.5), 0 0 10px rgba(255,215,0,0.3)'
}}
>
${difficulty * 100}
</span>
{/* Answered checkmark */}
{isAnswered && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
>
<span className="text-2xl opacity-40"></span>
</motion.div>
)}
</motion.button>
)
})}
</div>
))}
</motion.div>
{/* Question Modal */} {/* Question Modal */}
<AnimatePresence> <AnimatePresence>
{showingQuestion && currentQuestion && ( {currentQuestion && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50" className="fixed inset-0 bg-black/90 flex items-center justify-center p-4 z-50"
> >
<motion.div <motion.div
initial={{ scale: 0.9, y: 20 }} initial={{ scale: 0.8, y: 50, rotateX: -15 }}
animate={{ scale: 1, y: 0 }} animate={{ scale: 1, y: 0, rotateX: 0 }}
exit={{ scale: 0.9, y: 20 }} exit={{ scale: 0.8, y: 50, opacity: 0 }}
className="w-full max-w-lg p-6 rounded-lg" transition={{ type: "spring", damping: 20 }}
className="w-full max-w-2xl p-6 md:p-8 rounded-2xl relative overflow-hidden"
style={{ style={{
backgroundColor: config.colors.bg, background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
border: `3px solid ${config.colors.primary}`, border: `4px solid ${config.colors.primary}`,
boxShadow: `0 0 60px ${config.colors.primary}40, inset 0 0 60px ${config.colors.primary}10`
}} }}
> >
{/* Timer */} {/* Timer Bar */}
<div className="flex justify-between items-center mb-4"> <div className="absolute top-0 left-0 right-0 h-2 bg-black/30 overflow-hidden">
<span className="text-sm" style={styles.textSecondary}> <motion.div
{currentQuestion.points} puntos initial={{ width: '100%' }}
</span> animate={{ width: `${timerProgress}%` }}
<div transition={{ duration: 0.5 }}
className={`text-2xl font-bold ${timeLeft <= 5 ? 'text-red-500 animate-pulse' : ''}`} className="h-full"
style={{ color: timeLeft > 5 ? config.colors.primary : undefined }} style={{
backgroundColor: timerProgress > 30 ? config.colors.primary : timerProgress > 15 ? '#FFA500' : '#FF4444',
boxShadow: `0 0 10px ${timerProgress > 30 ? config.colors.primary : '#FF4444'}`
}}
/>
</div>
{/* Points & Timer */}
<div className="flex justify-between items-center mb-6 mt-2">
<motion.div
className="px-4 py-2 rounded-full font-bold"
style={{
backgroundColor: '#FFD700',
color: '#000',
boxShadow: '0 0 15px rgba(255,215,0,0.5)'
}}
> >
{timeLeft}s ${currentQuestion.points}
</div> </motion.div>
<motion.div
animate={timeLeft <= 5 ? { scale: [1, 1.1, 1] } : {}}
transition={{ duration: 0.3, repeat: timeLeft <= 5 ? Infinity : 0 }}
className={`text-4xl md:text-5xl font-black ${timeLeft <= 5 ? 'text-red-500' : ''}`}
style={{
color: timeLeft > 5 ? config.colors.primary : undefined,
textShadow: timeLeft <= 5 ? '0 0 20px #FF0000' : `0 0 20px ${config.colors.primary}50`
}}
>
{timeLeft}
</motion.div>
</div> </div>
{/* Question */} {/* Question */}
<p className="text-xl mb-6 text-center" style={{ color: config.colors.text }}> <motion.p
{currentQuestion.question_text || 'Pregunta de ejemplo: ¿En qué año se lanzó la NES?'} initial={{ opacity: 0, y: 20 }}
</p> animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-xl md:text-2xl mb-8 text-center leading-relaxed"
style={{ color: config.colors.text }}
>
{currentQuestion.question_text}
</motion.p>
{/* Answer Input */} {/* Answer Input */}
{amICurrentPlayer && ( {amICurrentPlayer && (
<div className="space-y-4"> <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-4"
>
<input <input
type="text" type="text"
value={answer} value={answer}
@@ -250,30 +511,49 @@ export default function Game() {
onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()} onKeyDown={(e) => e.key === 'Enter' && handleSubmitAnswer()}
placeholder="Escribe tu respuesta..." placeholder="Escribe tu respuesta..."
autoFocus autoFocus
className="w-full px-4 py-3 rounded-lg bg-transparent outline-none text-lg" className="w-full px-6 py-4 rounded-xl bg-black/30 outline-none text-xl text-center transition-all focus:ring-2"
style={{ style={{
border: `2px solid ${config.colors.primary}`, border: `3px solid ${config.colors.primary}50`,
color: config.colors.text, color: config.colors.text,
}} '--tw-ring-color': config.colors.primary,
} as React.CSSProperties}
/> />
<button <motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleSubmitAnswer} onClick={handleSubmitAnswer}
disabled={!answer.trim()} disabled={!answer.trim()}
className="w-full py-3 rounded-lg font-bold transition-all hover:scale-105 disabled:opacity-50" className="w-full py-4 rounded-xl font-bold text-xl transition-all disabled:opacity-30"
style={{ style={{
backgroundColor: config.colors.primary, background: `linear-gradient(135deg, ${config.colors.primary} 0%, ${config.colors.accent} 100%)`,
color: config.colors.bg, color: config.colors.bg,
boxShadow: answer.trim() ? `0 5px 30px ${config.colors.primary}50` : 'none'
}} }}
> >
Responder RESPONDER
</button> </motion.button>
</div> </motion.div>
)} )}
{!amICurrentPlayer && ( {!amICurrentPlayer && (
<p className="text-center" style={styles.textSecondary}> <motion.div
Esperando respuesta de {currentPlayer?.name}... initial={{ opacity: 0 }}
</p> animate={{ opacity: 1 }}
className="text-center py-4"
>
<div className="inline-flex items-center gap-2 px-6 py-3 rounded-full"
style={{ backgroundColor: config.colors.primary + '20' }}>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-4 h-4 border-2 rounded-full"
style={{ borderColor: config.colors.primary, borderTopColor: 'transparent' }}
/>
<span style={{ color: config.colors.textMuted }}>
Esperando respuesta de <strong style={{ color: config.colors.primary }}>{currentPlayer?.name}</strong>
</span>
</div>
</motion.div>
)} )}
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -287,68 +567,82 @@ export default function Game() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50" className="fixed inset-0 bg-black/90 flex items-center justify-center p-4 z-50"
> >
<motion.div <motion.div
initial={{ scale: 0.9 }} initial={{ scale: 0.5, rotate: -5 }}
animate={{ scale: 1 }} animate={{ scale: 1, rotate: 0 }}
className="p-6 rounded-lg text-center" exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: "spring", damping: 15 }}
className="p-8 rounded-2xl text-center max-w-md"
style={{ style={{
backgroundColor: config.colors.bg, background: `linear-gradient(145deg, ${config.colors.bg} 0%, ${config.colors.bg}F0 100%)`,
border: `3px solid ${config.colors.accent}`, border: `4px solid ${config.colors.accent}`,
boxShadow: `0 0 60px ${config.colors.accent}60`
}} }}
> >
<h3 className="text-2xl font-bold mb-4" style={{ color: config.colors.accent }}> <motion.div
¡Oportunidad de Robo! animate={{ rotate: [0, -10, 10, 0] }}
transition={{ duration: 0.5, repeat: Infinity }}
className="text-6xl mb-4"
>
🎯
</motion.div>
<h3 className="text-3xl font-black mb-4" style={{ color: config.colors.accent }}>
¡OPORTUNIDAD DE ROBO!
</h3> </h3>
<p className="mb-6" style={styles.textSecondary}> <p className="mb-2" style={{ color: config.colors.textMuted }}>
El equipo contrario falló. ¿Quieres intentar robar los puntos? El equipo contrario falló
<br /> </p>
<span className="text-red-500">Advertencia: Si fallas, perderás puntos</span> <p className="mb-6 text-red-400 text-sm">
Si fallas, perderás puntos
</p> </p>
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
<button <motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleStealDecision(true)} onClick={() => handleStealDecision(true)}
className="px-6 py-3 rounded-lg font-bold" className="px-8 py-4 rounded-xl font-bold text-lg"
style={{ style={{
backgroundColor: config.colors.accent, background: `linear-gradient(135deg, ${config.colors.accent} 0%, #FF6B6B 100%)`,
color: config.colors.bg, color: '#FFF',
boxShadow: `0 5px 30px ${config.colors.accent}50`
}} }}
> >
¡Robar! ¡ROBAR!
</button> </motion.button>
<button <motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleStealDecision(false)} onClick={() => handleStealDecision(false)}
className="px-6 py-3 rounded-lg font-bold" className="px-8 py-4 rounded-xl font-bold text-lg"
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: config.colors.text, color: config.colors.text,
border: `2px solid ${config.colors.text}`, border: `2px solid ${config.colors.text}50`,
}} }}
> >
Pasar Pasar
</button> </motion.button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Emoji Reactions Bar - Fixed at bottom */} {/* Emoji Reactions Bar */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30"> <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
<EmojiReactions /> <EmojiReactions />
</div> </div>
{/* Sound Control - Fixed at top right */} {/* Sound Control */}
<div className="fixed top-4 right-4 z-30"> <div className="fixed top-4 right-4 z-30">
<SoundControl compact popupPosition="bottom" /> <SoundControl compact />
</div> </div>
</div> </div>
{/* Reaction Overlay - Full screen overlay for floating reactions */}
<ReactionOverlay /> <ReactionOverlay />
{/* Team Chat - Only visible during the game */}
{isGameActive && ( {isGameActive && (
<TeamChat <TeamChat
roomCode={room.code} roomCode={room.code}

View File

@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket' import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore' import { useGameStore, getSavedSession, saveSession, clearSession } from '../stores/gameStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useThemeStyles } from '../themes/ThemeProvider' import { useThemeStyles } from '../themes/ThemeProvider'
import type { ThemeName } from '../types' import type { ThemeName } from '../types'
@@ -12,16 +12,63 @@ export default function Home() {
const [roomCode, setRoomCode] = useState('') const [roomCode, setRoomCode] = useState('')
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select') const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
const [error, setError] = useState('') const [error, setError] = useState('')
const [savedSession, setSavedSession] = useState<ReturnType<typeof getSavedSession>>(null)
const [reconnecting, setReconnecting] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const { createRoom, joinRoom } = useSocket() const { createRoom, joinRoom, rejoinRoom } = useSocket()
const { setPlayerName: storeSetPlayerName, room } = useGameStore() const { setPlayerName: storeSetPlayerName, room } = useGameStore()
const { currentTheme, setTheme } = useThemeStore() const { currentTheme, setTheme } = useThemeStore()
const { config, styles } = useThemeStyles() const { config, styles } = useThemeStyles()
// Check for saved session on mount
useEffect(() => {
const session = getSavedSession()
if (session) {
setSavedSession(session)
}
}, [])
// Navigate when room is created/joined // Navigate when room is created/joined
if (room) { useEffect(() => {
navigate(`/lobby/${room.code}`) if (room) {
// Save session when we have a room
const currentName = useGameStore.getState().playerName
const myTeam = room.teams.A.find(p => p.name === currentName) ? 'A' : 'B'
saveSession(room.code, currentName, myTeam)
setReconnecting(false)
// Navigate based on game status
if (room.status === 'playing') {
navigate(`/game/${room.code}`)
} else if (room.status === 'finished') {
navigate(`/results/${room.code}`)
} else {
navigate(`/lobby/${room.code}`)
}
}
}, [room, navigate])
const handleReconnect = () => {
if (!savedSession) return
setReconnecting(true)
storeSetPlayerName(savedSession.playerName)
rejoinRoom(savedSession.roomCode, savedSession.playerName, savedSession.team)
// Timeout for reconnection
setTimeout(() => {
if (!room) {
setReconnecting(false)
setError('No se pudo reconectar. La sala puede haber expirado.')
clearSession()
setSavedSession(null)
}
}, 5000)
}
const handleClearSession = () => {
clearSession()
setSavedSession(null)
} }
const handleCreateRoom = () => { const handleCreateRoom = () => {
@@ -106,6 +153,50 @@ export default function Home() {
: '0 4px 6px rgba(0,0,0,0.1)', : '0 4px 6px rgba(0,0,0,0.1)',
}} }}
> >
{/* Reconnect Banner */}
{savedSession && mode === 'select' && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 p-4 rounded-lg"
style={{
backgroundColor: config.colors.accent + '20',
border: `1px solid ${config.colors.accent}`,
}}
>
<p className="text-sm mb-2" style={{ color: config.colors.text }}>
Partida en progreso detectada
</p>
<p className="text-xs mb-3" style={{ color: config.colors.textMuted }}>
Sala: <strong>{savedSession.roomCode}</strong> Jugador: <strong>{savedSession.playerName}</strong>
</p>
<div className="flex gap-2">
<button
onClick={handleReconnect}
disabled={reconnecting}
className="flex-1 py-2 rounded-lg font-bold text-sm transition-all hover:scale-105 disabled:opacity-50"
style={{
backgroundColor: config.colors.accent,
color: '#FFF',
}}
>
{reconnecting ? 'Reconectando...' : 'Reconectar'}
</button>
<button
onClick={handleClearSession}
className="px-3 py-2 rounded-lg text-sm transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.textMuted,
border: `1px solid ${config.colors.textMuted}`,
}}
>
Ignorar
</button>
</div>
</motion.div>
)}
{mode === 'select' ? ( {mode === 'select' ? (
<div className="space-y-4"> <div className="space-y-4">
<button <button

View File

@@ -1,33 +1,75 @@
import { useEffect } from 'react' import { useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSound } from '../hooks/useSound' import { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore' import { useGameStore, getSavedGameResult, clearGameResult } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider' import { useThemeStyles } from '../themes/ThemeProvider'
export default function Results() { export default function Results() {
const navigate = useNavigate() const navigate = useNavigate()
const { roomCode } = useParams<{ roomCode: string }>()
const { play } = useSound() const { play } = useSound()
const { gameResult, resetGame, playerName, room } = useGameStore() const { gameResult, resetGame, playerName, room, setGameResult } = useGameStore()
const { config, styles } = useThemeStyles() const { config, styles } = useThemeStyles()
// Try to recover game result from localStorage if not in store
const effectiveGameResult = useMemo(() => {
if (gameResult) return gameResult
// Try localStorage
const saved = getSavedGameResult(roomCode)
if (saved) {
return {
winner: saved.winner,
finalScores: saved.finalScores,
replayCode: saved.replayCode,
achievementsUnlocked: saved.achievementsUnlocked
}
}
// Fallback: use room data if available and game is finished
if (room && room.status === 'finished') {
const teamAScore = room.scores?.A ?? 0
const teamBScore = room.scores?.B ?? 0
let winner: 'A' | 'B' | null = null
if (teamAScore > teamBScore) winner = 'A'
else if (teamBScore > teamAScore) winner = 'B'
return {
winner,
finalScores: { A: teamAScore, B: teamBScore },
replayCode: null,
achievementsUnlocked: []
}
}
return null
}, [gameResult, roomCode, room])
// Restore game result to store if recovered from localStorage
useEffect(() => {
if (!gameResult && effectiveGameResult) {
setGameResult(effectiveGameResult)
}
}, [gameResult, effectiveGameResult, setGameResult])
// Determine if current player won // Determine if current player won
const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B' const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
const won = gameResult?.winner === myTeam const won = effectiveGameResult?.winner === myTeam
const tied = gameResult?.winner === null const tied = effectiveGameResult?.winner === null
// Play victory/defeat sound // Play victory/defeat sound
useEffect(() => { useEffect(() => {
if (gameResult) { if (effectiveGameResult) {
if (won) { if (won) {
play('victory') play('victory')
} else if (!tied) { } else if (!tied) {
play('defeat') play('defeat')
} }
} }
}, [gameResult, won, tied, play]) }, [effectiveGameResult, won, tied, play])
if (!gameResult) { if (!effectiveGameResult) {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}> <div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No hay resultados disponibles</p> <p style={styles.textSecondary}>No hay resultados disponibles</p>
@@ -36,6 +78,7 @@ export default function Results() {
} }
const handlePlayAgain = () => { const handlePlayAgain = () => {
clearGameResult()
resetGame() resetGame()
navigate('/') navigate('/')
} }
@@ -54,15 +97,15 @@ export default function Results() {
transition={{ type: 'spring', bounce: 0.5 }} transition={{ type: 'spring', bounce: 0.5 }}
className="mb-8" className="mb-8"
> >
{gameResult.winner ? ( {effectiveGameResult.winner ? (
<h1 <h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`} className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
style={{ style={{
color: gameResult.winner === 'A' ? config.colors.primary : config.colors.secondary, color: effectiveGameResult.winner === 'A' ? config.colors.primary : config.colors.secondary,
fontFamily: config.fonts.heading, fontFamily: config.fonts.heading,
}} }}
> >
¡Equipo {gameResult.winner} Gana! ¡Equipo {effectiveGameResult.winner} Gana!
</h1> </h1>
) : ( ) : (
<h1 <h1
@@ -80,7 +123,7 @@ export default function Results() {
initial={{ x: -50, opacity: 0 }} initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className={`p-6 rounded-lg text-center ${gameResult.winner === 'A' ? 'ring-4' : ''}`} className={`p-6 rounded-lg text-center ${effectiveGameResult.winner === 'A' ? 'ring-4' : ''}`}
style={{ style={{
backgroundColor: config.colors.primary + '20', backgroundColor: config.colors.primary + '20',
border: `2px solid ${config.colors.primary}`, border: `2px solid ${config.colors.primary}`,
@@ -89,7 +132,7 @@ export default function Results() {
> >
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div> <div className="text-sm mb-2" style={styles.textSecondary}>Equipo A</div>
<div className="text-4xl font-bold" style={{ color: config.colors.primary }}> <div className="text-4xl font-bold" style={{ color: config.colors.primary }}>
{gameResult.finalScores.A} {effectiveGameResult.finalScores.A}
</div> </div>
</motion.div> </motion.div>
@@ -101,7 +144,7 @@ export default function Results() {
initial={{ x: 50, opacity: 0 }} initial={{ x: 50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className={`p-6 rounded-lg text-center ${gameResult.winner === 'B' ? 'ring-4' : ''}`} className={`p-6 rounded-lg text-center ${effectiveGameResult.winner === 'B' ? 'ring-4' : ''}`}
style={{ style={{
backgroundColor: config.colors.secondary + '20', backgroundColor: config.colors.secondary + '20',
border: `2px solid ${config.colors.secondary}`, border: `2px solid ${config.colors.secondary}`,
@@ -110,19 +153,19 @@ export default function Results() {
> >
<div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div> <div className="text-sm mb-2" style={styles.textSecondary}>Equipo B</div>
<div className="text-4xl font-bold" style={{ color: config.colors.secondary }}> <div className="text-4xl font-bold" style={{ color: config.colors.secondary }}>
{gameResult.finalScores.B} {effectiveGameResult.finalScores.B}
</div> </div>
</motion.div> </motion.div>
</div> </div>
{/* Achievements Unlocked */} {/* Achievements Unlocked */}
{gameResult.achievementsUnlocked && gameResult.achievementsUnlocked.length > 0 && ( {effectiveGameResult.achievementsUnlocked && effectiveGameResult.achievementsUnlocked.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}> <h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
Logros Desbloqueados Logros Desbloqueados
</h2> </h2>
<div className="grid gap-4"> <div className="grid gap-4">
{gameResult.achievementsUnlocked.map((unlock, i) => ( {effectiveGameResult.achievementsUnlocked.map((unlock, i) => (
<motion.div <motion.div
key={i} key={i}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -159,9 +202,9 @@ export default function Results() {
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="flex gap-4 justify-center" className="flex gap-4 justify-center"
> >
{gameResult.replayCode && ( {effectiveGameResult.replayCode && (
<button <button
onClick={() => navigate(`/replay/${gameResult.replayCode}`)} onClick={() => navigate(`/replay/${effectiveGameResult.replayCode}`)}
className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105" className="px-8 py-3 rounded-lg font-bold transition-all hover:scale-105"
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',

View File

@@ -4,16 +4,19 @@ const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8000'
class SocketService { class SocketService {
private socket: Socket | null = null private socket: Socket | null = null
private listeners: Map<string, Set<(data: unknown) => void>> = new Map() private initialized = false
connect(): Socket { connect(): Socket {
if (!this.socket) { if (!this.socket) {
console.log('Creating new socket connection to:', SOCKET_URL)
this.socket = io(SOCKET_URL, { this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'], transports: ['polling', 'websocket'],
autoConnect: true, autoConnect: true,
reconnection: true, reconnection: true,
reconnectionAttempts: 5, reconnectionAttempts: 10,
reconnectionDelay: 1000, reconnectionDelay: 1000,
upgrade: true,
rememberUpgrade: true,
}) })
this.socket.on('connect', () => { this.socket.on('connect', () => {
@@ -24,9 +27,12 @@ class SocketService {
console.log('Socket disconnected:', reason) console.log('Socket disconnected:', reason)
}) })
this.socket.on('error', (error) => { this.socket.on('connect_error', (error) => {
console.error('Socket error:', error) console.error('Socket connection error:', error)
}) })
} else if (!this.socket.connected) {
console.log('Reconnecting socket...')
this.socket.connect()
} }
return this.socket return this.socket
@@ -36,25 +42,35 @@ class SocketService {
if (this.socket) { if (this.socket) {
this.socket.disconnect() this.socket.disconnect()
this.socket = null this.socket = null
this.initialized = false
} }
} }
on(event: string, callback: (data: unknown) => void): void { on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.listeners.has(event)) { const socket = this.connect()
this.listeners.set(event, new Set()) // Remove existing listener to prevent duplicates
} socket.off(event, callback)
this.listeners.get(event)!.add(callback) socket.on(event, callback)
this.socket?.on(event, callback)
} }
off(event: string, callback: (data: unknown) => void): void { off(event: string, callback?: (...args: unknown[]) => void): void {
this.listeners.get(event)?.delete(callback) if (callback) {
this.socket?.off(event, callback) this.socket?.off(event, callback)
} else {
this.socket?.off(event)
}
} }
emit(event: string, data?: unknown): void { emit(event: string, data?: unknown): void {
this.socket?.emit(event, data) const socket = this.connect()
if (socket.connected) {
socket.emit(event, data)
} else {
// Wait for connection and then emit
socket.once('connect', () => {
socket.emit(event, data)
})
}
} }
get connected(): boolean { get connected(): boolean {
@@ -64,6 +80,15 @@ class SocketService {
get id(): string | undefined { get id(): string | undefined {
return this.socket?.id return this.socket?.id
} }
// Check if listeners are initialized
get isInitialized(): boolean {
return this.initialized
}
setInitialized(): void {
this.initialized = true
}
} }
export const socketService = new SocketService() export const socketService = new SocketService()

View File

@@ -1,6 +1,97 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { GameRoom, Question, ChatMessage, Achievement } from '../types' import type { GameRoom, Question, ChatMessage, Achievement } from '../types'
// Session persistence helpers
const SESSION_KEY = 'trivy_session'
const RESULT_KEY = 'trivy_game_result'
interface SavedSession {
roomCode: string
playerName: string
team: 'A' | 'B'
timestamp: number
}
export function saveSession(roomCode: string, playerName: string, team: 'A' | 'B') {
const session: SavedSession = {
roomCode,
playerName,
team,
timestamp: Date.now()
}
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
export function getSavedSession(): SavedSession | null {
try {
const data = localStorage.getItem(SESSION_KEY)
if (!data) return null
const session: SavedSession = JSON.parse(data)
// Session expires after 3 hours (same as room TTL)
const threeHours = 3 * 60 * 60 * 1000
if (Date.now() - session.timestamp > threeHours) {
clearSession()
return null
}
return session
} catch {
return null
}
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY)
}
// Game result persistence
export interface SavedGameResult {
winner: 'A' | 'B' | null
finalScores: { A: number; B: number }
replayCode: string | null
achievementsUnlocked: Array<{
player_name: string
team: 'A' | 'B'
achievement: Achievement
}>
roomCode: string
timestamp: number
}
export function saveGameResult(result: Omit<SavedGameResult, 'timestamp'>) {
const data: SavedGameResult = {
...result,
timestamp: Date.now()
}
localStorage.setItem(RESULT_KEY, JSON.stringify(data))
}
export function getSavedGameResult(roomCode?: string): SavedGameResult | null {
try {
const data = localStorage.getItem(RESULT_KEY)
if (!data) return null
const result: SavedGameResult = JSON.parse(data)
// Result expires after 1 hour
const oneHour = 60 * 60 * 1000
if (Date.now() - result.timestamp > oneHour) {
clearGameResult()
return null
}
// If roomCode provided, only return if it matches
if (roomCode && result.roomCode !== roomCode) {
return null
}
return result
} catch {
return null
}
}
export function clearGameResult() {
localStorage.removeItem(RESULT_KEY)
}
export interface Reaction { export interface Reaction {
id: string id: string
player_name: string player_name: string

View File

@@ -11,6 +11,11 @@ export type SoundEffect =
| 'victory' | 'victory'
| 'defeat' | 'defeat'
| 'select' | 'select'
| 'game_start'
| 'player_join'
| 'question_reveal'
| 'hover'
| 'countdown'
interface SoundState { interface SoundState {
volume: number volume: number
@@ -56,6 +61,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/drrr/victory.mp3', victory: '/sounds/drrr/victory.mp3',
defeat: '/sounds/drrr/defeat.mp3', defeat: '/sounds/drrr/defeat.mp3',
select: '/sounds/drrr/select.mp3', select: '/sounds/drrr/select.mp3',
game_start: '/sounds/drrr/game_start.mp3',
player_join: '/sounds/drrr/player_join.mp3',
question_reveal: '/sounds/drrr/question_reveal.mp3',
hover: '/sounds/drrr/hover.mp3',
countdown: '/sounds/drrr/countdown.mp3',
}, },
retro: { retro: {
correct: '/sounds/retro/correct.mp3', correct: '/sounds/retro/correct.mp3',
@@ -66,6 +76,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/retro/victory.mp3', victory: '/sounds/retro/victory.mp3',
defeat: '/sounds/retro/defeat.mp3', defeat: '/sounds/retro/defeat.mp3',
select: '/sounds/retro/select.mp3', select: '/sounds/retro/select.mp3',
game_start: '/sounds/retro/game_start.mp3',
player_join: '/sounds/retro/player_join.mp3',
question_reveal: '/sounds/retro/question_reveal.mp3',
hover: '/sounds/retro/hover.mp3',
countdown: '/sounds/retro/countdown.mp3',
}, },
minimal: { minimal: {
correct: '/sounds/minimal/correct.mp3', correct: '/sounds/minimal/correct.mp3',
@@ -76,6 +91,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/minimal/victory.mp3', victory: '/sounds/minimal/victory.mp3',
defeat: '/sounds/minimal/defeat.mp3', defeat: '/sounds/minimal/defeat.mp3',
select: '/sounds/minimal/select.mp3', select: '/sounds/minimal/select.mp3',
game_start: '/sounds/minimal/game_start.mp3',
player_join: '/sounds/minimal/player_join.mp3',
question_reveal: '/sounds/minimal/question_reveal.mp3',
hover: '/sounds/minimal/hover.mp3',
countdown: '/sounds/minimal/countdown.mp3',
}, },
rgb: { rgb: {
correct: '/sounds/rgb/correct.mp3', correct: '/sounds/rgb/correct.mp3',
@@ -86,6 +106,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/rgb/victory.mp3', victory: '/sounds/rgb/victory.mp3',
defeat: '/sounds/rgb/defeat.mp3', defeat: '/sounds/rgb/defeat.mp3',
select: '/sounds/rgb/select.mp3', select: '/sounds/rgb/select.mp3',
game_start: '/sounds/rgb/game_start.mp3',
player_join: '/sounds/rgb/player_join.mp3',
question_reveal: '/sounds/rgb/question_reveal.mp3',
hover: '/sounds/rgb/hover.mp3',
countdown: '/sounds/rgb/countdown.mp3',
}, },
anime: { anime: {
correct: '/sounds/anime/correct.mp3', correct: '/sounds/anime/correct.mp3',
@@ -96,6 +121,11 @@ export const soundPaths: Record<ThemeName, Record<SoundEffect, string>> = {
victory: '/sounds/anime/victory.mp3', victory: '/sounds/anime/victory.mp3',
defeat: '/sounds/anime/defeat.mp3', defeat: '/sounds/anime/defeat.mp3',
select: '/sounds/anime/select.mp3', select: '/sounds/anime/select.mp3',
game_start: '/sounds/anime/game_start.mp3',
player_join: '/sounds/anime/player_join.mp3',
question_reveal: '/sounds/anime/question_reveal.mp3',
hover: '/sounds/anime/hover.mp3',
countdown: '/sounds/anime/countdown.mp3',
}, },
} }
@@ -110,4 +140,9 @@ export const fallbackSoundConfigs: Record<SoundEffect, { frequency: number; dura
victory: { frequency: 523, duration: 0.5, type: 'sine' }, victory: { frequency: 523, duration: 0.5, type: 'sine' },
defeat: { frequency: 196, duration: 0.5, type: 'sine' }, defeat: { frequency: 196, duration: 0.5, type: 'sine' },
select: { frequency: 600, duration: 0.08, type: 'sine' }, select: { frequency: 600, duration: 0.08, type: 'sine' },
game_start: { frequency: 440, duration: 0.4, type: 'sine' },
player_join: { frequency: 520, duration: 0.12, type: 'sine' },
question_reveal: { frequency: 700, duration: 0.2, type: 'triangle' },
hover: { frequency: 400, duration: 0.03, type: 'sine' },
countdown: { frequency: 600, duration: 0.15, type: 'square' },
} }

View File

@@ -45,6 +45,7 @@ export interface GameRoom {
can_steal: boolean can_steal: boolean
scores: { A: number; B: number } scores: { A: number; B: number }
board: Record<string, Question[]> board: Record<string, Question[]>
current_round?: number
} }
export interface ChatMessage { export interface ChatMessage {

View File

@@ -5,7 +5,8 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 3000,
host: true host: true,
allowedHosts: ['trivy.consultoria-as.com', 'localhost', '192.168.10.217']
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',