Compare commits
8 Commits
e5a2b016a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e1daf94f6 | |||
| 2d4330ef74 | |||
| be5b1775a0 | |||
| e017c5804c | |||
| e0106502b1 | |||
| 112f489e40 | |||
| 6248037b47 | |||
| ab201e113a |
344
README.md
Normal file
344
README.md
Normal 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
45
backend/.env.example
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
34
backend/scripts/cron_generate_questions.sh
Executable file
34
backend/scripts/cron_generate_questions.sh
Executable 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
|
||||||
256
backend/scripts/generate_daily_questions.py
Executable file
256
backend/scripts/generate_daily_questions.py
Executable 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
492
docs/API.md
Normal 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
374
docs/ARCHITECTURE.md
Normal 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
469
docs/INSTALLATION.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user