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

View File

@@ -65,12 +65,20 @@ 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 5×5 para el juego. Genera el tablero 5×5 para el juego.
Selecciona 5 categorías aleatorias y 1 pregunta por dificultad. 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
""" """
@@ -82,6 +90,15 @@ class QuestionService:
# Get available category IDs that have questions # Get available category IDs that have questions
available_categories = list(full_board.keys()) 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) # Select random categories (up to CATEGORIES_PER_GAME)
num_categories = min(CATEGORIES_PER_GAME, len(available_categories)) num_categories = min(CATEGORIES_PER_GAME, len(available_categories))
selected_categories = random.sample(available_categories, num_categories) selected_categories = random.sample(available_categories, num_categories)
@@ -104,7 +121,10 @@ class QuestionService:
for difficulty in range(1, 6): # 1-5 for difficulty in range(1, 6): # 1-5
if difficulty in questions_by_difficulty: if difficulty in questions_by_difficulty:
questions = questions_by_difficulty[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) selected_questions.append(selected_q)
if selected_questions: if selected_questions:

View File

@@ -1,12 +1,17 @@
import json import json
import random import random
import string import string
import asyncio
from typing import Optional from typing import Optional
import redis.asyncio as redis import redis.asyncio as redis
from app.config import get_settings from app.config import get_settings
settings = get_settings() settings = get_settings()
# Lock timeout in seconds
LOCK_TIMEOUT = 5
LOCK_RETRY_DELAY = 0.05 # 50ms
class RoomManager: class RoomManager:
def __init__(self): def __init__(self):
@@ -20,6 +25,31 @@ class RoomManager:
if self.redis: if self.redis:
await self.redis.close() await self.redis.close()
async def _acquire_lock(self, room_code: str, timeout: float = LOCK_TIMEOUT) -> bool:
"""Acquire a lock for room operations."""
await self.connect()
lock_key = f"lock:room:{room_code}"
# Try to acquire lock with NX (only if not exists) and EX (expire)
acquired = await self.redis.set(lock_key, "1", nx=True, ex=int(timeout))
return acquired is not None
async def _release_lock(self, room_code: str):
"""Release a room lock."""
await self.connect()
lock_key = f"lock:room:{room_code}"
await self.redis.delete(lock_key)
async def _with_lock(self, room_code: str, operation, max_retries: int = 20):
"""Execute an operation with a room lock."""
for attempt in range(max_retries):
if await self._acquire_lock(room_code):
try:
return await operation()
finally:
await self._release_lock(room_code)
await asyncio.sleep(LOCK_RETRY_DELAY)
raise Exception(f"Could not acquire lock for room {room_code}")
def _generate_room_code(self) -> str: def _generate_room_code(self) -> str:
"""Generate a 6-character room code.""" """Generate a 6-character room code."""
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
@@ -88,7 +118,10 @@ 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)."""
await self.connect()
async def _do_add_player():
room = await self.get_room(room_code) room = await self.get_room(room_code)
if not room: if not room:
return None return None
@@ -122,31 +155,37 @@ class RoomManager:
await self.update_room(room_code, room) await self.update_room(room_code, room)
return 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]: 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() await self.connect()
# Get player info # 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"]
async def _do_remove_player():
# Get room # Get room
room = await self.get_room(room_code) room = await self.get_room(room_code)
if not room: if not room:
return None return None
# Remove player from team # Remove player from both teams (in case of inconsistency)
room["teams"][team] = [ for t in ["A", "B"]:
p for p in room["teams"][team] if p["socket_id"] != socket_id room["teams"][t] = [
p for p in room["teams"][t] if p["socket_id"] != socket_id
] ]
# Update positions # Update positions
for i, p in enumerate(room["teams"][team]): for i, p in enumerate(room["teams"][t]):
p["position"] = i p["position"] = i
# Delete player mapping # Delete player mapping
@@ -160,6 +199,79 @@ class RoomManager:
await self.update_room(room_code, room) await self.update_room(room_code, room)
return 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]: async def get_player(self, socket_id: str) -> Optional[dict]:
"""Get player info by socket ID.""" """Get player info by socket ID."""
await self.connect() await self.connect()
@@ -168,6 +280,23 @@ class RoomManager:
return json.loads(data) return json.loads(data)
return None return None
async def update_player(self, socket_id: str, updates: dict) -> Optional[dict]:
"""Update player info."""
await self.connect()
data = await self.redis.get(f"player:{socket_id}")
if not data:
return None
player = json.loads(data)
player.update(updates)
await self.redis.setex(
f"player:{socket_id}",
3600 * 3,
json.dumps(player)
)
return player
async def get_player_stats(self, room_code: str, player_name: str) -> Optional[dict]: async def get_player_stats(self, room_code: str, player_name: str) -> Optional[dict]:
"""Obtiene stats de un jugador.""" """Obtiene stats de un jugador."""
await self.connect() await self.connect()

View File

@@ -1,5 +1,6 @@
import socketio import socketio
import time import time
import json
from datetime import datetime from datetime import datetime
from app.services.room_manager import room_manager from app.services.room_manager import room_manager
from app.services.game_manager import game_manager from app.services.game_manager import game_manager
@@ -80,6 +81,105 @@ def register_socket_events(sio: socketio.AsyncServer):
# 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
@@ -269,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
@@ -306,6 +414,26 @@ def register_socket_events(sio: socketio.AsyncServer):
team=player["team"], team=player["team"],
question_id=question_id question_id=question_id
) )
# Verificar si terminó la ronda o el juego
if room.get("round_finished"):
async with await get_db_session() as db:
new_room = await game_manager.start_round_2(db, room_code)
if new_room:
if new_room.get("status") == "finished":
await finish_game_internal(room_code)
else:
await sio.emit(
"round_started",
{
"room": new_room,
"round": 2,
"message": "¡Ronda 2! Puntos dobles"
},
room=room_code
)
elif room.get("status") == "finished":
await finish_game_internal(room_code)
else: else:
# Will attempt steal - just notify, answer comes separately # Will attempt steal - just notify, answer comes separately
room = await room_manager.get_room(room_code) room = await room_manager.get_room(room_code)

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { useGameStore } from '../stores/gameStore' import { useGameStore, saveSession, clearSession, saveGameResult } 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'
@@ -58,6 +58,25 @@ 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)
@@ -66,6 +85,14 @@ export function useSocket() {
soundPlayer.play('game_start', 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)
// Find the question in the board and set it as current // Find the question in the board and set it as current
@@ -174,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,
@@ -183,6 +210,14 @@ 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
}) })
}) })
@@ -268,10 +303,19 @@ export function useSocket() {
socketService.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: socketService.connect(), socket: socketService.connect(),
createRoom, createRoom,
joinRoom, joinRoom,
rejoinRoom,
changeTeam, changeTeam,
startGame, startGame,
selectQuestion, selectQuestion,

View File

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

View File

@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSocket } from '../hooks/useSocket' import { useSocket } from '../hooks/useSocket'
import { useGameStore } from '../stores/gameStore' import { useGameStore, getSavedSession, saveSession, clearSession } from '../stores/gameStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useThemeStyles } from '../themes/ThemeProvider' import { useThemeStyles } from '../themes/ThemeProvider'
import type { ThemeName } from '../types' import type { ThemeName } from '../types'
@@ -12,17 +12,64 @@ 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
useEffect(() => {
if (room) { 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}`) 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 = () => {
if (!playerName.trim()) { if (!playerName.trim()) {
@@ -106,6 +153,50 @@ export default function Home() {
: '0 4px 6px rgba(0,0,0,0.1)', : '0 4px 6px rgba(0,0,0,0.1)',
}} }}
> >
{/* Reconnect Banner */}
{savedSession && mode === 'select' && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 p-4 rounded-lg"
style={{
backgroundColor: config.colors.accent + '20',
border: `1px solid ${config.colors.accent}`,
}}
>
<p className="text-sm mb-2" style={{ color: config.colors.text }}>
Partida en progreso detectada
</p>
<p className="text-xs mb-3" style={{ color: config.colors.textMuted }}>
Sala: <strong>{savedSession.roomCode}</strong> Jugador: <strong>{savedSession.playerName}</strong>
</p>
<div className="flex gap-2">
<button
onClick={handleReconnect}
disabled={reconnecting}
className="flex-1 py-2 rounded-lg font-bold text-sm transition-all hover:scale-105 disabled:opacity-50"
style={{
backgroundColor: config.colors.accent,
color: '#FFF',
}}
>
{reconnecting ? 'Reconectando...' : 'Reconectar'}
</button>
<button
onClick={handleClearSession}
className="px-3 py-2 rounded-lg text-sm transition-all hover:scale-105"
style={{
backgroundColor: 'transparent',
color: config.colors.textMuted,
border: `1px solid ${config.colors.textMuted}`,
}}
>
Ignorar
</button>
</div>
</motion.div>
)}
{mode === 'select' ? ( {mode === 'select' ? (
<div className="space-y-4"> <div className="space-y-4">
<button <button

View File

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

View File

@@ -10,11 +10,13 @@ class SocketService {
if (!this.socket) { if (!this.socket) {
console.log('Creating new socket connection to:', SOCKET_URL) 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: 10, reconnectionAttempts: 10,
reconnectionDelay: 1000, reconnectionDelay: 1000,
upgrade: true,
rememberUpgrade: true,
}) })
this.socket.on('connect', () => { this.socket.on('connect', () => {

View File

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

View File

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

View File

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