commit c5c0bad3e6e1ca858e72cbdca3a69b9a9b160298 Author: Maxi Date: Tue Sep 22 16:26:48 2026 +0200 Initial commit: Harry Potter Escape Adventure on bootle.dev.waiswuaschd.de diff --git a/.env b/.env new file mode 100755 index 0000000..813fb89 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +PORT=8002 +SECRET_KEY=hogwarts_magic_bootle_potion_key_2026_escape diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0ee3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv/ +__pycache__/ +*.pyc diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100755 index 0000000..71f543c Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..5e54e2b --- /dev/null +++ b/app/main.py @@ -0,0 +1,230 @@ +import os +import uuid +import json +import datetime +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware +from dotenv import load_dotenv + +load_dotenv() + +app = FastAPI(title="Hogwarts Escape Adventure - Die Kammern des Steins der Weisen") + +SECRET_KEY = os.getenv("SECRET_KEY", "alohomora_bootle_potion_secret_2026") +app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="escape_session") + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") +templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) + +# Standard Classic Riddle Solution (Book Accurate): +# 1: Poison +# 2: Wine +# 3: Ahead (Smallest / Dwarf - passes through black fire) +# 4: Poison +# 5: Poison +# 6: Wine +# 7: Back (Largest / Giant - passes through purple fire) +CLASSIC_SOLUTION = { + 1: "poison", + 2: "wine", + 3: "ahead", + 4: "poison", + 5: "poison", + 6: "wine", + 7: "back" +} + +def get_session_state(request: Request) -> dict: + if "escape" not in request.session: + request.session["escape"] = { + "session_id": str(uuid.uuid4()), + "broom_solved": False, + "broom_score": 0, + "broom_time": 0, + "bottles_solved": False, + "sliding_solved": False, + "started_at": datetime.datetime.utcnow().isoformat(), + "completed_at": None, + "lang": "de" + } + return request.session["escape"] + +@app.get("/", response_class=HTMLResponse) +async def page_index(request: Request): + state = get_session_state(request) + lang = request.query_params.get("lang", state.get("lang", "de")) + state["lang"] = lang + request.session["escape"] = state + return templates.TemplateResponse( + request=request, + name="index.html", + context={"state": state, "lang": lang} + ) + +@app.get("/broom", response_class=HTMLResponse) +async def page_broom(request: Request): + state = get_session_state(request) + lang = request.query_params.get("lang", state.get("lang", "de")) + state["lang"] = lang + request.session["escape"] = state + return templates.TemplateResponse( + request=request, + name="broom.html", + context={"state": state, "lang": lang} + ) + +@app.get("/bottles", response_class=HTMLResponse) +async def page_bottles(request: Request): + state = get_session_state(request) + lang = request.query_params.get("lang", state.get("lang", "de")) + state["lang"] = lang + request.session["escape"] = state + return templates.TemplateResponse( + request=request, + name="bottles.html", + context={"state": state, "lang": lang} + ) + +@app.get("/sliding", response_class=HTMLResponse) +async def page_sliding(request: Request): + state = get_session_state(request) + lang = request.query_params.get("lang", state.get("lang", "de")) + state["lang"] = lang + request.session["escape"] = state + return templates.TemplateResponse( + request=request, + name="sliding.html", + context={"state": state, "lang": lang} + ) + +@app.get("/finale", response_class=HTMLResponse) +async def page_finale(request: Request): + state = get_session_state(request) + lang = request.query_params.get("lang", state.get("lang", "de")) + state["lang"] = lang + request.session["escape"] = state + return templates.TemplateResponse( + request=request, + name="finale.html", + context={"state": state, "lang": lang} + ) + +@app.get("/api/state") +async def api_get_state(request: Request): + state = get_session_state(request) + return JSONResponse(state) + +@app.post("/api/reset") +async def api_reset_state(request: Request): + request.session["escape"] = { + "session_id": str(uuid.uuid4()), + "broom_solved": False, + "broom_score": 0, + "broom_time": 0, + "bottles_solved": False, + "sliding_solved": False, + "started_at": datetime.datetime.utcnow().isoformat(), + "completed_at": None, + "lang": "de" + } + return JSONResponse({"success": True, "message": "Escape progress reset."}) + +@app.post("/api/broom/complete") +async def api_broom_complete(request: Request): + try: + data = await request.json() + except Exception: + data = {} + state = get_session_state(request) + state["broom_solved"] = True + state["broom_score"] = int(data.get("score", 100)) + state["broom_time"] = int(data.get("time_taken", 30)) + request.session["escape"] = state + return JSONResponse({ + "success": True, + "message": "Der Schlüssel passt ins Schloss! Die Tür zur Flammenkammer steht offen!", + "state": state + }) + +@app.post("/api/bottles/drink") +async def api_bottles_drink(request: Request): + """ + Check the bottle the player decided to drink to pass through the flames. + Bottle 3 is Ahead (Black fire -> Success). + Bottle 7 is Back (Purple fire -> Escaped back). + Bottles 1, 4, 5 are Poison (Fizzle/Defeat). + Bottles 2, 6 are Nettle Wine (Harmless wine). + """ + try: + data = await request.json() + except Exception: + data = {} + bottle_index = int(data.get("bottle", 0)) + target_gate = data.get("gate", "ahead") # 'ahead' (black fire) or 'back' (purple fire) + lang = data.get("lang", "de") + + if bottle_index not in CLASSIC_SOLUTION: + raise HTTPException(status_code=400, detail="Ungültige Flaschennummer.") + + content = CLASSIC_SOLUTION[bottle_index] + state = get_session_state(request) + + if content == "ahead": + if target_gate == "ahead": + state["bottles_solved"] = True + if state.get("broom_solved"): + state["completed_at"] = datetime.datetime.utcnow().isoformat() + request.session["escape"] = state + msg_de = "Ein eisiger Schauer durchströmt dich! Das schwarze Feuer weicht vor dir zurück. Du trittst siegreich in die Schatzkammer des Steins der Weisen!" + msg_en = "An icy chill flows through you! The black flames retreat. You step victoriously into the chamber of the Philosopher's Stone!" + return JSONResponse({ + "success": True, + "result": "success_ahead", + "content": content, + "message": msg_de if lang == "de" else msg_en, + "next_url": "/finale" + }) + else: + msg_de = "Dieser Trank schützt dich nur vor dem schwarzen Feuer, nicht vor dem violetten!" + msg_en = "This potion only protects against black fire, not purple fire!" + return JSONResponse({"success": False, "result": "wrong_gate", "content": content, "message": msg_de if lang == "de" else msg_en}) + + elif content == "back": + if target_gate == "back": + msg_de = "Ein wohliger Kälteschleier hüllt dich ein. Du kannst sicher durch die violetten Flammen zurückweichen." + msg_en = "A protective cold mist envelops you. You can safely retreat through the purple flames." + return JSONResponse({ + "success": True, + "result": "success_back", + "content": content, + "message": msg_de if lang == "de" else msg_en, + "next_url": "/broom" + }) + else: + msg_de = "Dieser Trank schützt vor den violetten Flammen, führt dich aber nicht vorwärts durch das schwarze Feuer!" + msg_en = "This potion protects against purple flames, but does not allow forward passage through black fire!" + return JSONResponse({"success": False, "result": "wrong_gate", "content": content, "message": msg_de if lang == "de" else msg_en}) + + elif content == "poison": + msg_de = "💀 Gift! Dein Hals brennt wie Feuer. Ein Zauber bricht die Wirkung gerade noch rechtzeitig ab, doch du musst es erneut versuchen!" + msg_en = "💀 Poison! Your throat burns. A counter-curse breaks the effect just in time, but you must try again!" + return JSONResponse({"success": False, "result": "poison", "content": content, "message": msg_de if lang == "de" else msg_en}) + + elif content == "wine": + msg_de = "🍷 Milder Brennnesselwein! Köstlich erfrischend, aber ohne jegliche magische Schutzwirkung gegen die verzehrenden Flammen." + msg_en = "🍷 Mild nettle wine! Deliciously refreshing, but offering zero magical protection against the consuming flames." + return JSONResponse({"success": False, "result": "wine", "content": content, "message": msg_de if lang == "de" else msg_en}) + +@app.post("/api/sliding/complete") +async def api_sliding_complete(request: Request): + state = get_session_state(request) + state["sliding_solved"] = True + request.session["escape"] = state + return JSONResponse({ + "success": True, + "message": "Das Alchemie-Schiebesiegel ist gelöst! Die geheime Essenz wurde freigegeben." + }) diff --git a/app/static/css/escape.css b/app/static/css/escape.css new file mode 100755 index 0000000..716485b --- /dev/null +++ b/app/static/css/escape.css @@ -0,0 +1,590 @@ +:root { + --bg-dark: #0d0c13; + --bg-card: rgba(26, 24, 38, 0.92); + --gold: #e5b948; + --gold-glow: rgba(229, 185, 72, 0.4); + --gold-hover: #f7d274; + --parchment-bg: #f8f1de; + --parchment-border: #8b6d3b; + --parchment-ink: #2b1f14; + --purple-fire: #aa26eb; + --purple-fire-glow: rgba(170, 38, 235, 0.6); + --black-fire: #120d1e; + --black-fire-glow: rgba(67, 18, 114, 0.8); + --poison-green: #2ecc71; + --wine-red: #9e2a2b; + --text-light: #ece8e1; + --text-dim: #a59fa8; + --font-gothic: 'MedievalSharp', cursive, serif; + --font-script: 'Caveat', cursive, sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-dark); + background-image: + radial-gradient(circle at 15% 20%, rgba(138, 43, 226, 0.12) 0%, transparent 40%), + radial-gradient(circle at 85% 80%, rgba(229, 185, 72, 0.08) 0%, transparent 40%), + url('/static/img/parchment_texture.jpg'); + background-blend-mode: overlay; + color: var(--text-light); + font-family: var(--font-gothic); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Site Header */ +.site-header { + background: rgba(13, 12, 19, 0.95); + border-bottom: 2px solid var(--gold); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); + position: sticky; + top: 0; + z-index: 1000; + backdrop-filter: blur(10px); +} + +.header-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0.75rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; +} + +.brand-link { + display: flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: var(--gold); + font-size: 1.4rem; + text-shadow: 0 0 10px var(--gold-glow); + transition: transform 0.2s; +} + +.brand-link:hover { + transform: scale(1.03); +} + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.nav-links { + display: flex; + align-items: center; + gap: 1rem; +} + +.nav-link { + color: var(--text-light); + text-decoration: none; + font-size: 1rem; + padding: 0.35rem 0.75rem; + border-radius: 6px; + border: 1px solid transparent; + transition: all 0.2s; +} + +.nav-link:hover, .nav-link.active { + color: var(--gold); + border-color: var(--gold); + background: rgba(229, 185, 72, 0.1); +} + +/* Control Buttons */ +.btn-control { + background: rgba(255, 255, 255, 0.08); + color: var(--gold); + border: 1px solid var(--gold); + padding: 0.4rem 0.8rem; + border-radius: 6px; + cursor: pointer; + font-family: inherit; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.4rem; + transition: all 0.2s; +} + +.btn-control:hover { + background: var(--gold); + color: var(--bg-dark); + box-shadow: 0 0 12px var(--gold-glow); +} + +.lang-btn.active { + background: var(--gold); + color: var(--bg-dark); + font-weight: bold; +} + +/* Container */ +.container { + max-width: 1100px; + margin: 1.5rem auto 3rem auto; + padding: 0 1rem; + flex: 1; + width: 100%; +} + +/* Parchment Box */ +.parchment-scroll { + background: var(--parchment-bg); + color: var(--parchment-ink); + border: 4px double var(--parchment-border); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7), inset 0 0 40px rgba(139, 109, 59, 0.25); + position: relative; + margin-bottom: 2rem; +} + +.parchment-title { + font-size: 2rem; + text-align: center; + color: #5c1818; + margin-bottom: 1rem; + text-shadow: 1px 1px 0px rgba(255,255,255,0.7); +} + +.riddle-poem { + font-family: var(--font-script); + font-size: 1.5rem; + line-height: 1.8; + text-align: center; + margin: 1.5rem 0; + white-space: pre-line; + font-weight: bold; +} + +/* Dungeon Card */ +.dungeon-card { + background: var(--bg-card); + border: 1px solid rgba(229, 185, 72, 0.4); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + margin-bottom: 2rem; +} + +/* Chamber Overview Grid */ +.chamber-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.chamber-card { + background: rgba(20, 18, 30, 0.9); + border: 2px solid rgba(229, 185, 72, 0.3); + border-radius: 12px; + padding: 1.5rem; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: all 0.3s; + position: relative; + overflow: hidden; +} + +.chamber-card:hover { + transform: translateY(-5px); + border-color: var(--gold); + box-shadow: 0 10px 30px var(--gold-glow); +} + +.chamber-badge { + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(229, 185, 72, 0.2); + border: 1px solid var(--gold); + color: var(--gold); + font-size: 0.8rem; + padding: 0.2rem 0.6rem; + border-radius: 20px; +} + +.chamber-badge.solved { + background: rgba(46, 204, 113, 0.25); + border-color: #2ecc71; + color: #2ecc71; +} + +.chamber-icon { + font-size: 3.5rem; + margin-bottom: 1rem; + text-align: center; +} + +.chamber-title { + font-size: 1.5rem; + color: var(--gold); + margin-bottom: 0.75rem; + text-align: center; +} + +.chamber-desc { + color: var(--text-dim); + font-size: 0.95rem; + line-height: 1.6; + margin-bottom: 1.25rem; + text-align: center; +} + +/* Magical Buttons */ +.btn-magical { + display: inline-block; + background: linear-gradient(135deg, #b8860b, #d4af37, #f7d274); + color: #1a1505; + text-decoration: none; + font-weight: bold; + font-size: 1.1rem; + padding: 0.8rem 1.5rem; + border-radius: 8px; + border: none; + cursor: pointer; + text-align: center; + box-shadow: 0 4px 15px rgba(212, 175, 55, 0.4); + transition: all 0.2s; + text-shadow: 0 1px 1px rgba(255,255,255,0.4); +} + +.btn-magical:hover { + transform: translateY(-2px); + box-shadow: 0 6px 22px rgba(212, 175, 55, 0.7); + color: #000; +} + +/* Flaming Gates (Bottles Riddle) */ +.flame-chamber-arena { + position: relative; + background: radial-gradient(circle at center, #191629 0%, #0c0a14 100%); + border: 2px solid #4a3b63; + border-radius: 16px; + padding: 2rem 1.5rem; + box-shadow: inset 0 0 80px rgba(0,0,0,0.9), 0 10px 40px rgba(0,0,0,0.8); + overflow: hidden; +} + +.flame-wall-container { + display: flex; + justify-content: space-between; + margin-bottom: 2rem; + position: relative; +} + +.flame-gate { + width: 48%; + padding: 1.5rem 1rem; + border-radius: 12px; + text-align: center; + position: relative; + overflow: hidden; +} + +.flame-gate.purple { + background: rgba(75, 15, 110, 0.35); + border: 2px solid var(--purple-fire); + box-shadow: 0 0 35px var(--purple-fire-glow); +} + +.flame-gate.black { + background: rgba(18, 12, 28, 0.8); + border: 2px solid #5a287a; + box-shadow: 0 0 35px var(--black-fire-glow); +} + +.flame-icon-animated { + font-size: 3rem; + animation: flameFlicker 1.5s infinite alternate; +} + +@keyframes flameFlicker { + 0% { transform: scale(1) rotate(-2deg); opacity: 0.85; } + 50% { transform: scale(1.1) rotate(3deg); opacity: 1; filter: drop-shadow(0 0 15px currentColor); } + 100% { transform: scale(0.95) rotate(-1deg); opacity: 0.9; } +} + +/* 7 Bottles Table */ +.bottles-table-wrapper { + margin: 2rem 0; + padding: 1.5rem 1rem; + background: linear-gradient(180deg, #2a201b 0%, #17110e 100%); + border: 3px solid #6b5338; + border-radius: 14px; + box-shadow: 0 10px 30px rgba(0,0,0,0.9); +} + +.bottles-table { + display: flex; + justify-content: space-around; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: nowrap; + overflow-x: auto; + padding: 1rem 0; +} + +.bottle-slot { + display: flex; + flex-direction: column; + align-items: center; + min-width: 95px; + cursor: pointer; + transition: transform 0.2s; + user-select: none; +} + +.bottle-slot:hover { + transform: translateY(-8px); +} + +.bottle-slot.selected { + transform: translateY(-12px); + filter: drop-shadow(0 0 15px var(--gold)); +} + +.bottle-pedestal { + width: 80px; + height: 18px; + background: linear-gradient(180deg, #574635 0%, #2f251c 100%); + border: 2px solid #856a52; + border-radius: 6px; + margin-top: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + color: var(--gold); + font-weight: bold; +} + +/* SVG Bottles */ +.bottle-svg { + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: filter 0.3s; +} + +/* Bottle tags */ +.bottle-tag-select { + margin-top: 0.4rem; + background: rgba(13, 12, 19, 0.9); + border: 1px solid var(--gold); + color: var(--text-light); + font-family: inherit; + font-size: 0.75rem; + padding: 0.2rem; + border-radius: 4px; + cursor: pointer; +} + +/* Canvas Broom Game */ +.canvas-wrapper { + position: relative; + width: 100%; + max-width: 900px; + margin: 0 auto; + border: 3px solid var(--gold); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 0 40px rgba(0,0,0,0.9), 0 0 20px var(--gold-glow); + background: #000; +} + +#broomCanvas { + display: block; + width: 100%; + height: 520px; + cursor: crosshair; +} + +.game-hud { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.25rem; + background: rgba(13, 12, 19, 0.9); + border-bottom: 2px solid var(--gold); + font-size: 1.1rem; + color: var(--gold); +} + +.mobile-controls { + display: none; + margin-top: 1rem; + justify-content: center; + gap: 1rem; +} + +@media (max-width: 768px) { + .mobile-controls { + display: flex; + } + .flame-wall-container { + flex-direction: column; + gap: 1rem; + } + .flame-gate { + width: 100%; + } + #broomCanvas { + height: 380px; + } +} + +/* Modal Feedback */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 1rem; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; +} + +.modal-backdrop.show { + opacity: 1; + pointer-events: auto; +} + +.modal-content { + background: var(--bg-card); + border: 2px solid var(--gold); + border-radius: 14px; + max-width: 550px; + width: 100%; + padding: 2rem; + text-align: center; + box-shadow: 0 0 50px var(--gold-glow); + transform: scale(0.9); + transition: transform 0.3s; +} + +.modal-backdrop.show .modal-content { + transform: scale(1); +} + +.modal-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.modal-title { + font-size: 1.8rem; + color: var(--gold); + margin-bottom: 1rem; +} + +.modal-text { + color: var(--text-light); + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 1.5rem; +} + +/* Footer */ +.footer { + background: rgba(10, 9, 15, 0.95); + border-top: 1px solid rgba(229, 185, 72, 0.3); + text-align: center; + padding: 1.5rem 1rem; + color: var(--text-dim); + font-size: 0.9rem; +} + +.footer a { + color: var(--gold); + text-decoration: none; +} + +.footer a:hover { + text-decoration: underline; +} + +/* Sliding Puzzle (Schieberätsel) */ +.sliding-board-container { + max-width: 440px; + margin: 1.5rem auto; + background: #171421; + border: 3px solid var(--gold); + border-radius: 14px; + padding: 1.25rem; + box-shadow: 0 0 30px rgba(0,0,0,0.8), inset 0 0 30px rgba(0,0,0,0.6); +} + +.sliding-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-gap: 10px; + width: 100%; + aspect-ratio: 1 / 1; +} + +.sliding-tile { + background: linear-gradient(145deg, #2b253b, #1b1725); + border: 2px solid var(--gold); + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; + transition: transform 0.15s, background 0.2s; + box-shadow: 0 4px 8px rgba(0,0,0,0.5); + padding: 0.5rem; +} + +.sliding-tile:hover { + transform: scale(1.03); + background: #39324e; +} + +.sliding-tile.blank { + background: transparent; + border: 2px dashed rgba(229, 185, 72, 0.2); + box-shadow: none; + cursor: default; +} + +.tile-icon { + font-size: 2.2rem; +} + +.tile-num { + font-size: 0.85rem; + color: var(--gold); + font-weight: bold; +} + +.tile-name { + font-size: 0.75rem; + color: var(--text-dim); + text-align: center; + margin-top: 2px; +} + diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100755 index 0000000..7a6c687 Binary files /dev/null and b/app/static/favicon.ico differ diff --git a/app/static/img/hogwarts_seal.jpg b/app/static/img/hogwarts_seal.jpg new file mode 100755 index 0000000..8353180 Binary files /dev/null and b/app/static/img/hogwarts_seal.jpg differ diff --git a/app/static/img/parchment_texture.jpg b/app/static/img/parchment_texture.jpg new file mode 100755 index 0000000..f335bf4 Binary files /dev/null and b/app/static/img/parchment_texture.jpg differ diff --git a/app/static/js/audio.js b/app/static/js/audio.js new file mode 100755 index 0000000..42e5d5c --- /dev/null +++ b/app/static/js/audio.js @@ -0,0 +1,162 @@ +// Web Audio API Synthesizer for magical escape sounds +class SoundFX { + constructor() { + this.ctx = null; + this.muted = localStorage.getItem('escape_sound_muted') === 'true'; + } + + init() { + if (!this.ctx) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioContext(); + } + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } + + toggleMute() { + this.muted = !this.muted; + localStorage.setItem('escape_sound_muted', this.muted); + return !this.muted; + } + + isMuted() { + return this.muted; + } + + // Whoosh of flying broomstick + playWhoosh() { + if (this.muted) return; + this.init(); + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + const filter = this.ctx.createBiquadFilter(); + + osc.type = 'sawtooth'; + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(300, this.ctx.currentTime); + filter.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.15); + filter.frequency.exponentialRampToValueAtTime(200, this.ctx.currentTime + 0.35); + + gain.gain.setValueAtTime(0.15, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.35); + + osc.connect(filter); + filter.connect(gain); + gain.connect(this.ctx.destination); + + osc.start(); + osc.stop(this.ctx.currentTime + 0.36); + } + + // Key caught / fluttering chime + playKeyChime() { + if (this.muted) return; + this.init(); + const freqs = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6 + freqs.forEach((freq, idx) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + const startTime = this.ctx.currentTime + idx * 0.05; + gain.gain.setValueAtTime(0.1, startTime); + gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.4); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(startTime); + osc.stop(startTime + 0.41); + }); + } + + // Clink of glass bottle + playBottleClink() { + if (this.muted) return; + this.init(); + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(1200, this.ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.12); + gain.gain.setValueAtTime(0.2, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(); + osc.stop(this.ctx.currentTime + 0.16); + } + + // Success magical harmony + playMagicSuccess() { + if (this.muted) return; + this.init(); + const chords = [440, 554.37, 659.25, 880, 1108.73]; // A Major triumph + chords.forEach((freq, i) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, this.ctx.currentTime + i * 0.06); + gain.gain.setValueAtTime(0.12, this.ctx.currentTime + i * 0.06); + gain.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime + i * 0.06 + 1.2); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(this.ctx.currentTime + i * 0.06); + osc.stop(this.ctx.currentTime + i * 0.06 + 1.25); + }); + } + + // Poison fizzle + playPoisonSizzle() { + if (this.muted) return; + this.init(); + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(180, this.ctx.currentTime); + osc.frequency.linearRampToValueAtTime(90, this.ctx.currentTime + 0.5); + gain.gain.setValueAtTime(0.25, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(); + osc.stop(this.ctx.currentTime + 0.51); + } + + // Stone slide + playStoneSlide() { + if (this.muted) return; + this.init(); + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'square'; + osc.frequency.setValueAtTime(80, this.ctx.currentTime); + gain.gain.setValueAtTime(0.08, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(); + osc.stop(this.ctx.currentTime + 0.21); + } +} + +window.soundFX = new SoundFX(); + +function updateAudioButtonUI() { + const btn = document.getElementById('audioToggleBtn'); + if (!btn) return; + const isMuted = window.soundFX.isMuted(); + btn.innerHTML = isMuted ? '🔇 Ton: Aus' : '🔊 Ton: An'; +} + +function toggleSound() { + window.soundFX.toggleMute(); + updateAudioButtonUI(); +} + +document.addEventListener('DOMContentLoaded', () => { + updateAudioButtonUI(); + document.body.addEventListener('click', () => { + window.soundFX.init(); + }, { once: true }); +}); diff --git a/app/static/js/bottles.js b/app/static/js/bottles.js new file mode 100755 index 0000000..959eed6 --- /dev/null +++ b/app/static/js/bottles.js @@ -0,0 +1,181 @@ +// Snape's 7 Potions Riddle & Interactive Bottle Board +(function() { + let selectedBottle = null; + const slots = document.querySelectorAll('.bottle-slot'); + const selectedInfo = document.getElementById('selectedBottleInfo'); + const btnDrinkAhead = document.getElementById('btnDrinkAhead'); + const btnDrinkBack = document.getElementById('btnDrinkBack'); + const modalBackdrop = document.getElementById('modalBackdrop'); + const modalTitle = document.getElementById('modalTitle'); + const modalText = document.getElementById('modalText'); + const modalBtn = document.getElementById('modalBtn'); + const modalIcon = document.getElementById('modalIcon'); + + // Bottle Descriptions based on Book Clues + const bottleDescriptions = { + 1: "Eine dunkelbraune Phiole mittlerer Größe. Auf der linken Seite von Flasche 2.", + 2: "Eine schlanke, grüne Glasflasche. Schmeckt laut Hinweis 4 exakt wie Flasche 6!", + 3: "🔍 Die kleinste Flasche von allen (der 'Zwerg')! Sie enthält kaum mehr als einen einzigen Schluck.", + 4: "Ein eigenartiger, kantiger Glaskolben mit trüber, unruhiger Flüssigkeit.", + 5: "Eine bauchige Flasche aus Rauchglas. Steht direkt links von Flasche 6.", + 6: "Eine elegante Flasche, die Zwillingsschwester von Flasche 2, auch wenn sie äußerlich verschieden wirken.", + 7: "🏺 Die größte Flasche in der Reihe (der 'Riese')! Ein schwerer, bauchiger Dekanter." + }; + + slots.forEach(slot => { + slot.addEventListener('click', (e) => { + if (e.target.tagName === 'SELECT') return; // Don't trigger on tag dropdown + selectSlot(slot); + }); + + // Drag & Drop / Reordering support + slot.setAttribute('draggable', 'true'); + slot.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', slot.dataset.index); + if (window.soundFX) window.soundFX.playStoneSlide(); + }); + + slot.addEventListener('dragover', (e) => { + e.preventDefault(); + slot.classList.add('drag-over'); + }); + + slot.addEventListener('dragleave', () => { + slot.classList.remove('drag-over'); + }); + + slot.addEventListener('drop', (e) => { + e.preventDefault(); + slot.classList.remove('drag-over'); + const sourceIndex = e.dataTransfer.getData('text/plain'); + const sourceSlot = document.querySelector(`.bottle-slot[data-index="${sourceIndex}"]`); + if (sourceSlot && sourceSlot !== slot) { + swapSlots(sourceSlot, slot); + } + }); + }); + + function swapSlots(slotA, slotB) { + if (window.soundFX) { + window.soundFX.playStoneSlide(); + window.soundFX.playBottleClink(); + } + // Swap inner contents except the pedestal number + const tempHTML = slotA.innerHTML; + slotA.innerHTML = slotB.innerHTML; + slotB.innerHTML = tempHTML; + + // Restore event listeners on newly swapped selects + attachTagListeners(); + selectSlot(slotB); + } + + function selectSlot(slot) { + slots.forEach(s => s.classList.remove('selected')); + slot.classList.add('selected'); + selectedBottle = parseInt(slot.dataset.index, 10); + + if (window.soundFX) window.soundFX.playBottleClink(); + + if (btnDrinkAhead) btnDrinkAhead.disabled = false; + if (btnDrinkBack) btnDrinkBack.disabled = false; + + if (selectedInfo) { + const desc = bottleDescriptions[selectedBottle] || "Eine geheimnisvolle Flasche."; + selectedInfo.innerHTML = `Gewählt: Flasche ${selectedBottle} — ${desc}`; + } + } + + function attachTagListeners() { + document.querySelectorAll('.bottle-tag-select').forEach(sel => { + sel.addEventListener('change', () => { + if (window.soundFX) window.soundFX.playStoneSlide(); + }); + }); + } + attachTagListeners(); + + // Drink to pass Ahead (Black Fire) + if (btnDrinkAhead) { + btnDrinkAhead.addEventListener('click', () => { + if (!selectedBottle) return; + drinkBottle(selectedBottle, 'ahead'); + }); + } + + // Drink to pass Back (Purple Fire) + if (btnDrinkBack) { + btnDrinkBack.addEventListener('click', () => { + if (!selectedBottle) return; + drinkBottle(selectedBottle, 'back'); + }); + } + + async function drinkBottle(bottleIdx, gate) { + if (window.soundFX) window.soundFX.playBottleClink(); + + try { + const res = await fetch('/api/bottles/drink', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bottle: bottleIdx, gate: gate, lang: 'de' }) + }); + const data = await res.json(); + + if (data.result === 'success_ahead') { + if (window.soundFX) window.soundFX.playMagicSuccess(); + modalIcon.innerText = "❄️⚡"; + modalTitle.innerText = "Hervorragend kombiniert!"; + modalTitle.style.color = "#ffd700"; + modalText.innerHTML = `Du hast die kleinste Flasche (Flasche 3, den 'Zwerg') getrunken!

+ ${data.message}

+ Das schwarze Feuer teilt sich mit einem frostigen Zischen. Der Weg zum Stein der Weisen liegt frei vor dir!`; + modalBtn.innerText = "Zum Finale & Escape-Triumph 🏆"; + modalBtn.onclick = () => { window.location.href = data.next_url; }; + modalBackdrop.classList.add('show'); + } else if (data.result === 'success_back') { + if (window.soundFX) window.soundFX.playMagicSuccess(); + modalIcon.innerText = "🟣"; + modalTitle.innerText = "Rückzug gesichert!"; + modalText.innerHTML = `Du hast die größte Flasche (Flasche 7, den 'Riesen') getrunken.

+ ${data.message}

+ Du kannst sicher durch die violetten Flammen zurückweichen, um deinen Gefährten zu helfen oder den Besenflug nochmals zu betreten.`; + modalBtn.innerText = "Zurück zum Besenflug 🧹"; + modalBtn.onclick = () => { window.location.href = data.next_url; }; + modalBackdrop.classList.add('show'); + } else if (data.result === 'poison') { + if (window.soundFX) window.soundFX.playPoisonSizzle(); + modalIcon.innerText = "💀"; + modalTitle.innerText = "Tödliches Gift!"; + modalTitle.style.color = "#e74c3c"; + modalText.innerHTML = `${data.message}

+ Snapes Rätsel-Hinweis:
+ "So schlau sich das Gift auch zu tarnen mag,
+ stets steht es zur Linken vom Wein im Gelag."
`; + modalBtn.innerText = "Erneut analysieren 📜"; + modalBtn.onclick = () => { modalBackdrop.classList.remove('show'); }; + modalBackdrop.classList.add('show'); + } else if (data.result === 'wine') { + if (window.soundFX) window.soundFX.playPoisonSizzle(); + modalIcon.innerText = "🍷"; + modalTitle.innerText = "Harmloser Brennnesselwein"; + modalTitle.style.color = "#e67e22"; + modalText.innerHTML = `${data.message}

+ Du spürst keinerlei Schutz vor der Hitze der Flammen. Wenn du jetzt hineintrittst, verbrennst du! Lies die Verse auf dem Pergament noch einmal gründlich.`; + modalBtn.innerText = "Verstanden 📜"; + modalBtn.onclick = () => { modalBackdrop.classList.remove('show'); }; + modalBackdrop.classList.add('show'); + } else { + if (window.soundFX) window.soundFX.playPoisonSizzle(); + modalIcon.innerText = "⚠️"; + modalTitle.innerText = "Falsche Flamme!"; + modalText.innerText = data.message; + modalBtn.innerText = "Zurück"; + modalBtn.onclick = () => { modalBackdrop.classList.remove('show'); }; + modalBackdrop.classList.add('show'); + } + } catch (e) { + console.error(e); + } + } +})(); diff --git a/app/static/js/broom.js b/app/static/js/broom.js new file mode 100755 index 0000000..168d359 --- /dev/null +++ b/app/static/js/broom.js @@ -0,0 +1,425 @@ +// Broom flight mini-game: Winged Keys Chamber (Flitwick's Challenge) +(function() { + const canvas = document.getElementById('broomCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + // UI elements + const scoreVal = document.getElementById('scoreVal'); + const timerVal = document.getElementById('timerVal'); + const statusMsg = document.getElementById('statusMsg'); + const modalBackdrop = document.getElementById('modalBackdrop'); + const modalTitle = document.getElementById('modalTitle'); + const modalText = document.getElementById('modalText'); + const modalBtn = document.getElementById('modalBtn'); + + let width, height; + function resize() { + width = canvas.parentElement.clientWidth; + height = Math.min(520, window.innerHeight * 0.65); + canvas.width = width; + canvas.height = height; + } + window.addEventListener('resize', resize); + resize(); + + // Game State + let gameRunning = true; + let startTime = Date.now(); + let score = 0; + let keyCaught = false; + let doorUnlocked = false; + + // Player (Broomstick) + const player = { + x: 80, + y: height / 2, + vx: 0, + vy: 0, + speed: 5.5, + boost: 1.0, + angle: 0, + trail: [] + }; + + // Keys Swarm + const keys = []; + const NUM_KEYS = 24; + + // Master Key (Damaged wing silver key) + const masterKey = { + x: width - 150, + y: height / 2, + vx: -2, + vy: 1.5, + wingPhase: 0, + caught: false, + size: 16 + }; + + // Target Door + const door = { + x: width - 60, + y: height / 2 - 45, + w: 45, + h: 90 + }; + + // Spawn decoy keys + for (let i = 0; i < NUM_KEYS; i++) { + keys.push({ + x: Math.random() * (width - 150) + 100, + y: Math.random() * (height - 60) + 30, + vx: (Math.random() - 0.5) * 4, + vy: (Math.random() - 0.5) * 3, + size: Math.random() * 6 + 8, + wingPhase: Math.random() * Math.PI * 2, + wingSpeed: Math.random() * 0.3 + 0.2, + gold: Math.random() > 0.4 + }); + } + + // Controls + const keysDown = {}; + window.addEventListener('keydown', e => { + keysDown[e.key.toLowerCase()] = true; + if (e.key === ' ' || e.key === 'Shift') player.boost = 1.6; + }); + window.addEventListener('keyup', e => { + keysDown[e.key.toLowerCase()] = false; + if (e.key === ' ' || e.key === 'Shift') player.boost = 1.0; + }); + + // Pointer / Touch control + let pointerTarget = null; + canvas.addEventListener('pointermove', e => { + const rect = canvas.getBoundingClientRect(); + pointerTarget = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + }); + canvas.addEventListener('pointerdown', e => { + player.boost = 1.6; + if (window.soundFX) window.soundFX.playWhoosh(); + }); + canvas.addEventListener('pointerup', () => { + player.boost = 1.0; + }); + canvas.addEventListener('pointerleave', () => { + pointerTarget = null; + player.boost = 1.0; + }); + + // Mobile buttons + const btnUp = document.getElementById('mBtnUp'); + const btnDown = document.getElementById('mBtnDown'); + const btnBoost = document.getElementById('mBtnBoost'); + if (btnUp) { + btnUp.addEventListener('pointerdown', () => { keysDown['arrowup'] = true; }); + btnUp.addEventListener('pointerup', () => { keysDown['arrowup'] = false; }); + } + if (btnDown) { + btnDown.addEventListener('pointerdown', () => { keysDown['arrowdown'] = true; }); + btnDown.addEventListener('pointerup', () => { keysDown['arrowdown'] = false; }); + } + if (btnBoost) { + btnBoost.addEventListener('pointerdown', () => { player.boost = 1.8; if (window.soundFX) window.soundFX.playWhoosh(); }); + btnBoost.addEventListener('pointerup', () => { player.boost = 1.0; }); + } + + function update() { + if (!gameRunning) return; + + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + if (timerVal) timerVal.innerText = elapsedSec + 's'; + + // Keyboard Movement + let ax = 0, ay = 0; + if (keysDown['arrowup'] || keysDown['w']) ay -= 1; + if (keysDown['arrowdown'] || keysDown['s']) ay += 1; + if (keysDown['arrowleft'] || keysDown['a']) ax -= 1; + if (keysDown['arrowright'] || keysDown['d']) ax += 1; + + // Pointer steer + if (pointerTarget) { + const dx = pointerTarget.x - player.x; + const dy = pointerTarget.y - player.y; + const dist = Math.hypot(dx, dy); + if (dist > 15) { + ax = dx / dist; + ay = dy / dist; + } + } + + const curSpeed = player.speed * player.boost; + player.vx = player.vx * 0.85 + ax * curSpeed * 0.2; + player.vy = player.vy * 0.85 + ay * curSpeed * 0.2; + + player.x += player.vx; + player.y += player.vy; + + // Bounds + player.x = Math.max(30, Math.min(width - 70, player.x)); + player.y = Math.max(30, Math.min(height - 30, player.y)); + + player.angle = Math.atan2(player.vy, player.vx + 2) * 0.5; + + // Particles trail + player.trail.push({ x: player.x - 20, y: player.y, alpha: 1, size: Math.random() * 4 + 2 }); + if (player.trail.length > 20) player.trail.shift(); + player.trail.forEach(p => { p.alpha *= 0.9; p.x -= 2; }); + + // Update decoy keys + keys.forEach(k => { + k.wingPhase += k.wingSpeed; + k.x += k.vx; + k.y += k.vy; + if (k.x < 50 || k.x > width - 100) k.vx *= -1; + if (k.y < 30 || k.y > height - 30) k.vy *= -1; + }); + + // Update Master Key + if (!masterKey.caught) { + masterKey.wingPhase += 0.35; + // Evasive maneuver if player is close + const dToPlayer = Math.hypot(player.x - masterKey.x, player.y - masterKey.y); + if (dToPlayer < 140) { + masterKey.vx += (masterKey.x - player.x) * 0.04; + masterKey.vy += (masterKey.y - player.y) * 0.04; + } else { + masterKey.vx += (Math.random() - 0.5) * 0.8; + masterKey.vy += (Math.random() - 0.5) * 0.8; + } + masterKey.vx = Math.max(-5, Math.min(5, masterKey.vx * 0.95)); + masterKey.vy = Math.max(-4, Math.min(4, masterKey.vy * 0.95)); + + masterKey.x += masterKey.vx; + masterKey.y += masterKey.vy; + + masterKey.x = Math.max(120, Math.min(width - 120, masterKey.x)); + masterKey.y = Math.max(40, Math.min(height - 40, masterKey.y)); + + // Catch check + if (dToPlayer < 35) { + masterKey.caught = true; + keyCaught = true; + score += 500; + if (scoreVal) scoreVal.innerText = score; + if (window.soundFX) window.soundFX.playKeyChime(); + if (statusMsg) { + statusMsg.innerHTML = "✨ Schlüssel gefangen! Fliege jetzt zur schweren Holztür rechts und öffne das Schloss!"; + statusMsg.style.color = "#2ecc71"; + } + } + } else { + // Master key follows player + masterKey.x = player.x + 10; + masterKey.y = player.y - 15; + + // Check if player reached the door + const dToDoor = Math.hypot(player.x - (door.x + door.w / 2), player.y - (door.y + door.h / 2)); + if (dToDoor < 55 && !doorUnlocked) { + doorUnlocked = true; + gameRunning = false; + onGameWin(elapsedSec); + } + } + } + + function render() { + ctx.clearRect(0, 0, width, height); + + // Dungeon stone background & arches + ctx.fillStyle = '#0f0e17'; + ctx.fillRect(0, 0, width, height); + + // Stone pillars + ctx.fillStyle = 'rgba(25, 22, 36, 0.7)'; + for (let x = 120; x < width - 100; x += 180) { + ctx.fillRect(x, 0, 35, height); + ctx.strokeStyle = '#352e47'; + ctx.lineWidth = 2; + ctx.strokeRect(x, 0, 35, height); + } + + // Vaulted arches at top + ctx.strokeStyle = '#2d273d'; + ctx.lineWidth = 4; + ctx.beginPath(); + for (let x = 0; x < width; x += 90) { + ctx.moveTo(x, 0); + ctx.quadraticCurveTo(x + 45, 60, x + 90, 0); + } + ctx.stroke(); + + // The Door + ctx.fillStyle = doorUnlocked ? '#1f6f3e' : '#4a2f16'; + ctx.fillRect(door.x, door.y, door.w, door.h); + ctx.strokeStyle = '#e5b948'; + ctx.lineWidth = 3; + ctx.strokeRect(door.x, door.y, door.w, door.h); + + // Keyhole + ctx.fillStyle = keyCaught ? '#ffd700' : '#111'; + ctx.beginPath(); + ctx.arc(door.x + door.w / 2, door.y + door.h / 2 - 6, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillRect(door.x + door.w / 2 - 3, door.y + door.h / 2 - 6, 6, 14); + + if (keyCaught) { + ctx.shadowColor = '#ffd700'; + ctx.shadowBlur = 15; + } + + // Sparkle trail + player.trail.forEach(p => { + ctx.fillStyle = `rgba(229, 185, 72, ${p.alpha})`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + }); + ctx.shadowBlur = 0; + + // Decoy keys + keys.forEach(k => { + ctx.save(); + ctx.translate(k.x, k.y); + // wings + const wingY = Math.sin(k.wingPhase) * (k.size * 0.7); + ctx.fillStyle = k.gold ? 'rgba(255, 215, 0, 0.7)' : 'rgba(200, 220, 255, 0.65)'; + ctx.beginPath(); + ctx.ellipse(-k.size * 0.6, wingY, k.size * 0.8, k.size * 0.35, 0.3, 0, Math.PI * 2); + ctx.ellipse(k.size * 0.6, wingY, k.size * 0.8, k.size * 0.35, -0.3, 0, Math.PI * 2); + ctx.fill(); + + // Key body + ctx.fillStyle = k.gold ? '#e5b948' : '#c0c5cc'; + ctx.beginPath(); + ctx.arc(0, 0, k.size * 0.35, 0, Math.PI * 2); + ctx.fill(); + ctx.fillRect(-1.5, 0, 3, k.size * 1.1); + ctx.fillRect(0, k.size * 0.7, k.size * 0.4, 2.5); + ctx.restore(); + }); + + // Master Key (Damaged Wing) + ctx.save(); + ctx.translate(masterKey.x, masterKey.y); + const mWingY = Math.sin(masterKey.wingPhase) * (masterKey.size * 0.8); + ctx.shadowColor = '#00ffff'; + ctx.shadowBlur = masterKey.caught ? 5 : 12; + + // Right intact wing + ctx.fillStyle = 'rgba(180, 225, 255, 0.85)'; + ctx.beginPath(); + ctx.ellipse(masterKey.size * 0.7, mWingY, masterKey.size * 0.9, masterKey.size * 0.4, -0.3, 0, Math.PI * 2); + ctx.fill(); + + // Left BENT wing (as in the book!) + ctx.fillStyle = 'rgba(140, 190, 230, 0.75)'; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-masterKey.size * 0.5, mWingY * 0.6); + ctx.lineTo(-masterKey.size * 0.9, mWingY * 0.3); // bent angle! + ctx.lineTo(-masterKey.size * 0.4, mWingY * 0.2); + ctx.closePath(); + ctx.fill(); + + // Heavy antique key body + ctx.fillStyle = '#e0e8f0'; + ctx.beginPath(); + ctx.arc(0, 0, masterKey.size * 0.4, 0, Math.PI * 2); + ctx.fill(); + ctx.fillRect(-2, 0, 4, masterKey.size * 1.3); + ctx.fillRect(0, masterKey.size * 0.8, masterKey.size * 0.5, 3); + ctx.fillRect(0, masterKey.size * 1.05, masterKey.size * 0.4, 3); + ctx.restore(); + ctx.shadowBlur = 0; + + // Player (Broom & Wizard) + ctx.save(); + ctx.translate(player.x, player.y); + ctx.rotate(player.angle); + + // Broomstick handle + ctx.strokeStyle = '#5a3d1b'; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.moveTo(-35, 2); + ctx.lineTo(30, -2); + ctx.stroke(); + + // Broom twigs + ctx.fillStyle = '#8a6229'; + ctx.beginPath(); + ctx.moveTo(-35, 2); + ctx.lineTo(-55, -8); + ctx.lineTo(-58, 12); + ctx.closePath(); + ctx.fill(); + + // Wizard silhouette (Cloak & Hat) + ctx.fillStyle = '#1c172a'; + ctx.beginPath(); + ctx.arc(5, -12, 7, 0, Math.PI * 2); // head + ctx.fill(); + // Hat + ctx.fillStyle = '#120e1e'; + ctx.beginPath(); + ctx.moveTo(-4, -13); + ctx.lineTo(14, -13); + ctx.lineTo(2, -26); + ctx.closePath(); + ctx.fill(); + // Cloak fluttering + ctx.fillStyle = '#7a1f28'; // Gryffindor red cloak + ctx.beginPath(); + ctx.moveTo(0, -6); + ctx.lineTo(-20, -10 + Math.sin(Date.now() * 0.01) * 3); + ctx.lineTo(-24, 6); + ctx.lineTo(10, 0); + ctx.closePath(); + ctx.fill(); + + // Wand tip spark + ctx.fillStyle = '#ffff99'; + ctx.shadowColor = '#ffff00'; + ctx.shadowBlur = 10; + ctx.beginPath(); + ctx.arc(24, -8, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + + ctx.restore(); + } + + function loop() { + update(); + render(); + requestAnimationFrame(loop); + } + requestAnimationFrame(loop); + + async function onGameWin(timeTaken) { + if (window.soundFX) window.soundFX.playMagicSuccess(); + try { + await fetch('/api/broom/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ score: score, time_taken: timeTaken }) + }); + } catch (e) { + console.error(e); + } + + modalTitle.innerText = "🏆 Raum 1 gemeistert!"; + modalText.innerHTML = `Mit waghalsigem Flug hast du den großen Silberschlüssel mit dem geknickten Flügel erwischt und das schwere Schloss entriegelt!

+ Flugzeit: ${timeTaken} Sekunden
+ Punkte: ${score}

+ Die Tür schwingt knarrend auf... Vor dir lodern violette und schwarze Flammen in Snapes Prüfung!`; + modalBtn.innerText = "Weiter zur Flammenkammer 🧪"; + modalBtn.onclick = () => { window.location.href = '/bottles'; }; + modalBackdrop.classList.add('show'); + } +})(); diff --git a/app/static/js/sliding.js b/app/static/js/sliding.js new file mode 100755 index 0000000..79216e9 --- /dev/null +++ b/app/static/js/sliding.js @@ -0,0 +1,147 @@ +// Alchemy Potion Sliding Block Puzzle (Schieberätsel) +(function() { + const gridEl = document.getElementById('slidingGrid'); + if (!gridEl) return; + + const movesVal = document.getElementById('slidingMoves'); + const timerVal = document.getElementById('slidingTimer'); + const modalBackdrop = document.getElementById('modalBackdrop'); + const modalTitle = document.getElementById('modalTitle'); + const modalText = document.getElementById('modalText'); + const modalBtn = document.getElementById('modalBtn'); + const btnScramble = document.getElementById('btnScramble'); + + const SIZE = 3; // 3x3 sliding puzzle (8 tiles + 1 blank) + let tiles = []; + let blankIndex = 8; + let moves = 0; + let timerInterval = null; + let secondsElapsed = 0; + let isSolved = false; + + // Tile images / potion runes + const tileSymbols = [ + { id: 1, icon: "🧪", name: "Alraune", color: "#2ecc71" }, + { id: 2, icon: "🍷", name: "Brennnesselwein", color: "#e74c3c" }, + { id: 3, icon: "⚡", name: "Blitzphiole", color: "#f1c40f" }, + { id: 4, icon: "🔮", name: "Phönix-Träne", color: "#9b59b6" }, + { id: 5, icon: "🐉", name: "Drachenblut", color: "#e67e22" }, + { id: 6, icon: "💀", name: "Tobsuchtsgift", color: "#1abc9c" }, + { id: 7, icon: "💎", name: "Elixier des Lebens", color: "#3498db" }, + { id: 8, icon: "🕯️", name: "Ewige Flamme", color: "#d35400" }, + { id: 0, icon: "", name: "Leerer Runen-Sockel", color: "transparent" } // Blank + ]; + + function init() { + tiles = [1, 2, 3, 4, 5, 6, 7, 8, 0]; + blankIndex = 8; + moves = 0; + secondsElapsed = 0; + isSolved = false; + if (movesVal) movesVal.innerText = moves; + if (timerVal) timerVal.innerText = '0s'; + if (timerInterval) clearInterval(timerInterval); + timerInterval = setInterval(() => { + if (!isSolved) { + secondsElapsed++; + if (timerVal) timerVal.innerText = secondsElapsed + 's'; + } + }, 1000); + + scramble(); + render(); + } + + function scramble() { + // Perform 60 random valid moves from blank to ensure solvability + for (let i = 0; i < 60; i++) { + const neighbors = getNeighbors(blankIndex); + const randomNeighbor = neighbors[Math.floor(Math.random() * neighbors.length)]; + tiles[blankIndex] = tiles[randomNeighbor]; + tiles[randomNeighbor] = 0; + blankIndex = randomNeighbor; + } + moves = 0; + if (movesVal) movesVal.innerText = moves; + } + + function getNeighbors(idx) { + const row = Math.floor(idx / SIZE); + const col = idx % SIZE; + const res = []; + if (row > 0) res.push(idx - SIZE); // Up + if (row < SIZE - 1) res.push(idx + SIZE); // Down + if (col > 0) res.push(idx - 1); // Left + if (col < SIZE - 1) res.push(idx + 1); // Right + return res; + } + + function render() { + gridEl.innerHTML = ''; + tiles.forEach((val, idx) => { + const tile = document.createElement('div'); + tile.className = 'sliding-tile' + (val === 0 ? ' blank' : ''); + tile.dataset.index = idx; + + if (val !== 0) { + const info = tileSymbols.find(s => s.id === val); + tile.innerHTML = ` + ${info.icon} + ${val} + ${info.name} + `; + tile.style.borderColor = info.color; + } + + tile.addEventListener('click', () => onTileClick(idx)); + gridEl.appendChild(tile); + }); + } + + function onTileClick(idx) { + if (isSolved) return; + const neighbors = getNeighbors(idx); + if (neighbors.includes(blankIndex)) { + // Swap with blank + tiles[blankIndex] = tiles[idx]; + tiles[idx] = 0; + blankIndex = idx; + moves++; + if (movesVal) movesVal.innerText = moves; + + if (window.soundFX) window.soundFX.playStoneSlide(); + render(); + checkWin(); + } + } + + function checkWin() { + const winning = [1, 2, 3, 4, 5, 6, 7, 8, 0]; + const match = tiles.every((v, i) => v === winning[i]); + if (match) { + isSolved = true; + clearInterval(timerInterval); + if (window.soundFX) window.soundFX.playMagicSuccess(); + + fetch('/api/sliding/complete', { method: 'POST' }).catch(console.error); + + modalTitle.innerText = "✨ Schieberätsel gelöst!"; + modalText.innerHTML = `Mit geschicktem Schieben hast du alle 8 alchemistischen Phiolen in die perfekte harmonische Ordnung gebracht!

+ Züge: ${moves}
+ Zeit: ${secondsElapsed} Sekunden

+ Die Runenplatte leuchtet golden auf und der Weg zur Flammenkammer öffnet sich!`; + modalBtn.innerText = "Zur Flammenkammer 🧪"; + modalBtn.onclick = () => { window.location.href = '/bottles'; }; + modalBackdrop.classList.add('show'); + } + } + + if (btnScramble) { + btnScramble.addEventListener('click', () => { + if (window.soundFX) window.soundFX.playStoneSlide(); + init(); + }); + } + + init(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100755 index 0000000..8bbc2c2 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,66 @@ + + + + + + {% block title %}Hogwarts Escape Adventure{% endblock %} · bootle + + + + + + {% block head %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/bottles.html b/app/templates/bottles.html new file mode 100755 index 0000000..22b30a0 --- /dev/null +++ b/app/templates/bottles.html @@ -0,0 +1,242 @@ +{% extends "base.html" %} + +{% block title %}Kammer II: Snapes Flaschenrätsel & Flammenkammer · Hogwarts Escape{% endblock %} + +{% block content %} +
+

🧪 Kammer II: Snapes Flaschenrätsel 🔥

+ +
+ "Gefahr liegt vor euch, die Rettung zurück, + Zwei von uns helfen euch, habt ihr das Glück, + Eine von sieben führt euch hindurch, + Eine bringt den, der sie trinkt, sicher zurück, + Zwei von ihnen enthalten nur Brennnesselwein, + Drei sind tödlich, schenkt ihr sie ein. + Wählt oder bleibt auf ewig hier stehen, + Vier Hinweise helfen euch, alles zu sehen: + + 1. So schlau sich das Gift auch zu tarnen mag, + stets steht es zur Linken vom Wein im Gelag. + 2. Verschieden sind jene am Ende der Reih', + doch wollt ihr nach vorn, ist keines getreu. + 3. Wie ihr deutlich seht, sind alle verschieden groß, + weder Zwerg noch Riese birgt des Todes Los. + 4. Die Zweite von links und die Zweite von rechts + sind Zwillinge im Geschmack des Geflechts, + obwohl sie dem Auge ganz ungleich erscheinen." +
+
+ +
+ +
+ +
+
🟣🔥
+

Violette Flammen

+

Der Rückweg in den Besenraum

+
+ + +
+
🖤🔥
+

Schwarze Flammen

+

Das Tor zur Kammer des Steins der Weisen!

+
+
+ + +
+

+ 🖐️ Wähle oder schiebe die Flaschen auf den Sockeln (Drag & Drop / Klick): +

+

+ Nutze die Notiz-Dropdowns unter jeder Flasche, um deine logische Deduktion festzuhalten! +

+ +
+ +
+ + + + + + + + +
I
+ +
+ + +
+ + + + + + + + +
II
+ +
+ + +
+ + + + + + + +
III (Zwerg)
+ +
+ + +
+ + + + + + + +
IV
+ +
+ + +
+ + + + + + + +
V
+ +
+ + +
+ + + + + + + + +
VI
+ +
+ + +
+ + + + + + + + +
VII (Riese)
+ +
+
+
+ + +
+
+ 👆 Klicke auf eine der 7 Flaschen auf dem Altar, um sie zu prüfen und zu trinken. +
+ +
+ + +
+
+
+ + +
+
+ + 📖 Hermines Logik-Spickzettel & Denkhilfe öffnen (Hier klicken) + +
+

Die 4 genialen Schlussfolgerungen aus Snapes Versen:

+
    +
  • Hinweis 4: Flasche II und VI schmecken identisch (Zwillinge). Da es nur 2 Brennnesselweine gibt, sind II und VI zwingend der Wein!
  • +
  • Hinweis 1: Links neben jedem Wein steht immer Gift. Daher müssen Flasche I (links von II) und Flasche V (links von VI) Gift sein!
  • +
  • Hinweis 2: Flasche I und VII sind unterschiedlich. Da I Gift ist, kann VII kein Gift sein. Außerdem bringt keines der beiden Enden vorwärts.
  • +
  • Hinweis 3: Weder der Zwerg (Flasche III) noch der Riese (Flasche VII) bergen Gift. Da Flasche III die Vorwärts-Flasche ist, führt nur sie durch das schwarze Feuer! Flasche VII bringt dich durch die violetten Flammen sicher zurück.
  • +
+
+
+
+ +
+ + 🧩 Zum mechanischen Alchemie-Schieberätsel ➔ + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/broom.html b/app/templates/broom.html new file mode 100755 index 0000000..33776af --- /dev/null +++ b/app/templates/broom.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}Kammer I: Der Flug der geflügelten Schlüssel · Hogwarts Escape{% endblock %} + +{% block content %} +
+

🧹 Kammer I: Der Besenflug 🗝️

+

+ "Hunderte metallischer Flügel schwirren wie Vögel unter der Decke des Gewölbes.
+ Die schwere Kerkertür am anderen Ende ist fest verschlossen.
+ Nur ein einziger Schlüssel passt: Ein alter, großer Silberschlüssel mit geknicktem blauem Flügel!" +

+
+ +
+
+
⏱️ Zeit: 0s
+
✨ Punkte: 0
+
🎯 Mission: Fange den Schlüssel mit geknicktem Flügel!
+
+ + +
+ + +
+ + + +
+ +
+

+ 🎮 Steuerung: Pfeiltasten / WASD / Mausbewegung zum Fliegen · Leertaste / Klick für Besen-Turbo! +

+

+ Fliege durch den Raum, jage den ausweichenden Silberschlüssel mit dem verletzten Flügel und fliege nach dem Fang zur Tür ganz rechts! +

+
+ +
+ Direkt weiter zu Snapes Flaschenkammer 🧪 ➔ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/finale.html b/app/templates/finale.html new file mode 100755 index 0000000..4e04c67 --- /dev/null +++ b/app/templates/finale.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Escape Gemeistert! Der Stein der Weisen ist in Sicherheit!{% endblock %} + +{% block content %} +
+

⚡ ESCAPE GEMEISTERT! ⚡

+
+ 💎 +
+ +

+ "Der eisige Kälteschleier der dritten Phiole trug dich unbeschadet durch das schwarze Feuer.
+ Dahinter stand der goldene Spiegel Nerhegeb...
+ Und tief in deiner Tasche spürst du plötzlich ein schweres, pulsierendes Gewicht:
+ Der Stein der Weisen ist in Sicherheit!" +

+
+ +
+

📜 Hogwarts Escape Urkunde

+

+ Du hast bewiesen, dass Mut, Flugkunst auf dem Besen und unbestechliche Logik stärker sind als jede dunkle Magie. + Professor Dumbledore und das gesamte Kollegium von Hogwarts sprechen dir ihre höchste Anerkennung aus! +

+ +
+
+ Auszeichnung: Retter des Steins der Weisen +
+
+ Besenflug gemeistert: {% if state.broom_solved %}Ja ({{ state.broom_score }} Pkt){% else %}Übersprungen{% endif %}
+ Snapes Flammenkammer gelöst: Ja (Flasche III) +
+
+ +
+ + 📜 Zum großen Hogwarts-Quiz ↗ + + +
+
+ + +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100755 index 0000000..8575ab5 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}Die Prüfungen des Steins der Weisen · Hogwarts Escape{% endblock %} + +{% block content %} +
+

⚡ Die Kammern des Steins der Weisen ⚡

+

+ "Die Falltür unter Fluffys Pfoten hat sich geöffnet.
+ Tief unter den Kerkern von Hogwarts wachen uralte Zauber über das größte Geheimnis der Alchemie.
+ Beweise deinen Scharfsinn, dein Geschick auf dem Besen und deine eiserne Logik!" +

+
+ +
+

+ Wähle deine Prüfung im Escape Adventure +

+

+ Du kannst die Kammern einzeln erkunden oder die Prüfungen nacheinander bezwingen: +

+ +
+ +
+ + {% if state.broom_solved %}✅ Gemeistert{% else %}🔓 Bereit{% endif %} + +
🧹🗝️
+

Kammer I: Der Besenflug

+

+ Professor Flitwicks verzauberte Kammer. Schwärme geflügelter Schlüssel surren unter der hohen Gewölbedecke. + Finde den großen, alten Silberschlüssel mit dem geknickten Flügel und schließe die Tür auf! +

+ Auf den Besen schwingen 🧹 +
+ + +
+ + {% if state.bottles_solved %}✅ Gemeistert{% else %}🔥 Bereit{% endif %} + +
🧪🔥
+

Kammer II: Snapes Flaschenrätsel

+

+ Schwarze Flammen versperren den Weg zum Stein, violette Flammen den Rückweg. + Auf dem Tisch stehen 7 Flaschen und Snapes unerbittliches Logik-Gedicht. + Schiebe, etikettiere und wähle die richtige Phiole! +

+ Flammenkammer betreten 🧪 +
+ + +
+ + {% if state.sliding_solved %}✅ Gelöst{% else %}🧩 Zusatz-Rätsel{% endif %} + +
🧩⚗️
+

Alchemie-Schieberätsel

+

+ Das mechanische Runen-Schiebesiegel auf der Steintafel. + Verschiebe die alchemistischen Elixier-Kacheln, um die verborgene Essenz der Alraune und des Lebenselixiers freizuschalten. +

+ Schieberätsel lösen 🧩 +
+ + +
+ + ✨ Quiz Portal + +
🏰📜
+

Die Große Halle: Quiz

+

+ Möchtest du dein gesamtes theoretisches Wissen über die Zaubererwelt auf die Probe stellen? + Hunderte Fragen aus allen 7 Schuljahren warten im Hogwarts-Quiz! +

+ Zum Hogwarts-Quiz ↗ +
+
+
+ +
+ +
+ + +{% endblock %} diff --git a/app/templates/sliding.html b/app/templates/sliding.html new file mode 100755 index 0000000..b162ea8 --- /dev/null +++ b/app/templates/sliding.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Alchemie-Schieberätsel · Hogwarts Escape{% endblock %} + +{% block content %} +
+

🧩 Das Alchemie-Schieberätsel ⚗️

+

+ "Auf dem antiken Steinsockel ruht ein uralter Verschlussmechanismus.
+ Acht alchemistische Essenzen sind durcheinandergeraten.
+ Schiebe die Phiolen und Runen auf die freien Felder, bis alle Plätze von 1 bis 8 geordnet sind!" +

+
+ +
+
+
⏱️ Zeit: 0s
+
🔄 Züge: 0
+
+ +
+
+ +
+
+ +
+ + + 🧪 Zu Snapes Flaschenrätsel + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/bootle.dev.waiswuaschd.de.conf b/bootle.dev.waiswuaschd.de.conf new file mode 100755 index 0000000..65646a0 --- /dev/null +++ b/bootle.dev.waiswuaschd.de.conf @@ -0,0 +1,39 @@ +server { + listen 80; + listen [::]:80; + server_name bootle.dev.waiswuaschd.de; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name bootle.dev.waiswuaschd.de; + + ssl_certificate /etc/nginx/ssl/bootle.dev.waiswuaschd.de/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/bootle.dev.waiswuaschd.de/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location /static/ { + alias /home/maxi/Projekt/bootle.waiswuaschd.de/app/static/; + expires 7d; + } + + location / { + proxy_pass http://127.0.0.1:8002; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/bootle.service b/bootle.service new file mode 100755 index 0000000..94d8adf --- /dev/null +++ b/bootle.service @@ -0,0 +1,16 @@ +[Unit] +Description=Magisches Hogwarts Escape Adventure (FastAPI) +After=network.target + +[Service] +Type=simple +User=maxi +Group=maxi +WorkingDirectory=/home/maxi/Projekt/bootle.waiswuaschd.de +Environment="PATH=/home/maxi/Projekt/bootle.waiswuaschd.de/venv/bin:/usr/local/bin:/usr/bin:/bin" +ExecStart=/home/maxi/Projekt/bootle.waiswuaschd.de/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8002 --workers 2 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..7f4321a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110.0 +uvicorn>=0.29.0 +jinja2>=3.1.3 +python-dotenv>=1.0.1 diff --git a/test_app.py b/test_app.py new file mode 100755 index 0000000..600bb6c --- /dev/null +++ b/test_app.py @@ -0,0 +1,63 @@ +import sys +from starlette.testclient import TestClient +from app.main import app + +def test_endpoints(): + client = TestClient(app) + + # Test pages + res = client.get("/") + assert res.status_code == 200, f"Index failed: {res.status_code}" + assert "Die Kammern des Steins der Weisen" in res.text + + res = client.get("/broom") + assert res.status_code == 200 + assert "Der Besenflug" in res.text + + res = client.get("/bottles") + assert res.status_code == 200 + assert "Snapes Flaschenrätsel" in res.text + + res = client.get("/sliding") + assert res.status_code == 200 + assert "Alchemie-Schieberätsel" in res.text + + res = client.get("/finale") + assert res.status_code == 200 + assert "ESCAPE GEMEISTERT" in res.text + + # Test API Broom complete + res = client.post("/api/broom/complete", json={"score": 500, "time_taken": 25}) + assert res.status_code == 200 + data = res.json() + assert data["success"] is True + + # Test API Bottles drink: Poison (Bottle 1) + res = client.post("/api/bottles/drink", json={"bottle": 1, "gate": "ahead"}) + assert res.status_code == 200 + assert res.json()["result"] == "poison" + + # Test API Bottles drink: Wine (Bottle 2) + res = client.post("/api/bottles/drink", json={"bottle": 2, "gate": "ahead"}) + assert res.status_code == 200 + assert res.json()["result"] == "wine" + + # Test API Bottles drink: Back (Bottle 7) + res = client.post("/api/bottles/drink", json={"bottle": 7, "gate": "back"}) + assert res.status_code == 200 + assert res.json()["result"] == "success_back" + + # Test API Bottles drink: Ahead / Black fire (Bottle 3 - Dwarf) + res = client.post("/api/bottles/drink", json={"bottle": 3, "gate": "ahead"}) + assert res.status_code == 200 + assert res.json()["result"] == "success_ahead" + + # Test API Reset + res = client.post("/api/reset") + assert res.status_code == 200 + assert res.json()["success"] is True + + print("ALL TESTS PASSED SUCCESSFULLY! 🧙‍♂️✨") + +if __name__ == "__main__": + test_endpoints()