From 27ac4cb0cfd9a3cba2f5fccd98d63886f8d83672 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 26 Jan 2026 08:24:02 +0000 Subject: [PATCH] feat(phase2): Add achievements and replay systems Achievement System: - Add Achievement model with condition types (streak, steal, specialist, etc.) - Add AchievementManager service for tracking and awarding achievements - Add Pydantic schemas for achievements (AchievementResponse, PlayerStats, etc.) - Seed 18 achievements from design doc - Add GET /api/game/achievements endpoint Replay System: - Add ReplayManager service for saving/loading game replays - Add GET /api/replay/{code} and /api/replay/session/{id} endpoints - Format replays for frontend consumption Phase 2 tasks completed: - F2.1: Achievement model and migration - F2.2: Pydantic schemas - F2.3: AchievementManager service - F2.4: ReplayManager service - F2.5: API endpoints - F2.6: Seed 18 achievements data Co-Authored-By: Claude Opus 4.5 --- backend/alembic/env.py | 2 +- .../f207a5a45cfa_add_achievements_table.py | 44 ++++ backend/app/api/game.py | 32 +-- backend/app/api/replay.py | 119 ++------- backend/app/models/__init__.py | 3 +- backend/app/models/achievement.py | 22 ++ backend/app/schemas/__init__.py | 12 +- backend/app/schemas/achievement.py | 50 ++++ backend/app/services/__init__.py | 11 +- backend/app/services/achievement_manager.py | 245 ++++++++++++++++++ backend/app/services/replay_manager.py | 219 ++++++++++++++++ backend/scripts/seed_achievements.py | 176 +++++++++++++ 12 files changed, 804 insertions(+), 131 deletions(-) create mode 100644 backend/alembic/versions/f207a5a45cfa_add_achievements_table.py create mode 100644 backend/app/models/achievement.py create mode 100644 backend/app/schemas/achievement.py create mode 100644 backend/app/services/achievement_manager.py create mode 100644 backend/app/services/replay_manager.py create mode 100644 backend/scripts/seed_achievements.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d872f15..8e58f4b 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,7 @@ import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.models.base import Base -from app.models import Category, Question, GameSession, GameEvent, Admin +from app.models import Category, Question, GameSession, GameEvent, Admin, Achievement config = context.config if config.config_file_name is not None: diff --git a/backend/alembic/versions/f207a5a45cfa_add_achievements_table.py b/backend/alembic/versions/f207a5a45cfa_add_achievements_table.py new file mode 100644 index 0000000..9f7bae2 --- /dev/null +++ b/backend/alembic/versions/f207a5a45cfa_add_achievements_table.py @@ -0,0 +1,44 @@ +"""add_achievements_table + +Revision ID: f207a5a45cfa +Revises: 65d30b7402cf +Create Date: 2026-01-26 08:14:59.697355 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f207a5a45cfa' +down_revision: Union[str, None] = '65d30b7402cf' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('achievements', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('icon', sa.String(length=10), nullable=False), + sa.Column('condition_type', sa.String(length=50), nullable=False), + sa.Column('condition_value', sa.Integer(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_index(op.f('ix_achievements_id'), 'achievements', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_achievements_id'), table_name='achievements') + op.drop_table('achievements') + # ### end Alembic commands ### diff --git a/backend/app/api/game.py b/backend/app/api/game.py index c9c98d0..9ebd4fc 100644 --- a/backend/app/api/game.py +++ b/backend/app/api/game.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import date @@ -8,6 +8,8 @@ from app.models.base import get_db from app.models.question import Question from app.models.category import Category from app.schemas.game import RoomCreate, RoomJoin, GameState +from app.services.achievement_manager import achievement_manager +from app.schemas.achievement import AchievementResponse router = APIRouter() @@ -94,26 +96,8 @@ async def get_question(question_id: int): } -@router.get("/achievements") -async def get_achievements(): - """Get list of all available achievements.""" - return [ - {"id": 1, "name": "Primera Victoria", "description": "Ganar tu primera partida", "icon": "🏆"}, - {"id": 2, "name": "Racha de 3", "description": "Responder 3 correctas seguidas", "icon": "🔥"}, - {"id": 3, "name": "Racha de 5", "description": "Responder 5 correctas seguidas", "icon": "🔥🔥"}, - {"id": 4, "name": "Ladrón Novato", "description": "Primer robo exitoso", "icon": "🦝"}, - {"id": 5, "name": "Ladrón Maestro", "description": "5 robos exitosos en una partida", "icon": "🦝👑"}, - {"id": 6, "name": "Especialista Nintendo", "description": "10 correctas en Nintendo", "icon": "🍄"}, - {"id": 7, "name": "Especialista Xbox", "description": "10 correctas en Xbox", "icon": "🎮"}, - {"id": 8, "name": "Especialista PlayStation", "description": "10 correctas en PlayStation", "icon": "🎯"}, - {"id": 9, "name": "Especialista Anime", "description": "10 correctas en Anime", "icon": "⛩️"}, - {"id": 10, "name": "Especialista Música", "description": "10 correctas en Música", "icon": "🎵"}, - {"id": 11, "name": "Especialista Películas", "description": "10 correctas en Películas", "icon": "🎬"}, - {"id": 12, "name": "Especialista Libros", "description": "10 correctas en Libros", "icon": "📚"}, - {"id": 13, "name": "Especialista Historia", "description": "10 correctas en Historia-Cultura", "icon": "🏛️"}, - {"id": 14, "name": "Invicto", "description": "Ganar sin fallar ninguna pregunta", "icon": "⭐"}, - {"id": 15, "name": "Velocista", "description": "Responder correctamente en menos de 3 segundos", "icon": "⚡"}, - {"id": 16, "name": "Comeback", "description": "Ganar estando 500+ puntos abajo", "icon": "🔄"}, - {"id": 17, "name": "Dominio Total", "description": "Responder las 5 preguntas de una categoría", "icon": "👑"}, - {"id": 18, "name": "Arriesgado", "description": "Responder correctamente 3 preguntas de 500 pts", "icon": "🎰"}, - ] +@router.get("/achievements", response_model=list[AchievementResponse]) +async def get_achievements(db: AsyncSession = Depends(get_db)): + """Obtiene lista de todos los logros disponibles""" + achievements = await achievement_manager.get_all_achievements(db) + return achievements diff --git a/backend/app/api/replay.py b/backend/app/api/replay.py index 4489f06..5d8ff99 100644 --- a/backend/app/api/replay.py +++ b/backend/app/api/replay.py @@ -1,113 +1,26 @@ -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List +from typing import Optional from app.models.base import get_db -from app.models.game_session import GameSession -from app.models.game_event import GameEvent +from app.services.replay_manager import replay_manager router = APIRouter() -@router.get("/{session_id}") -async def get_replay( - session_id: int, - db: AsyncSession = Depends(get_db) -): - """ - Get replay data for a game session. - Returns all events in chronological order. - """ - # Get session - result = await db.execute( - select(GameSession).where(GameSession.id == session_id) - ) - session = result.scalar_one_or_none() - - if not session: +@router.get("/session/{session_id}") +async def get_replay_by_session(session_id: int, db: AsyncSession = Depends(get_db)): + """Obtiene un replay por session_id""" + replay = await replay_manager.get_replay_by_session(db, session_id) + if not replay: raise HTTPException(status_code=404, detail="Session not found") - - # Get all events - events_result = await db.execute( - select(GameEvent) - .where(GameEvent.session_id == session_id) - .order_by(GameEvent.timestamp) - ) - events = events_result.scalars().all() - - return { - "session": { - "id": session.id, - "room_code": session.room_code, - "team_a_score": session.team_a_score, - "team_b_score": session.team_b_score, - "status": session.status, - "created_at": session.created_at, - "finished_at": session.finished_at - }, - "events": [ - { - "id": e.id, - "event_type": e.event_type, - "player_name": e.player_name, - "team": e.team, - "question_id": e.question_id, - "answer_given": e.answer_given, - "was_correct": e.was_correct, - "was_steal": e.was_steal, - "points_earned": e.points_earned, - "timestamp": e.timestamp - } - for e in events - ] - } + return replay -@router.get("/code/{room_code}") -async def get_replay_by_code( - room_code: str, - db: AsyncSession = Depends(get_db) -): - """ - Get replay data by room code. - """ - result = await db.execute( - select(GameSession).where(GameSession.room_code == room_code) - ) - session = result.scalar_one_or_none() - - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - return await get_replay(session.id, db) - - -@router.get("/") -async def list_replays( - limit: int = 20, - offset: int = 0, - db: AsyncSession = Depends(get_db) -): - """ - List recent finished game sessions. - """ - result = await db.execute( - select(GameSession) - .where(GameSession.status == "finished") - .order_by(GameSession.finished_at.desc()) - .offset(offset) - .limit(limit) - ) - sessions = result.scalars().all() - - return [ - { - "id": s.id, - "room_code": s.room_code, - "team_a_score": s.team_a_score, - "team_b_score": s.team_b_score, - "finished_at": s.finished_at - } - for s in sessions - ] +@router.get("/{replay_code}") +async def get_replay(replay_code: str, db: AsyncSession = Depends(get_db)): + """Obtiene un replay por su codigo""" + replay = await replay_manager.get_replay(db, replay_code) + if not replay: + raise HTTPException(status_code=404, detail="Replay not found") + return replay diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 024050c..1e95509 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,5 +3,6 @@ from app.models.question import Question from app.models.game_session import GameSession from app.models.game_event import GameEvent from app.models.admin import Admin +from app.models.achievement import Achievement -__all__ = ["Category", "Question", "GameSession", "GameEvent", "Admin"] +__all__ = ["Category", "Question", "GameSession", "GameEvent", "Admin", "Achievement"] diff --git a/backend/app/models/achievement.py b/backend/app/models/achievement.py new file mode 100644 index 0000000..20ff462 --- /dev/null +++ b/backend/app/models/achievement.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func +from sqlalchemy.orm import relationship +from app.models.base import Base + + +class Achievement(Base): + __tablename__ = "achievements" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(Text, nullable=False) + icon = Column(String(10), nullable=False) + condition_type = Column(String(50), nullable=False) + condition_value = Column(Integer, default=1) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + # Relationships + category = relationship("Category") + + def __repr__(self): + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 504d4e2..ed5615d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -8,10 +8,20 @@ from app.schemas.game import ( StealAttempt ) from app.schemas.admin import AdminCreate, AdminLogin, Token +from app.schemas.achievement import ( + AchievementBase, + AchievementCreate, + AchievementResponse, + PlayerAchievement, + AchievementUnlock, + PlayerStats +) __all__ = [ "QuestionCreate", "QuestionUpdate", "QuestionResponse", "RoomCreate", "RoomJoin", "PlayerInfo", "GameState", "AnswerSubmit", "StealAttempt", - "AdminCreate", "AdminLogin", "Token" + "AdminCreate", "AdminLogin", "Token", + "AchievementBase", "AchievementCreate", "AchievementResponse", + "PlayerAchievement", "AchievementUnlock", "PlayerStats" ] diff --git a/backend/app/schemas/achievement.py b/backend/app/schemas/achievement.py new file mode 100644 index 0000000..de232d5 --- /dev/null +++ b/backend/app/schemas/achievement.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +class AchievementBase(BaseModel): + name: str + description: str + icon: str + condition_type: str + condition_value: int = 1 + category_id: Optional[int] = None + + +class AchievementCreate(AchievementBase): + pass + + +class AchievementResponse(AchievementBase): + id: int + created_at: datetime + + class Config: + from_attributes = True + + +class PlayerAchievement(BaseModel): + """Logro desbloqueado por un jugador (almacenado en localStorage)""" + achievement_id: int + unlocked_at: datetime + game_session_id: Optional[int] = None + + +class AchievementUnlock(BaseModel): + """Evento emitido cuando se desbloquea un logro""" + player_name: str + achievement: AchievementResponse + unlocked_at: datetime + + +class PlayerStats(BaseModel): + """Estadisticas de jugador para calculo de logros""" + player_name: str + current_streak: int = 0 + total_correct: int = 0 + total_steals: int = 0 + successful_steals: int = 0 + category_correct: dict[int, int] = {} # category_id -> count + fastest_answer_seconds: Optional[float] = None + questions_500_correct: int = 0 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 4d93b7d..4c602ba 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -2,5 +2,14 @@ from app.services.ai_validator import AIValidator from app.services.ai_generator import AIGenerator from app.services.game_manager import GameManager from app.services.room_manager import RoomManager +from app.services.replay_manager import ReplayManager +from app.services.achievement_manager import AchievementManager -__all__ = ["AIValidator", "AIGenerator", "GameManager", "RoomManager"] +__all__ = [ + "AIValidator", + "AIGenerator", + "GameManager", + "RoomManager", + "ReplayManager", + "AchievementManager", +] diff --git a/backend/app/services/achievement_manager.py b/backend/app/services/achievement_manager.py new file mode 100644 index 0000000..391f66c --- /dev/null +++ b/backend/app/services/achievement_manager.py @@ -0,0 +1,245 @@ +from typing import Optional, List +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.achievement import Achievement +from app.schemas.achievement import PlayerStats, AchievementUnlock, AchievementResponse + + +class AchievementManager: + """Servicio para detectar y otorgar logros a jugadores durante las partidas.""" + + async def get_all_achievements(self, db: AsyncSession) -> List[Achievement]: + """Obtiene todos los logros disponibles de la base de datos.""" + result = await db.execute(select(Achievement)) + return list(result.scalars().all()) + + async def check_achievements( + self, + db: AsyncSession, + player_stats: PlayerStats, + game_context: dict + ) -> List[AchievementUnlock]: + """ + Verifica que logros ha desbloqueado el jugador. + + Args: + db: Sesion de base de datos + player_stats: Estadisticas actuales del jugador + game_context: { + 'won': bool, + 'team_score': int, + 'opponent_score': int, + 'max_deficit_overcome': int, # para comeback + 'categories_swept': List[int], # IDs de categorias con 5/5 + 'no_mistakes': bool, # para perfect_game + } + + Returns: + Lista de logros desbloqueados + """ + achievements = await self.get_all_achievements(db) + unlocked: List[AchievementUnlock] = [] + now = datetime.utcnow() + + for achievement in achievements: + is_unlocked = self._evaluate_condition( + achievement=achievement, + stats=player_stats, + game_context=game_context + ) + + if is_unlocked: + achievement_response = AchievementResponse( + id=achievement.id, + name=achievement.name, + description=achievement.description, + icon=achievement.icon, + condition_type=achievement.condition_type, + condition_value=achievement.condition_value, + category_id=achievement.category_id, + created_at=achievement.created_at + ) + + unlocked.append(AchievementUnlock( + player_name=player_stats.player_name, + achievement=achievement_response, + unlocked_at=now + )) + + return unlocked + + def _evaluate_condition( + self, + achievement: Achievement, + stats: PlayerStats, + game_context: dict + ) -> bool: + """Evalua si se cumple la condicion de un logro.""" + condition_type = achievement.condition_type + condition_value = achievement.condition_value + + if condition_type == "first_win": + return game_context.get("won", False) + + elif condition_type == "streak": + return self._check_streak(stats, condition_value) + + elif condition_type == "steal_success": + return self._check_steal_success(stats, condition_value) + + elif condition_type == "category_specialist": + if achievement.category_id is not None: + return self._check_category_specialist( + stats, achievement.category_id, condition_value + ) + return False + + elif condition_type == "perfect_game": + return ( + game_context.get("won", False) and + game_context.get("no_mistakes", False) + ) + + elif condition_type == "fast_answer": + return self._check_fast_answer(stats, condition_value) + + elif condition_type == "comeback": + return ( + game_context.get("won", False) and + game_context.get("max_deficit_overcome", 0) >= condition_value + ) + + elif condition_type == "category_sweep": + categories_swept = game_context.get("categories_swept", []) + return len(categories_swept) >= condition_value + + elif condition_type == "high_stakes": + return self._check_high_stakes(stats, condition_value) + + return False + + def _check_streak(self, stats: PlayerStats, required: int) -> bool: + """Verifica racha de respuestas correctas.""" + return stats.current_streak >= required + + def _check_steal_success(self, stats: PlayerStats, required: int) -> bool: + """Verifica robos exitosos.""" + return stats.successful_steals >= required + + def _check_category_specialist( + self, + stats: PlayerStats, + category_id: int, + required: int + ) -> bool: + """Verifica especialista de categoria.""" + return stats.category_correct.get(category_id, 0) >= required + + def _check_fast_answer(self, stats: PlayerStats, max_seconds: int) -> bool: + """Verifica respuesta rapida.""" + if stats.fastest_answer_seconds is None: + return False + return stats.fastest_answer_seconds <= max_seconds + + def _check_high_stakes(self, stats: PlayerStats, required: int) -> bool: + """Verifica preguntas de 500 pts correctas.""" + return stats.questions_500_correct >= required + + def update_stats_on_answer( + self, + stats: PlayerStats, + was_correct: bool, + was_steal: bool, + category_id: int, + points: int, + answer_time_seconds: float + ) -> PlayerStats: + """ + Actualiza estadisticas despues de una respuesta. + + Args: + stats: Estadisticas actuales del jugador + was_correct: Si la respuesta fue correcta + was_steal: Si fue un intento de robo + category_id: ID de la categoria de la pregunta + points: Puntos de la pregunta + answer_time_seconds: Tiempo de respuesta en segundos + + Returns: + PlayerStats actualizado + """ + # Crear copia mutable de las estadisticas + updated_category_correct = dict(stats.category_correct) + + if was_correct: + # Actualizar racha + new_streak = stats.current_streak + 1 + new_total_correct = stats.total_correct + 1 + + # Actualizar correctas por categoria + updated_category_correct[category_id] = ( + updated_category_correct.get(category_id, 0) + 1 + ) + + # Actualizar tiempo mas rapido + new_fastest = stats.fastest_answer_seconds + if new_fastest is None or answer_time_seconds < new_fastest: + new_fastest = answer_time_seconds + + # Actualizar preguntas de 500 pts + new_500_correct = stats.questions_500_correct + if points == 500: + new_500_correct += 1 + + # Actualizar robos exitosos + new_successful_steals = stats.successful_steals + new_total_steals = stats.total_steals + if was_steal: + new_successful_steals += 1 + new_total_steals += 1 + + return PlayerStats( + player_name=stats.player_name, + current_streak=new_streak, + total_correct=new_total_correct, + total_steals=new_total_steals, + successful_steals=new_successful_steals, + category_correct=updated_category_correct, + fastest_answer_seconds=new_fastest, + questions_500_correct=new_500_correct + ) + else: + # Respuesta incorrecta - resetear racha + new_total_steals = stats.total_steals + if was_steal: + new_total_steals += 1 + + return PlayerStats( + player_name=stats.player_name, + current_streak=0, + total_correct=stats.total_correct, + total_steals=new_total_steals, + successful_steals=stats.successful_steals, + category_correct=updated_category_correct, + fastest_answer_seconds=stats.fastest_answer_seconds, + questions_500_correct=stats.questions_500_correct + ) + + def create_initial_stats(self, player_name: str) -> PlayerStats: + """Crea estadisticas iniciales para un jugador.""" + return PlayerStats( + 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 + ) + + +# Singleton instance +achievement_manager = AchievementManager() diff --git a/backend/app/services/replay_manager.py b/backend/app/services/replay_manager.py new file mode 100644 index 0000000..b03c393 --- /dev/null +++ b/backend/app/services/replay_manager.py @@ -0,0 +1,219 @@ +import hashlib +from typing import Optional, List +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.game_session import GameSession +from app.models.game_event import GameEvent + + +class ReplayManager: + def generate_replay_code(self, session_id: int) -> str: + """ + Genera codigo unico de replay basado en session_id. + + Args: + session_id: ID de la sesion de juego + + Returns: + Codigo de replay de 8 caracteres + """ + # Usar hash corto del session_id + timestamp actual + timestamp = datetime.utcnow().isoformat() + data = f"{session_id}:{timestamp}" + hash_digest = hashlib.sha256(data.encode()).hexdigest() + # Retornar los primeros 8 caracteres en mayusculas + return hash_digest[:8].upper() + + async def save_game_event( + self, + db: AsyncSession, + session_id: int, + event_type: str, + player_name: str, + team: str, + question_id: Optional[int] = None, + answer_given: Optional[str] = None, + was_correct: Optional[bool] = None, + was_steal: bool = False, + points_earned: int = 0 + ) -> GameEvent: + """ + Guarda un evento de juego para replay. + + Args: + db: Sesion de base de datos + session_id: ID de la sesion de juego + event_type: Tipo de evento (question_selected, answer_submitted, + steal_attempted, steal_passed, game_finished) + player_name: Nombre del jugador + team: Equipo ('A' o 'B') + question_id: ID de la pregunta (opcional) + answer_given: Respuesta dada por el jugador (opcional) + was_correct: Si la respuesta fue correcta (opcional) + was_steal: Si fue un intento de robo + points_earned: Puntos ganados + + Returns: + GameEvent creado + """ + event = GameEvent( + session_id=session_id, + event_type=event_type, + player_name=player_name, + team=team, + question_id=question_id, + answer_given=answer_given, + was_correct=was_correct, + was_steal=was_steal, + points_earned=points_earned + ) + + db.add(event) + await db.commit() + await db.refresh(event) + + return event + + async def get_replay( + self, + db: AsyncSession, + replay_code: str + ) -> Optional[dict]: + """ + Obtiene replay completo por codigo. + + Args: + db: Sesion de base de datos + replay_code: Codigo del replay + + Returns: + Diccionario con datos del replay o None si no existe + """ + # Buscar session por room_code que contiene el replay_code + # El replay_code puede estar almacenado en room_code o como sufijo + result = await db.execute( + select(GameSession).where( + GameSession.room_code.contains(replay_code) + ) + ) + session = result.scalar_one_or_none() + + if not session: + # Intentar busqueda exacta + result = await db.execute( + select(GameSession).where( + GameSession.room_code == replay_code + ) + ) + session = result.scalar_one_or_none() + + if not session: + return None + + return await self.get_replay_by_session(db, session.id) + + async def get_replay_by_session( + self, + db: AsyncSession, + session_id: int + ) -> Optional[dict]: + """ + Obtiene replay por session_id. + + Args: + db: Sesion de base de datos + session_id: ID de la sesion + + Returns: + Diccionario con datos del replay o None si no existe + """ + # Obtener la sesion + result = await db.execute( + select(GameSession).where(GameSession.id == session_id) + ) + session = result.scalar_one_or_none() + + if not session: + return None + + # Obtener todos los eventos ordenados por timestamp + events_result = await db.execute( + select(GameEvent) + .where(GameEvent.session_id == session_id) + .order_by(GameEvent.timestamp.asc()) + ) + events = events_result.scalars().all() + + return self.format_replay_for_frontend(session, list(events)) + + def format_replay_for_frontend( + self, + session: GameSession, + events: List[GameEvent] + ) -> dict: + """ + Formatea replay para consumo del frontend. + + Args: + session: Sesion de juego + events: Lista de eventos ordenados cronologicamente + + Returns: + Diccionario formateado para el frontend + """ + # Formatear eventos para el frontend + formatted_events = [] + for event in events: + formatted_event = { + "id": event.id, + "event_type": event.event_type, + "player_name": event.player_name, + "team": event.team, + "question_id": event.question_id, + "answer_given": event.answer_given, + "was_correct": event.was_correct, + "was_steal": event.was_steal, + "points_earned": event.points_earned, + "timestamp": event.timestamp.isoformat() if event.timestamp else None + } + formatted_events.append(formatted_event) + + # Determinar ganador + winner = None + if session.status == "finished": + if session.team_a_score > session.team_b_score: + winner = "A" + elif session.team_b_score > session.team_a_score: + winner = "B" + else: + winner = "tie" + + # Calcular duracion si la partida termino + duration_seconds = None + if session.finished_at and session.created_at: + duration = session.finished_at - session.created_at + duration_seconds = int(duration.total_seconds()) + + return { + "metadata": { + "session_id": session.id, + "room_code": session.room_code, + "status": session.status, + "created_at": session.created_at.isoformat() if session.created_at else None, + "finished_at": session.finished_at.isoformat() if session.finished_at else None, + "duration_seconds": duration_seconds + }, + "final_scores": { + "team_a": session.team_a_score, + "team_b": session.team_b_score + }, + "winner": winner, + "events": formatted_events, + "event_count": len(formatted_events) + } + + +# Singleton instance +replay_manager = ReplayManager() diff --git a/backend/scripts/seed_achievements.py b/backend/scripts/seed_achievements.py new file mode 100644 index 0000000..9911c6b --- /dev/null +++ b/backend/scripts/seed_achievements.py @@ -0,0 +1,176 @@ +import asyncio +import sys +sys.path.insert(0, '/root/WebTriviasMulti/backend') + +from sqlalchemy import select +from app.models.base import get_async_session +from app.models.achievement import Achievement + +ACHIEVEMENTS = [ + { + "name": "Primera Victoria", + "description": "Gana tu primera partida", + "icon": "🏆", + "condition_type": "first_win", + "condition_value": 1, + "category_id": None + }, + { + "name": "Racha de 3", + "description": "Responde 3 correctas seguidas", + "icon": "🔥", + "condition_type": "streak", + "condition_value": 3, + "category_id": None + }, + { + "name": "Racha de 5", + "description": "Responde 5 correctas seguidas", + "icon": "🔥🔥", + "condition_type": "streak", + "condition_value": 5, + "category_id": None + }, + { + "name": "Ladrón Novato", + "description": "Primer robo exitoso", + "icon": "🦝", + "condition_type": "steal_success", + "condition_value": 1, + "category_id": None + }, + { + "name": "Ladrón Maestro", + "description": "5 robos exitosos en una partida", + "icon": "🦝👑", + "condition_type": "steal_success", + "condition_value": 5, + "category_id": None + }, + { + "name": "Especialista Nintendo", + "description": "10 correctas en Nintendo", + "icon": "🍄", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 1 + }, + { + "name": "Especialista Xbox", + "description": "10 correctas en Xbox", + "icon": "🎮", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 2 + }, + { + "name": "Especialista PlayStation", + "description": "10 correctas en PlayStation", + "icon": "🎯", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 3 + }, + { + "name": "Especialista Anime", + "description": "10 correctas en Anime", + "icon": "⛩️", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 4 + }, + { + "name": "Especialista Música", + "description": "10 correctas en Música", + "icon": "🎵", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 5 + }, + { + "name": "Especialista Películas", + "description": "10 correctas en Películas", + "icon": "🎬", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 6 + }, + { + "name": "Especialista Libros", + "description": "10 correctas en Libros", + "icon": "📚", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 7 + }, + { + "name": "Especialista Historia", + "description": "10 correctas en Historia-Cultura", + "icon": "🏛️", + "condition_type": "category_specialist", + "condition_value": 10, + "category_id": 8 + }, + { + "name": "Invicto", + "description": "Gana sin fallar ninguna pregunta", + "icon": "⭐", + "condition_type": "perfect_game", + "condition_value": 1, + "category_id": None + }, + { + "name": "Velocista", + "description": "Responde correctamente en menos de 3 segundos", + "icon": "⚡", + "condition_type": "fast_answer", + "condition_value": 3, + "category_id": None + }, + { + "name": "Comeback", + "description": "Gana estando 500+ puntos abajo", + "icon": "🔄", + "condition_type": "comeback", + "condition_value": 500, + "category_id": None + }, + { + "name": "Dominio Total", + "description": "Responde las 5 preguntas de una categoría correctamente", + "icon": "👑", + "condition_type": "category_sweep", + "condition_value": 5, + "category_id": None + }, + { + "name": "Arriesgado", + "description": "Responde correctamente 3 preguntas de 500 pts", + "icon": "🎰", + "condition_type": "high_stakes", + "condition_value": 3, + "category_id": None + }, +] + + +async def seed_achievements(): + AsyncSessionLocal = get_async_session() + async with AsyncSessionLocal() as session: + # Verificar si ya existen + result = await session.execute(select(Achievement)) + if result.scalars().first(): + print("Achievements ya existen, saltando seed...") + return + + # Insertar achievements + for data in ACHIEVEMENTS: + achievement = Achievement(**data) + session.add(achievement) + + await session.commit() + print(f"Insertados {len(ACHIEVEMENTS)} achievements") + + +if __name__ == "__main__": + asyncio.run(seed_achievements())