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["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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface GameRoom {
|
|||||||
can_steal: boolean
|
can_steal: boolean
|
||||||
scores: { A: number; B: number }
|
scores: { A: number; B: number }
|
||||||
board: Record<string, Question[]>
|
board: Record<string, Question[]>
|
||||||
|
current_round?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: true
|
host: true,
|
||||||
|
allowedHosts: ['trivy.consultoria-as.com', 'localhost', '192.168.10.217']
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
|||||||
Reference in New Issue
Block a user