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 Prüfungen der Zauberei") 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): CLASSIC_SOLUTION = { 1: "poison", 2: "wine", 3: "ahead", 4: "poison", 5: "poison", 6: "wine", 7: "back" } ALL_ITEMS = { "wand": { "id": "wand", "name": "Zauberstab", "icon": "🪄", "desc": "Stechpalme und Phönixfeder, 11 Zoll. Liegt warm und vibrationsbereit in deiner Hand.", "action": "lumos", "action_label": "Lumos / Licht 💡" }, "broom": { "id": "broom", "name": "Nimbus 2000", "icon": "🧹", "desc": "Ein pfeilschneller Rennbesen. Perfekt für den rasanten Flug durch schwebende Ringe!", "action": "broom_polish", "action_label": "Besen polieren ✨" }, "duel_badge": { "id": "duel_badge", "name": "Duellanten-Abzeichen", "icon": "⚡", "desc": "Ein goldenes Abzeichen für meisterhafte Zauberstab-Gesten im Duellier-Club von Hogwarts.", "action": "spark_wand", "action_label": "Funkenregen sprühen ⚡" }, "felix_felicis": { "id": "felix_felicis", "name": "Felix Felicis", "icon": "🧪", "desc": "Ein Tropfen flüssiges Gold! Verleiht dir unerschütterliches Glück bei deinen magischen Taten.", "action": "lucky_aura", "action_label": "Glücks-Aura aktivieren 🍀" }, "cryptex_key": { "id": "cryptex_key", "name": "Schlüssel der Gründer", "icon": "🗝️", "desc": "Ein schwerer, mit uralten Runen gravierter Messingschlüssel aus dem Tresor von Hogwarts.", "action": "inspect_key", "action_label": "Runen betrachten 🔍" }, "pixie_jar": { "id": "pixie_jar", "name": "Wichtelglas", "icon": "🧚", "desc": "Ein dickbauchiges Glas mit einem gebannten blauen Wichtel. Er schneidet lustige Fratzen!", "action": "poke_jar", "action_label": "Glas antippen ⚡" }, "snape_scroll": { "id": "snape_scroll", "name": "Snapes Logik-Rolle", "icon": "📜", "desc": "Ein vergilbtes Pergament mit Snapes Vierzeilern zur Flammenkammer.", "action": "read_scroll", "action_label": "Rätsel-Verse lesen 📖" }, "philosophers_stone": { "id": "philosophers_stone", "name": "Stein der Weisen", "icon": "💎", "desc": "Der legendäre blutrote Alchemistenstein. Er schenkt unsterbliches Leben und pure Weisheit!", "action": "feel_power", "action_label": "Macht spüren ⚡" } } HOUSES = { "gryffindor": {"id": "gryffindor", "name": "Gryffindor", "icon": "🦁", "color": "#740001", "accent": "#d3a625"}, "slytherin": {"id": "slytherin", "name": "Slytherin", "icon": "🐍", "color": "#1a472a", "accent": "#aaaaaa"}, "ravenclaw": {"id": "ravenclaw", "name": "Ravenclaw", "icon": "🦅", "color": "#0e1a40", "accent": "#946b2d"}, "hufflepuff": {"id": "hufflepuff", "name": "Hufflepuff", "icon": "🦡", "color": "#ecb939", "accent": "#372e29"} } CRYPTEX_SOLUTION = ["🦁", "🌙", "🔥", "🪄"] def get_session_state(request: Request) -> dict: if "escape" not in request.session: request.session["escape"] = { "session_id": str(uuid.uuid4()), "house": "gryffindor", "inventory": ["wand", "broom", "snape_scroll"], "broom_solved": False, "broom_score": 0, "broom_time": 0, "duel_solved": False, "brew_solved": False, "cryptex_solved": False, "pixies_solved": False, "bottles_solved": False, "sliding_solved": False, "started_at": datetime.datetime.utcnow().isoformat(), "completed_at": None, "lang": "de" } else: state = request.session["escape"] if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] if state.get("duel_solved") and "duel_badge" not in state["inventory"]: state["inventory"].append("duel_badge") if state.get("brew_solved") and "felix_felicis" not in state["inventory"]: state["inventory"].append("felix_felicis") if state.get("cryptex_solved") and "cryptex_key" not in state["inventory"]: state["inventory"].append("cryptex_key") if state.get("pixies_solved") and "pixie_jar" not in state["inventory"]: state["inventory"].append("pixie_jar") if state.get("bottles_solved") and "philosophers_stone" not in state["inventory"]: state["inventory"].append("philosophers_stone") if "house" not in state: state["house"] = "gryffindor" if "duel_solved" not in state: state["duel_solved"] = False if "brew_solved" not in state: state["brew_solved"] = False if "cryptex_solved" not in state: state["cryptex_solved"] = False if "pixies_solved" not in state: state["pixies_solved"] = False request.session["escape"] = state return request.session["escape"] def get_template_context(request: Request, state: dict, lang: str) -> dict: inv_ids = state.get("inventory", ["wand", "broom", "snape_scroll"]) inv_items = [ALL_ITEMS[i] for i in inv_ids if i in ALL_ITEMS] house_info = HOUSES.get(state.get("house", "gryffindor"), HOUSES["gryffindor"]) return { "state": state, "lang": lang, "inventory_items": inv_items, "all_items": ALL_ITEMS, "house": house_info, "houses": HOUSES } @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=get_template_context(request, state, 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=get_template_context(request, state, lang) ) @app.get("/duel", response_class=HTMLResponse) async def page_duel(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="duel.html", context=get_template_context(request, state, lang) ) @app.get("/brew", response_class=HTMLResponse) async def page_brew(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="brew.html", context=get_template_context(request, state, lang) ) @app.get("/cryptex", response_class=HTMLResponse) async def page_cryptex(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="cryptex.html", context=get_template_context(request, state, lang) ) @app.get("/pixies", response_class=HTMLResponse) async def page_pixies(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="pixies.html", context=get_template_context(request, state, lang) ) @app.get("/chess") async def redirect_chess(): # Graceful redirect for old links return RedirectResponse(url="/duel", status_code=302) @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=get_template_context(request, state, 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=get_template_context(request, state, 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=get_template_context(request, state, lang) ) @app.get("/api/state") async def api_get_state(request: Request): state = get_session_state(request) return JSONResponse(state) @app.get("/api/inventory") async def api_get_inventory(request: Request): state = get_session_state(request) inv_ids = state.get("inventory", ["wand", "broom", "snape_scroll"]) inv_items = [ALL_ITEMS[i] for i in inv_ids if i in ALL_ITEMS] house_info = HOUSES.get(state.get("house", "gryffindor"), HOUSES["gryffindor"]) return JSONResponse({ "inventory": inv_items, "house": house_info, "score": state.get("broom_score", 0), "broom_solved": state.get("broom_solved", False), "duel_solved": state.get("duel_solved", False), "brew_solved": state.get("brew_solved", False), "cryptex_solved": state.get("cryptex_solved", False), "pixies_solved": state.get("pixies_solved", False), "bottles_solved": state.get("bottles_solved", False), "sliding_solved": state.get("sliding_solved", False) }) @app.post("/api/player/house") async def api_set_house(request: Request): try: data = await request.json() except Exception: data = {} house_id = data.get("house", "gryffindor").lower() if house_id not in HOUSES: raise HTTPException(status_code=400, detail="Ungültiges Hogwarts-Haus.") state = get_session_state(request) state["house"] = house_id request.session["escape"] = state return JSONResponse({"success": True, "house": HOUSES[house_id]}) @app.post("/api/reset") async def api_reset_state(request: Request): request.session["escape"] = { "session_id": str(uuid.uuid4()), "house": "gryffindor", "inventory": ["wand", "broom", "snape_scroll"], "broom_solved": False, "broom_score": 0, "broom_time": 0, "duel_solved": False, "brew_solved": False, "cryptex_solved": False, "pixies_solved": False, "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": "Parcours gemeistert! Die Tür zum Zauberspruch-Duell steht offen!", "state": state }) @app.post("/api/duel/complete") async def api_duel_complete(request: Request): state = get_session_state(request) state["duel_solved"] = True if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] if "duel_badge" not in state["inventory"]: state["inventory"].append("duel_badge") request.session["escape"] = state return JSONResponse({ "success": True, "message": "Zauberspruch-Duell gewonnen! Die Pforte zur Brauküche steht weit offen!", "new_item": ALL_ITEMS["duel_badge"], "state": state }) @app.post("/api/brew/complete") async def api_brew_complete(request: Request): state = get_session_state(request) state["brew_solved"] = True if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] if "felix_felicis" not in state["inventory"]: state["inventory"].append("felix_felicis") request.session["escape"] = state return JSONResponse({ "success": True, "message": "Goldener Glanz erfüllt den Kessel! Felix Felicis ist meisterhaft gebraut.", "new_item": ALL_ITEMS["felix_felicis"], "state": state }) @app.post("/api/cryptex/unlock") async def api_cryptex_unlock(request: Request): try: data = await request.json() except Exception: data = {} combo = data.get("combination", []) # Check match against CRYPTEX_SOLUTION ["🦁", "🌙", "🔥", "🪄"] is_correct = False if combo == CRYPTEX_SOLUTION: is_correct = True elif combo == [0, 1, 0, 1]: is_correct = True elif combo == ["lion", "moon", "fire", "wand"]: is_correct = True if not is_correct: return JSONResponse({ "success": False, "message": "Die Runenzylinder rasten nicht ein. Die Gravur bleibt verschlossen!" }) state = get_session_state(request) state["cryptex_solved"] = True if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] if "cryptex_key" not in state["inventory"]: state["inventory"].append("cryptex_key") request.session["escape"] = state return JSONResponse({ "success": True, "message": "Das Runenschloss springt mit einem goldenen Glühen auf! Der Schlüssel der Gründer gehört dir.", "new_item": ALL_ITEMS["cryptex_key"], "state": state }) @app.post("/api/pixies/complete") async def api_pixies_complete(request: Request): try: data = await request.json() except Exception: data = {} caught = int(data.get("caught", 0)) if caught < 10: return JSONResponse({ "success": False, "message": f"Du hast erst {caught}/10 Wichtel gebannt. Halte durch!" }) state = get_session_state(request) state["pixies_solved"] = True if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] if "pixie_jar" not in state["inventory"]: state["inventory"].append("pixie_jar") request.session["escape"] = state return JSONResponse({ "success": True, "message": "Alle frechen Wichtel mit Immobilus gebannt! Das Wichtelglas wandert in deinen Beutel.", "new_item": ALL_ITEMS["pixie_jar"], "state": state }) @app.post("/api/bottles/drink") async def api_bottles_drink(request: Request): try: data = await request.json() except Exception: data = {} bottle_index = int(data.get("bottle", 0)) target_gate = data.get("gate", "ahead") 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") or state.get("duel_solved"): state["completed_at"] = datetime.datetime.utcnow().isoformat() if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] if "philosophers_stone" not in state["inventory"]: state["inventory"].append("philosophers_stone") 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, "new_item": ALL_ITEMS["philosophers_stone"], "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": "/duel" }) 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." })