Compare commits
6 Commits
6248037b47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e1daf94f6 | |||
| 2d4330ef74 | |||
| be5b1775a0 | |||
| e017c5804c | |||
| e0106502b1 | |||
| 112f489e40 |
@@ -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,18 +149,32 @@ 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:
|
||||
room["status"] = "finished"
|
||||
current_round = room.get("current_round", 1)
|
||||
if current_round == 1:
|
||||
# Round 1 finished - need to start round 2
|
||||
room["round_finished"] = True
|
||||
else:
|
||||
# Round 2 finished - game over
|
||||
room["status"] = "finished"
|
||||
|
||||
await room_manager.update_room(room_code, room)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,78 +118,160 @@ class RoomManager:
|
||||
team: str,
|
||||
socket_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Add a player to a room."""
|
||||
room = await self.get_room(room_code)
|
||||
if not room:
|
||||
return None
|
||||
|
||||
# Check if team is full
|
||||
if len(room["teams"][team]) >= 4:
|
||||
return None
|
||||
|
||||
# Check if name is taken
|
||||
for t in ["A", "B"]:
|
||||
for p in room["teams"][t]:
|
||||
if p["name"].lower() == player_name.lower():
|
||||
return None
|
||||
|
||||
# Add player
|
||||
player = {
|
||||
"name": player_name,
|
||||
"team": team,
|
||||
"position": len(room["teams"][team]),
|
||||
"socket_id": socket_id
|
||||
}
|
||||
room["teams"][team].append(player)
|
||||
|
||||
# Save player mapping
|
||||
await self.redis.setex(
|
||||
f"player:{socket_id}",
|
||||
3600 * 3,
|
||||
json.dumps({"name": player_name, "room": room_code, "team": team})
|
||||
)
|
||||
|
||||
await self.update_room(room_code, room)
|
||||
return room
|
||||
|
||||
async def remove_player(self, socket_id: str) -> Optional[dict]:
|
||||
"""Remove a player from their room."""
|
||||
"""Add a player to a room (with lock to prevent race conditions)."""
|
||||
await self.connect()
|
||||
|
||||
# Get player info
|
||||
async def _do_add_player():
|
||||
room = await self.get_room(room_code)
|
||||
if not room:
|
||||
return None
|
||||
|
||||
# Check if team is full
|
||||
if len(room["teams"][team]) >= 4:
|
||||
return None
|
||||
|
||||
# Check if name is taken
|
||||
for t in ["A", "B"]:
|
||||
for p in room["teams"][t]:
|
||||
if p["name"].lower() == player_name.lower():
|
||||
return None
|
||||
|
||||
# Add player
|
||||
player = {
|
||||
"name": player_name,
|
||||
"team": team,
|
||||
"position": len(room["teams"][team]),
|
||||
"socket_id": socket_id
|
||||
}
|
||||
room["teams"][team].append(player)
|
||||
|
||||
# Save player mapping
|
||||
await self.redis.setex(
|
||||
f"player:{socket_id}",
|
||||
3600 * 3,
|
||||
json.dumps({"name": player_name, "room": room_code, "team": team})
|
||||
)
|
||||
|
||||
await self.update_room(room_code, room)
|
||||
return room
|
||||
|
||||
try:
|
||||
return await self._with_lock(room_code, _do_add_player)
|
||||
except Exception as e:
|
||||
print(f"Error adding player: {e}")
|
||||
return None
|
||||
|
||||
async def remove_player(self, socket_id: str) -> Optional[dict]:
|
||||
"""Remove a player from their room (with lock)."""
|
||||
await self.connect()
|
||||
|
||||
# Get player info first (outside lock)
|
||||
player_data = await self.redis.get(f"player:{socket_id}")
|
||||
if not player_data:
|
||||
return None
|
||||
|
||||
player_info = json.loads(player_data)
|
||||
room_code = player_info["room"]
|
||||
team = player_info["team"]
|
||||
|
||||
# Get room
|
||||
room = await self.get_room(room_code)
|
||||
if not room:
|
||||
async def _do_remove_player():
|
||||
# Get room
|
||||
room = await self.get_room(room_code)
|
||||
if not room:
|
||||
return None
|
||||
|
||||
# Remove player from both teams (in case of inconsistency)
|
||||
for t in ["A", "B"]:
|
||||
room["teams"][t] = [
|
||||
p for p in room["teams"][t] if p["socket_id"] != socket_id
|
||||
]
|
||||
# Update positions
|
||||
for i, p in enumerate(room["teams"][t]):
|
||||
p["position"] = i
|
||||
|
||||
# Delete player mapping
|
||||
await self.redis.delete(f"player:{socket_id}")
|
||||
|
||||
# If room is empty, delete it
|
||||
if not room["teams"]["A"] and not room["teams"]["B"]:
|
||||
await self.redis.delete(f"room:{room_code}")
|
||||
return None
|
||||
|
||||
await self.update_room(room_code, room)
|
||||
return room
|
||||
|
||||
try:
|
||||
return await self._with_lock(room_code, _do_remove_player)
|
||||
except Exception as e:
|
||||
print(f"Error removing player: {e}")
|
||||
return None
|
||||
|
||||
# Remove player from team
|
||||
room["teams"][team] = [
|
||||
p for p in room["teams"][team] if p["socket_id"] != socket_id
|
||||
]
|
||||
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()
|
||||
|
||||
# Update positions
|
||||
for i, p in enumerate(room["teams"][team]):
|
||||
p["position"] = i
|
||||
async def _do_change_team():
|
||||
room = await self.get_room(room_code)
|
||||
if not room:
|
||||
return None
|
||||
|
||||
# Delete player mapping
|
||||
await self.redis.delete(f"player:{socket_id}")
|
||||
# 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 room is empty, delete it
|
||||
if not room["teams"]["A"] and not room["teams"]["B"]:
|
||||
await self.redis.delete(f"room:{room_code}")
|
||||
# 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
|
||||
|
||||
await self.update_room(room_code, room)
|
||||
return room
|
||||
|
||||
async def get_player(self, socket_id: str) -> Optional[dict]:
|
||||
"""Get player info by socket ID."""
|
||||
await self.connect()
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
34
backend/scripts/cron_generate_questions.sh
Executable file
34
backend/scripts/cron_generate_questions.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Cron wrapper para generar preguntas diarias
|
||||
# Ejecutar a medianoche: 0 0 * * * /root/Trivy/backend/scripts/cron_generate_questions.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="/root/Trivy/backend"
|
||||
VENV_PATH="$SCRIPT_DIR/venv/bin/python3"
|
||||
SCRIPT_PATH="$SCRIPT_DIR/scripts/generate_daily_questions.py"
|
||||
LOG_FILE="/var/log/trivy-questions.log"
|
||||
|
||||
# Load environment variables
|
||||
if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||
export $(grep -v '^#' "$SCRIPT_DIR/.env" | xargs)
|
||||
fi
|
||||
|
||||
# Timestamp
|
||||
echo "========================================" >> "$LOG_FILE"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Iniciando generación de preguntas" >> "$LOG_FILE"
|
||||
|
||||
# Run the script
|
||||
cd "$SCRIPT_DIR"
|
||||
$VENV_PATH "$SCRIPT_PATH" >> "$LOG_FILE" 2>&1
|
||||
|
||||
EXIT_CODE=$?
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Generación completada exitosamente" >> "$LOG_FILE"
|
||||
else
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: La generación falló con código $EXIT_CODE" >> "$LOG_FILE"
|
||||
fi
|
||||
|
||||
echo "" >> "$LOG_FILE"
|
||||
exit $EXIT_CODE
|
||||
256
backend/scripts/generate_daily_questions.py
Executable file
256
backend/scripts/generate_daily_questions.py
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script para generar preguntas diarias automáticamente usando Claude API.
|
||||
Ejecutar con cron a medianoche para generar preguntas del día siguiente.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict
|
||||
import json
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, '/root/Trivy/backend')
|
||||
|
||||
import anthropic
|
||||
from sqlalchemy import select
|
||||
from app.models.base import get_async_session
|
||||
from app.models.category import Category
|
||||
from app.models.question import Question
|
||||
|
||||
# Configuration
|
||||
QUESTIONS_PER_DIFFICULTY = 5 # 5 preguntas por cada dificultad
|
||||
DIFFICULTIES = [1, 2, 3, 4, 5]
|
||||
POINTS_MAP = {1: 100, 2: 200, 3: 300, 4: 400, 5: 500}
|
||||
TIME_MAP = {1: 30, 2: 30, 3: 25, 4: 20, 5: 15}
|
||||
|
||||
# Get API key from environment
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
||||
|
||||
|
||||
def get_difficulty_description(difficulty: int) -> str:
|
||||
"""Get description for difficulty level."""
|
||||
descriptions = {
|
||||
1: "muy fácil, conocimiento básico que casi todos saben",
|
||||
2: "fácil, conocimiento común para fans casuales",
|
||||
3: "moderada, requiere conocimiento intermedio del tema",
|
||||
4: "difícil, requiere conocimiento profundo del tema",
|
||||
5: "muy difícil, solo expertos o super fans sabrían la respuesta"
|
||||
}
|
||||
return descriptions.get(difficulty, "moderada")
|
||||
|
||||
|
||||
async def generate_questions_for_category(
|
||||
client: anthropic.Anthropic,
|
||||
category: Dict,
|
||||
difficulty: int,
|
||||
target_date: date,
|
||||
existing_questions: List[str]
|
||||
) -> List[Dict]:
|
||||
"""Generate questions for a specific category and difficulty using Claude."""
|
||||
|
||||
difficulty_desc = get_difficulty_description(difficulty)
|
||||
points = POINTS_MAP[difficulty]
|
||||
time_seconds = TIME_MAP[difficulty]
|
||||
|
||||
# Build prompt
|
||||
prompt = f"""Genera exactamente {QUESTIONS_PER_DIFFICULTY} preguntas de trivia sobre "{category['name']}" con dificultad {difficulty} ({difficulty_desc}).
|
||||
|
||||
REGLAS IMPORTANTES:
|
||||
1. Las preguntas deben ser en español
|
||||
2. Las respuestas deben ser cortas (1-4 palabras idealmente)
|
||||
3. Incluye respuestas alternativas válidas cuando aplique
|
||||
4. NO repitas estas preguntas existentes: {json.dumps(existing_questions[:20], ensure_ascii=False) if existing_questions else "ninguna"}
|
||||
5. Cada pregunta debe tener un dato curioso relacionado
|
||||
6. Las preguntas deben ser verificables y tener una respuesta objetiva correcta
|
||||
|
||||
Responde SOLO con un JSON array válido con esta estructura exacta:
|
||||
[
|
||||
{{
|
||||
"question_text": "¿Pregunta aquí?",
|
||||
"correct_answer": "Respuesta correcta",
|
||||
"alt_answers": ["alternativa1", "alternativa2"],
|
||||
"fun_fact": "Dato curioso relacionado con la pregunta"
|
||||
}}
|
||||
]
|
||||
|
||||
Genera exactamente {QUESTIONS_PER_DIFFICULTY} preguntas diferentes y variadas sobre {category['name']}."""
|
||||
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2000,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
# Extract JSON from response
|
||||
response_text = response.content[0].text.strip()
|
||||
|
||||
# Try to find JSON array in response
|
||||
start_idx = response_text.find('[')
|
||||
end_idx = response_text.rfind(']') + 1
|
||||
|
||||
if start_idx == -1 or end_idx == 0:
|
||||
print(f" ERROR: No se encontró JSON válido para {category['name']} dificultad {difficulty}")
|
||||
return []
|
||||
|
||||
json_str = response_text[start_idx:end_idx]
|
||||
questions_data = json.loads(json_str)
|
||||
|
||||
# Format questions for database
|
||||
formatted_questions = []
|
||||
for q in questions_data:
|
||||
formatted_questions.append({
|
||||
"category_id": category['id'],
|
||||
"question_text": q["question_text"],
|
||||
"correct_answer": q["correct_answer"],
|
||||
"alt_answers": q.get("alt_answers", []),
|
||||
"difficulty": difficulty,
|
||||
"points": points,
|
||||
"time_seconds": time_seconds,
|
||||
"date_active": target_date,
|
||||
"status": "approved",
|
||||
"fun_fact": q.get("fun_fact", "")
|
||||
})
|
||||
|
||||
return formatted_questions
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ERROR JSON para {category['name']} dificultad {difficulty}: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f" ERROR generando para {category['name']} dificultad {difficulty}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_existing_questions(db, category_id: int) -> List[str]:
|
||||
"""Get existing question texts to avoid duplicates."""
|
||||
result = await db.execute(
|
||||
select(Question.question_text).where(Question.category_id == category_id)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def generate_daily_questions(target_date: date = None):
|
||||
"""Main function to generate all daily questions."""
|
||||
|
||||
if not ANTHROPIC_API_KEY:
|
||||
print("ERROR: ANTHROPIC_API_KEY no está configurada")
|
||||
sys.exit(1)
|
||||
|
||||
if target_date is None:
|
||||
# Generate for tomorrow by default
|
||||
target_date = date.today() + timedelta(days=1)
|
||||
|
||||
print(f"=== Generando preguntas para {target_date} ===")
|
||||
|
||||
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
AsyncSessionLocal = get_async_session()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get all categories
|
||||
result = await db.execute(select(Category))
|
||||
categories = result.scalars().all()
|
||||
|
||||
if not categories:
|
||||
print("ERROR: No hay categorías en la base de datos")
|
||||
return
|
||||
|
||||
print(f"Categorías encontradas: {len(categories)}")
|
||||
|
||||
total_generated = 0
|
||||
|
||||
for category in categories:
|
||||
cat_dict = {"id": category.id, "name": category.name}
|
||||
print(f"\n📁 Categoría: {category.name}")
|
||||
|
||||
# Get existing questions to avoid duplicates
|
||||
existing = await get_existing_questions(db, category.id)
|
||||
|
||||
for difficulty in DIFFICULTIES:
|
||||
print(f" Dificultad {difficulty}...", end=" ", flush=True)
|
||||
|
||||
questions = await generate_questions_for_category(
|
||||
client, cat_dict, difficulty, target_date, existing
|
||||
)
|
||||
|
||||
if questions:
|
||||
# Insert into database
|
||||
for q_data in questions:
|
||||
question = Question(**q_data)
|
||||
db.add(question)
|
||||
existing.append(q_data["question_text"])
|
||||
|
||||
print(f"✓ {len(questions)} preguntas")
|
||||
total_generated += len(questions)
|
||||
else:
|
||||
print("✗ Error")
|
||||
|
||||
# Small delay to avoid rate limiting
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"\n=== COMPLETADO ===")
|
||||
print(f"Total de preguntas generadas: {total_generated}")
|
||||
print(f"Fecha activa: {target_date}")
|
||||
|
||||
|
||||
async def check_existing_questions(target_date: date = None):
|
||||
"""Check if questions already exist for target date."""
|
||||
if target_date is None:
|
||||
target_date = date.today() + timedelta(days=1)
|
||||
|
||||
AsyncSessionLocal = get_async_session()
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(Question).where(Question.date_active == target_date)
|
||||
)
|
||||
existing = result.scalars().all()
|
||||
return len(existing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generar preguntas diarias para Trivy")
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
type=str,
|
||||
help="Fecha objetivo (YYYY-MM-DD). Default: mañana"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Generar aunque ya existan preguntas para esa fecha"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Solo verificar si existen preguntas"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
target_date = None
|
||||
if args.date:
|
||||
target_date = date.fromisoformat(args.date)
|
||||
|
||||
if args.check:
|
||||
count = asyncio.run(check_existing_questions(target_date))
|
||||
check_date = target_date or (date.today() + timedelta(days=1))
|
||||
print(f"Preguntas para {check_date}: {count}")
|
||||
sys.exit(0)
|
||||
|
||||
# Check if questions already exist
|
||||
if not args.force:
|
||||
count = asyncio.run(check_existing_questions(target_date))
|
||||
if count > 0:
|
||||
check_date = target_date or (date.today() + timedelta(days=1))
|
||||
print(f"Ya existen {count} preguntas para {check_date}")
|
||||
print("Usa --force para regenerar")
|
||||
sys.exit(0)
|
||||
|
||||
asyncio.run(generate_daily_questions(target_date))
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,16 +12,63 @@ 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
|
||||
if (room) {
|
||||
navigate(`/lobby/${room.code}`)
|
||||
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 = () => {
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user