- Añade sistema de reconexión tras refresh/cierre del navegador - Persistencia de sesión en localStorage (3h TTL) - Banner de reconexión en Home - Evento rejoin_room en backend - Nuevas categorías: Series TV, Marvel/DC, Disney, Memes, Pokémon, Mitología - Correcciones de bugs: - Fix: juego bloqueado al fallar robo (steal decision) - Fix: jugador duplicado al cambiar de equipo - Fix: rotación incorrecta de turno tras fallo - Config: soporte para Cloudflare tunnel (allowedHosts) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
716 lines
24 KiB
Python
716 lines
24 KiB
Python
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
|
|
from app.services.replay_manager import replay_manager
|
|
from app.services.achievement_manager import achievement_manager
|
|
from app.schemas.achievement import PlayerStats
|
|
from app.models.base import get_async_session
|
|
|
|
|
|
# Rate limiting para reacciones: {room_code: {player_name: last_reaction_timestamp}}
|
|
reaction_rate_limits: dict[str, dict[str, float]] = {}
|
|
REACTION_COOLDOWN_SECONDS = 3
|
|
|
|
|
|
async def get_db_session():
|
|
"""Helper para obtener sesion de BD en contexto de socket."""
|
|
AsyncSessionLocal = get_async_session()
|
|
return AsyncSessionLocal()
|
|
|
|
|
|
def register_socket_events(sio: socketio.AsyncServer):
|
|
"""Register all Socket.IO event handlers."""
|
|
|
|
@sio.event
|
|
async def connect(sid, environ):
|
|
print(f"Client connected: {sid}")
|
|
|
|
@sio.event
|
|
async def disconnect(sid):
|
|
print(f"Client disconnected: {sid}")
|
|
# Remove player from room
|
|
room = await room_manager.remove_player(sid)
|
|
if room:
|
|
await sio.emit(
|
|
"player_left",
|
|
{"room": room},
|
|
room=room["code"]
|
|
)
|
|
|
|
@sio.event
|
|
async def create_room(sid, data):
|
|
"""Create a new game room."""
|
|
player_name = data.get("player_name", "Player")
|
|
|
|
room = await room_manager.create_room(player_name, sid)
|
|
|
|
# Inicializar stats del jugador (host) para logros
|
|
await room_manager.init_player_stats(room["code"], player_name)
|
|
|
|
# Join socket room
|
|
await sio.enter_room(sid, room["code"])
|
|
|
|
await sio.emit("room_created", {"room": room}, to=sid)
|
|
|
|
@sio.event
|
|
async def join_room(sid, data):
|
|
"""Join an existing room."""
|
|
room_code = data.get("room_code", "").upper()
|
|
player_name = data.get("player_name", "Player")
|
|
team = data.get("team", "A")
|
|
|
|
room = await room_manager.add_player(room_code, player_name, team, sid)
|
|
|
|
if not room:
|
|
await sio.emit(
|
|
"error",
|
|
{"message": "Could not join room. It may be full or the name is taken."},
|
|
to=sid
|
|
)
|
|
return
|
|
|
|
# Inicializar stats del jugador para logros
|
|
await room_manager.init_player_stats(room_code, player_name)
|
|
|
|
# Join socket room
|
|
await sio.enter_room(sid, room_code)
|
|
|
|
# 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."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
new_team = data.get("team")
|
|
current_team = player["team"]
|
|
|
|
# Don't do anything if already on that team
|
|
if current_team == new_team:
|
|
return
|
|
|
|
room = await room_manager.get_room(room_code)
|
|
if not room or len(room["teams"][new_team]) >= 4:
|
|
await sio.emit(
|
|
"error",
|
|
{"message": "Cannot change team. It may be full."},
|
|
to=sid
|
|
)
|
|
return
|
|
|
|
# Remove from current team (by socket_id to be safe)
|
|
room["teams"][current_team] = [
|
|
p for p in room["teams"][current_team] if p["socket_id"] != sid
|
|
]
|
|
|
|
# Also remove from new team if somehow already there (prevent duplicates)
|
|
room["teams"][new_team] = [
|
|
p for p in room["teams"][new_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
|
|
})
|
|
|
|
# Update room state
|
|
await room_manager.update_room(room_code, room)
|
|
|
|
# Update player record with new team
|
|
await room_manager.update_player(sid, {"team": new_team})
|
|
|
|
await sio.emit("team_changed", {"room": room}, room=room_code)
|
|
|
|
@sio.event
|
|
async def start_game(sid, data):
|
|
"""Start the game (host only)."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
room = await room_manager.get_room(room_code)
|
|
|
|
if not room:
|
|
return
|
|
|
|
# Check if player is host
|
|
if room["host"] != player["name"]:
|
|
await sio.emit(
|
|
"error",
|
|
{"message": "Only the host can start the game."},
|
|
to=sid
|
|
)
|
|
return
|
|
|
|
# Check minimum players
|
|
if not room["teams"]["A"] or not room["teams"]["B"]:
|
|
await sio.emit(
|
|
"error",
|
|
{"message": "Both teams need at least one player."},
|
|
to=sid
|
|
)
|
|
return
|
|
|
|
# Load board from database and start game
|
|
async with await get_db_session() as db:
|
|
updated_room = await game_manager.start_game_with_db(db, room_code)
|
|
|
|
if updated_room:
|
|
await sio.emit("game_started", {"room": updated_room}, room=room_code)
|
|
else:
|
|
await sio.emit(
|
|
"error",
|
|
{"message": "No hay preguntas disponibles para hoy. Contacta al administrador."},
|
|
to=sid
|
|
)
|
|
|
|
@sio.event
|
|
async def select_question(sid, data):
|
|
"""Select a question from the board."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
question_id = data.get("question_id")
|
|
category_id = data.get("category_id")
|
|
|
|
room = await game_manager.select_question(room_code, question_id, category_id)
|
|
|
|
if room:
|
|
# Get current player info
|
|
current_player = await game_manager.get_current_player(room)
|
|
|
|
await sio.emit(
|
|
"question_selected",
|
|
{
|
|
"room": room,
|
|
"question_id": question_id,
|
|
"current_player": current_player
|
|
},
|
|
room=room_code
|
|
)
|
|
|
|
# Guardar evento para replay
|
|
if room.get("db_session_id"):
|
|
async with await get_db_session() as db:
|
|
await replay_manager.save_game_event(
|
|
db=db,
|
|
session_id=room["db_session_id"],
|
|
event_type="question_selected",
|
|
player_name=player["name"],
|
|
team=player["team"],
|
|
question_id=question_id
|
|
)
|
|
|
|
@sio.event
|
|
async def submit_answer(sid, data):
|
|
"""Submit an answer to the current question."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
answer = data.get("answer", "")
|
|
question = data.get("question", {})
|
|
is_steal = data.get("is_steal", False)
|
|
|
|
result = await game_manager.submit_answer(
|
|
room_code, question, answer, is_steal
|
|
)
|
|
|
|
if "error" in result:
|
|
await sio.emit("error", {"message": result["error"]}, to=sid)
|
|
return
|
|
|
|
# Actualizar stats del jugador para logros
|
|
stats_dict = await room_manager.get_player_stats(room_code, player["name"])
|
|
if stats_dict:
|
|
stats = PlayerStats(**stats_dict)
|
|
|
|
# Actualizar con achievement_manager
|
|
updated_stats = achievement_manager.update_stats_on_answer(
|
|
stats=stats,
|
|
was_correct=result["valid"],
|
|
was_steal=is_steal,
|
|
category_id=question.get("category_id", 0),
|
|
points=question.get("points", 0),
|
|
answer_time_seconds=data.get("answer_time", 30) # Frontend debe enviar esto
|
|
)
|
|
|
|
# Guardar stats actualizadas
|
|
await room_manager.set_player_stats(room_code, player["name"], updated_stats.model_dump())
|
|
|
|
await sio.emit(
|
|
"answer_result",
|
|
{
|
|
"player_name": player["name"],
|
|
"team": player["team"],
|
|
"answer": answer,
|
|
"valid": result["valid"],
|
|
"reason": result["reason"],
|
|
"points_earned": result["points_earned"],
|
|
"was_steal": is_steal,
|
|
"room": result["room"]
|
|
},
|
|
room=room_code
|
|
)
|
|
|
|
# Guardar evento para replay
|
|
room_data = result.get("room", {})
|
|
if room_data.get("db_session_id"):
|
|
async with await get_db_session() as db:
|
|
await replay_manager.save_game_event(
|
|
db=db,
|
|
session_id=room_data["db_session_id"],
|
|
event_type="answer_submitted",
|
|
player_name=player["name"],
|
|
team=player["team"],
|
|
question_id=question.get("id"),
|
|
answer_given=answer,
|
|
was_correct=result["valid"],
|
|
was_steal=is_steal,
|
|
points_earned=result["points_earned"]
|
|
)
|
|
|
|
# Verificar si el juego termino (todas las preguntas respondidas)
|
|
if room_data.get("status") == "finished":
|
|
# Disparar finalizacion automatica
|
|
await finish_game_internal(room_code)
|
|
|
|
@sio.event
|
|
async def steal_decision(sid, data):
|
|
"""Decide whether to attempt stealing."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
attempt = data.get("attempt", False)
|
|
question_id = data.get("question_id")
|
|
|
|
if not attempt:
|
|
# Pass on steal
|
|
room = await game_manager.pass_steal(room_code, question_id)
|
|
if room:
|
|
await sio.emit(
|
|
"steal_passed",
|
|
{"room": room, "team": player["team"]},
|
|
room=room_code
|
|
)
|
|
|
|
# Guardar evento para replay
|
|
if room.get("db_session_id"):
|
|
async with await get_db_session() as db:
|
|
await replay_manager.save_game_event(
|
|
db=db,
|
|
session_id=room["db_session_id"],
|
|
event_type="steal_passed",
|
|
player_name=player["name"],
|
|
team=player["team"],
|
|
question_id=question_id
|
|
)
|
|
else:
|
|
# Will attempt steal - just notify, answer comes separately
|
|
room = await room_manager.get_room(room_code)
|
|
await sio.emit(
|
|
"steal_attempted",
|
|
{
|
|
"team": player["team"],
|
|
"player_name": player["name"],
|
|
"room": room
|
|
},
|
|
room=room_code
|
|
)
|
|
|
|
# Guardar evento para replay
|
|
if room and room.get("db_session_id"):
|
|
async with await get_db_session() as db:
|
|
await replay_manager.save_game_event(
|
|
db=db,
|
|
session_id=room["db_session_id"],
|
|
event_type="steal_attempted",
|
|
player_name=player["name"],
|
|
team=player["team"],
|
|
question_id=question_id
|
|
)
|
|
|
|
@sio.event
|
|
async def chat_message(sid, data):
|
|
"""Send a chat message to team."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
message = data.get("message", "")[:500] # Limit message length
|
|
|
|
# Get all team members' socket IDs
|
|
room = await room_manager.get_room(room_code)
|
|
if not room:
|
|
return
|
|
|
|
team_sockets = [
|
|
p["socket_id"] for p in room["teams"][player["team"]]
|
|
]
|
|
|
|
# Send only to team members
|
|
for socket_id in team_sockets:
|
|
await sio.emit(
|
|
"chat_message",
|
|
{
|
|
"player_name": player["name"],
|
|
"team": player["team"],
|
|
"message": message,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
to=socket_id
|
|
)
|
|
|
|
@sio.event
|
|
async def team_message(sid, data):
|
|
"""Send a team chat message - only visible to teammates."""
|
|
room_code = data.get("room_code", "")
|
|
team = data.get("team", "")
|
|
player_name = data.get("player_name", "")
|
|
message = data.get("message", "")[:500] # Limit message length
|
|
|
|
if not all([room_code, team, player_name, message]):
|
|
return
|
|
|
|
# Validate player exists in room
|
|
player = await room_manager.get_player(sid)
|
|
if not player or player["room"] != room_code or player["team"] != team:
|
|
return
|
|
|
|
# Get room data to find team members
|
|
room = await room_manager.get_room(room_code)
|
|
if not room:
|
|
return
|
|
|
|
# Get socket IDs of team members
|
|
team_sockets = [
|
|
p["socket_id"] for p in room["teams"][team]
|
|
if p.get("socket_id")
|
|
]
|
|
|
|
# Emit only to team members
|
|
message_data = {
|
|
"player_name": player_name,
|
|
"team": team,
|
|
"message": message,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
}
|
|
|
|
for socket_id in team_sockets:
|
|
await sio.emit(
|
|
"receive_team_message",
|
|
message_data,
|
|
to=socket_id
|
|
)
|
|
|
|
@sio.event
|
|
async def send_reaction(sid, data):
|
|
"""Send an emoji reaction visible to all players in the room."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = data.get("room_code", player["room"])
|
|
player_name = data.get("player_name", player["name"])
|
|
emoji = data.get("emoji", "")
|
|
|
|
# Validate emoji
|
|
allowed_emojis = ["👏", "😮", "😂", "🔥", "💀", "🎉", "😭", "🤔"]
|
|
if emoji not in allowed_emojis:
|
|
return
|
|
|
|
# Rate limiting: max 1 reaction every 3 seconds per player
|
|
current_time = time.time()
|
|
if room_code not in reaction_rate_limits:
|
|
reaction_rate_limits[room_code] = {}
|
|
|
|
last_reaction = reaction_rate_limits[room_code].get(player_name, 0)
|
|
if current_time - last_reaction < REACTION_COOLDOWN_SECONDS:
|
|
# Player is rate limited, ignore the reaction
|
|
return
|
|
|
|
# Update last reaction time
|
|
reaction_rate_limits[room_code][player_name] = current_time
|
|
|
|
# Emit to ALL players in the room (both teams)
|
|
await sio.emit(
|
|
"receive_reaction",
|
|
{
|
|
"player_name": player_name,
|
|
"team": player["team"],
|
|
"emoji": emoji,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
room=room_code
|
|
)
|
|
|
|
# Keep old event name for backwards compatibility
|
|
@sio.event
|
|
async def emoji_reaction(sid, data):
|
|
"""Alias for send_reaction (backwards compatibility)."""
|
|
await send_reaction(sid, data)
|
|
|
|
@sio.event
|
|
async def timer_expired(sid, data):
|
|
"""Handle timer expiration."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
room = await room_manager.get_room(room_code)
|
|
|
|
if not room:
|
|
return
|
|
|
|
# Treat as wrong answer
|
|
if room["can_steal"]:
|
|
# Steal timer expired - pass
|
|
question_id = room["current_question"]
|
|
room = await game_manager.pass_steal(room_code, question_id)
|
|
await sio.emit("time_up", {"room": room, "was_steal": True}, room=room_code)
|
|
else:
|
|
# Answer timer expired - enable steal
|
|
room["can_steal"] = True
|
|
room["current_team"] = "B" if room["current_team"] == "A" else "A"
|
|
await room_manager.update_room(room_code, room)
|
|
await sio.emit("time_up", {"room": room, "was_steal": False}, room=room_code)
|
|
|
|
async def finish_game_internal(room_code: str):
|
|
"""
|
|
Funcion interna para finalizar la partida.
|
|
Se llama automaticamente cuando todas las preguntas fueron respondidas,
|
|
o manualmente desde el evento finish_game.
|
|
"""
|
|
room = await room_manager.get_room(room_code)
|
|
if not room or room["status"] != "finished":
|
|
return
|
|
|
|
async with await get_db_session() as db:
|
|
# 1. Guardar evento de fin de partida
|
|
if room.get("db_session_id"):
|
|
await replay_manager.save_game_event(
|
|
db=db,
|
|
session_id=room["db_session_id"],
|
|
event_type="game_finished",
|
|
player_name="system",
|
|
team="",
|
|
points_earned=0
|
|
)
|
|
|
|
# 2. Finalizar sesion en BD
|
|
team_a_score = room["scores"]["A"]
|
|
team_b_score = room["scores"]["B"]
|
|
questions_used = [
|
|
q["id"]
|
|
for questions in room["board"].values()
|
|
for q in questions
|
|
if q.get("answered")
|
|
]
|
|
|
|
db_session = await game_manager.finish_game(
|
|
db, room_code, team_a_score, team_b_score, questions_used
|
|
)
|
|
|
|
# 3. Generar codigo de replay
|
|
replay_code = None
|
|
if db_session:
|
|
replay_code = replay_manager.generate_replay_code(db_session.id)
|
|
|
|
# 4. Determinar ganador
|
|
winner = None
|
|
if team_a_score > team_b_score:
|
|
winner = "A"
|
|
elif team_b_score > team_a_score:
|
|
winner = "B"
|
|
# else: empate
|
|
|
|
# 5. Verificar logros para cada jugador
|
|
all_achievements = []
|
|
for team in ["A", "B"]:
|
|
for player_info in room["teams"][team]:
|
|
stats_dict = await room_manager.get_player_stats(room_code, player_info["name"])
|
|
if stats_dict:
|
|
stats = PlayerStats(**stats_dict)
|
|
|
|
# Contexto del juego para este jugador
|
|
player_won = (winner == team)
|
|
own_score = team_a_score if team == "A" else team_b_score
|
|
opp_score = team_b_score if team == "A" else team_a_score
|
|
|
|
game_context = {
|
|
"won": player_won,
|
|
"team_score": own_score,
|
|
"opponent_score": opp_score,
|
|
"max_deficit_overcome": max(0, opp_score - own_score) if player_won else 0,
|
|
"categories_swept": [], # TODO: calcular si completo categoria
|
|
"no_mistakes": stats.total_correct == stats.total_correct + 0 # TODO: rastrear errores
|
|
}
|
|
|
|
unlocked = await achievement_manager.check_achievements(db, stats, game_context)
|
|
for ach in unlocked:
|
|
all_achievements.append({
|
|
"player_name": player_info["name"],
|
|
"team": team,
|
|
"achievement": ach.achievement.model_dump() if hasattr(ach.achievement, 'model_dump') else ach.achievement
|
|
})
|
|
|
|
# 6. Actualizar estado de la sala (ya esta en finished desde game_manager)
|
|
await room_manager.update_room(room_code, room)
|
|
|
|
# 7. Emitir evento a todos
|
|
await sio.emit(
|
|
"game_finished",
|
|
{
|
|
"room": room,
|
|
"winner": winner,
|
|
"final_scores": {
|
|
"A": team_a_score,
|
|
"B": team_b_score
|
|
},
|
|
"replay_code": replay_code,
|
|
"achievements_unlocked": all_achievements
|
|
},
|
|
room=room_code
|
|
)
|
|
|
|
@sio.event
|
|
async def finish_game(sid, data):
|
|
"""Finaliza la partida y procesa resultados."""
|
|
player = await room_manager.get_player(sid)
|
|
if not player:
|
|
return
|
|
|
|
room_code = player["room"]
|
|
room = await room_manager.get_room(room_code)
|
|
|
|
if not room or room["status"] != "playing":
|
|
return
|
|
|
|
# Solo el host puede finalizar (o se detecta automaticamente)
|
|
if room["host"] != player["name"] and not data.get("auto_finish"):
|
|
return
|
|
|
|
# Marcar como terminado
|
|
room["status"] = "finished"
|
|
await room_manager.update_room(room_code, room)
|
|
|
|
# Procesar finalizacion
|
|
await finish_game_internal(room_code)
|