Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
1130 lines
37 KiB
Python
1130 lines
37 KiB
Python
# ==============================================
|
|
# 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 []
|