feat(phase3): Implement complete game logic with WebSocket events
Timer System: - Add TimerManager service with asyncio for server-side timers - Support steal time reduction (50% time) - Automatic timer cancellation on answer Question Loading: - Add QuestionService to load daily questions from PostgreSQL - Generate 8×5 board (categories × difficulties) - Filter by date_active and approved status Database Integration: - Create GameSession in PostgreSQL when game starts - Update scores during game and on finish - Store db_session_id in Redis for cross-reference Replay Integration: - Save all game events: question_selected, answer_submitted, steal_attempted, steal_passed, game_finished - Generate unique replay code on game finish Achievement Integration: - Initialize PlayerStats in Redis when joining room - Update stats on every answer (streak, category, speed, etc.) - Check achievements on game finish for all players Game Finish: - Automatic finish when all questions answered - Manual finish by host - Emit game_finished with winner, scores, replay_code, achievements Phase 3 tasks completed: - F3.1: Timer manager with asyncio - F3.2: Question service for board loading - F3.3: GameSession PostgreSQL integration - F3.4: Replay event saving - F3.5: Achievement stats tracking - F3.6: Complete game finish flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ from app.services.game_manager import GameManager
|
|||||||
from app.services.room_manager import RoomManager
|
from app.services.room_manager import RoomManager
|
||||||
from app.services.replay_manager import ReplayManager
|
from app.services.replay_manager import ReplayManager
|
||||||
from app.services.achievement_manager import AchievementManager
|
from app.services.achievement_manager import AchievementManager
|
||||||
|
from app.services.timer_manager import TimerManager, timer_manager
|
||||||
|
from app.services.question_service import QuestionService, question_service
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AIValidator",
|
"AIValidator",
|
||||||
@@ -12,4 +14,8 @@ __all__ = [
|
|||||||
"RoomManager",
|
"RoomManager",
|
||||||
"ReplayManager",
|
"ReplayManager",
|
||||||
"AchievementManager",
|
"AchievementManager",
|
||||||
|
"TimerManager",
|
||||||
|
"timer_manager",
|
||||||
|
"QuestionService",
|
||||||
|
"question_service",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
from app.services.room_manager import room_manager
|
from app.services.room_manager import room_manager
|
||||||
from app.services.ai_validator import ai_validator
|
from app.services.ai_validator import ai_validator
|
||||||
|
from app.services.question_service import question_service
|
||||||
|
from app.models.game_session import GameSession
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -200,5 +204,105 @@ class GameManager:
|
|||||||
return datetime.utcnow() + timedelta(seconds=time_seconds)
|
return datetime.utcnow() + timedelta(seconds=time_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Database Integration Methods
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def create_db_session(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
room_code: str
|
||||||
|
) -> GameSession:
|
||||||
|
"""Crea una sesión de juego en PostgreSQL."""
|
||||||
|
session = GameSession(
|
||||||
|
room_code=room_code,
|
||||||
|
status="waiting"
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def get_db_session(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
room_code: str
|
||||||
|
) -> Optional[GameSession]:
|
||||||
|
"""Obtiene sesión de BD por room_code."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(GameSession).where(GameSession.room_code == room_code)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def update_db_session(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
room_code: str,
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[GameSession]:
|
||||||
|
"""Actualiza sesión en BD."""
|
||||||
|
session = await self.get_db_session(db, room_code)
|
||||||
|
if session:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(session, key):
|
||||||
|
setattr(session, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def start_game_with_db(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
room_code: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Inicia juego: crea sesión en BD, carga tablero, actualiza Redis.
|
||||||
|
"""
|
||||||
|
# Crear sesión en BD
|
||||||
|
db_session = await self.create_db_session(db, room_code)
|
||||||
|
|
||||||
|
# Cargar tablero del día
|
||||||
|
board = await question_service.get_board_for_game(db)
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
# No hay preguntas para hoy
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Iniciar en Redis (método existente)
|
||||||
|
room = await self.start_game(room_code, board)
|
||||||
|
|
||||||
|
if room:
|
||||||
|
# Guardar session_id en Redis para referencia
|
||||||
|
room["db_session_id"] = db_session.id
|
||||||
|
await room_manager.update_room(room_code, room)
|
||||||
|
|
||||||
|
# Actualizar BD
|
||||||
|
await self.update_db_session(
|
||||||
|
db, room_code,
|
||||||
|
status="playing"
|
||||||
|
)
|
||||||
|
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def finish_game(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
room_code: str,
|
||||||
|
team_a_score: int,
|
||||||
|
team_b_score: int,
|
||||||
|
questions_used: list
|
||||||
|
) -> Optional[GameSession]:
|
||||||
|
"""Finaliza el juego y guarda en BD."""
|
||||||
|
session = await self.update_db_session(
|
||||||
|
db, room_code,
|
||||||
|
status="finished",
|
||||||
|
team_a_score=team_a_score,
|
||||||
|
team_b_score=team_b_score,
|
||||||
|
questions_used=questions_used,
|
||||||
|
finished_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
game_manager = GameManager()
|
game_manager = GameManager()
|
||||||
|
|||||||
136
backend/app/services/question_service.py
Normal file
136
backend/app/services/question_service.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import date
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.question import Question
|
||||||
|
from app.models.category import Category
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionService:
|
||||||
|
async def get_daily_questions(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
) -> Dict[int, List[dict]]:
|
||||||
|
"""
|
||||||
|
Obtiene preguntas del día organizadas por categoría.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Sesión de BD
|
||||||
|
target_date: Fecha (default: hoy)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[category_id, List[question_dict]] para el tablero
|
||||||
|
"""
|
||||||
|
if target_date is None:
|
||||||
|
target_date = date.today()
|
||||||
|
|
||||||
|
# Buscar preguntas aprobadas para la fecha
|
||||||
|
query = select(Question).where(
|
||||||
|
and_(
|
||||||
|
Question.date_active == target_date,
|
||||||
|
Question.status == "approved"
|
||||||
|
)
|
||||||
|
).order_by(Question.category_id, Question.difficulty)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
questions = result.scalars().all()
|
||||||
|
|
||||||
|
# Organizar por categoría
|
||||||
|
board: Dict[int, List[dict]] = {}
|
||||||
|
for q in questions:
|
||||||
|
if q.category_id not in board:
|
||||||
|
board[q.category_id] = []
|
||||||
|
board[q.category_id].append({
|
||||||
|
"id": q.id,
|
||||||
|
"category_id": q.category_id,
|
||||||
|
"question_text": q.question_text,
|
||||||
|
"correct_answer": q.correct_answer,
|
||||||
|
"alt_answers": q.alt_answers or [],
|
||||||
|
"difficulty": q.difficulty,
|
||||||
|
"points": q.points,
|
||||||
|
"time_seconds": q.time_seconds,
|
||||||
|
"fun_fact": q.fun_fact,
|
||||||
|
"answered": False,
|
||||||
|
"selected": False
|
||||||
|
})
|
||||||
|
|
||||||
|
return board
|
||||||
|
|
||||||
|
async def get_board_for_game(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
) -> Dict[str, List[dict]]:
|
||||||
|
"""
|
||||||
|
Genera el tablero 8×5 para el juego.
|
||||||
|
Si no hay suficientes preguntas, retorna lo disponible.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con category_id como string (para JSON) -> lista de preguntas
|
||||||
|
"""
|
||||||
|
board = await self.get_daily_questions(db, target_date)
|
||||||
|
|
||||||
|
# Convertir keys a string para JSON
|
||||||
|
return {str(k): v for k, v in board.items()}
|
||||||
|
|
||||||
|
async def get_question_by_id(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
question_id: int
|
||||||
|
) -> Optional[Question]:
|
||||||
|
"""Obtiene una pregunta por ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Question).where(Question.id == question_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def mark_question_used(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
question_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""Marca una pregunta como usada."""
|
||||||
|
question = await self.get_question_by_id(db, question_id)
|
||||||
|
if question:
|
||||||
|
question.status = "used"
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_categories_with_questions(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
target_date: Optional[date] = None
|
||||||
|
) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Obtiene categorías que tienen preguntas para la fecha.
|
||||||
|
Útil para mostrar solo categorías disponibles.
|
||||||
|
"""
|
||||||
|
board = await self.get_daily_questions(db, target_date)
|
||||||
|
|
||||||
|
# Obtener info de categorías
|
||||||
|
cat_ids = list(board.keys())
|
||||||
|
if not cat_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Category).where(Category.id.in_(cat_ids))
|
||||||
|
)
|
||||||
|
categories = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"icon": c.icon,
|
||||||
|
"color": c.color,
|
||||||
|
"question_count": len(board.get(c.id, []))
|
||||||
|
}
|
||||||
|
for c in categories
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
question_service = QuestionService()
|
||||||
@@ -168,6 +168,38 @@ class RoomManager:
|
|||||||
return json.loads(data)
|
return json.loads(data)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_player_stats(self, room_code: str, player_name: str) -> Optional[dict]:
|
||||||
|
"""Obtiene stats de un jugador."""
|
||||||
|
await self.connect()
|
||||||
|
data = await self.redis.get(f"stats:{room_code}:{player_name}")
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_player_stats(self, room_code: str, player_name: str, stats: dict) -> None:
|
||||||
|
"""Guarda stats de un jugador."""
|
||||||
|
await self.connect()
|
||||||
|
await self.redis.setex(
|
||||||
|
f"stats:{room_code}:{player_name}",
|
||||||
|
3600 * 3,
|
||||||
|
json.dumps(stats)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def init_player_stats(self, room_code: str, player_name: str) -> dict:
|
||||||
|
"""Inicializa stats para un nuevo jugador."""
|
||||||
|
stats = {
|
||||||
|
"player_name": player_name,
|
||||||
|
"current_streak": 0,
|
||||||
|
"total_correct": 0,
|
||||||
|
"total_steals": 0,
|
||||||
|
"successful_steals": 0,
|
||||||
|
"category_correct": {},
|
||||||
|
"fastest_answer_seconds": None,
|
||||||
|
"questions_500_correct": 0
|
||||||
|
}
|
||||||
|
await self.set_player_stats(room_code, player_name, stats)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
room_manager = RoomManager()
|
room_manager = RoomManager()
|
||||||
|
|||||||
86
backend/app/services/timer_manager.py
Normal file
86
backend/app/services/timer_manager.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Optional, Dict, Callable, Awaitable
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class TimerManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_timers: Dict[str, asyncio.Task] = {} # room_code -> task
|
||||||
|
self.timer_end_times: Dict[str, datetime] = {} # room_code -> end_time
|
||||||
|
|
||||||
|
async def start_timer(
|
||||||
|
self,
|
||||||
|
room_code: str,
|
||||||
|
seconds: int,
|
||||||
|
on_expire: Callable[[str], Awaitable[None]],
|
||||||
|
is_steal: bool = False
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Inicia un timer para una sala.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_code: Codigo de la sala
|
||||||
|
seconds: Segundos base (se reduce si is_steal)
|
||||||
|
on_expire: Callback async cuando expire el timer
|
||||||
|
is_steal: Si es intento de robo (tiempo reducido)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime cuando expira el timer
|
||||||
|
"""
|
||||||
|
# Cancelar timer anterior si existe
|
||||||
|
await self.cancel_timer(room_code)
|
||||||
|
|
||||||
|
# Calcular tiempo real
|
||||||
|
if is_steal:
|
||||||
|
seconds = int(seconds * settings.steal_time_multiplier)
|
||||||
|
|
||||||
|
end_time = datetime.utcnow() + timedelta(seconds=seconds)
|
||||||
|
self.timer_end_times[room_code] = end_time
|
||||||
|
|
||||||
|
# Crear task
|
||||||
|
async def timer_task():
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
# Timer expiro
|
||||||
|
if room_code in self.active_timers:
|
||||||
|
del self.active_timers[room_code]
|
||||||
|
del self.timer_end_times[room_code]
|
||||||
|
await on_expire(room_code)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Timer fue cancelado (respuesta recibida a tiempo)
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.active_timers[room_code] = asyncio.create_task(timer_task())
|
||||||
|
return end_time
|
||||||
|
|
||||||
|
async def cancel_timer(self, room_code: str) -> bool:
|
||||||
|
"""Cancela el timer de una sala."""
|
||||||
|
if room_code in self.active_timers:
|
||||||
|
self.active_timers[room_code].cancel()
|
||||||
|
try:
|
||||||
|
await self.active_timers[room_code]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
del self.active_timers[room_code]
|
||||||
|
if room_code in self.timer_end_times:
|
||||||
|
del self.timer_end_times[room_code]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_remaining_time(self, room_code: str) -> Optional[float]:
|
||||||
|
"""Obtiene segundos restantes del timer."""
|
||||||
|
if room_code in self.timer_end_times:
|
||||||
|
remaining = (self.timer_end_times[room_code] - datetime.utcnow()).total_seconds()
|
||||||
|
return max(0, remaining)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_timer_active(self, room_code: str) -> bool:
|
||||||
|
"""Verifica si hay un timer activo para la sala."""
|
||||||
|
return room_code in self.active_timers
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
timer_manager = TimerManager()
|
||||||
@@ -2,6 +2,16 @@ import socketio
|
|||||||
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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def register_socket_events(sio: socketio.AsyncServer):
|
||||||
@@ -30,6 +40,9 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
|
|
||||||
room = await room_manager.create_room(player_name, sid)
|
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
|
# Join socket room
|
||||||
sio.enter_room(sid, room["code"])
|
sio.enter_room(sid, room["code"])
|
||||||
|
|
||||||
@@ -52,6 +65,9 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Inicializar stats del jugador para logros
|
||||||
|
await room_manager.init_player_stats(room_code, player_name)
|
||||||
|
|
||||||
# Join socket room
|
# Join socket room
|
||||||
sio.enter_room(sid, room_code)
|
sio.enter_room(sid, room_code)
|
||||||
|
|
||||||
@@ -160,6 +176,18 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
room=room_code
|
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
|
@sio.event
|
||||||
async def submit_answer(sid, data):
|
async def submit_answer(sid, data):
|
||||||
"""Submit an answer to the current question."""
|
"""Submit an answer to the current question."""
|
||||||
@@ -180,6 +208,24 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
await sio.emit("error", {"message": result["error"]}, to=sid)
|
await sio.emit("error", {"message": result["error"]}, to=sid)
|
||||||
return
|
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(
|
await sio.emit(
|
||||||
"answer_result",
|
"answer_result",
|
||||||
{
|
{
|
||||||
@@ -195,6 +241,28 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
room=room_code
|
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
|
@sio.event
|
||||||
async def steal_decision(sid, data):
|
async def steal_decision(sid, data):
|
||||||
"""Decide whether to attempt stealing."""
|
"""Decide whether to attempt stealing."""
|
||||||
@@ -215,6 +283,18 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
{"room": room, "team": player["team"]},
|
{"room": room, "team": player["team"]},
|
||||||
room=room_code
|
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:
|
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)
|
||||||
@@ -228,6 +308,18 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
room=room_code
|
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
|
@sio.event
|
||||||
async def chat_message(sid, data):
|
async def chat_message(sid, data):
|
||||||
"""Send a chat message to team."""
|
"""Send a chat message to team."""
|
||||||
@@ -310,3 +402,125 @@ def register_socket_events(sio: socketio.AsyncServer):
|
|||||||
room["current_team"] = "B" if room["current_team"] == "A" else "A"
|
room["current_team"] = "B" if room["current_team"] == "A" else "A"
|
||||||
await room_manager.update_room(room_code, room)
|
await room_manager.update_room(room_code, room)
|
||||||
await sio.emit("time_up", {"room": room, "was_steal": False}, room=room_code)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user