eis-website/app.py

1528 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import time
import sqlite3
from flask import Flask, request, redirect, session
from datetime import datetime, timezone, timedelta
app = Flask(__name__)
app.secret_key = "octan-tower-kragle-everything-is-awesome-2026" # für session (Adventure-Fortschritt)
# ─── SQLite-Datenbank (U18) ──────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "adventure.db")
def get_db():
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row # Spalten per Name ansprechen
return db
# ─── Zeitzone: CEST (UTC+2) ──────────────────────────────────────────────────
BERLIN = timezone(timedelta(hours=2))
def jetzt():
return datetime.now(BERLIN).strftime("%d.%m.%Y, %H:%M Uhr")
# ─── Kommentare im Arbeitsspeicher ───────────────────────────────────────────
# Schema: {"name": str, "text": str, "zeit": str,
# "antworten": [{"name": str, "text": str, "zeit": str}]}
eintraege = []
# ─────────────────────────────────────────────────────────────────────────────
# Hilfsfunktion: gemeinsames HTML-Gerüst
# ─────────────────────────────────────────────────────────────────────────────
def seite(inhalt):
return """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask-App David & Karo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, sans-serif;
background: #f9f9f9;
color: #333;
min-height: 100vh;
}
.topbar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 0 20px;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-logo {
font-size: 20px;
font-weight: bold;
color: #cc181e;
letter-spacing: -1px;
text-decoration: none;
}
.topbar nav { display: flex; gap: 12px; margin-left: 16px; }
.topbar nav a {
font-size: 13px;
color: #555;
text-decoration: none;
padding: 4px 10px;
border-radius: 2px;
border: 1px solid transparent;
transition: border-color 0.15s;
}
.topbar nav a:hover { border-color: #ccc; background: #f5f5f5; }
.topbar nav a.aktiv { color: #cc181e; border-color: #cc181e; }
.topbar-back {
margin-left: auto;
font-size: 13px;
color: #167ac6;
text-decoration: none;
padding: 6px 14px;
border: 1px solid #167ac6;
border-radius: 2px;
font-weight: bold;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.topbar-back:hover { background: #167ac6; color: #fff; }
.container {
max-width: 900px;
margin: 30px auto;
padding: 0 20px;
}
</style>
</head>
<body>
<div class="topbar">
<a href="/app/" class="topbar-logo">&#9654; Flask-App</a>
<nav>
<a href="/app/">🧱 Adventure</a>
<a href="/app/pinnwand" class="aktiv">Kommentare</a>
</nav>
<a href="https://isa2.edumake.de" class="topbar-back">&#8592; Zurück zur Website</a>
</div>
<div class="container">""" + inhalt + """
</div>
</body>
</html>"""
# ─────────────────────────────────────────────────────────────────────────────
# U18 Klick-Adventure "Einbruch im Octan-Tower" (The-Lego-Movie-Style)
# Räume, Ausgänge und Trivia-Fragen kommen aus der SQLite-Datenbank adventure.db
# ─────────────────────────────────────────────────────────────────────────────
def adventure_seite(inhalt, akzent="#f59e0b"):
kopf = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einbruch im Octan-Tower Klick-Adventure</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#0f172a; color:#e5e7eb;
font-family: system-ui, "Segoe UI", Arial, sans-serif;
min-height:100vh; line-height:1.6; }
.adv-topbar { display:flex; align-items:center; gap:10px; padding:12px 20px;
background:#111827; border-bottom:2px solid ACCENT; }
.adv-logo { font-weight:800; letter-spacing:-.5px; color:ACCENT;
text-decoration:none; font-size:18px; }
.adv-topbar .link { color:#93c5fd; text-decoration:none; font-size:14px;
border:1px solid #334155; border-radius:8px; padding:6px 12px; }
.adv-topbar .link:hover { background:#1e293b; }
.adv-topbar .spacer { margin-left:auto; }
.adv-player { max-width:680px; margin:14px auto 0; padding:0 18px;
color:#cbd5e1; font-size:13px; }
.adv-player strong { color:#facc15; }
.adv-wrap { max-width:680px; margin:44px auto; padding:0 18px; }
.adv-card { background:#1e293b; border:1px solid #334155; border-radius:16px;
padding:30px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
.adv-card h1 { font-size:26px; margin-bottom:6px; }
.adv-floor { font-size:12px; text-transform:uppercase; letter-spacing:2px;
color:ACCENT; font-weight:700; }
.adv-desc { color:#cbd5e1; margin:14px 0 22px; }
.adv-progress { font-size:13px; color:#94a3b8; margin-bottom:16px; }
.adv-frage { font-size:19px; font-weight:600; margin-bottom:18px; }
.opt-form { display:flex; flex-direction:column; gap:12px; }
.opt-btn { display:block; width:100%; text-align:left; background:#0f172a;
color:#e5e7eb; border:1px solid #334155; border-radius:10px;
padding:14px 16px; font-size:16px; cursor:pointer;
transition:.15s; font-family:inherit; }
.opt-btn:hover { border-color:ACCENT; background:#172033; transform:translateY(-1px); }
.exit-link { display:inline-block; background:ACCENT; color:#111827; font-weight:700;
text-decoration:none; border-radius:10px; padding:12px 18px;
margin:6px 8px 0 0; }
.exit-link:hover { filter:brightness(1.08); }
.ghost-link { display:inline-block; color:#93c5fd; text-decoration:none;
border:1px solid #334155; border-radius:10px; padding:12px 18px;
margin:6px 8px 0 0; }
.ghost-link:hover { background:#0f172a; }
.pfade { display:flex; flex-direction:column; gap:14px; margin-top:8px; }
.pfad { background:#0f172a; border:1px solid #334155; border-radius:12px;
padding:14px 16px; transition:.15s; }
.pfad:hover { border-color:ACCENT; transform:translateY(-1px); }
.pfad .exit-link { margin:0 0 6px 0; }
.pfad-hinweis { display:block; color:#94a3b8; font-size:14px; }
.deal { display:flex; flex-wrap:wrap; gap:10px; margin:14px 0 22px; }
.deal span { background:#0f172a; border:1px solid #334155; border-radius:8px;
padding:8px 12px; font-size:14px; }
.timer-wrap { margin:2px 0 18px; }
.timer { font-size:42px; font-weight:800; color:#f87171; line-height:1;
font-variant-numeric:tabular-nums; }
.timer small { font-size:16px; color:#94a3b8; font-weight:600; }
.timer-bar { height:8px; background:#0f172a; border:1px solid #334155;
border-radius:6px; overflow:hidden; margin-top:10px; }
.timer-bar > div { height:100%; background:#f87171; width:100%;
transition:width .12s linear; }
.exit-link.danger { background:#f87171; }
.exit-link.danger:hover { filter:brightness(1.08); }
.feedback-bad { background:#7f1d1d; border:1px solid #b91c1c; color:#fecaca;
border-radius:10px; padding:12px 14px; margin-bottom:16px; font-weight:600; }
.win-emoji { font-size:54px; text-align:center; margin-bottom:8px; }
.muted { color:#94a3b8; font-size:14px; margin-top:20px; }
.muted a { color:#93c5fd; }
/* ── Inventar-Sidebar (U19) ── */
.inv-panel { position:fixed; top:90px; left:18px; width:190px; z-index:40;
background:#111827; border:1px solid #334155; border-radius:14px;
padding:14px; box-shadow:0 10px 30px rgba(0,0,0,.4); }
.inv-title { font-size:13px; font-weight:800; letter-spacing:1px;
text-transform:uppercase; color:#facc15; margin-bottom:10px; }
.inv-items { display:flex; flex-direction:column; gap:10px; }
.inv-item { display:flex; align-items:center; gap:10px; cursor:grab;
background:#0f172a; border:1px solid #334155; border-radius:10px;
padding:9px 10px; transition:.12s; user-select:none; }
.inv-item:hover { border-color:#facc15; transform:translateY(-1px); }
.inv-item:active { cursor:grabbing; }
.inv-item.dragging { opacity:.4; }
.inv-ico { font-size:22px; line-height:1; }
.inv-lbl { font-size:12px; color:#e5e7eb; line-height:1.25; }
.inv-hint { margin-top:12px; font-size:11px; color:#64748b; line-height:1.4; }
/* ── Drop-Zone ── */
.dropzone { border:2px dashed #64748b; border-radius:14px; padding:26px 18px;
text-align:center; color:#94a3b8; transition:.15s;
background:#0f172a; margin:6px 0 18px; }
.dropzone .dz-ico { font-size:38px; display:block; margin-bottom:8px; }
.dropzone.over { border-color:#22c55e; background:#0d2818; color:#bbf7d0;
transform:scale(1.02); }
.dropzone.wrong { border-color:#ef4444; background:#2a0d0d;
animation:dzshake .5s; }
@keyframes dzshake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-8px)}
75%{transform:translateX(8px)} }
/* ── Scoreboard (U19) ── */
.sb-wrap { margin-top:26px; }
.sb-box { background:#111827; border:1px solid #334155; border-radius:16px;
padding:20px 22px; margin-bottom:18px; }
.sb-box h3 { font-size:16px; color:#facc15; margin-bottom:12px;
display:flex; align-items:center; gap:8px; }
.sb-table { width:100%; border-collapse:collapse; }
.sb-table td { padding:8px 6px; border-bottom:1px solid #1f2a3a; font-size:15px; }
.sb-table tr:last-child td { border-bottom:none; }
.sb-rank { width:44px; font-weight:800; }
.sb-time { text-align:right; color:#67e8f9; font-variant-numeric:tabular-nums;
font-weight:700; white-space:nowrap; }
.sb-empty { color:#64748b; font-style:italic; }
.sb-stages { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
gap:12px; }
.sb-stage { background:#0f172a; border:1px solid #334155; border-radius:12px;
padding:12px 14px; }
.sb-stage h4 { font-size:13px; color:#93c5fd; margin-bottom:8px; }
.sb-stage ul { list-style:none; display:flex; flex-direction:column; gap:5px; }
.sb-stage li { font-size:13px; color:#cbd5e1; display:flex; gap:6px;
align-items:baseline; }
.sb-mini-rank { color:#64748b; font-weight:700; }
.sb-mini-time { margin-left:auto; color:#67e8f9; font-variant-numeric:tabular-nums; }
.name-form { display:flex; flex-wrap:wrap; gap:10px; margin-top:14px; }
.name-form input { background:#0f172a; color:#e5e7eb; border:1px solid #334155;
border-radius:10px; padding:12px 14px; font-size:16px;
flex:1; min-width:180px; font-family:inherit; }
.name-form input:focus { outline:none; border-color:#facc15; }
@media (max-width:1100px){
.inv-panel { position:static; width:auto; margin:0 auto 18px;
max-width:680px; }
.inv-items { flex-direction:row; flex-wrap:wrap; }
.inv-hint { display:none; }
}
</style>
</head>
<body>
<div class="adv-topbar">
<a href="/app/" class="adv-logo">🧱 Octan-Tower</a>
<a href="/app/pinnwand" class="link spacer">💬 Kommentare</a>
<a href="https://isa2.edumake.de" class="link">← Website</a>
</div>
<div class="adv-wrap">""".replace("ACCENT", akzent)
fuss = """</div>
__DRAGDROP_JS__
</body>
</html>"""
dd_js = """
<script>
(function(){
var dragged=null;
document.querySelectorAll('.inv-item').forEach(function(el){
el.addEventListener('dragstart',function(e){
dragged=el.getAttribute('data-item-id');
el.classList.add('dragging');
if(e.dataTransfer){ e.dataTransfer.setData('text/plain',dragged);
e.dataTransfer.effectAllowed='move'; }
});
el.addEventListener('dragend',function(){ el.classList.remove('dragging'); });
});
document.querySelectorAll('.dropzone').forEach(function(z){
z.addEventListener('dragover',function(e){ e.preventDefault();
z.classList.add('over'); if(e.dataTransfer) e.dataTransfer.dropEffect='move'; });
z.addEventListener('dragleave',function(){ z.classList.remove('over'); });
z.addEventListener('drop',function(e){ e.preventDefault(); z.classList.remove('over');
var id=(e.dataTransfer && e.dataTransfer.getData('text/plain')) || dragged;
var accept=(z.getAttribute('data-accept')||'').split(',');
if(accept.indexOf(id)!==-1){ window.location = z.getAttribute('data-go'); }
else { z.classList.add('wrong'); setTimeout(function(){z.classList.remove('wrong');},550); }
});
});
})();
</script>"""
sidebar = _inventar_sidebar()
return kopf + _spieler_banner() + sidebar + inhalt + fuss.replace("__DRAGDROP_JS__", dd_js)
# ─────────────────────────────────────────────────────────────────────────────
# U19 Spielstand, Inventar, Zeitmessung & Scoreboards
# ─────────────────────────────────────────────────────────────────────────────
# Der Führerschein liegt von Anfang an im Inventar (Polizei-Kontrolle im Finale).
FUEHRERSCHEIN = {"id": "fuehrerschein", "name": "Führerschein", "icon": "🪪"}
# Jeder Einzelabschnitt, der zeitlich gemessen wird → eigener Scoreboard-Bereich.
STAGE_NAMES = {
11: "Hai-Fragen (Prüf-Weg)",
21: "Laser-Fragen (Prüf-Weg)",
31: "Laserhai-Fragen (Prüf-Weg)",
12: "Abkürzung Biologe",
22: "Abkürzung Elektriker",
32: "Abkürzung Cyborgs",
40: "Polizei-Kontrolle",
}
# Schlüssel/Zugangs-Items, die man beim Abschließen eines Abschnitts erhält.
STAGE_KEYS = {
31: {"id": "laserhai_key", "name": "Laserhai-Schlüssel", "icon": "🦈"},
12: {"id": "bio_card", "name": "Zugangskarte vom Biologen", "icon": "🔬"},
22: {"id": "elec_key", "name": "Sicherungsschlüssel des Elektrikers", "icon": ""},
32: {"id": "cyborg_chip", "name": "Cyborg-Zugangschip", "icon": "🤖"},
}
# Alle Items, die den Kragle-Tresor entsperren können.
ACCESS_KEYS = ["laserhai_key", "bio_card", "elec_key", "cyborg_chip"]
# ── Inventar (Session ist Spielwahrheit, DB spiegelt für Persistenz) ──────────
def _inv_list():
return session.get("inv", [])
def _inv_has(item_id):
return any(x["id"] == item_id for x in session.get("inv", []))
def _inv_add(item):
inv = session.get("inv", [])
if not any(x["id"] == item["id"] for x in inv):
session["inv"] = inv + [item]
session.modified = True
sp = session.get("spieler")
if sp:
db = get_db()
db.execute("INSERT INTO inventar (spieler, gegenstand) VALUES (?,?)",
(sp, item["id"]))
db.commit(); db.close()
def _inv_remove(item_id):
session["inv"] = [x for x in session.get("inv", []) if x["id"] != item_id]
session.modified = True
sp = session.get("spieler")
if sp:
db = get_db()
db.execute("DELETE FROM inventar WHERE spieler=? AND gegenstand=?",
(sp, item_id))
db.commit(); db.close()
def _inv_hydrate_from_db(spieler):
"""Inventar aus der DB in die Session laden (z. B. bei App-Neustart)."""
item_map = {
"fuehrerschein": dict(FUEHRERSCHEIN),
"laserhai_key": STAGE_KEYS[31],
"bio_card": STAGE_KEYS[12],
"elec_key": STAGE_KEYS[22],
"cyborg_chip": STAGE_KEYS[32],
}
db = get_db()
rows = db.execute("SELECT gegenstand FROM inventar WHERE spieler=?", (spieler,)).fetchall()
db.close()
inv = []
for r in rows:
if r["gegenstand"] in item_map:
inv.append(dict(item_map[r["gegenstand"]]))
session["inv"] = inv
session.modified = True
def _spieler_banner():
if not session.get("spieler"):
return ""
return ('<div class="adv-player">Unterwegs als: <strong>%s</strong></div>'
% esc(session["spieler"]))
def _inventar_sidebar():
"""Fixe Inventar-Leiste links nur für angemeldete Spieler mit Items."""
inv = session.get("inv", [])
if not session.get("spieler") or not inv:
return ""
items = ""
for x in inv:
items += ('<div class="inv-item" draggable="true" data-item-id="%s" '
'title="%s"><span class="inv-ico">%s</span>'
'<span class="inv-lbl">%s</span></div>'
% (esc(x["id"]), esc(x["name"]), x["icon"], esc(x["name"])))
return ('<aside class="inv-panel"><div class="inv-title">🎒 Inventar</div>'
'<div class="inv-items">%s</div>'
'<div class="inv-hint">Zieh ein passendes Item auf das markierte '
'Feld, um es einzusetzen.</div></aside>' % items)
# ── Zeitmessung: Einzelabschnitte (Stages) ───────────────────────────────────
def _stage_start(rid):
key = "stg_%d" % rid
if key not in session and not session.get("stgdone_%d" % rid):
session[key] = time.time()
session.modified = True
def _stage_finish(rid, grant_key=True):
"""Dauer des Abschnitts speichern; optional zugehörigen Schlüssel vergeben."""
key = "stg_%d" % rid
done_key = "stgdone_%d" % rid
sp = session.get("spieler")
if sp and key in session and not session.get(done_key):
dauer = time.time() - session[key]
db = get_db()
db.execute("INSERT INTO stage_zeiten (spieler, stage, dauer, zeit) "
"VALUES (?,?,?,?)",
(sp, STAGE_NAMES.get(rid, "Stage %d" % rid), dauer, time.time()))
db.commit(); db.close()
session[done_key] = True
session.pop(key, None)
session.modified = True
k = STAGE_KEYS.get(rid)
if grant_key and k:
_inv_add(k)
# ── Gesamtlauf protokollieren (Sieg/Niederlage) ──────────────────────────────
def _record_run(status):
sp = session.get("spieler"); st = session.get("run_start")
if sp and st:
now = time.time(); dauer = now - st
db = get_db()
db.execute("INSERT INTO laeufe (spieler, start_zeit, fertig_zeit, dauer, status) "
"VALUES (?,?,?,?,?)", (sp, st, now, dauer, status))
if status == "gewonnen":
db.execute("UPDATE spielstand SET fertig_zeit=? WHERE spieler=?", (now, sp))
# start_zeit leeren, damit der Lauf nicht erneut (re-hydratisiert) gezählt wird
db.execute("UPDATE spielstand SET start_zeit=NULL WHERE spieler=?", (sp,))
db.commit(); db.close()
session.pop("run_start", None)
session.modified = True
def _run_reset_keys():
"""Alle lauf-spezifischen Session-Keys entfernen (Name bleibt erhalten)."""
for key in [k for k in list(session.keys())
if k.startswith("raum_") or k.startswith("chal_")
or k.startswith("stg_") or k.startswith("stgdone_")
or k.startswith("fs40")
or k in ("inv", "run_start", "vault_unlocked")]:
session.pop(key, None)
session.modified = True
# ── Zeit-Formatierung & Scoreboards ──────────────────────────────────────────
def _fmt_zeit(sek):
if sek is None:
return ""
sek = float(sek)
if sek < 60:
return "%.1f s" % sek
m = int(sek // 60); s = sek - m * 60
return "%d:%04.1f min" % (m, s)
def _sb_gesamt(limit=10):
db = get_db()
try:
rows = db.execute(
"SELECT spieler, dauer AS best FROM laeufe "
"WHERE status='gewonnen' ORDER BY dauer LIMIT ?", (limit,)
).fetchall()
except sqlite3.OperationalError:
rows = []
db.close(); return rows
def _sb_stage(stage, limit=3):
db = get_db()
try:
rows = db.execute(
"SELECT spieler, dauer AS best FROM stage_zeiten "
"WHERE stage=? ORDER BY dauer LIMIT ?", (stage, limit)
).fetchall()
except sqlite3.OperationalError:
rows = []
db.close(); return rows
def _scoreboard_html():
ges = _sb_gesamt(10)
rows = ""
if ges:
for i, r in enumerate(ges, 1):
medal = ["🥇", "🥈", "🥉"][i - 1] if i <= 3 else ("%d." % i)
rows += ('<tr><td class="sb-rank">%s</td><td>%s</td>'
'<td class="sb-time">%s</td></tr>'
% (medal, esc(r["spieler"]), _fmt_zeit(r["best"])))
else:
rows = ('<tr><td colspan="3" class="sb-empty">Noch keine Zeiten '
'sei die/der Erste, die/der entkommt!</td></tr>')
stage_blocks = ""
for rid in sorted(STAGE_NAMES):
name = STAGE_NAMES[rid]
st = _sb_stage(name, 3)
items = ""
if st:
for i, r in enumerate(st, 1):
items += ('<li><span class="sb-mini-rank">%d.</span> %s'
'<span class="sb-mini-time">%s</span></li>'
% (i, esc(r["spieler"]), _fmt_zeit(r["best"])))
else:
items = '<li class="sb-empty">— noch leer —</li>'
stage_blocks += ('<div class="sb-stage"><h4>%s</h4><ul>%s</ul></div>'
% (esc(name), items))
return ("""
<div class="sb-wrap">
<div class="sb-box">
<h3>🏁 Bestenliste schnellster Einbruch</h3>
<table class="sb-table">%s</table>
</div>
<div class="sb-box">
<h3>⏱️ Bestzeiten pro Abschnitt</h3>
<div class="sb-stages">%s</div>
</div>
</div>""" % (rows, stage_blocks))
@app.route("/", methods=["GET", "POST"])
def start():
# ── Formular abgeschickt: Name → INSERT + neuen Lauf starten ──
if request.method == "POST":
name = request.form.get("name", "").strip()
if name:
_run_reset_keys() # evtl. alten Lauf verwerfen
session["spieler"] = name # App merkt sich: das bist du
session["run_start"] = time.time() # Gesamtzeit-Messung startet
session["inv"] = [dict(FUEHRERSCHEIN)] # Führerschein von Anfang an
session.modified = True
db = get_db()
db.execute(
"INSERT OR IGNORE INTO spielstand (spieler, raum_id, start_zeit) "
"VALUES (?, 1, ?)", (name, session["run_start"]))
db.execute("UPDATE spielstand SET raum_id=1, start_zeit=?, fertig_zeit=NULL "
"WHERE spieler=?", (session["run_start"], name))
db.execute("DELETE FROM inventar WHERE spieler=?", (name,))
db.execute("INSERT INTO inventar (spieler, gegenstand) VALUES (?,?)",
(name, FUEHRERSCHEIN["id"]))
db.commit(); db.close()
return redirect("/app/raum/1")
intro = """
<div class="adv-card">
<div class="adv-floor">LEGO • Geheimmission</div>
<h1>Einbruch im Octan-Tower 🧱</h1>
<p class="adv-desc">Präsident Business hortet ganz oben in seinem Wolkenkratzer
das <strong>Kragle</strong> und will damit die ganze Welt für immer festkleben.
Nur du kannst ihn stoppen! Kämpf dich Etage für Etage nach oben: vorbei an
<strong>Haien</strong> 🦈, <strong>Lasern</strong> 🔴 und natürlich
<strong>Laserhaien</strong> 🦈⚡.</p>
<p class="adv-desc">Jede Etage gabelt sich: Der <strong>sichere Prüf-Weg</strong>
stellt thematische Fragen, die <strong>riskante Abkürzung</strong> läuft gegen die
Uhr. Unterwegs sammelst du <strong>Schlüssel</strong> ins Inventar ganz oben
brauchst du einen davon, um den <strong>Kragle-Tresor</strong> zu öffnen.
<strong>Deine Zeit läuft mit</strong> und landet in der Bestenliste!</p>
__MITTE__
</div>"""
# ── Angemeldet? Dann „Willkommen zurück“ + Weiterspielen ──
mitte = ""
if session.get("spieler"):
sp = session["spieler"]
db = get_db()
s = db.execute("SELECT raum_id, start_zeit FROM spielstand WHERE spieler=?", (sp,)).fetchone()
anzahl = db.execute("SELECT COUNT(*) AS n FROM spielstand").fetchone()
db.close()
if not session.get("inv"):
_inv_hydrate_from_db(sp)
if s and s["start_zeit"] and not session.get("run_start"):
session["run_start"] = float(s["start_zeit"])
session.modified = True
weiter_raum = s["raum_id"] if s and s["raum_id"] else 1
mitte = (
'<p class="adv-frage" style="color:#facc15">Willkommen zurück, %s! 🎉</p>'
'<a href="/app/raum/%d" class="exit-link">Weiterspielen ▶</a>'
'<a href="/app/neustart" class="ghost-link">Neuer Versuch 🔁</a>'
'<a href="/app/reset" class="ghost-link">Anderer Name 🚪</a>'
'<p class="muted">%d Abenteurer:innen waren schon hier.</p>'
% (esc(sp), weiter_raum, anzahl["n"]))
else:
db = get_db()
anzahl = db.execute("SELECT COUNT(*) AS n FROM spielstand").fetchone()
db.close()
mitte = (
'<form method="post" class="name-form">'
'<input name="name" placeholder="Dein Name" maxlength="40" autofocus>'
'<button class="exit-link" style="border:none">Abenteuer starten ▶</button>'
'</form>'
'<p class="muted">%d Abenteurer:innen waren schon hier. '
'Lieber quatschen? <a href="/app/pinnwand">Zu den Kommentaren →</a></p>'
% anzahl["n"])
inhalt = intro.replace("__MITTE__", mitte) + _scoreboard_html()
return adventure_seite(inhalt)
@app.route("/neustart")
def neustart():
"""Gleicher Name, neuer Lauf Fortschritt & Zeit zurücksetzen."""
_run_reset_keys()
sp = session.get("spieler")
if not sp:
return redirect("/app/")
session["run_start"] = time.time()
session["inv"] = [dict(FUEHRERSCHEIN)]
session.modified = True
db = get_db()
db.execute("UPDATE spielstand SET raum_id=1, start_zeit=?, fertig_zeit=NULL "
"WHERE spieler=?", (session["run_start"], sp))
db.execute("DELETE FROM inventar WHERE spieler=?", (sp,))
db.execute("INSERT INTO inventar (spieler, gegenstand) VALUES (?,?)",
(sp, FUEHRERSCHEIN["id"]))
db.commit(); db.close()
return redirect("/app/raum/1")
@app.route("/reset")
def reset():
"""Anderer Name Session vergessen (Datenbank/Bestzeiten bleiben!)."""
_run_reset_keys()
session.pop("spieler", None)
session.modified = True
return redirect("/app/")
@app.route("/scoreboard")
def scoreboard():
zurueck = ('<a href="/app/raum/1" class="ghost-link">Weiterspielen ▶</a>'
if session.get("spieler") else "")
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">🏆 Bestenlisten</div>
<h1>Wer entkommt am schnellsten?</h1>
<p class="adv-desc">Gesamtzeit vom Start bis zum Kragle-Tresor plus die
Bestzeiten für jeden einzelnen Abschnitt.</p>
<a href="/app/" class="exit-link">Zur Startseite ▶</a>%s
</div>%s""" % (zurueck, _scoreboard_html()))
return adventure_seite(inhalt)
@app.route("/frage")
def frage():
return redirect("/app/")
# ─── Adventure-Helfer: Sieg / Game Over / Challenge ──────────────────────────
def _clear_challenge(rid):
"""Timer- und Fortschritts-Keys einer Challenge aus der Session entfernen."""
session.pop("chal_%d_deadline" % rid, None)
session.pop("chal_%d_idx" % rid, None)
session.modified = True
def sieg_screen(text=None):
body = text or ("Du hast Präsident Business gestoppt und das Kragle gesichert. "
"Die Welt bleibt frei und kreativ!")
_record_run("gewonnen")
zeit_html = ""
inhalt = ("""
<div class="adv-card" style="text-align:center">
<div class="win-emoji">🎉🧱🎉</div>
<div class="adv-floor">Mission erfüllt</div>
<h1>Befreit!</h1>
<p class="adv-desc">%s</p>
<p class="adv-frage" style="color:#f59e0b">„Everything is awesome!“ 🎵</p>
<a href="/app/neustart" class="exit-link">Nochmal spielen 🔁</a>
<a href="/app/scoreboard" class="ghost-link">🏆 Scoreboard ansehen</a>
</div>%s""" % (esc(body), zeit_html))
return adventure_seite(inhalt)
def gameover_screen(grund):
_record_run("gescheitert")
inhalt = ("""
<div class="adv-card" style="text-align:center">
<div class="win-emoji">💥🤖💥</div>
<div class="adv-floor" style="color:#f87171">Mission gescheitert</div>
<h1>Erwischt!</h1>
<p class="adv-desc">%s</p>
<a href="/app/neustart" class="exit-link">Erneut versuchen ▶</a>
<a href="/app/scoreboard" class="ghost-link">🏆 Scoreboard ansehen</a>
</div>""" % esc(grund))
return adventure_seite(inhalt, akzent="#f87171")
def challenge_raum(db, r):
"""Risk/Reward-Abkürzung mit Zeitlimit (Biologe, Elektriker, Cyborgs)."""
rid = r["id"]
limit = int(r["zeit_limit"] or 40)
ziel = int(r["ziel_anzahl"] or 10)
erfolg_raum = r["erfolg_raum"] or 0
erfolg_text = r["erfolg_text"] or ""
dl_key = "chal_%d_deadline" % rid
idx_key = "chal_%d_idx" % rid
fragen = db.execute(
"SELECT * FROM fragen WHERE raum_id=? ORDER BY id", (rid,)
).fetchall()
back = db.execute(
"SELECT nach_raum FROM ausgaenge WHERE von_raum=? ORDER BY rowid LIMIT 1",
(rid,)
).fetchone()
db.close()
back_raum = back["nach_raum"] if back else 1
gesamt = min(ziel, len(fragen)) if fragen else ziel
now = time.time()
# ── Noch nicht gestartet → Intro (die Entscheidung: Risk vs. Reward) ──
if dl_key not in session:
if request.args.get("start") == "1" and fragen:
session[dl_key] = now + limit
session[idx_key] = 0
session.modified = True
_stage_start(rid) # Zeitmessung dieses Abschnitts
return redirect("/app/raum/%d" % rid)
reward = ("🏆 Bei Erfolg: sofort gewonnen!" if not erfolg_raum
else "🏆 Bei Erfolg: Express ganz nach oben")
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">⚠️ Risiko-Abkürzung · Etage %d</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<div class="deal">
<span>🎯 %d Fragen richtig</span>
<span>⏱️ %d Sekunden</span>
<span>%s</span>
<span>💀 Ein Fehler = Aus</span>
</div>
<a href="/app/raum/%d?start=1" class="exit-link danger">Herausforderung annehmen ⏱️</a>
<a href="/app/raum/%d" class="ghost-link">Lieber den sicheren Prüf-Weg</a>
</div>""" % (r["etage"] or 0, esc(r["name"]), esc(r["beschreibung"]),
gesamt, limit, reward, rid, back_raum))
return adventure_seite(inhalt, akzent="#f87171")
# ── Challenge läuft ──
deadline = session.get(dl_key, 0)
idx = session.get(idx_key, 0)
# Zeit abgelaufen? (Server ist die Wahrheit; JS meldet Timeout per ?timeout=1)
if now > deadline or request.args.get("timeout") == "1":
_clear_challenge(rid)
_stage_finish(rid, grant_key=False)
return gameover_screen(
"⏱️ Zeit abgelaufen! Die Wachen kommen näher du musst fliehen. "
"Der riskante Weg hat dich diesmal erwischt.")
# Antwort verarbeiten
if request.method == "POST":
gewaehlt = request.form.get("antwort", "").strip()
if idx < len(fragen) and gewaehlt.lower() == fragen[idx]["antwort"].strip().lower():
idx += 1
session[idx_key] = idx
session.modified = True
if idx >= gesamt:
_clear_challenge(rid)
_stage_finish(rid) # Zeit speichern + Schlüssel vergeben
if erfolg_raum and erfolg_raum > 0:
k = STAGE_KEYS.get(rid)
key_hinweis = ("" if not k else
'<p class="adv-progress">🎒 Erhalten: <strong>%s</strong> '
'%s damit öffnest du oben den Kragle-Tresor.</p>'
% (esc(k["name"]), k["icon"]))
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">✅ Abkürzung geschafft!</div>
<h1>Geschafft in letzter Sekunde!</h1>
<p class="adv-desc">%s</p>
%s
<a href="/app/raum/%d" class="exit-link">Weiter ▶</a>
</div>""" % (esc(erfolg_text), key_hinweis, erfolg_raum))
return adventure_seite(inhalt)
return sieg_screen(erfolg_text)
return redirect("/app/raum/%d" % rid)
# falsche Antwort → sofortiges Game Over
_clear_challenge(rid)
_stage_finish(rid, grant_key=False)
return gameover_screen(
"❌ Falsch! Dein Gegenüber durchschaut dich sofort und schlägt Alarm. "
"So knapp aber der riskante Weg verzeiht keinen einzigen Fehler.")
# GET → aktuelle Frage mit Countdown zeigen
if idx >= len(fragen):
_clear_challenge(rid)
return sieg_screen(erfolg_text)
aktuelle = fragen[idx]
remaining = deadline - now
buttons = ""
for opt in aktuelle["optionen"].split("|"):
buttons += ('<button class="opt-btn" name="antwort" value="%s">%s</button>'
% (esc(opt), esc(opt)))
tpl = """
<div class="adv-card">
<div class="adv-floor">⏱️ Risiko-Challenge · __NAME__</div>
<h1>__NAME__</h1>
<div class="timer-wrap">
<div class="timer"><span id="tval">__REMAIN__</span><small> s</small></div>
<div class="timer-bar"><div id="tbar"></div></div>
</div>
<p class="adv-progress">Frage __NR__ von __GES__ · schon richtig: __IDX__</p>
<p class="adv-frage">__FRAGE__</p>
<form method="post" class="opt-form">__BTN__</form>
</div>
<script>
(function(){
var end = Date.now() + __REMAINMS__;
var full = __LIMIT__;
var v = document.getElementById('tval');
var bar = document.getElementById('tbar');
function tick(){
var rem = (end - Date.now())/1000;
if(rem < 0){ rem = 0; }
v.textContent = rem.toFixed(1);
bar.style.width = Math.max(0, Math.min(100, (rem/full)*100)) + '%';
if(rem <= 0){ window.location = '/app/raum/__ID__?timeout=1'; return; }
requestAnimationFrame(tick);
}
tick();
})();
</script>"""
html = (tpl
.replace("__NAME__", esc(r["name"]))
.replace("__REMAINMS__", str(int(max(0.0, remaining) * 1000)))
.replace("__REMAIN__", "%.1f" % max(0.0, remaining))
.replace("__LIMIT__", str(limit))
.replace("__NR__", str(idx + 1))
.replace("__GES__", str(gesamt))
.replace("__IDX__", str(idx))
.replace("__FRAGE__", esc(aktuelle["frage"]))
.replace("__BTN__", buttons)
.replace("__ID__", str(rid)))
return adventure_seite(html, akzent="#f87171")
# ─── Polizei-Führerschein-Kontrolle vor dem Lord-Business-Finale (U18) ────────
# Die drei Aufgaben stammen aus der deutschen Führerschein-Theorieprüfung.
# "richtig" = Menge der (1-basierten) anzukreuzenden Optionen. Mehrfachauswahl!
FS_FRAGEN = [
{
"frage": "Welches Verhalten ist hier richtig?",
"bild": "/fs_frage1.png",
"optionen": ["Ich muss den roten Bus durchfahren lassen",
"Ich darf vor dem blauen Pkw fahren",
"Ich muss den blauen Pkw durchfahren lassen"],
"richtig": {1, 3},
},
{
"frage": "Wann kann es hier zu einer gefährlichen Situation kommen?",
"bild": "/fs_frage2.png",
"optionen": ["Wenn der Radfahrer am Ende des Radweges anhält",
"Wenn ich unvermindert weiterfahre",
"Wenn der Radfahrer auf meine Fahrbahn wechselt"],
"richtig": {2, 3},
},
{
"frage": "Warum ist man nach dem Konsum der Droge „Crystal Meth“ fahruntauglich?",
"bild": None,
"optionen": ["Weil plötzliche Erschöpfung eintreten kann",
"Weil Wahnvorstellungen auftreten können",
"Weil die eigene Leistungsfähigkeit überschätzt werden kann"],
"richtig": {1, 2, 3},
},
]
FS_IDX_KEY = "fs40_idx"
FS_DONE_KEY = "fs40_done"
def _tresor_schloss():
"""Finale-Gate: Der Kragle-Tresor lässt nur mit Zugangsschlüssel öffnen."""
have = [k for k in ACCESS_KEYS if _inv_has(k)]
accept = ",".join(have) if have else "none"
namen = {"laserhai_key": "Laserhai-Schlüssel", "bio_card": "Zugangskarte vom Biologen",
"elec_key": "Sicherungsschlüssel des Elektrikers", "cyborg_chip": "Cyborg-Chip"}
besitz = (", ".join(namen[k] for k in have)
if have else "leider kein passender Schlüssel")
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">🔒 Kragle-Tresor · Zugang gesperrt</div>
<h1>Der Tresor ist verriegelt</h1>
<p class="adv-desc">Ganz oben doch ein schweres Sicherheitsschloss versperrt
den letzten Schritt. Setze deinen <strong>Zugangsschlüssel</strong> ein:
Zieh ihn aus dem <strong>Inventar</strong> (links) auf das Schloss.</p>
<div class="dropzone" data-accept="%s" data-go="/app/raum/40?unlock=1">
<span class="dz-ico">🔒</span>
Schlüssel hierher ziehen, um den Tresor zu öffnen
</div>
<p class="adv-progress">🎒 In deinem Besitz: %s</p>
</div>""" % (accept, esc(besitz)))
return adventure_seite(inhalt, akzent="#facc15")
def _blaulicht_intro():
"""Grelles deutsches Blaulicht + Polizei-Ansage vor der Kontrolle."""
inhalt = """
<style>
.blitz-overlay {
position:fixed; inset:0; z-index:5; pointer-events:none;
animation:blaulicht 0.9s steps(1,end) infinite;
}
@keyframes blaulicht {
0% { background:rgba(10,40,255,0.00); box-shadow:none; }
10% { background:rgba(30,90,255,0.55); box-shadow:inset 0 0 240px 80px #1e50ff; }
20% { background:rgba(10,40,255,0.00); box-shadow:none; }
50% { background:rgba(10,40,255,0.00); box-shadow:none; }
60% { background:rgba(30,90,255,0.55); box-shadow:inset 0 0 240px 80px #1e50ff; }
70% { background:rgba(10,40,255,0.00); box-shadow:none; }
100% { background:rgba(10,40,255,0.00); box-shadow:none; }
}
.polizei-card { position:relative; z-index:6; text-align:center;
border:2px solid #1e50ff !important;
box-shadow:0 0 40px rgba(30,80,255,0.6) !important; }
.polizei-siren { font-size:60px; margin-bottom:6px;
animation:sirene 0.9s ease-in-out infinite; }
@keyframes sirene {
0%,100% { filter:drop-shadow(0 0 4px #1e50ff); transform:scale(1); }
50% { filter:drop-shadow(0 0 22px #1e50ff); transform:scale(1.12); }
}
.polizei-badge { display:inline-block; background:#0b2a8a; color:#dbe5ff;
border:1px solid #1e50ff; border-radius:8px;
padding:6px 14px; font-weight:800; letter-spacing:2px;
margin-bottom:14px; }
</style>
<div class="blitz-overlay"></div>
<div class="adv-card polizei-card">
<div class="polizei-siren">🚨</div>
<span class="polizei-badge">🚔 POLIZEI · VERKEHRSKONTROLLE</span>
<h1>Anhalten Fahrzeugkontrolle!</h1>
<p class="adv-desc">Kurz vor dem Kragle-Tresor stoppt dich eine Streife.
Ein Beamter tritt ans Fenster: <em>„Guten Tag. Fahrzeugkontrolle
Ihren Führerschein bitte!“</em></p>
<div class="dropzone" data-accept="fuehrerschein" data-go="/app/raum/40?fs=gezeigt">
<span class="dz-ico">🪪</span>
Zieh deinen <strong>Führerschein</strong> aus dem Inventar (links) hierher,
um ihn den Beamten vorzuzeigen.
</div>
</div>"""
return adventure_seite(inhalt, akzent="#1e50ff")
def _fs_ansage():
"""Nach dem Vorzeigen des Führerscheins: misstrauische Ansage der Beamten."""
inhalt = """
<style>
.blitz-overlay {
position:fixed; inset:0; z-index:5; pointer-events:none;
animation:blaulicht 0.9s steps(1,end) infinite;
}
@keyframes blaulicht {
0% { background:rgba(10,40,255,0.00); box-shadow:none; }
10% { background:rgba(30,90,255,0.55); box-shadow:inset 0 0 240px 80px #1e50ff; }
20% { background:rgba(10,40,255,0.00); box-shadow:none; }
50% { background:rgba(10,40,255,0.00); box-shadow:none; }
60% { background:rgba(30,90,255,0.55); box-shadow:inset 0 0 240px 80px #1e50ff; }
70% { background:rgba(10,40,255,0.00); box-shadow:none; }
100% { background:rgba(10,40,255,0.00); box-shadow:none; }
}
.polizei-card { position:relative; z-index:6; text-align:center;
border:2px solid #1e50ff !important;
box-shadow:0 0 40px rgba(30,80,255,0.6) !important; }
.polizei-siren { font-size:60px; margin-bottom:6px;
animation:sirene 0.9s ease-in-out infinite; }
@keyframes sirene {
0%,100% { filter:drop-shadow(0 0 4px #1e50ff); transform:scale(1); }
50% { filter:drop-shadow(0 0 22px #1e50ff); transform:scale(1.12); }
}
.polizei-badge { display:inline-block; background:#0b2a8a; color:#dbe5ff;
border:1px solid #1e50ff; border-radius:8px;
padding:6px 14px; font-weight:800; letter-spacing:2px;
margin-bottom:14px; }
</style>
<div class="blitz-overlay"></div>
<div class="adv-card polizei-card">
<div class="polizei-siren">🚨</div>
<span class="polizei-badge">🚔 POLIZEI · FÜHRERSCHEIN-PRÜFUNG</span>
<h1>„Der ist doch nie echt!“</h1>
<p class="adv-desc">Die Beamten mustern deinen Führerschein misstrauisch:
<em>„Diesen Lappen haben Sie doch nie im Leben ehrlich bestanden!
Wenn er echt ist, beweisen Sie es: Lösen Sie diese drei Aufgaben
aus der Theorieprüfung dann dürfen Sie weiter.“</em></p>
<p class="adv-desc">🚦 <strong>Achtung:</strong> Bei manchen Fragen ist
<strong>mehr als eine Antwort</strong> richtig. Kreuze <strong>alle</strong>
zutreffenden an ein falsches Kreuz und du fällst durch.</p>
<a href="/app/raum/40?fs=1" class="exit-link">Theorieprüfung starten ▶</a>
</div>"""
return adventure_seite(inhalt, akzent="#1e50ff")
def _fs_frage_screen(idx, feedback=""):
"""Eine Führerschein-Frage im (bewusst hässlichen) grünen Prüfungs-Stil."""
f = FS_FRAGEN[idx]
bild_html = ""
if f["bild"]:
bild_html = ('<div class="fs-bild"><img src="%s" alt="Verkehrssituation"></div>'
% f["bild"])
checks = ""
for i, opt in enumerate(f["optionen"], start=1):
checks += ("""
<label class="fs-opt">
<input type="checkbox" name="opt%d" value="1">
<span class="fs-box"></span>
<span class="fs-text">%s</span>
</label>""" % (i, esc(opt)))
fb = ""
if feedback:
fb = '<div class="fs-feedback">%s</div>' % esc(feedback)
inhalt = """
<style>
.fs-shell { background:#c3d64a; border:3px solid #6b8e23;
border-radius:6px; padding:0; overflow:hidden;
box-shadow:0 6px 0 #4d6b16; }
.fs-head { background:#6b8e23; color:#f2ffd0; padding:12px 18px;
display:flex; justify-content:space-between; align-items:center;
font-weight:800; letter-spacing:.5px;
border-bottom:3px solid #3f5a10; }
.fs-head .fs-nr { background:#3f5a10; color:#eaffb0; border-radius:4px;
padding:4px 10px; font-size:14px; }
.fs-body { padding:18px; color:#1e2b05; }
.fs-frage { font-size:20px; font-weight:800; color:#243a06;
margin-bottom:14px; }
.fs-bild { margin:0 0 16px; border:3px solid #6b8e23; border-radius:4px;
overflow:hidden; background:#000; }
.fs-bild img { display:block; width:100%; height:auto; }
.fs-opts { display:flex; flex-direction:column; gap:10px; }
.fs-opt { display:flex; align-items:flex-start; gap:12px;
background:#e4f09a; border:2px solid #7c9a2e; border-radius:4px;
padding:12px 14px; cursor:pointer; transition:.12s;
font-size:16px; color:#243a06; }
.fs-opt:hover { background:#eef7b6; border-color:#4d6b16; }
.fs-opt input { position:absolute; opacity:0; width:0; height:0; }
.fs-box { width:22px; height:22px; flex-shrink:0; background:#fff;
border:2px solid #4d6b16; border-radius:3px; position:relative;
margin-top:1px; }
.fs-opt input:checked + .fs-box { background:#3f5a10; }
.fs-opt input:checked + .fs-box::after { content:""; color:#d4ff5a;
position:absolute; top:-3px; left:2px; font-size:18px;
font-weight:900; }
.fs-btn { margin-top:18px; width:100%; background:#3f5a10; color:#eaffb0;
border:none; border-bottom:4px solid #263806; border-radius:5px;
padding:14px; font-size:17px; font-weight:800; cursor:pointer;
font-family:inherit; letter-spacing:.5px; }
.fs-btn:hover { background:#4d6b16; }
.fs-feedback { background:#a11c1c; color:#ffe0e0; border:2px solid #7a0f0f;
border-radius:4px; padding:10px 14px; margin-bottom:14px;
font-weight:700; }
.fs-hint { margin-top:12px; font-size:13px; color:#3f5a10; text-align:center; }
</style>
<div class="adv-card" style="background:transparent;border:none;padding:0;box-shadow:none">
<div class="fs-shell">
<div class="fs-head">
<span>🚔 THEORIEPRÜFUNG · POLIZEIKONTROLLE</span>
<span class="fs-nr">Frage __NR__ / __GES__</span>
</div>
<div class="fs-body">
__FB__
<p class="fs-frage">__FRAGE__</p>
__BILD__
<form method="post">
<div class="fs-opts">__CHECKS__</div>
<button type="submit" class="fs-btn">Antwort abgeben ▶</button>
</form>
<p class="fs-hint">Mehrfachauswahl möglich kreuze alle richtigen Antworten an.</p>
</div>
</div>
</div>"""
html = (inhalt
.replace("__NR__", str(idx + 1))
.replace("__GES__", str(len(FS_FRAGEN)))
.replace("__FB__", fb)
.replace("__FRAGE__", esc(f["frage"]))
.replace("__BILD__", bild_html)
.replace("__CHECKS__", checks))
return adventure_seite(html, akzent="#6b8e23")
def fuehrerschein_kontrolle():
"""Steuert die dreiteilige Polizei-Führerschein-Kontrolle vor dem Finale."""
# Ablauf: Blaulicht + Führerschein droppen → misstrauische Ansage → 3 Fragen
if FS_IDX_KEY not in session:
if request.args.get("fs") == "1":
session[FS_IDX_KEY] = 0
session.modified = True
return redirect("/app/raum/40")
if request.args.get("fs") == "gezeigt":
return _fs_ansage() # Führerschein vorgezeigt → Ansage
return _blaulicht_intro() # Kontrolle: Führerschein hierher ziehen
idx = session.get(FS_IDX_KEY, 0)
feedback = ""
if request.method == "POST" and idx < len(FS_FRAGEN):
gewaehlt = set()
for i in range(1, len(FS_FRAGEN[idx]["optionen"]) + 1):
if request.form.get("opt%d" % i):
gewaehlt.add(i)
if gewaehlt == FS_FRAGEN[idx]["richtig"]:
idx += 1
session[FS_IDX_KEY] = idx
session.modified = True
elif not gewaehlt:
feedback = ("🚨 Kein Kreuz gesetzt! „So kommen Sie hier nicht durch.“ "
"Kreuze alle richtigen Antworten an.")
else:
feedback = ("🚨 Durchgefallen bei dieser Aufgabe! Die Beamten schütteln "
"den Kopf: „Nochmal und diesmal richtig.“")
if idx >= len(FS_FRAGEN):
session[FS_DONE_KEY] = True
session.modified = True
_stage_finish(40) # Zeit der Polizei-Kontrolle speichern
return redirect("/app/raum/40")
return _fs_frage_screen(idx, feedback)
@app.route("/raum/<int:id>", methods=["GET", "POST"])
def raum(id):
db = get_db()
r = db.execute("SELECT * FROM raeume WHERE id=?", (id,)).fetchone()
if r is None:
db.close()
return adventure_seite(
'<div class="adv-card"><div class="adv-floor">Fehler</div>'
'<h1>Diese Etage existiert nicht 🤖</h1>'
'<p class="adv-desc">Da hat sich wohl ein Bauplan verheddert.</p>'
'<a href="/app/" class="exit-link">Zurück zum Start</a></div>')
# Spielstand fortschreiben: Wo war der Spieler zuletzt? (UPDATE)
if session.get("spieler"):
db.execute("UPDATE spielstand SET raum_id=? WHERE spieler=?",
(id, session["spieler"]))
if not session.get("inv"):
_inv_hydrate_from_db(session["spieler"])
if not session.get("run_start"):
st = db.execute("SELECT start_zeit FROM spielstand WHERE spieler=?",
(session["spieler"],)).fetchone()
if st and st["start_zeit"]:
session["run_start"] = float(st["start_zeit"])
session.modified = True
db.commit()
typ = r["typ"]
# ═══ Risk/Reward-Abkürzung mit Timer (Biologe / Elektriker / Cyborgs) ═══
if typ == "challenge":
return challenge_raum(db, r)
# ═══ Räume mit Fragen: thema (Prüf-Weg) · ziel (Finale) ═══
if typ in ("thema", "ziel"):
# ── Finale (Raum 40): erst Polizei-Kontrolle, dann Tresor-Schloss ──
if typ == "ziel" and id == 40:
# 1) Polizei-Führerschein-Kontrolle zuerst
if not session.get(FS_DONE_KEY):
_stage_start(40) # Polizei-Kontrolle Zeitmessung
db.close()
return fuehrerschein_kontrolle()
# 2) Danach: Zugangsschlüssel in den Kragle-Tresor (per Drag&Drop)
if request.args.get("unlock") == "1" and not session.get("vault_unlocked"):
used = next((k for k in ACCESS_KEYS if _inv_has(k)), None)
if used:
_inv_remove(used)
session["vault_unlocked"] = True
session.modified = True
db.close()
return redirect("/app/raum/40")
if not session.get("vault_unlocked"):
db.close()
return _tresor_schloss()
# ── Prüf-Weg-Räume: Zeitmessung dieses Abschnitts startet ──
if typ == "thema":
_stage_start(id)
fragen = db.execute(
"SELECT * FROM fragen WHERE raum_id=? ORDER BY id", (id,)
).fetchall()
prog_key = "raum_%d_idx" % id
idx = session.get(prog_key, 0)
# ── Noch offene Fragen → nächste Frage zeigen ──
if fragen and idx < len(fragen):
aktuelle = fragen[idx]
feedback = ""
if request.method == "POST":
gewaehlt = request.form.get("antwort", "").strip()
if gewaehlt.lower() == aktuelle["antwort"].strip().lower():
session[prog_key] = idx + 1
session.modified = True
db.close()
return redirect("/app/raum/%d" % id)
feedback = ('<div class="feedback-bad">🚨 Alarm! Falsche Antwort '
'die Wächter werden fast auf dich aufmerksam. Nochmal!</div>')
optionen = aktuelle["optionen"].split("|")
db.close()
buttons = ""
for opt in optionen:
buttons += ('<button class="opt-btn" name="antwort" value="%s">%s</button>'
% (esc(opt), esc(opt)))
if typ == "ziel":
label = "🧱 FINALE · %s" % esc(r["name"])
else:
label = "🔐 Sicherheitsprüfung · %s" % esc(r["name"])
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">%s</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<p class="adv-progress">Frage %d von %d</p>
%s
<p class="adv-frage">%s</p>
<form method="post" class="opt-form">%s</form>
</div>""" % (label, esc(r["name"]), esc(r["beschreibung"]),
idx + 1, len(fragen), feedback,
esc(aktuelle["frage"]), buttons))
return adventure_seite(inhalt)
# ── Alle Fragen dieses Raums beantwortet ──
ausgaenge = db.execute(
"SELECT richtung, nach_raum, hinweis FROM ausgaenge "
"WHERE von_raum=? ORDER BY rowid", (id,)
).fetchall()
db.close()
# ZIEL erreicht → Sieg
if typ == "ziel":
return sieg_screen(r["beschreibung"])
# THEMA gelöst → Erfolg + Ausgang zur nächsten Etage
_stage_finish(id) # Zeit speichern (+ ggf. Schlüssel)
k = STAGE_KEYS.get(id)
key_hinweis = ("" if not k else
'<p class="adv-progress">🎒 Erhalten: <strong>%s</strong> %s '
'damit öffnest du oben den Kragle-Tresor.</p>' % (esc(k["name"]), k["icon"]))
weiter = ausgaenge[0] if ausgaenge else None
weiter_btn = (
'<a class="exit-link" href="/app/raum/%d">%s</a>'
% (weiter["nach_raum"], esc(weiter["richtung"]))
if weiter else
'<a class="exit-link" href="/app/">Zurück zum Start</a>')
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">✅ Richtiger Weg</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<p class="adv-progress">✅ Alle Fragen geknackt der Weg nach oben ist frei!</p>
%s
%s
</div>""" % (esc(r["name"]), esc(r["beschreibung"]), key_hinweis, weiter_btn))
return adventure_seite(inhalt)
# ═══ Gang / Start: Beschreibung + Wege (Navigations-Entscheidung) ═══
ausgaenge = db.execute(
"SELECT richtung, nach_raum, hinweis FROM ausgaenge "
"WHERE von_raum=? ORDER BY rowid", (id,)
).fetchall()
db.close()
wege_html = ""
for a in ausgaenge:
wege_html += ("""
<div class="pfad">
<a class="exit-link" href="/app/raum/%d">%s</a>
<span class="pfad-hinweis">%s</span>
</div>""" % (a["nach_raum"], esc(a["richtung"]), esc(a["hinweis"] or "")))
if not ausgaenge:
wege_html = '<a class="exit-link" href="/app/">Zurück zum Start</a>'
if r["etage"] and r["etage"] > 0:
etage_label = "🏢 Etage %d" % r["etage"]
else:
etage_label = "🚪 Eingang"
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">%s</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<div class="pfade">%s</div>
</div>""" % (etage_label, esc(r["name"]), esc(r["beschreibung"]), wege_html))
return adventure_seite(inhalt)
# ─────────────────────────────────────────────────────────────────────────────
# Route 3: Pinnwand YouTube-Kommentar-Style mit Antwort-Funktion
# ─────────────────────────────────────────────────────────────────────────────
PINNWAND_CSS = """
<style>
.pw-header { font-size:14px; color:#333; margin-bottom:18px;
border-bottom:1px solid #e0e0e0; padding-bottom:10px; }
.pw-header strong { font-size:16px; }
/* ── Eingabe-Bereich ── */
.pw-input-row { display:flex; gap:12px; align-items:flex-start; margin-bottom:22px; }
.pw-avatar { width:40px; height:40px; border-radius:50%; color:#fff;
display:flex; align-items:center; justify-content:center;
font-size:18px; font-weight:bold; flex-shrink:0; }
.pw-input-wrap { flex:1; }
.pw-input-wrap input[type=text] {
width:100%; border:none; border-bottom:1px solid #bbb;
background:transparent; padding:6px 2px; font-size:14px; color:#333;
outline:none; transition:border-color .2s; margin-bottom:8px; }
.pw-input-wrap input[type=text]:focus { border-bottom-color:#1155cc; }
.pw-input-wrap input[type=text]::placeholder { color:#aaa; }
.pw-btn-row { display:flex; justify-content:flex-end; }
.pw-btn { padding:6px 14px; border:none; border-radius:2px; font-size:13px;
cursor:pointer; font-weight:bold; background:#167ac6; color:#fff; }
.pw-btn:hover { background:#1266a8; }
/* ── Kommentar-Liste ── */
.pw-count { font-size:14px; color:#555; margin-bottom:12px; }
.pw-comment { display:flex; gap:12px; padding:12px 0; border-bottom:1px solid #f0f0f0; }
.pw-comment:last-child { border-bottom:none; }
.pw-comment-body { flex:1; min-width:0; }
.pw-meta { display:flex; align-items:baseline; gap:8px; margin-bottom:4px; flex-wrap:wrap; }
.pw-username { font-size:13px; font-weight:bold; color:#167ac6; }
.pw-time { font-size:12px; color:#999; }
.pw-text { font-size:14px; color:#333; line-height:1.5; word-break:break-word; }
.pw-actions { display:flex; gap:10px; margin-top:6px; align-items:center; }
.pw-action-btn { font-size:12px; color:#888; background:none; border:none;
cursor:pointer; padding:0; }
.pw-action-btn:hover { color:#333; }
.pw-thumb { font-size:13px; }
/* ── Antwort-Formular (toggle per JS) ── */
.pw-reply-form { display:none; margin-top:10px; padding:10px 0 4px 0;
border-top:1px solid #f0f0f0; }
.pw-reply-form input[type=text] {
width:100%; border:none; border-bottom:1px solid #ccc; background:transparent;
padding:5px 2px; font-size:13px; color:#333; outline:none; margin-bottom:6px;
transition:border-color .2s; }
.pw-reply-form input[type=text]:focus { border-bottom-color:#1155cc; }
.pw-reply-form input[type=text]::placeholder { color:#bbb; }
.pw-reply-btn-row { display:flex; justify-content:flex-end; gap:8px; }
.pw-reply-cancel { font-size:12px; color:#888; background:none; border:none;
cursor:pointer; padding:4px 10px; font-weight:bold; }
.pw-reply-cancel:hover { color:#333; }
.pw-reply-submit { padding:5px 12px; border:none; border-radius:2px; font-size:12px;
cursor:pointer; font-weight:bold; background:#167ac6; color:#fff; }
.pw-reply-submit:hover { background:#1266a8; }
/* ── Antworten unter Kommentar ── */
.pw-replies { margin-left:52px; margin-top:6px; }
.pw-reply-item { display:flex; gap:10px; padding:8px 0; border-top:1px solid #f5f5f5; }
.pw-reply-avatar { width:28px; height:28px; border-radius:50%; color:#fff;
display:flex; align-items:center; justify-content:center;
font-size:12px; font-weight:bold; flex-shrink:0; }
.pw-reply-body { flex:1; min-width:0; }
.pw-show-replies-btn { font-size:12px; color:#167ac6; background:none; border:none;
cursor:pointer; padding:4px 0; margin-top:4px; font-weight:bold; }
.pw-show-replies-btn:hover { text-decoration:underline; }
.pw-leer { color:#aaa; font-size:14px; text-align:center; padding:30px 0; }
</style>
<script>
function toggleReplyForm(idx) {
var f = document.getElementById('rf-' + idx);
if (!f) return;
if (f.style.display === 'none' || f.style.display === '') {
f.style.display = 'block';
f.querySelector('input[name="nachricht"]').focus();
} else {
f.style.display = 'none';
}
}
function toggleReplies(idx) {
var box = document.getElementById('replies-' + idx);
var btn = document.getElementById('sb-' + idx);
if (!box || !btn) return;
if (box.style.display === 'none' || box.style.display === '') {
box.style.display = 'block';
btn.textContent = btn.textContent.replace('','').replace('anzeigen','ausblenden');
} else {
box.style.display = 'none';
btn.textContent = btn.textContent.replace('','').replace('ausblenden','anzeigen');
}
}
</script>
"""
def esc(s):
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
def avatar_farbe(name):
farben = ["#e53935", "#8e24aa", "#1e88e5", "#00897b",
"#43a047", "#fb8c00", "#6d4c41", "#546e7a"]
idx = sum(ord(c) for c in name) % len(farben)
return farben[idx]
def render_antworten(antworten):
html = ""
for a in antworten:
farbe = avatar_farbe(a["name"])
initial = a["name"][0].upper()
html += f"""
<div class="pw-reply-item">
<div class="pw-reply-avatar" style="background:{farbe}">{initial}</div>
<div class="pw-reply-body">
<div class="pw-meta">
<span class="pw-username">{esc(a['name'])}</span>
<span class="pw-time">{esc(a['zeit'])}</span>
</div>
<div class="pw-text">{esc(a['text'])}</div>
</div>
</div>"""
return html
@app.route("/pinnwand", methods=["GET", "POST"])
def pinnwand():
if request.method == "POST":
name = request.form.get("name", "").strip() or "Anonym"
text = request.form.get("nachricht", "").strip()
parent_raw = request.form.get("parent_id", "").strip()
if text:
if parent_raw.isdigit():
idx = int(parent_raw)
if 0 <= idx < len(eintraege):
eintraege[idx]["antworten"].append(
{"name": name, "text": text, "zeit": jetzt()}
)
else:
eintraege.append(
{"name": name, "text": text, "zeit": jetzt(), "antworten": []}
)
return redirect("/app/pinnwand")
# ── Kommentare rendern (neueste zuerst, echter Index für parent_id) ──
komm_html = ""
for real_idx, e in reversed(list(enumerate(eintraege))):
farbe = avatar_farbe(e["name"])
initial = e["name"][0].upper()
n_ant = len(e["antworten"])
# Block: Antworten anzeigen/verbergen
antworten_block = ""
if n_ant > 0:
label = f"{n_ant} Antwort{'en' if n_ant != 1 else ''}"
antworten_block = f"""
<button class="pw-show-replies-btn" id="sb-{real_idx}"
onclick="toggleReplies({real_idx})">
{label} anzeigen
</button>
<div class="pw-replies" id="replies-{real_idx}" style="display:none">
{render_antworten(e['antworten'])}
</div>"""
komm_html += f"""
<div class="pw-comment" id="c-{real_idx}">
<div class="pw-avatar" style="background:{farbe}">{initial}</div>
<div class="pw-comment-body">
<div class="pw-meta">
<span class="pw-username">{esc(e['name'])}</span>
<span class="pw-time">{esc(e['zeit'])}</span>
</div>
<div class="pw-text">{esc(e['text'])}</div>
<div class="pw-actions">
<button class="pw-action-btn pw-thumb">&#128077;</button>
<button class="pw-action-btn pw-thumb">&#128078;</button>
<button class="pw-action-btn"
onclick="toggleReplyForm({real_idx})">Antworten</button>
</div>
<!-- Antwort-Formular (initial versteckt) -->
<div class="pw-reply-form" id="rf-{real_idx}">
<form method="post">
<input type="hidden" name="parent_id" value="{real_idx}">
<input type="text" name="name"
placeholder="Dein Name (optional)">
<input type="text" name="nachricht"
placeholder="Antwort hinzufügen ..." required>
<div class="pw-reply-btn-row">
<button type="button" class="pw-reply-cancel"
onclick="toggleReplyForm({real_idx})">Abbrechen</button>
<button type="submit" class="pw-reply-submit">Antworten</button>
</div>
</form>
</div>
{antworten_block}
</div>
</div>"""
if not eintraege:
komm_html = '<p class="pw-leer">Noch keine Kommentare. Sei der Erste! 💬</p>'
n = len(eintraege)
anzahl_text = f"{n} Kommentar{'e' if n != 1 else ''}"
return seite(PINNWAND_CSS + f"""
<div class="pw-header"><strong>Kommentare</strong></div>
<form method="post">
<div class="pw-input-row">
<div class="pw-avatar" style="background:#888; font-size:22px">&#128100;</div>
<div class="pw-input-wrap">
<input type="text" name="name"
placeholder="Dein Name (optional)">
<input type="text" name="nachricht"
placeholder="Öffentlichen Kommentar hinzufügen ..." autofocus required>
<div class="pw-btn-row">
<button type="submit" class="pw-btn">Kommentieren</button>
</div>
</div>
</div>
</form>
<p class="pw-count">{anzahl_text}</p>
<div id="kommentare">{komm_html}</div>
""")
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
app.run(host="0.0.0.0", port=9002) # isa2 → Port 9002