Feature: In-game timer, anonymous leaderboard, witty tiered finale results, legal pages, button renames

This commit is contained in:
kobold-ai
2026-09-21 13:23:29 +02:00
parent 16bffa3be8
commit 26ff0660d3
12 changed files with 1508 additions and 74 deletions
+203 -9
View File
@@ -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(