eis-website/app.py

997 lines
43 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-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; }
</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>
</body>
</html>"""
return kopf + inhalt + fuss
@app.route("/")
def start():
# Fortschritt für einen frischen Durchlauf zurücksetzen
for key in [k for k in list(session.keys())
if k.startswith("raum_") or k.startswith("chal_")
or k.startswith("fs40")]:
session.pop(key, None)
inhalt = """
<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 ist ein Gang, der sich gabelt. <strong>Lies die
Hinweise genau!</strong> Der <strong>sichere Prüf-Weg</strong> stellt dir ein paar
thematische Fragen und führt gemütlich eine Etage höher. Der andere Weg ist eine
<strong>riskante Abkürzung</strong>: Dort wartet jemand mit einem Deal schaffst
du seine Fragen <strong>gegen die Uhr</strong>, springst du weit nach oben. Ein
Fehler oder zu langsam? <strong>Mission vorbei.</strong> Ganz oben wartet die
letzte Frage zu Präsident Business.</p>
<a href="/app/raum/1" class="exit-link">Abenteuer starten ▶</a>
<p class="muted">Lieber quatschen statt einbrechen?
<a href="/app/pinnwand">Zu den Kommentaren →</a></p>
</div>"""
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!")
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/" class="exit-link">Nochmal spielen 🔁</a>
<a href="/app/pinnwand" class="ghost-link">Sieg in den Kommentaren feiern 💬</a>
</div>""" % esc(body))
return adventure_seite(inhalt)
def gameover_screen(grund):
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/" class="exit-link">Nochmal von vorn ▶</a>
<a href="/app/pinnwand" class="ghost-link">Frust in den Kommentaren lassen 💬</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
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)
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)
if erfolg_raum and erfolg_raum > 0:
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>
<a href="/app/raum/%d" class="exit-link">Weiter ▶</a>
</div>""" % (esc(erfolg_text), 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)
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 _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.
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">Führerschein beweisen 📝</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."""
# Noch nicht gestartet → Blaulicht-Intro (oder Start per ?fs=1)
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")
return _blaulicht_intro()
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
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>')
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"):
# ── Polizei-Führerschein-Kontrolle vor dem Lord-Business-Finale ──
if typ == "ziel" and id == 40 and not session.get(FS_DONE_KEY):
db.close()
return fuehrerschein_kontrolle()
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":
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/" class="exit-link">Nochmal spielen 🔁</a>
<a href="/app/pinnwand" class="ghost-link">Sieg in den Kommentaren feiern 💬</a>
</div>""" % esc(r["beschreibung"]))
return adventure_seite(inhalt)
# THEMA gelöst → Erfolg + Ausgang zur nächsten Etage
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
</div>""" % (esc(r["name"]), esc(r["beschreibung"]), 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