From a93812327102369480f1a4ce17c364e3b3b40aab Mon Sep 17 00:00:00 2001 From: kobold-ai Date: Mon, 21 Sep 2026 13:53:33 +0200 Subject: [PATCH] Feature: 100-point scoring, question memory & spaced repetition, random wizard name generator, cache-busting v2.2, gold footer pills --- app/main.py | 75 +++++++++++++++++++++++++++---- app/schemas.py | 4 ++ app/static/css/style.css | 55 +++++++++++++++++++---- app/static/js/quiz.js | 82 ++++++++++++++++++++++++++++++++-- app/templates/admin.html | 2 +- app/templates/base.html | 2 +- app/templates/bestenliste.html | 2 +- app/templates/finale.html | 64 ++++++++++++++++++++++---- app/templates/index.html | 2 +- app/templates/vorschlag.html | 2 +- 10 files changed, 257 insertions(+), 33 deletions(-) diff --git a/app/main.py b/app/main.py index c0a672c..ae32850 100755 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,7 @@ def format_question_for_client(q: Question, number: int, total: int, lang: str = random.shuffle(options_list) return { + "id": q.id, "question_number": number, "total_questions": total, "category": q.category, @@ -59,10 +60,15 @@ def format_question_for_client(q: Question, number: int, total: int, lang: str = } def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict: - pct = (score / total) if total > 0 else 0 + if total <= 10 and score <= 10: + pct = (score / total) if total > 0 else 0 + else: + max_possible = (total * 100) if total <= 10 else total + pct = (score / max_possible) if max_possible > 0 else 0 + is_de = (lang != "en") - if score >= 10 or pct >= 1.0: + if pct >= 0.90: 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)" @@ -76,7 +82,7 @@ def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict: "The Ministry of Magic bows in deep awe!" ) badge_class = "tier-outstanding" - elif score >= 8 or pct >= 0.8: + elif pct >= 0.70: 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)" @@ -88,7 +94,7 @@ def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict: "Your wandwork is razor-sharp and your knowledge makes even Ravenclaws jealous." ) badge_class = "tier-exceeds" - elif score >= 5 or pct >= 0.5: + elif pct >= 0.50: 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)" @@ -100,7 +106,7 @@ def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict: "but you bravely fought your way through. A solid magical performance!" ) badge_class = "tier-acceptable" - elif score >= 2 or pct >= 0.2: + elif pct >= 0.20: 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)" @@ -163,8 +169,43 @@ async def start_game(payload: GameStartRequest = None, db: Session = Depends(get if not pool: raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.") - sample_size = min(10, len(pool)) - selected_questions = random.sample(pool, sample_size) + recent_set = set(payload.recent_ids or []) if payload else set() + wrong_set = set(payload.wrong_ids or []) if payload else set() + + # Priority pool 1: Questions user previously failed (learning pool) + wrong_pool = [q for q in pool if q.id in wrong_set] + # Priority pool 2: Fresh questions not seen recently and not in wrong pool + fresh_pool = [q for q in pool if q.id not in recent_set and q.id not in wrong_set] + # Priority pool 3: Recent questions fallback + recent_pool = [q for q in pool if q.id in recent_set and q.id not in wrong_set] + + selected_questions = [] + # Up to 3 questions from wrong_pool for spaced repetition + if wrong_pool: + k_wrong = min(3, len(wrong_pool)) + selected_questions.extend(random.sample(wrong_pool, k_wrong)) + + # Fill remaining from fresh_pool + needed = min(10, len(pool)) - len(selected_questions) + if needed > 0 and fresh_pool: + k_fresh = min(needed, len(fresh_pool)) + selected_questions.extend(random.sample(fresh_pool, k_fresh)) + + # If still needed, fill from recent_pool + needed = min(10, len(pool)) - len(selected_questions) + if needed > 0 and recent_pool: + k_recent = min(needed, len(recent_pool)) + selected_questions.extend(random.sample(recent_pool, k_recent)) + + # Fallback to any remaining from pool + needed = min(10, len(pool)) - len(selected_questions) + if needed > 0: + remaining = [q for q in pool if q not in selected_questions] + if remaining: + selected_questions.extend(random.sample(remaining, min(needed, len(remaining)))) + + # Shuffle to mix learning and fresh questions + random.shuffle(selected_questions) question_ids = [q.id for q in selected_questions] session_id = str(uuid.uuid4()) @@ -211,6 +252,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) return { "correct": False, "score": session.score, + "points_earned": 0, + "question_id": None, "attempts_used": session.attempts_current, "attempts_left": 0, "is_finished": True, @@ -226,7 +269,15 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) correct = is_answer_correct(payload.answer, current_q.canonical_answer, current_q.accepted_answers, current_q.answer_en) if correct: - session.score += 1 + # Tiered points based on attempt + if session.attempts_current == 0: + points_earned = 100 + elif session.attempts_current == 1: + points_earned = 70 + else: + points_earned = 40 + + session.score += points_earned session.current_question_idx += 1 session.attempts_current = 0 @@ -248,6 +299,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) return { "correct": True, "score": session.score, + "points_earned": points_earned, + "question_id": current_q_id, "attempts_used": 0, "attempts_left": 3, "is_finished": is_finished, @@ -286,6 +339,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) return { "correct": False, "score": session.score, + "points_earned": 0, + "question_id": current_q_id, "attempts_used": 3, "attempts_left": 0, "is_finished": is_finished, @@ -298,6 +353,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) return { "correct": False, "score": session.score, + "points_earned": 0, + "question_id": current_q_id, "attempts_used": session.attempts_current, "attempts_left": attempts_left, "is_finished": False, @@ -330,6 +387,7 @@ async def get_hint(session_id: str, db: Session = Depends(get_db)): @app.get("/finale", response_class=HTMLResponse) async def finale(request: Request, score: int = 0, total: int = 10, time: int = 0, lang: str = "de"): + max_score = 1000 if (total <= 10 and score > 10) else (total * 100 if total <= 10 else total) tier = get_tier_evaluation(score, total, lang) if time >= 60: minutes = time // 60 @@ -344,6 +402,7 @@ async def finale(request: Request, score: int = 0, total: int = 10, time: int = context={ "score": score, "total": total, + "max_score": max_score, "time_seconds": time, "time_formatted": time_str, "lang": lang, diff --git a/app/schemas.py b/app/schemas.py index 6659bf5..86fc535 100755 --- a/app/schemas.py +++ b/app/schemas.py @@ -4,6 +4,8 @@ from typing import Optional, List class GameStartRequest(BaseModel): lang: Optional[str] = "de" difficulty: Optional[str] = "all" + recent_ids: Optional[List[int]] = None + wrong_ids: Optional[List[int]] = None class GameStartResponse(BaseModel): session_id: str @@ -24,6 +26,8 @@ class GameCheckRequest(BaseModel): class GameCheckResponse(BaseModel): correct: bool score: int + points_earned: Optional[int] = 0 + question_id: Optional[int] = None attempts_used: int attempts_left: int max_attempts: int = 3 diff --git a/app/static/css/style.css b/app/static/css/style.css index e161d55..eaaaf22 100755 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -957,6 +957,32 @@ body { text-align: left; } +.btn-dice { + background: rgba(201, 168, 106, 0.18); + border: 1px solid var(--gold); + color: var(--gold-bright); + padding: 0 14px; + border-radius: 8px; + cursor: pointer; + font-size: 0.95rem; + font-family: inherit; + font-weight: bold; + display: inline-flex; + align-items: center; + gap: 6px; + justify-content: center; + transition: all 0.2s ease; + min-height: 46px; + white-space: nowrap; +} + +.btn-dice:hover { + background: var(--gold); + color: #0b0b16; + box-shadow: 0 0 12px rgba(201, 168, 106, 0.5); + transform: translateY(-1px); +} + /* Leaderboard tables & filters */ .diff-filter-tabs { display: flex; @@ -1245,27 +1271,40 @@ body { } .footer-links { - margin-top: 8px; + margin-top: 12px; display: flex; justify-content: center; align-items: center; - gap: 10px; + gap: 12px; + flex-wrap: wrap; } .footer-link { - color: var(--text-muted); + color: var(--gold-soft); text-decoration: none; - font-size: 0.8rem; - transition: color 0.2s ease; + font-size: 0.82rem; + font-weight: 600; + padding: 4px 12px; + border-radius: 16px; + background: rgba(201, 168, 106, 0.12); + border: 1px solid rgba(201, 168, 106, 0.35); + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 5px; } .footer-link:hover { - color: var(--gold-bright); - text-decoration: underline; + color: #0b0b16; + background: var(--gold); + border-color: var(--gold-bright); + box-shadow: 0 0 12px rgba(201, 168, 106, 0.5); + text-decoration: none; + transform: translateY(-1px); } .footer-sep { - color: rgba(255, 255, 255, 0.2); + display: none; } .legal-card { diff --git a/app/static/js/quiz.js b/app/static/js/quiz.js index bc2eccd..b32ba60 100755 --- a/app/static/js/quiz.js +++ b/app/static/js/quiz.js @@ -159,6 +159,58 @@ function applyQuizTranslations(lang) { } } +let sessionQuestionIds = []; + +function getRecentQuestionIds() { + try { + const stored = localStorage.getItem("quiz_recent_qids"); + return stored ? JSON.parse(stored) : []; + } catch(e) { + return []; + } +} + +function getWrongQuestionIds() { + try { + const stored = localStorage.getItem("quiz_wrong_qids"); + return stored ? JSON.parse(stored) : []; + } catch(e) { + return []; + } +} + +function recordQuestionWrong(qid) { + if (!qid) return; + try { + const wrong = getWrongQuestionIds(); + if (!wrong.includes(qid)) { + wrong.push(qid); + if (wrong.length > 30) wrong.shift(); + localStorage.setItem("quiz_wrong_qids", JSON.stringify(wrong)); + } + } catch(e) {} +} + +function recordQuestionMastered(qid) { + if (!qid) return; + try { + let wrong = getWrongQuestionIds(); + if (wrong.includes(qid)) { + wrong = wrong.filter(id => id !== qid); + localStorage.setItem("quiz_wrong_qids", JSON.stringify(wrong)); + } + } catch(e) {} +} + +function saveRecentQuestions(ids) { + if (!ids || ids.length === 0) return; + try { + let recent = getRecentQuestionIds(); + recent = Array.from(new Set([...ids, ...recent])).slice(0, 20); + localStorage.setItem("quiz_recent_qids", JSON.stringify(recent)); + } catch(e) {} +} + async function startGame() { const envelope = document.getElementById("envelope"); const startBtn = document.getElementById("startBtn"); @@ -167,6 +219,7 @@ async function startGame() { startBtn.disabled = true; startBtn.innerText = t.startBtnCasting; envelope.classList.add("envelope-opened"); + sessionQuestionIds = []; // Start background music if enabled or first click const bgm = document.getElementById("bgm"); @@ -183,12 +236,17 @@ async function startGame() { } try { + const recentIds = getRecentQuestionIds(); + const wrongIds = getWrongQuestionIds(); + const res = await fetch("/api/game/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang: currentLang, - difficulty: selectedDifficulty + difficulty: selectedDifficulty, + recent_ids: recentIds, + wrong_ids: wrongIds }) }); @@ -239,6 +297,9 @@ function updateTimer() { function renderQuestion(qData) { lastQuestionData = qData; + if (qData.id && !sessionQuestionIds.includes(qData.id)) { + sessionQuestionIds.push(qData.id); + } const t = UI_TEXT[currentLang] || UI_TEXT.de; document.getElementById("questionCounter").innerText = `${t.questionLabel} ${qData.question_number} ${t.ofLabel} ${totalQuestions}`; @@ -364,7 +425,17 @@ async function submitAnswer(answer, clickedBtn = null) { clickedBtn.classList.add("choice-correct"); } frageBox.classList.add("sparkle-effect"); - feedbackBox.innerHTML = `${t.correctFeedback}`; + + // Mark question as mastered in history + if (result.question_id) { + recordQuestionMastered(result.question_id); + } + + const pts = result.points_earned || 100; + const ptsText = (currentLang === "en") + ? `✨ Correct! +${pts} House Points! ✨` + : `✨ Richtig! +${pts} Hauspunkte für dein Haus! ✨`; + feedbackBox.innerHTML = `${ptsText}`; setTimeout(() => { frageBox.classList.remove("sparkle-effect"); @@ -391,7 +462,11 @@ async function submitAnswer(answer, clickedBtn = null) { document.getElementById("attemptBadge").innerText = `${t.attemptLabel} ${Math.min(attemptsUsed + 1, maxAttempts)} ${t.ofLabel} ${maxAttempts}`; if (result.attempts_left === 0) { - // Exhausted all attempts + // Exhausted all attempts -> record for spaced repetition learning + if (result.question_id) { + recordQuestionWrong(result.question_id); + } + feedbackBox.innerHTML = `
${t.exhaustedWrong}
${t.solutionLabel} ${result.revealed_answer}
@@ -448,6 +523,7 @@ async function requestHint() { function finishGame() { if (timerInterval) clearInterval(timerInterval); + saveRecentQuestions(sessionQuestionIds); document.getElementById("progressBar").style.width = "100%"; window.location.href = `/finale?score=${currentScore}&total=${totalQuestions}&time=${elapsedSeconds}&lang=${currentLang}`; } diff --git a/app/templates/admin.html b/app/templates/admin.html index f970503..868306d 100755 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -309,5 +309,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 17c1860..0dbce72 100755 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,7 +5,7 @@ {% block title %}Magisches Harry-Potter-Escape-Quiz{% endblock %} - + {% block head %}{% endblock %} diff --git a/app/templates/bestenliste.html b/app/templates/bestenliste.html index 3799d0c..805fad9 100644 --- a/app/templates/bestenliste.html +++ b/app/templates/bestenliste.html @@ -181,7 +181,7 @@ tr.innerHTML = ` ${rankDisplay} ${escapeHtml(e.nickname)} - ${e.score}/${e.total_questions} + ${e.score} Pkt. ${e.time_formatted} ${diffLabel} ${e.date} diff --git a/app/templates/finale.html b/app/templates/finale.html index 5bceb7b..65507d8 100755 --- a/app/templates/finale.html +++ b/app/templates/finale.html @@ -17,9 +17,9 @@

{% if lang == 'en' %} - You achieved {{ score }} out of {{ total }} House Points! + You achieved {{ score }} out of {{ max_score }} House Points! {% else %} - Du hast {{ score }} von {{ total }} Hauspunkten erzielt! + Du hast {{ score }} von {{ max_score }} Hauspunkten erzielt! {% endif %}

@@ -45,6 +45,9 @@

+ @@ -91,17 +94,55 @@ {% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 10898b9..4db80b3 100755 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -113,5 +113,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/app/templates/vorschlag.html b/app/templates/vorschlag.html index 6ae4ee4..bfb7623 100755 --- a/app/templates/vorschlag.html +++ b/app/templates/vorschlag.html @@ -70,5 +70,5 @@ {% endblock %} {% block scripts %} - + {% endblock %}