Files

621 lines
24 KiB
Python
Executable File

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 ✨"
},
"winged_key": {
"id": "winged_key",
"name": "Geflügelter Schlüssel",
"icon": "🗝️",
"desc": "Ein alter Silberschlüssel mit zarten Libellenflügeln. Er sperrt die Eichentür zur Duellier-Kammer auf!",
"action": "flutter_key",
"action_label": "Flügel surren lassen 🪽"
},
"duel_badge": {
"id": "duel_badge",
"name": "Duellanten-Abzeichen & Snapes Notiz",
"icon": "⚡",
"desc": "Ein goldenes Abzeichen mit Snapes handschriftlicher Rezeptur-Notiz für Felix Felicis auf der Rückseite.",
"action": "read_recipe",
"action_label": "Snapes Rezeptur lesen 📜"
},
"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("broom_solved") and "winged_key" not in state["inventory"]:
state["inventory"].append("winged_key")
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"]
CHAMBER_REQUIREMENTS = {
"duel": {
"item_id": "winged_key",
"item_name": "Geflügelter Schlüssel",
"item_icon": "🗝️",
"from_chamber": "Kammer I: Der Ring-Flug",
"from_url": "/broom",
"reason": "Die schwere Eichentür zur Duellier-Kammer ist mit einem verzauberten Flügelschloss versiegelt. Fange zuerst den geflügelten Schlüssel im Besenflug!"
},
"brew": {
"item_id": "duel_badge",
"item_name": "Duellanten-Abzeichen & Kessel-Rezept",
"item_icon": "⚡",
"from_chamber": "Kammer II: Das Spruch-Duell",
"from_url": "/duel",
"reason": "Snapes Kupferkessel bleibt kalt. Du benötigst die geheime Rezeptur aus der bezwungenen Trainingsrüstung des Duellier-Clubs!"
},
"cryptex": {
"item_id": "felix_felicis",
"item_name": "Felix Felicis",
"item_icon": "🧪",
"from_chamber": "Kammer III: Die Brauküche",
"from_url": "/brew",
"reason": "Ein uralter Verwirrungsbann verschleiert die Runen des Gründerschlosses. Braue 'Felix Felicis', um die Symbole der vier Gründer zu durchschauen!"
},
"pixies": {
"item_id": "cryptex_key",
"item_name": "Schlüssel der Gründer",
"item_icon": "🗝️",
"from_chamber": "Kammer IV: Das Runen-Drehschloss",
"from_url": "/cryptex",
"reason": "Das Verteidigungs-Klassenzimmer ist mit dem uralten Siegel der vier Gründer verriegelt. Knacke das Drehschloss in Kammer IV!"
},
"bottles": {
"item_id": "cryptex_key",
"item_name": "Schlüssel der Gründer",
"item_icon": "🗝️",
"from_chamber": "Kammer V: Wichtel-Panik",
"from_url": "/pixies",
"reason": "Tödliche Flammen und schwere Ketten versperren den Eingang. Schließe zuerst die Wichtel-Prüfung ab und banne die Plagegeister!"
},
"finale": {
"item_id": "philosophers_stone",
"item_name": "Stein der Weisen",
"item_icon": "💎",
"from_chamber": "Kammer VI: Snapes Flaschen",
"from_url": "/bottles",
"reason": "Der Spiegel Nerhegeb offenbart sich nur jenem Zauberer, der Snapes schwarzes Feuer erfolgreich durchquert hat!"
}
}
def get_template_context(request: Request, state: dict, lang: str, chamber_name: str = None) -> 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"])
is_locked = False
lock_info = None
has_alohomora = request.query_params.get("alohomora") == "1" or state.get("alohomora_mode")
if request.query_params.get("alohomora") == "1":
state["alohomora_mode"] = True
request.session["escape"] = state
req = CHAMBER_REQUIREMENTS.get(chamber_name)
if req and not has_alohomora:
has_item = req["item_id"] in inv_ids
solved_bypass = {
"duel": state.get("broom_solved"),
"brew": state.get("duel_solved"),
"cryptex": state.get("brew_solved"),
"pixies": state.get("cryptex_solved"),
"bottles": state.get("pixies_solved"),
"finale": state.get("bottles_solved")
}
if not has_item and not solved_bypass.get(chamber_name, False):
is_locked = True
lock_info = req
return {
"state": state,
"lang": lang,
"inventory_items": inv_items,
"all_items": ALL_ITEMS,
"house": house_info,
"houses": HOUSES,
"is_locked": is_locked,
"lock_info": lock_info,
"chamber_requirements": CHAMBER_REQUIREMENTS
}
@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, chamber_name="broom")
)
@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, chamber_name="duel")
)
@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, chamber_name="brew")
)
@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, chamber_name="cryptex")
)
@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, chamber_name="pixies")
)
@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, chamber_name="bottles")
)
@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, chamber_name="sliding")
)
@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, chamber_name="finale")
)
@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))
if "inventory" not in state:
state["inventory"] = ["wand", "broom", "snape_scroll"]
if "winged_key" not in state["inventory"]:
state["inventory"].append("winged_key")
request.session["escape"] = state
return JSONResponse({
"success": True,
"message": "Parcours gemeistert! Der geflügelte Schlüssel öffnet die Tür zum Zauberspruch-Duell!",
"new_item": ALL_ITEMS["winged_key"],
"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."
})