# ============================================== # Breakpilot Drive - Game API # ============================================== # API-Endpunkte fuer das Lernspiel: # - Lernniveau aus Breakpilot abrufen # - Quiz-Fragen bereitstellen # - Spielsessions protokollieren # - Offline-Sync unterstuetzen # # Mit PostgreSQL-Integration fuer persistente Speicherung. # Fallback auf In-Memory wenn DB nicht verfuegbar. # # Auth: Optional via GAME_REQUIRE_AUTH=true from fastapi import APIRouter, HTTPException, Query, Depends, Request from pydantic import BaseModel from typing import List, Optional, Literal, Dict, Any from datetime import datetime import random import uuid import os import logging 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" ) # ============================================== # Pydantic Models # ============================================== class LearningLevel(BaseModel): """Lernniveau eines Benutzers aus dem Breakpilot-System""" user_id: str overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6) math_level: float german_level: float english_level: float last_updated: datetime class GameDifficulty(BaseModel): """Spielschwierigkeit basierend auf Lernniveau""" lane_speed: float # Geschwindigkeit in m/s obstacle_frequency: float # Hindernisse pro Sekunde power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1) question_complexity: int # 1-5 answer_time: int # Sekunden zum Antworten hints_enabled: bool speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version class QuizQuestion(BaseModel): """Quiz-Frage fuer das Spiel""" id: str question_text: str audio_url: Optional[str] = None options: List[str] # 2-4 Antwortmoeglichkeiten correct_index: int # 0-3 difficulty: int # 1-5 subject: Literal["math", "german", "english", "general"] grade_level: Optional[int] = None # 2-6 # NEU: Quiz-Modus quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick) class QuizAnswer(BaseModel): """Antwort auf eine Quiz-Frage""" question_id: str selected_index: int answer_time_ms: int # Zeit bis zur Antwort in ms was_correct: bool class GameSession(BaseModel): """Spielsession-Daten fuer Analytics""" user_id: str game_mode: Literal["video", "audio"] duration_seconds: int distance_traveled: float score: int questions_answered: int questions_correct: int difficulty_level: int quiz_answers: Optional[List[QuizAnswer]] = None class SessionResponse(BaseModel): """Antwort nach Session-Speicherung""" session_id: str status: str new_level: Optional[int] = None # Falls Lernniveau angepasst wurde # ============================================== # Schwierigkeits-Mapping # ============================================== DIFFICULTY_MAPPING = { 1: GameDifficulty( lane_speed=3.0, obstacle_frequency=0.3, power_up_chance=0.4, question_complexity=1, answer_time=15, hints_enabled=True, speech_speed=0.8 ), 2: GameDifficulty( lane_speed=4.0, obstacle_frequency=0.4, power_up_chance=0.35, question_complexity=2, answer_time=12, hints_enabled=True, speech_speed=0.9 ), 3: GameDifficulty( lane_speed=5.0, obstacle_frequency=0.5, power_up_chance=0.3, question_complexity=3, answer_time=10, hints_enabled=True, speech_speed=1.0 ), 4: GameDifficulty( lane_speed=6.0, obstacle_frequency=0.6, power_up_chance=0.25, question_complexity=4, answer_time=8, hints_enabled=False, speech_speed=1.1 ), 5: GameDifficulty( lane_speed=7.0, obstacle_frequency=0.7, power_up_chance=0.2, question_complexity=5, answer_time=6, hints_enabled=False, speech_speed=1.2 ), } # ============================================== # Beispiel Quiz-Fragen (spaeter aus DB laden) # ============================================== SAMPLE_QUESTIONS = [ # ============================================== # QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert) # ============================================== # Englisch Vokabeln - Objekte im Spiel (QUICK MODE) QuizQuestion( id="vq-bridge", question_text="What is this?", options=["Bridge", "House"], correct_index=0, difficulty=1, subject="english", grade_level=3, quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0 ), QuizQuestion( id="vq-tree", question_text="What is this?", options=["Tree", "Flower"], correct_index=0, difficulty=1, subject="english", grade_level=3, quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0 ), QuizQuestion( id="vq-house", question_text="What is this?", options=["House", "Car"], correct_index=0, difficulty=1, subject="english", grade_level=3, quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0 ), QuizQuestion( id="vq-car", question_text="What is this?", options=["Car", "Bus"], correct_index=0, difficulty=1, subject="english", grade_level=3, quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5 ), QuizQuestion( id="vq-mountain", question_text="What is this?", options=["Hill", "Mountain", "Valley"], correct_index=1, difficulty=2, subject="english", grade_level=4, quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5 ), QuizQuestion( id="vq-river", question_text="What is this?", options=["Lake", "River", "Sea"], correct_index=1, difficulty=2, subject="english", grade_level=4, quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5 ), # Schnelle Rechenaufgaben (QUICK MODE) QuizQuestion( id="mq-1", question_text="3 + 4 = ?", options=["6", "7"], correct_index=1, difficulty=1, subject="math", grade_level=2, quiz_mode="quick", time_limit_seconds=4.0 ), QuizQuestion( id="mq-2", question_text="5 x 2 = ?", options=["10", "12"], correct_index=0, difficulty=1, subject="math", grade_level=2, quiz_mode="quick", time_limit_seconds=4.0 ), QuizQuestion( id="mq-3", question_text="8 - 3 = ?", options=["4", "5"], correct_index=1, difficulty=1, subject="math", grade_level=2, quiz_mode="quick", time_limit_seconds=3.5 ), QuizQuestion( id="mq-4", question_text="6 x 7 = ?", options=["42", "48"], correct_index=0, difficulty=2, subject="math", grade_level=3, quiz_mode="quick", time_limit_seconds=5.0 ), QuizQuestion( id="mq-5", question_text="9 x 8 = ?", options=["72", "64"], correct_index=0, difficulty=3, subject="math", grade_level=4, quiz_mode="quick", time_limit_seconds=5.0 ), # ============================================== # PAUSE QUESTIONS (Spiel haelt an, mehr Zeit) # ============================================== # Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE QuizQuestion( id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?", options=["6", "7", "8", "9"], correct_index=2, difficulty=1, subject="math", grade_level=2, quiz_mode="pause" ), QuizQuestion( id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?", options=["4", "5", "6", "7"], correct_index=2, difficulty=2, subject="math", grade_level=3, quiz_mode="pause" ), QuizQuestion( id="mp2-2", question_text="Was ist 45 + 27?", options=["72", "62", "82", "70"], correct_index=0, difficulty=2, subject="math", grade_level=3, quiz_mode="pause" ), # Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE QuizQuestion( id="mp3-1", question_text="Was ist 7 x 8?", options=["54", "56", "58", "48"], correct_index=1, difficulty=3, subject="math", grade_level=4, quiz_mode="pause" ), QuizQuestion( id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?", options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1, difficulty=3, subject="math", grade_level=4, quiz_mode="pause" ), QuizQuestion( id="mp4-1", question_text="Was ist 15% von 80?", options=["10", "12", "8", "15"], correct_index=1, difficulty=4, subject="math", grade_level=5, quiz_mode="pause" ), QuizQuestion( id="mp4-2", question_text="Was ist 3/4 + 1/2?", options=["5/4", "4/6", "1", "5/6"], correct_index=0, difficulty=4, subject="math", grade_level=5, quiz_mode="pause" ), # Mathe Level 5 (Klasse 6) - PAUSE MODE QuizQuestion( id="mp5-1", question_text="Was ist (-5) x (-3)?", options=["-15", "15", "-8", "8"], correct_index=1, difficulty=5, subject="math", grade_level=6, quiz_mode="pause" ), QuizQuestion( id="mp5-2", question_text="Loesung von 2x + 5 = 11?", options=["2", "3", "4", "6"], correct_index=1, difficulty=5, subject="math", grade_level=6, quiz_mode="pause" ), # Deutsch - PAUSE MODE (brauchen Lesezeit) QuizQuestion( id="dp1-1", question_text="Welches Wort ist ein Nomen?", options=["laufen", "schnell", "Hund", "und"], correct_index=2, difficulty=1, subject="german", grade_level=2, quiz_mode="pause" ), QuizQuestion( id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?", options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1, difficulty=2, subject="german", grade_level=3, quiz_mode="pause" ), QuizQuestion( id="dp3-1", question_text="Welches Verb steht im Praeteritum?", options=["geht", "ging", "gegangen", "gehen"], correct_index=1, difficulty=3, subject="german", grade_level=4, quiz_mode="pause" ), QuizQuestion( id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'", options=["Hund", "leuft", "schnell", "Der"], correct_index=1, difficulty=3, subject="german", grade_level=4, quiz_mode="pause" ), # Englisch Saetze - PAUSE MODE QuizQuestion( id="ep3-1", question_text="How do you say 'Schmetterling'?", options=["bird", "bee", "butterfly", "beetle"], correct_index=2, difficulty=3, subject="english", grade_level=4, quiz_mode="pause" ), QuizQuestion( id="ep4-1", question_text="Choose the correct form: She ___ to school.", options=["go", "goes", "going", "gone"], correct_index=1, difficulty=4, subject="english", grade_level=5, quiz_mode="pause" ), QuizQuestion( id="ep4-2", question_text="What is the past tense of 'run'?", options=["runned", "ran", "runed", "running"], correct_index=1, difficulty=4, subject="english", grade_level=5, quiz_mode="pause" ), ] # 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!" } @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) } # ============================================== # Phase 5: Erweiterte Features # ============================================== @router.get("/achievements/{user_id}") async def get_achievements( user_id: str, user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> dict: """ Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck. Achievements werden basierend auf Spielstatistiken berechnet. """ # Verify access rights user_id = get_user_id_from_auth(user, user_id) db = await get_game_database() if not db: return {"achievements": [], "message": "Database not available"} try: achievements = await db.get_student_achievements(user_id) unlocked = [a for a in achievements if a.unlocked] locked = [a for a in achievements if not a.unlocked] return { "user_id": user_id, "total": len(achievements), "unlocked_count": len(unlocked), "achievements": [ { "id": a.id, "name": a.name, "description": a.description, "icon": a.icon, "category": a.category, "threshold": a.threshold, "progress": a.progress, "unlocked": a.unlocked, } for a in achievements ] } except Exception as e: logger.error(f"Failed to get achievements: {e}") return {"achievements": [], "message": str(e)} @router.get("/progress/{user_id}") async def get_progress( user_id: str, days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"), user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> dict: """ Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts). - Taegliche Statistiken - Fuer Eltern-Dashboard und Fortschrittsanzeige """ # Verify access rights user_id = get_user_id_from_auth(user, user_id) db = await get_game_database() if not db: return {"progress": [], "message": "Database not available"} try: progress = await db.get_progress_over_time(user_id, days) return { "user_id": user_id, "days": days, "data_points": len(progress), "progress": progress, } except Exception as e: logger.error(f"Failed to get progress: {e}") return {"progress": [], "message": str(e)} @router.get("/parent/children") async def get_children_dashboard( user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> dict: """ Eltern-Dashboard: Statistiken fuer alle Kinder. Erfordert Auth mit Eltern-Rolle und children_ids Claim. """ if not REQUIRE_AUTH or user is None: return { "message": "Auth required for parent dashboard", "children": [] } # Get children IDs from token children_ids = user.get("raw_claims", {}).get("children_ids", []) if not children_ids: return { "message": "No children associated with this account", "children": [] } db = await get_game_database() if not db: return {"children": [], "message": "Database not available"} try: children_stats = await db.get_children_stats(children_ids) return { "parent_id": user.get("user_id"), "children_count": len(children_ids), "children": children_stats, } except Exception as e: logger.error(f"Failed to get children stats: {e}") return {"children": [], "message": str(e)} @router.get("/leaderboard/class/{class_id}") async def get_class_leaderboard( class_id: str, timeframe: str = Query("week", description="day, week, month, all"), limit: int = Query(10, ge=1, le=50), user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) ) -> List[dict]: """ Klassenspezifische Rangliste. Nur fuer Lehrer oder Schueler der Klasse sichtbar. """ db = await get_game_database() if not db: return [] try: leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit) return leaderboard except Exception as e: logger.error(f"Failed to get class leaderboard: {e}") return [] @router.get("/leaderboard/display") async def get_display_leaderboard( timeframe: str = Query("day", description="day, week, month, all"), limit: int = Query(10, ge=1, le=100), anonymize: bool = Query(True, description="Namen anonymisieren") ) -> List[dict]: """ Oeffentliche Rangliste mit Anzeigenamen. Standardmaessig anonymisiert fuer Datenschutz. """ db = await get_game_database() if not db: return [] try: return await db.get_leaderboard_with_names(timeframe, limit, anonymize) except Exception as e: logger.error(f"Failed to get display leaderboard: {e}") return []