# ============================================== # Breakpilot Drive - Game API Core Routes # ============================================== # Core game endpoints: learning level, difficulty, quiz questions. # Session/stats/leaderboard routes are in game_session_routes.py. # Extracted from game_api.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 random import uuid import os import logging from game_models import ( LearningLevel, GameDifficulty, QuizQuestion, QuizAnswer, GameSession, SessionResponse, DIFFICULTY_MAPPING, SAMPLE_QUESTIONS, ) logger = logging.getLogger(__name__) # Feature flags USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) # ============================================== # Auth Dependency (Optional) # ============================================== async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: """ Optional auth dependency for Game API. If GAME_REQUIRE_AUTH=true: Requires valid JWT token If GAME_REQUIRE_AUTH=false: Returns None (anonymous access) In development mode without auth, returns demo user. """ if not REQUIRE_AUTH: return None try: from auth import get_current_user return await get_current_user(request) except ImportError: logger.warning("Auth module not available") return None except HTTPException: raise # Re-raise auth errors except Exception as e: logger.error(f"Auth error: {e}") raise HTTPException(status_code=401, detail="Authentication failed") def get_user_id_from_auth( user: Optional[Dict[str, Any]], requested_user_id: str ) -> str: """ Get the effective user ID, respecting auth when enabled. If auth is enabled and user is authenticated: - Returns user's own ID if requested_user_id matches - For parents: allows access to child IDs from token - For teachers: allows access to student IDs (future) If auth is disabled: Returns requested_user_id as-is """ if not REQUIRE_AUTH or user is None: return requested_user_id user_id = user.get("user_id", "") # Same user - always allowed if requested_user_id == user_id: return user_id # Check for parent accessing child data children_ids = user.get("raw_claims", {}).get("children_ids", []) if requested_user_id in children_ids: return requested_user_id # Check for teacher accessing student data (future) realm_roles = user.get("realm_roles", []) if "lehrer" in realm_roles or "teacher" in realm_roles: # Teachers can access any student in their class (implement class check later) return requested_user_id # Admin bypass if "admin" in realm_roles: return requested_user_id # Not authorized raise HTTPException( status_code=403, detail="Not authorized to access this user's data" ) # In-Memory Session Storage (Fallback wenn DB nicht verfuegbar) _sessions: dict[str, GameSession] = {} _user_levels: dict[str, LearningLevel] = {} # Database integration _game_db = None async def get_game_database(): """Get game database instance with lazy initialization.""" global _game_db if not USE_DATABASE: return None if _game_db is None: try: from game.database import get_game_db _game_db = await get_game_db() logger.info("Game database initialized") except Exception as e: logger.warning(f"Game database not available, using in-memory: {e}") return _game_db # ============================================== # API Endpunkte # ============================================== @router.get("/learning-level/{user_id}", response_model=LearningLevel) async def get_learning_level( user_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> LearningLevel: """ Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot. - Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen - Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten) - Cached Werte fuer schnellen Zugriff - Speichert in PostgreSQL wenn verfuegbar - 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: state = await db.get_learning_state(user_id) if state: return LearningLevel( user_id=user_id, overall_level=state.overall_level, math_level=state.math_level, german_level=state.german_level, english_level=state.english_level, last_updated=state.updated_at or datetime.now() ) # Create new state in database new_state = await db.create_or_update_learning_state( student_id=user_id, overall_level=3, math_level=3.0, german_level=3.0, english_level=3.0 ) if new_state: return LearningLevel( user_id=user_id, overall_level=new_state.overall_level, math_level=new_state.math_level, german_level=new_state.german_level, english_level=new_state.english_level, last_updated=new_state.updated_at or datetime.now() ) # Fallback to in-memory if user_id in _user_levels: return _user_levels[user_id] # Standard-Level fuer neue Benutzer default_level = LearningLevel( user_id=user_id, overall_level=3, # Mittleres Level als Default math_level=3.0, german_level=3.0, english_level=3.0, last_updated=datetime.now() ) _user_levels[user_id] = default_level return default_level @router.get("/difficulty/{level}", response_model=GameDifficulty) async def get_game_difficulty(level: int) -> GameDifficulty: """ Gibt Spielparameter basierend auf Lernniveau zurueck. Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz, Fragen-Schwierigkeit etc. gemappt. """ if level < 1 or level > 5: raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein") return DIFFICULTY_MAPPING[level] @router.get("/quiz/questions", response_model=List[QuizQuestion]) async def get_quiz_questions( difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"), subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"), mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide") ) -> List[QuizQuestion]: """ Holt Quiz-Fragen fuer das Spiel. - Filtert nach Schwierigkeitsgrad (+/- 1 Level) - Optional nach Fach filterbar - Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben) - Gibt zufaellige Auswahl zurueck """ # Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz) filtered = [ q for q in SAMPLE_QUESTIONS if abs(q.difficulty - difficulty) <= 1 and (subject is None or q.subject == subject) and (mode is None or q.quiz_mode == mode) ] if not filtered: # Fallback: Alle Fragen wenn keine passenden gefunden filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode] # Zufaellige Auswahl selected = random.sample(filtered, min(count, len(filtered))) return selected @router.get("/quiz/visual-triggers") async def get_visual_triggers() -> List[dict]: """ Gibt alle verfuegbaren visuellen Trigger zurueck. Unity verwendet diese Liste um zu wissen, welche Objekte im Spiel Quiz-Fragen ausloesen koennen. """ triggers = {} for q in SAMPLE_QUESTIONS: if q.visual_trigger and q.quiz_mode == "quick": if q.visual_trigger not in triggers: triggers[q.visual_trigger] = { "trigger": q.visual_trigger, "question_count": 0, "difficulties": set(), "subjects": set() } triggers[q.visual_trigger]["question_count"] += 1 triggers[q.visual_trigger]["difficulties"].add(q.difficulty) triggers[q.visual_trigger]["subjects"].add(q.subject) # Sets zu Listen konvertieren fuer JSON return [ { "trigger": t["trigger"], "question_count": t["question_count"], "difficulties": list(t["difficulties"]), "subjects": list(t["subjects"]) } for t in triggers.values() ] @router.post("/quiz/answer") async def submit_quiz_answer(answer: QuizAnswer) -> dict: """ Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback). In der finalen Version: Speichert in Session, updated Analytics. """ return { "question_id": answer.question_id, "was_correct": answer.was_correct, "points": 500 if answer.was_correct else -100, "message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!" }