Compare commits

...

6 Commits

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

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

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

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

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

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

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

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

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

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

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

- Config: soporte para Cloudflare tunnel (allowedHosts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 01:53:32 +00:00
14 changed files with 1062 additions and 116 deletions

View File

@@ -37,6 +37,8 @@ class GameManager:
room["current_player_index"] = {"A": 0, "B": 0}
room["board"] = board
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)
return room
@@ -134,6 +136,12 @@ class GameManager:
q["answered"] = True
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
room["current_team"] = "B" if stealing_team == "A" else "A"
room["current_question"] = None
@@ -141,17 +149,31 @@ class GameManager:
else:
# Original team failed - enable steal
failed_team = room["current_team"]
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(
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:
# 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)
@@ -176,11 +198,31 @@ class GameManager:
q["answered"] = True
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
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["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)
return room
@@ -197,6 +239,45 @@ class GameManager:
index = room["current_player_index"][team]
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:
"""Calculate when the timer should end."""
if is_steal:

View File

@@ -65,12 +65,20 @@ class QuestionService:
async def get_board_for_game(
self,
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]]:
"""
Genera el tablero 5×5 para el juego.
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:
Dict con category_id como string (para JSON) -> lista de preguntas
"""
@@ -82,6 +90,15 @@ class QuestionService:
# 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)
@@ -104,7 +121,10 @@ class QuestionService:
for difficulty in range(1, 6): # 1-5
if difficulty in questions_by_difficulty:
questions = questions_by_difficulty[difficulty]
selected_q = random.choice(questions)
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:

View File

@@ -1,12 +1,17 @@
import json
import random
import string
import asyncio
from typing import Optional
import redis.asyncio as redis
from app.config import get_settings
settings = get_settings()
# Lock timeout in seconds
LOCK_TIMEOUT = 5
LOCK_RETRY_DELAY = 0.05 # 50ms
class RoomManager:
def __init__(self):
@@ -20,6 +25,31 @@ class RoomManager:
if self.redis:
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:
"""Generate a 6-character room code."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
@@ -88,7 +118,10 @@ class RoomManager:
team: str,
socket_id: str
) -> Optional[dict]:
"""Add a player to a room."""
"""Add a player to a room (with lock to prevent race conditions)."""
await self.connect()
async def _do_add_player():
room = await self.get_room(room_code)
if not room:
return None
@@ -122,31 +155,37 @@ class RoomManager:
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."""
"""Remove a player from their room (with lock)."""
await self.connect()
# Get player info
# Get player info first (outside lock)
player_data = await self.redis.get(f"player:{socket_id}")
if not player_data:
return None
player_info = json.loads(player_data)
room_code = player_info["room"]
team = player_info["team"]
async def _do_remove_player():
# Get room
room = await self.get_room(room_code)
if not room:
return None
# Remove player from team
room["teams"][team] = [
p for p in room["teams"][team] if p["socket_id"] != socket_id
# 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"][team]):
for i, p in enumerate(room["teams"][t]):
p["position"] = i
# Delete player mapping
@@ -160,6 +199,79 @@ class RoomManager:
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
async def change_player_team(
self,
room_code: str,
player_name: str,
socket_id: str,
new_team: str
) -> Optional[dict]:
"""Change a player's team (with lock)."""
await self.connect()
async def _do_change_team():
room = await self.get_room(room_code)
if not room:
return None
# Find current team
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 already on target team, just return room
if current_team == new_team:
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
async def get_player(self, socket_id: str) -> Optional[dict]:
"""Get player info by socket ID."""
await self.connect()
@@ -168,6 +280,23 @@ class RoomManager:
return json.loads(data)
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]:
"""Obtiene stats de un jugador."""
await self.connect()

View File

@@ -1,5 +1,6 @@
import socketio
import time
import json
from datetime import datetime
from app.services.room_manager import room_manager
from app.services.game_manager import game_manager
@@ -80,6 +81,105 @@ def register_socket_events(sio: socketio.AsyncServer):
# Notify all players
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
async def change_team(sid, data):
"""Switch player to another team."""
@@ -90,8 +190,12 @@ def register_socket_events(sio: socketio.AsyncServer):
room_code = player["room"]
new_team = data.get("team")
room = await room_manager.get_room(room_code)
if not room or len(room["teams"][new_team]) >= 4:
# Use room_manager method with lock to prevent race conditions
room = await room_manager.change_player_team(
room_code, player["name"], sid, new_team
)
if not room:
await sio.emit(
"error",
{"message": "Cannot change team. It may be full."},
@@ -99,21 +203,6 @@ def register_socket_events(sio: socketio.AsyncServer):
)
return
# Remove from current team
current_team = player["team"]
room["teams"][current_team] = [
p for p in room["teams"][current_team] if p["socket_id"] != sid
]
# Add to new team
room["teams"][new_team].append({
"name": player["name"],
"team": new_team,
"position": len(room["teams"][new_team]),
"socket_id": sid
})
await room_manager.update_room(room_code, room)
await sio.emit("team_changed", {"room": room}, room=room_code)
@sio.event
@@ -269,9 +358,28 @@ def register_socket_events(sio: socketio.AsyncServer):
points_earned=result["points_earned"]
)
# Verificar si el juego termino (todas las preguntas respondidas)
if room_data.get("status") == "finished":
# Disparar finalizacion automatica
# Verificar si terminó la ronda o el juego
if room_data.get("round_finished"):
# 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)
@sio.event
@@ -306,6 +414,26 @@ def register_socket_events(sio: socketio.AsyncServer):
team=player["team"],
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:
# Will attempt steal - just notify, answer comes separately
room = await room_manager.get_room(room_code)

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react'
import { useGameStore } from '../stores/gameStore'
import { useGameStore, saveSession, clearSession, saveGameResult } from '../stores/gameStore'
import { soundPlayer } from './useSound'
import { useThemeStore } from '../stores/themeStore'
import { useSoundStore } from '../stores/soundStore'
@@ -58,6 +58,25 @@ export function useSocket() {
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
socket.on('game_started', (data: { room: GameRoom }) => {
setRoom(data.room)
@@ -66,6 +85,14 @@ export function useSocket() {
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 }) => {
setRoom(data.room)
// Find the question in the board and set it as current
@@ -174,7 +201,7 @@ export function useSocket() {
soundPlayer.play('defeat', volume)
}
setGameResult({
const gameResultData = {
winner: data.winner,
finalScores: data.final_scores,
replayCode: data.replay_code,
@@ -183,6 +210,14 @@ export function useSocket() {
team: a.team,
achievement: a.achievement as Achievement
}))
}
setGameResult(gameResultData)
// Persist game result to localStorage
saveGameResult({
...gameResultData,
roomCode: data.room.code
})
})
@@ -268,10 +303,19 @@ export function useSocket() {
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 {
socket: socketService.connect(),
createRoom,
joinRoom,
rejoinRoom,
changeTeam,
startGame,
selectQuestion,

View File

@@ -21,6 +21,12 @@ const allCategories: Record<number, { name: string; icon: string; color: string
6: { name: 'Películas', icon: '🎬', color: '#F5C518' },
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() {
@@ -116,7 +122,13 @@ export default function Game() {
const handleStealDecision = (attempt: boolean) => {
if (!currentQuestion) return
if (!attempt) {
if (attempt) {
// 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 {
// Pass on steal
stealDecision(false, currentQuestion.id)
}
setShowStealPrompt(false)
@@ -145,7 +157,7 @@ export default function Game() {
return (
<div className="min-h-screen p-2 md:p-4 overflow-hidden" style={styles.bgPrimary}>
<div className="max-w-7xl mx-auto">
{/* Header with Room Code */}
{/* Header with Room Code and Round */}
<div className="text-center mb-4">
<motion.h1
initial={{ y: -20, opacity: 0 }}
@@ -155,8 +167,21 @@ export default function Game() {
>
TRIVY
</motion.h1>
<div className="text-xs opacity-60" style={{ color: config.colors.textMuted }}>
Sala: {room.code}
<div className="flex items-center justify-center gap-3 text-xs" style={{ color: config.colors.textMuted }}>
<span className="opacity-60">Sala: {room.code}</span>
<span className="opacity-40">|</span>
<motion.span
key={room.current_round}
initial={{ scale: 1.5, color: config.colors.accent }}
animate={{ scale: 1, color: config.colors.textMuted }}
className="font-bold"
style={{
color: room.current_round === 2 ? config.colors.accent : config.colors.textMuted
}}
>
Ronda {room.current_round || 1}
{room.current_round === 2 && ' (x2)'}
</motion.span>
</div>
</div>

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
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 { useThemeStyles } from '../themes/ThemeProvider'
import type { ThemeName } from '../types'
@@ -12,17 +12,64 @@ export default function Home() {
const [roomCode, setRoomCode] = useState('')
const [mode, setMode] = useState<'select' | 'create' | 'join'>('select')
const [error, setError] = useState('')
const [savedSession, setSavedSession] = useState<ReturnType<typeof getSavedSession>>(null)
const [reconnecting, setReconnecting] = useState(false)
const navigate = useNavigate()
const { createRoom, joinRoom } = useSocket()
const { createRoom, joinRoom, rejoinRoom } = useSocket()
const { setPlayerName: storeSetPlayerName, room } = useGameStore()
const { currentTheme, setTheme } = useThemeStore()
const { config, styles } = useThemeStyles()
// Check for saved session on mount
useEffect(() => {
const session = getSavedSession()
if (session) {
setSavedSession(session)
}
}, [])
// Navigate when room is created/joined
useEffect(() => {
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 = () => {
if (!playerName.trim()) {
@@ -106,6 +153,50 @@ export default function Home() {
: '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' ? (
<div className="space-y-4">
<button

View File

@@ -1,33 +1,75 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useEffect, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useSound } from '../hooks/useSound'
import { useGameStore } from '../stores/gameStore'
import { useGameStore, getSavedGameResult, clearGameResult } from '../stores/gameStore'
import { useThemeStyles } from '../themes/ThemeProvider'
export default function Results() {
const navigate = useNavigate()
const { roomCode } = useParams<{ roomCode: string }>()
const { play } = useSound()
const { gameResult, resetGame, playerName, room } = useGameStore()
const { gameResult, resetGame, playerName, room, setGameResult } = useGameStore()
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
const myTeam = room?.teams.A.find(p => p.name === playerName) ? 'A' : 'B'
const won = gameResult?.winner === myTeam
const tied = gameResult?.winner === null
const won = effectiveGameResult?.winner === myTeam
const tied = effectiveGameResult?.winner === null
// Play victory/defeat sound
useEffect(() => {
if (gameResult) {
if (effectiveGameResult) {
if (won) {
play('victory')
} else if (!tied) {
play('defeat')
}
}
}, [gameResult, won, tied, play])
}, [effectiveGameResult, won, tied, play])
if (!gameResult) {
if (!effectiveGameResult) {
return (
<div className="min-h-screen flex items-center justify-center" style={styles.bgPrimary}>
<p style={styles.textSecondary}>No hay resultados disponibles</p>
@@ -36,6 +78,7 @@ export default function Results() {
}
const handlePlayAgain = () => {
clearGameResult()
resetGame()
navigate('/')
}
@@ -54,15 +97,15 @@ export default function Results() {
transition={{ type: 'spring', bounce: 0.5 }}
className="mb-8"
>
{gameResult.winner ? (
{effectiveGameResult.winner ? (
<h1
className={`text-5xl font-bold mb-2 ${styles.glowEffect}`}
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,
}}
>
¡Equipo {gameResult.winner} Gana!
¡Equipo {effectiveGameResult.winner} Gana!
</h1>
) : (
<h1
@@ -80,7 +123,7 @@ export default function Results() {
initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
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={{
backgroundColor: config.colors.primary + '20',
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-4xl font-bold" style={{ color: config.colors.primary }}>
{gameResult.finalScores.A}
{effectiveGameResult.finalScores.A}
</div>
</motion.div>
@@ -101,7 +144,7 @@ export default function Results() {
initial={{ x: 50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
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={{
backgroundColor: config.colors.secondary + '20',
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-4xl font-bold" style={{ color: config.colors.secondary }}>
{gameResult.finalScores.B}
{effectiveGameResult.finalScores.B}
</div>
</motion.div>
</div>
{/* Achievements Unlocked */}
{gameResult.achievementsUnlocked && gameResult.achievementsUnlocked.length > 0 && (
{effectiveGameResult.achievementsUnlocked && effectiveGameResult.achievementsUnlocked.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-4" style={{ color: config.colors.accent }}>
Logros Desbloqueados
</h2>
<div className="grid gap-4">
{gameResult.achievementsUnlocked.map((unlock, i) => (
{effectiveGameResult.achievementsUnlocked.map((unlock, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
@@ -159,9 +202,9 @@ export default function Results() {
transition={{ delay: 0.5 }}
className="flex gap-4 justify-center"
>
{gameResult.replayCode && (
{effectiveGameResult.replayCode && (
<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"
style={{
backgroundColor: 'transparent',

View File

@@ -10,11 +10,13 @@ class SocketService {
if (!this.socket) {
console.log('Creating new socket connection to:', SOCKET_URL)
this.socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
transports: ['polling', 'websocket'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
upgrade: true,
rememberUpgrade: true,
})
this.socket.on('connect', () => {

View File

@@ -1,6 +1,97 @@
import { create } from 'zustand'
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 {
id: string
player_name: string

View File

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

View File

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