Feature: In-game timer, anonymous leaderboard, witty tiered finale results, legal pages, button renames
This commit is contained in:
+203
-9
@@ -12,11 +12,11 @@ from sqlalchemy.orm import Session
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Question, QuizSession, AdminUser
|
||||
from app.models import Question, QuizSession, AdminUser, LeaderboardEntry
|
||||
from app.matcher import is_answer_correct, levenshtein_distance, normalize_text
|
||||
from app.auth import verify_password
|
||||
from app.seed import seed_db
|
||||
from app.schemas import GameCheckRequest, SuggestionCreateRequest, GameStartRequest
|
||||
from app.schemas import GameCheckRequest, SuggestionCreateRequest, GameStartRequest, LeaderboardSubmitRequest
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -58,6 +58,81 @@ def format_question_for_client(q: Question, number: int, total: int, lang: str =
|
||||
"difficulty": q.difficulty or "medium"
|
||||
}
|
||||
|
||||
def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict:
|
||||
pct = (score / total) if total > 0 else 0
|
||||
is_de = (lang != "en")
|
||||
|
||||
if score >= 10 or pct >= 1.0:
|
||||
grade = "O"
|
||||
title = "🧙♂️ Großmeister der Zauberei" if is_de else "🧙♂️ Grandmaster of Wizardry"
|
||||
subtitle = "Note: O (Ohnegleichen)" if is_de else "Grade: O (Outstanding)"
|
||||
desc = (
|
||||
"Unglaublich! Albus Dumbledore überlegt bereits, dir das Büro des Schulleiters zu überlassen. "
|
||||
"Du kennst selbst die geheimsten Winkel von Hogwarts besser als der Fast Kopflose Nick. "
|
||||
"Das Zaubereiministerium verneigt sich in Ehrfurcht!"
|
||||
if is_de else
|
||||
"Incredible! Albus Dumbledore is already considering handing you the headmaster's office. "
|
||||
"You know the secret passages of Hogwarts better than Nearly Headless Nick. "
|
||||
"The Ministry of Magic bows in deep awe!"
|
||||
)
|
||||
badge_class = "tier-outstanding"
|
||||
elif score >= 8 or pct >= 0.8:
|
||||
grade = "E"
|
||||
title = "⚡ Meister-Hexe / Zauberer" if is_de else "⚡ Master Witch / Wizard"
|
||||
subtitle = "Note: E (Erwartungen übertroffen)" if is_de else "Grade: E (Exceeds Expectations)"
|
||||
desc = (
|
||||
"Hermine Granger nickt anerkennend und Professor McGonagall vergibt 50 Punkte für dein Haus! "
|
||||
"Dein Zauberstab zuckt blitzschnell und dein Wissen lässt selbst Ravenclaws erblassen."
|
||||
if is_de else
|
||||
"Hermione Granger nods in deep approval and Professor McGonagall awards 50 points to your House! "
|
||||
"Your wandwork is razor-sharp and your knowledge makes even Ravenclaws jealous."
|
||||
)
|
||||
badge_class = "tier-exceeds"
|
||||
elif score >= 5 or pct >= 0.5:
|
||||
grade = "A"
|
||||
title = "📜 Solider Hogwarts-Schüler" if is_de else "📜 Solid Hogwarts Student"
|
||||
subtitle = "Note: A (Annehmbar)" if is_de else "Grade: A (Acceptable)"
|
||||
desc = (
|
||||
"Bestanden! Ein paar Kessel sind zwar im Zaubertränke-Unterricht explodiert und Peeves hat dich mit Kreide beworfen, "
|
||||
"aber du hast dich tapfer durchgeschlagen. Eine solide magische Leistung!"
|
||||
if is_de else
|
||||
"Passed! A few cauldrons blew up during Potions class and Peeves threw chalk at your head, "
|
||||
"but you bravely fought your way through. A solid magical performance!"
|
||||
)
|
||||
badge_class = "tier-acceptable"
|
||||
elif score >= 2 or pct >= 0.2:
|
||||
grade = "M"
|
||||
title = "🧹 Quaffel-Fänger in Not" if is_de else "🧹 Quaffle Catcher in Distress"
|
||||
subtitle = "Note: M (Mies)" if is_de else "Grade: P (Poor)"
|
||||
desc = (
|
||||
"Au weia... Hat Ron Weasley dir seinen mit Tesafilm geklebten Zauberstab geliehen? "
|
||||
"Deine Eule hat sich wohl im Nebel verflogen. Zeit für ein paar Extrastunden in der Bibliothek bei Madam Pince!"
|
||||
if is_de else
|
||||
"Yikes... Did Ron Weasley lend you his Spellotaped wand? "
|
||||
"Your owl must have gotten lost in the fog. Time for extra study sessions in the library with Madam Pince!"
|
||||
)
|
||||
badge_class = "tier-poor"
|
||||
else:
|
||||
grade = "T"
|
||||
title = "🧌 Bergtroll im Kerker" if is_de else "🧌 Mountain Troll in the Dungeon"
|
||||
subtitle = "Note: T (Troll)" if is_de else "Grade: T (Troll)"
|
||||
desc = (
|
||||
"Alarm im Schloss! Du hast mit der Holzkeule auf die Fragen eingeschlagen. "
|
||||
"Schnell weg, bevor Hausmeister Filch dich beim lebenslangen Nachsitzen in den Kerkern erwischt!"
|
||||
if is_de else
|
||||
"Troll in the dungeon! You smashed the questions with a wooden club. "
|
||||
"Run quickly before caretaker Filch catches you for lifelong detention in the dungeons!"
|
||||
)
|
||||
badge_class = "tier-troll"
|
||||
|
||||
return {
|
||||
"grade": grade,
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"description": desc,
|
||||
"badge_class": badge_class
|
||||
}
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
seed_db()
|
||||
@@ -78,19 +153,16 @@ async def start_game(payload: GameStartRequest = None, db: Session = Depends(get
|
||||
lang = payload.lang if payload and payload.lang in ["de", "en"] else "de"
|
||||
difficulty = payload.difficulty if payload and payload.difficulty in ["easy", "medium", "hard"] else "all"
|
||||
|
||||
# Fetch active questions matching difficulty (or all active)
|
||||
query = db.query(Question).filter(Question.status == "active")
|
||||
if difficulty != "all":
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
|
||||
pool = query.all()
|
||||
if not pool:
|
||||
# Fallback to all active if filtered pool empty
|
||||
pool = db.query(Question).filter(Question.status == "active").all()
|
||||
if not pool:
|
||||
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
|
||||
|
||||
# Randomly sample 10 questions (or all if fewer)
|
||||
sample_size = min(10, len(pool))
|
||||
selected_questions = random.sample(pool, sample_size)
|
||||
question_ids = [q.id for q in selected_questions]
|
||||
@@ -104,7 +176,8 @@ async def start_game(payload: GameStartRequest = None, db: Session = Depends(get
|
||||
attempts_current=0,
|
||||
is_finished=False,
|
||||
lang=lang,
|
||||
difficulty=difficulty
|
||||
difficulty=difficulty,
|
||||
created_at=datetime.datetime.utcnow()
|
||||
)
|
||||
db.add(new_session)
|
||||
db.commit()
|
||||
@@ -131,6 +204,9 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
question_ids = json.loads(session.question_ids)
|
||||
if session.current_question_idx >= len(question_ids):
|
||||
session.is_finished = True
|
||||
session.finished_at = datetime.datetime.utcnow()
|
||||
if session.created_at:
|
||||
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
|
||||
db.commit()
|
||||
return {
|
||||
"correct": False,
|
||||
@@ -138,6 +214,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
"attempts_used": session.attempts_current,
|
||||
"attempts_left": 0,
|
||||
"is_finished": True,
|
||||
"duration_seconds": session.duration_seconds,
|
||||
"next_question": None
|
||||
}
|
||||
|
||||
@@ -157,6 +234,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
if is_finished:
|
||||
session.is_finished = True
|
||||
session.finished_at = datetime.datetime.utcnow()
|
||||
if session.created_at:
|
||||
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -172,6 +251,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
"attempts_used": 0,
|
||||
"attempts_left": 3,
|
||||
"is_finished": is_finished,
|
||||
"duration_seconds": session.duration_seconds,
|
||||
"revealed_answer": None,
|
||||
"next_question": next_q_data
|
||||
}
|
||||
@@ -180,7 +260,6 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
attempts_left = max(0, 3 - session.attempts_current)
|
||||
|
||||
if attempts_left == 0:
|
||||
# 3 failed attempts: move to next question, reveal answer
|
||||
if session_lang == "en" and current_q.answer_en:
|
||||
revealed = current_q.answer_en
|
||||
else:
|
||||
@@ -193,6 +272,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
if is_finished:
|
||||
session.is_finished = True
|
||||
session.finished_at = datetime.datetime.utcnow()
|
||||
if session.created_at:
|
||||
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -208,6 +289,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
||||
"attempts_used": 3,
|
||||
"attempts_left": 0,
|
||||
"is_finished": is_finished,
|
||||
"duration_seconds": session.duration_seconds,
|
||||
"revealed_answer": revealed,
|
||||
"next_question": next_q_data
|
||||
}
|
||||
@@ -247,13 +329,125 @@ async def get_hint(session_id: str, db: Session = Depends(get_db)):
|
||||
return {"hint": hint_text}
|
||||
|
||||
@app.get("/finale", response_class=HTMLResponse)
|
||||
async def finale(request: Request, score: int = 0, total: int = 10, lang: str = "de"):
|
||||
async def finale(request: Request, score: int = 0, total: int = 10, time: int = 0, lang: str = "de"):
|
||||
tier = get_tier_evaluation(score, total, lang)
|
||||
if time >= 60:
|
||||
minutes = time // 60
|
||||
secs = time % 60
|
||||
time_str = f"{minutes} Min. {secs} Sek." if lang != "en" else f"{minutes} min. {secs} sec."
|
||||
else:
|
||||
time_str = f"{time} Sek." if lang != "en" else f"{time} sec."
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="finale.html",
|
||||
context={"score": score, "total": total, "lang": lang}
|
||||
context={
|
||||
"score": score,
|
||||
"total": total,
|
||||
"time_seconds": time,
|
||||
"time_formatted": time_str,
|
||||
"lang": lang,
|
||||
"tier": tier
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/bestenliste", response_class=HTMLResponse)
|
||||
async def bestenliste_page(request: Request, lang: str = "de"):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="bestenliste.html",
|
||||
context={"lang": lang}
|
||||
)
|
||||
|
||||
@app.get("/impressum", response_class=HTMLResponse)
|
||||
async def impressum_page(request: Request, lang: str = "de"):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="impressum.html",
|
||||
context={"lang": lang}
|
||||
)
|
||||
|
||||
@app.get("/datenschutz", response_class=HTMLResponse)
|
||||
async def datenschutz_page(request: Request, lang: str = "de"):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="datenschutz.html",
|
||||
context={"lang": lang}
|
||||
)
|
||||
|
||||
@app.post("/api/leaderboard")
|
||||
async def submit_leaderboard(payload: LeaderboardSubmitRequest, db: Session = Depends(get_db)):
|
||||
nick = payload.nickname.strip()
|
||||
if not nick:
|
||||
wizard_adjectives = ["Mutiger", "Schlauer", "Weiser", "Flinke", "Listiger", "Fliegender"]
|
||||
wizard_nouns = ["Greif", "Phönix", "Zauberer", "Drache", "Niffler", "Thestral"]
|
||||
nick = f"{random.choice(wizard_adjectives)}_{random.choice(wizard_nouns)}_{random.randint(10, 99)}"
|
||||
nick = nick[:30]
|
||||
|
||||
time_sec = max(1, payload.time_seconds)
|
||||
diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "all"
|
||||
|
||||
entry = LeaderboardEntry(
|
||||
nickname=nick,
|
||||
score=payload.score,
|
||||
total_questions=payload.total_questions or 10,
|
||||
time_seconds=time_sec,
|
||||
difficulty=diff,
|
||||
lang=payload.lang or "de",
|
||||
session_id=payload.session_id,
|
||||
created_at=datetime.datetime.utcnow()
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
better_count = db.query(LeaderboardEntry).filter(
|
||||
(LeaderboardEntry.score > entry.score) |
|
||||
((LeaderboardEntry.score == entry.score) & (LeaderboardEntry.time_seconds < entry.time_seconds)) |
|
||||
((LeaderboardEntry.score == entry.score) & (LeaderboardEntry.time_seconds == entry.time_seconds) & (LeaderboardEntry.id < entry.id))
|
||||
).count()
|
||||
rank = better_count + 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"entry_id": entry.id,
|
||||
"rank": rank,
|
||||
"nickname": entry.nickname,
|
||||
"score": entry.score,
|
||||
"total_questions": entry.total_questions,
|
||||
"time_seconds": entry.time_seconds
|
||||
}
|
||||
|
||||
@app.get("/api/leaderboard")
|
||||
async def get_leaderboard(difficulty: str = None, limit: int = 20, db: Session = Depends(get_db)):
|
||||
query = db.query(LeaderboardEntry)
|
||||
if difficulty and difficulty != "all":
|
||||
query = query.filter(LeaderboardEntry.difficulty == difficulty)
|
||||
|
||||
entries = query.order_by(
|
||||
LeaderboardEntry.score.desc(),
|
||||
LeaderboardEntry.time_seconds.asc(),
|
||||
LeaderboardEntry.created_at.asc()
|
||||
).limit(min(100, limit)).all()
|
||||
|
||||
result = []
|
||||
for idx, e in enumerate(entries, 1):
|
||||
m = e.time_seconds // 60
|
||||
s = e.time_seconds % 60
|
||||
time_str = f"{m:02d}:{s:02d}"
|
||||
result.append({
|
||||
"rank": idx,
|
||||
"id": e.id,
|
||||
"nickname": e.nickname,
|
||||
"score": e.score,
|
||||
"total_questions": e.total_questions,
|
||||
"time_seconds": e.time_seconds,
|
||||
"time_formatted": time_str,
|
||||
"difficulty": e.difficulty,
|
||||
"date": e.created_at.strftime("%d.%m.%Y") if e.created_at else ""
|
||||
})
|
||||
return result
|
||||
|
||||
@app.get("/vorschlag", response_class=HTMLResponse)
|
||||
async def vorschlag_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
|
||||
Reference in New Issue
Block a user