# ============================================== # Breakpilot Drive - Game Session & Stats Routes # ============================================== # Session saving, leaderboard, stats, suggestions, # quiz generation, and health check. # Extracted from game_routes.py for file-size compliance. from fastapi import APIRouter, HTTPException, Query, Depends, Request from typing import List, Optional, Dict, Any from datetime import datetime import uuid import logging from game_models import ( LearningLevel, QuizQuestion, GameSession, SessionResponse, SAMPLE_QUESTIONS, ) logger = logging.getLogger(__name__) # Import shared state and helpers from game_routes # (these are the canonical instances) from game_routes import ( get_optional_current_user, get_user_id_from_auth, get_game_database, get_quiz_questions, _sessions, _user_levels, REQUIRE_AUTH, ) router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) @router.post("/session", response_model=SessionResponse) async def save_game_session( session: GameSession, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> SessionResponse: """ Speichert eine komplette Spielsession. - Protokolliert Score, Distanz, Fragen-Performance - Aktualisiert Lernniveau bei genuegend Daten - Wird am Ende jedes Spiels aufgerufen - Speichert in PostgreSQL wenn verfuegbar - Bei GAME_REQUIRE_AUTH=true: User-ID aus Token """ # If auth is enabled, use user_id from token (ignore session.user_id) effective_user_id = session.user_id if REQUIRE_AUTH and user: effective_user_id = user.get("user_id", session.user_id) session_id = str(uuid.uuid4()) # Lernniveau-Anpassung basierend auf Performance new_level = None old_level = 3 # Default # Try to get current level first db = await get_game_database() if db: state = await db.get_learning_state(effective_user_id) if state: old_level = state.overall_level else: # Create initial state if not exists await db.create_or_update_learning_state(effective_user_id) old_level = 3 elif effective_user_id in _user_levels: old_level = _user_levels[effective_user_id].overall_level # Calculate level adjustment if session.questions_answered >= 5: accuracy = session.questions_correct / session.questions_answered # Anpassung: Wenn >80% korrekt und max nicht erreicht -> Level up if accuracy >= 0.8 and old_level < 5: new_level = old_level + 1 # Wenn <40% korrekt und min nicht erreicht -> Level down elif accuracy < 0.4 and old_level > 1: new_level = old_level - 1 # Save to database if db: # Save session db_session_id = await db.save_game_session( student_id=effective_user_id, game_mode=session.game_mode, duration_seconds=session.duration_seconds, distance_traveled=session.distance_traveled, score=session.score, questions_answered=session.questions_answered, questions_correct=session.questions_correct, difficulty_level=session.difficulty_level, ) if db_session_id: session_id = db_session_id # Save individual quiz answers if provided if session.quiz_answers: for answer in session.quiz_answers: await db.save_quiz_answer( session_id=session_id, question_id=answer.question_id, subject="general", # Could be enhanced to track actual subject difficulty=session.difficulty_level, is_correct=answer.was_correct, answer_time_ms=answer.answer_time_ms, ) # Update learning stats duration_minutes = session.duration_seconds // 60 await db.update_learning_stats( student_id=effective_user_id, duration_minutes=duration_minutes, questions_answered=session.questions_answered, questions_correct=session.questions_correct, new_level=new_level, ) else: # Fallback to in-memory _sessions[session_id] = session if new_level: _user_levels[effective_user_id] = LearningLevel( user_id=effective_user_id, overall_level=new_level, math_level=float(new_level), german_level=float(new_level), english_level=float(new_level), last_updated=datetime.now() ) return SessionResponse( session_id=session_id, status="saved", new_level=new_level ) @router.get("/sessions/{user_id}") async def get_user_sessions( user_id: str, limit: int = Query(10, ge=1, le=100), user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> List[dict]: """ Holt die letzten Spielsessions eines Benutzers. Fuer Statistiken und Fortschrittsanzeige. Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. """ # Verify access rights user_id = get_user_id_from_auth(user, user_id) # Try database first db = await get_game_database() if db: sessions = await db.get_user_sessions(user_id, limit) if sessions: return sessions # Fallback to in-memory user_sessions = [ {"session_id": sid, **s.model_dump()} for sid, s in _sessions.items() if s.user_id == user_id ] return user_sessions[:limit] @router.get("/leaderboard") async def get_leaderboard( timeframe: str = Query("day", description="day, week, month, all"), limit: int = Query(10, ge=1, le=100) ) -> List[dict]: """ Gibt Highscore-Liste zurueck. - Sortiert nach Punktzahl - Optional nach Zeitraum filterbar """ # Try database first db = await get_game_database() if db: leaderboard = await db.get_leaderboard(timeframe, limit) if leaderboard: return leaderboard # Fallback to in-memory # Aggregiere Scores pro User user_scores: dict[str, int] = {} for session in _sessions.values(): if session.user_id not in user_scores: user_scores[session.user_id] = 0 user_scores[session.user_id] += session.score # Sortieren und limitieren leaderboard = [ {"rank": i + 1, "user_id": uid, "total_score": score} for i, (uid, score) in enumerate( sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit] ) ] return leaderboard @router.get("/stats/{user_id}") async def get_user_stats( user_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> dict: """ Gibt detaillierte Statistiken fuer einen Benutzer zurueck. - Gesamtstatistiken - Fach-spezifische Statistiken - Lernniveau-Verlauf - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten """ # Verify access rights user_id = get_user_id_from_auth(user, user_id) db = await get_game_database() if db: state = await db.get_learning_state(user_id) subject_stats = await db.get_subject_stats(user_id) if state: return { "user_id": user_id, "overall_level": state.overall_level, "math_level": state.math_level, "german_level": state.german_level, "english_level": state.english_level, "total_play_time_minutes": state.total_play_time_minutes, "total_sessions": state.total_sessions, "questions_answered": state.questions_answered, "questions_correct": state.questions_correct, "accuracy": state.accuracy, "subjects": subject_stats, } # Fallback - return defaults return { "user_id": user_id, "overall_level": 3, "math_level": 3.0, "german_level": 3.0, "english_level": 3.0, "total_play_time_minutes": 0, "total_sessions": 0, "questions_answered": 0, "questions_correct": 0, "accuracy": 0.0, "subjects": {}, } @router.get("/suggestions/{user_id}") async def get_learning_suggestions( user_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> dict: """ Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck. Basierend auf aktueller Performance und Lernhistorie. Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. """ # Verify access rights user_id = get_user_id_from_auth(user, user_id) db = await get_game_database() if not db: return {"suggestions": [], "message": "Database not available"} state = await db.get_learning_state(user_id) if not state: return {"suggestions": [], "message": "No learning state found"} try: from game.learning_rules import ( LearningContext, get_rule_engine, ) # Create context from state context = LearningContext.from_learning_state(state) # Get suggestions from rule engine engine = get_rule_engine() suggestions = engine.evaluate(context) return { "user_id": user_id, "overall_level": state.overall_level, "suggestions": [ { "title": s.title, "description": s.description, "action": s.action.value, "priority": s.priority.name.lower(), "metadata": s.metadata or {}, } for s in suggestions[:3] # Top 3 suggestions ] } except ImportError: return {"suggestions": [], "message": "Learning rules not available"} except Exception as e: logger.warning(f"Failed to get suggestions: {e}") return {"suggestions": [], "message": str(e)} @router.get("/quiz/generate") async def generate_quiz_questions( difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"), subject: Optional[str] = Query(None, description="Fach: math, german, english"), mode: str = Query("quick", description="Quiz-Modus: quick oder pause"), visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.") ) -> List[dict]: """ Generiert Quiz-Fragen dynamisch via LLM. Fallback auf statische Fragen wenn LLM nicht verfuegbar. """ try: from game.quiz_generator import get_quiz_generator generator = await get_quiz_generator() questions = await generator.get_questions( difficulty=difficulty, subject=subject or "general", mode=mode, count=count, visual_trigger=visual_trigger ) if questions: return [ { "id": f"gen-{i}", "question_text": q.question_text, "options": q.options, "correct_index": q.correct_index, "difficulty": q.difficulty, "subject": q.subject, "grade_level": q.grade_level, "quiz_mode": q.quiz_mode, "visual_trigger": q.visual_trigger, "time_limit_seconds": q.time_limit_seconds, } for i, q in enumerate(questions) ] except ImportError: logger.info("Quiz generator not available, using static questions") except Exception as e: logger.warning(f"Quiz generation failed: {e}") # Fallback to static questions return await get_quiz_questions(difficulty, count, subject, mode) @router.get("/health") async def health_check() -> dict: """Health-Check fuer das Spiel-Backend.""" db = await get_game_database() db_status = "connected" if db and db._connected else "disconnected" # Check LLM availability llm_status = "disabled" try: from game.quiz_generator import get_quiz_generator generator = await get_quiz_generator() llm_status = "connected" if generator._llm_available else "disconnected" except: pass return { "status": "healthy", "service": "breakpilot-drive", "database": db_status, "llm_generator": llm_status, "auth_required": REQUIRE_AUTH, "questions_available": len(SAMPLE_QUESTIONS), "active_sessions": len(_sessions) }