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:
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()
|
||||
Reference in New Issue
Block a user