from flask import Flask, request, session, redirect import html import random import sqlite3 from pathlib import Path app = Flask(__name__) app.secret_key = "hier-eigenen-geheimen-text" DB_PATH = Path(__file__).with_name("adventure.db") def get_db(): db = sqlite3.connect(DB_PATH) db.row_factory = sqlite3.Row return db DENY_MESSAGES = [ "ZUGRIFF VERWEIGERT.", "AUTHENTIFIZIERUNG FEHLGESCHLAGEN.", "CODE UNGÜLTIG — VERSUCH PROTOKOLLIERT.", ] STUFE_HINWEISE = [ "Tipp: Sichte das Navigationslog auf der Brücke noch einmal genau.", "Tipp: Der Reaktor im Maschinenraum trägt ein Typenschild mit einer Signatur.", "Tipp: Das Klemmbrett im Forschungslabor listet den Zustand der Kryokapseln.", ] def parse_stufen(r): if not r["raetsel_frage"]: return [] fragen = r["raetsel_frage"].split("|") antworten = r["raetsel_antwort"].split("|") return list(zip(fragen, antworten)) def get_fortschritt(db, spieler, raum_id): if not spieler: return session.get(f"stufe_r{raum_id}", 0), session.get(f"fehler_r{raum_id}", 0) row = db.execute( "SELECT stufe, fehler FROM raetsel_fortschritt WHERE spieler=? AND raum_id=?", (spieler, raum_id) ).fetchone() if row: return row["stufe"], row["fehler"] return 0, 0 def set_fortschritt(db, spieler, raum_id, stufe, fehler): if not spieler: session[f"stufe_r{raum_id}"] = stufe session[f"fehler_r{raum_id}"] = fehler return db.execute( "INSERT INTO raetsel_fortschritt (spieler, raum_id, stufe, fehler) VALUES (?,?,?,?) " "ON CONFLICT(spieler, raum_id) DO UPDATE SET stufe=excluded.stufe, fehler=excluded.fehler", (spieler, raum_id, stufe, fehler) ) db.commit() BASE_CSS = """ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500&display=swap'); :root { --void: #030507; --panel: #0a1512; --panel-2: #0d1a16; --line: rgba(127, 219, 182, 0.22); --text: #d7fbe8; --text-dim: #77988b; --amber: #ffb454; --amber-dim: #8a5e2a; --danger: #ff6b5e; --success: #4ade9a; } * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 50% -10%, rgba(74, 222, 154, 0.06), transparent 60%), var(--void); color: var(--text); font-family: 'IBM Plex Sans', sans-serif; position: relative; overflow-x: hidden; } body::before { content: ""; position: fixed; inset: 0; pointer-events: none; background: repeating-linear-gradient( to bottom, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 2px, transparent 3px ); z-index: 2; mix-blend-mode: overlay; } body::after { content: ""; position: fixed; inset: 0; pointer-events: none; background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%); z-index: 1; } .shell { min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2.5rem 1rem; position: relative; z-index: 3; } .console-strip { width: min(760px, 100%); display: flex; justify-content: space-between; align-items: center; font-family: 'IBM Plex Mono', monospace; font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); border: 1px solid var(--line); border-bottom: none; border-radius: 10px 10px 0 0; padding: 0.55rem 1rem; background: var(--panel-2); } .console-strip .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--success); margin-right: 0.4em; box-shadow: 0 0 6px var(--success); } .card { width: min(760px, 100%); background: var(--panel); border: 1px solid var(--line); border-radius: 0 0 10px 10px; padding: 2.2rem 2.2rem 2.4rem; box-shadow: 0 30px 80px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.03); } .eyebrow { font-family: 'IBM Plex Mono', monospace; font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--amber); margin: 0 0 0.6rem; } .card h1 { font-family: 'IBM Plex Mono', monospace; margin: 0 0 1rem; font-size: 1.7rem; font-weight: 600; color: var(--text); letter-spacing: 0.01em; } .card h1::after { content: "_"; color: var(--amber); animation: blink 1.1s steps(1) infinite; } @keyframes blink { 50% { opacity: 0; } } .card p { line-height: 1.75; color: #b9d6c9; font-size: 1rem; } .actions, .links { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1.5rem; } .btn, .card a, .card button { font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; letter-spacing: 0.04em; text-transform: uppercase; display: inline-flex; align-items: center; gap: 0.4em; background: transparent; color: var(--amber); border: 1px solid var(--amber-dim); border-radius: 6px; padding: 0.65rem 1rem; text-decoration: none; cursor: pointer; transition: all .18s ease; } .btn::before, .card a::before, .card button::before { content: "▸"; opacity: .6; } .btn:hover, .card a:hover, .card button:hover { background: rgba(255,180,84,0.08); border-color: var(--amber); color: #ffd08a; } .btn.secondary, .card a.secondary { color: var(--text-dim); border-color: var(--line); } .btn.secondary:hover, .card a.secondary:hover { color: var(--text); border-color: rgba(127,219,182,0.5); background: rgba(127,219,182,0.06); } form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; margin-top: 1.2rem; } form::before { content: ">"; font-family: 'IBM Plex Mono', monospace; color: var(--success); font-weight: 700; } input { flex: 1 1 220px; font-family: 'IBM Plex Mono', monospace; border: 1px solid var(--line); border-radius: 6px; padding: 0.7rem 0.9rem; background: #050b09; color: var(--text); } input::placeholder { color: #4d6a5f; } input:focus { outline: none; border-color: var(--success); box-shadow: 0 0 0 3px rgba(74,222,154,0.15); } .status-line { font-family: 'IBM Plex Mono', monospace; font-size: 0.75rem; color: var(--text-dim); margin-top: 1.4rem; padding-top: 1rem; border-top: 1px dashed var(--line); } .terminal-log { font-family: 'IBM Plex Mono', monospace; font-size: 0.78rem; margin-bottom: 1rem; } .log-line { margin: 0.15rem 0; } .log-line.done { color: var(--success); } .progress-dots { display: flex; gap: 0.4rem; margin: 0 0 1.2rem; } .dot-step { width: 22px; height: 6px; border-radius: 3px; background: rgba(127,219,182,0.15); border: 1px solid var(--line); } .dot-step.filled { background: var(--success); border-color: var(--success); box-shadow: 0 0 8px var(--success); } .frage { font-family: 'IBM Plex Mono', monospace; color: var(--amber); font-size: 0.95rem; margin: 0 0 0.4rem; } .denied { font-family: 'IBM Plex Mono', monospace; color: var(--danger); font-size: 0.85rem; animation: flash 0.4s ease; border-left: 2px solid var(--danger); padding-left: 0.6rem; } @keyframes flash { 0% { opacity: 0; transform: translateX(-4px); } 100% { opacity: 1; transform: translateX(0); } } .hint { font-family: 'IBM Plex Mono', monospace; font-size: 0.78rem; color: var(--text-dim); border-left: 2px solid var(--amber-dim); padding-left: 0.6rem; margin-top: 0.8rem; } .verify-ok { font-family: 'IBM Plex Mono', monospace; color: var(--success); letter-spacing: 0.06em; font-size: 0.85rem; } .danger-btn { font-family: 'IBM Plex Mono', monospace; text-transform: uppercase; color: #ffe1c9; background: linear-gradient(135deg, rgba(255,107,94,0.15), rgba(255,180,84,0.15)); border: 1px solid var(--danger); border-radius: 6px; padding: 0.9rem 1.4rem; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5em; animation: pulse-glow 1.6s ease-in-out infinite; } .danger-btn::before { content: "⚠"; } @keyframes pulse-glow { 0%, 100% { box-shadow: 0 0 10px rgba(255,107,94,0.25); } 50% { box-shadow: 0 0 22px rgba(255,107,94,0.55); } } @media (prefers-reduced-motion: reduce) { .card h1::after { animation: none; opacity: 1; } .danger-btn { animation: none; } .denied { animation: none; } } """ BASE_JS = """ window.addEventListener('DOMContentLoaded', () => { const firstInput = document.querySelector('input'); if (firstInput) firstInput.focus(); }); """ def render_page(title, body_html, eyebrow=None): spieler = session.get("spieler") status = f'{html.escape(spieler)}' if spieler else 'NOTSTROM AKTIV' eyebrow_html = f'

{html.escape(eyebrow)}

' if eyebrow else "" return f""" {html.escape(title)}
{status} SCHIFF-OS // NOTBETRIEB
{eyebrow_html} {body_html}
""" @app.route("/", methods=["GET", "POST"]) @app.route("/app", methods=["GET", "POST"]) @app.route("/app/", methods=["GET", "POST"]) def start(): if request.method == "POST": name = request.form.get("name", "").strip() if name != "": session["spieler"] = name db = get_db() db.execute( "INSERT OR IGNORE INTO spielstand (spieler, raum_id) VALUES (?, 1)", (name,) ) db.commit() db.close() return redirect("/app/raum/1") db = get_db() anzahl = db.execute("SELECT COUNT(*) AS n FROM spielstand").fetchone() weiterspielen_html = "" if "spieler" in session: s = db.execute( "SELECT raum_id FROM spielstand WHERE spieler=?", (session["spieler"],) ).fetchone() if s: weiterspielen_html = f"""
Weiterspielen Neues Abenteuer
""" db.close() body = f"""

Verlassenes Raumschiff

Du bist der einzige Überlebende an Bord. Erkunde das Schiff und aktiviere das Notsignal.

{weiterspielen_html}

{anzahl['n']} Abenteurer waren schon an Bord.

""" return render_page("Verlassenes Raumschiff", body, eyebrow="SCHIFF-OS v2.4 // BOOT") @app.route("/raum/", methods=["GET", "POST"]) @app.route("/app/raum/", methods=["GET", "POST"]) def raum(id): db = get_db() if "spieler" in session: db.execute( "UPDATE spielstand SET raum_id=? WHERE spieler=?", (id, session["spieler"]) ) db.commit() r = db.execute("SELECT * FROM raeume WHERE id=?", (id,)).fetchone() if not r: db.close() return render_page( "Raum nicht gefunden", '

Raum nicht gefunden

Der angeforderte Raum existiert nicht.

' '', eyebrow="FEHLER" ) eyebrow_basis = f"SEKTOR {id:02d}" if r["raetsel_frage"]: stufen = parse_stufen(r) spieler = session.get("spieler") stufe, fehler = get_fortschritt(db, spieler, id) deny_msg = None if request.method == "POST" and stufe < len(stufen): antwort = request.form.get("antwort", "").strip() erwartet = stufen[stufe][1] if antwort.upper() == erwartet.upper(): stufe += 1 fehler = 0 else: fehler += 1 deny_msg = random.choice(DENY_MESSAGES) set_fortschritt(db, spieler, id, stufe, fehler) db.close() log_html = "".join( f'
✓ Stufe {i+1}: {html.escape(f)}
' for i, (f, a) in enumerate(stufen[:stufe]) ) if stufe >= len(stufen): body = f"""
{log_html}

IDENTITÄT BESTÄTIGT — ALLE SYSTEME GRÜN

{html.escape(r['beschreibung'])}

""" return render_page(html.escape(r["name"]), body, eyebrow=f"{eyebrow_basis} · VERIFIZIERT") frage = stufen[stufe][0] hinweis_html = f'

{html.escape(STUFE_HINWEISE[stufe])}

' if fehler >= 2 else "" deny_html = f'

{deny_msg}

' if deny_msg else "" dots = "".join( f'' for i in range(len(stufen)) ) body = f"""
{log_html}

{dots}

{html.escape(r['beschreibung'])}

{html.escape(frage)}

{deny_html}
{hinweis_html} """ return render_page(html.escape(r["name"]), body, eyebrow=f"{eyebrow_basis} · STUFE {stufe+1}/{len(stufen)}") ausgaenge = db.execute( "SELECT richtung, nach_raum FROM ausgaenge WHERE von_raum=?", (id,) ).fetchall() db.close() body = f"""

{html.escape(r['name'])}

{html.escape(r['beschreibung'])}

" return render_page(html.escape(r["name"]), body, eyebrow=eyebrow_basis) @app.route("/raum//aktivieren") @app.route("/app/raum//aktivieren") def aktivieren(id): db = get_db() r = db.execute("SELECT * FROM raeume WHERE id=?", (id,)).fetchone() if not r: db.close() return redirect("/app/") stufen = parse_stufen(r) spieler = session.get("spieler") stufe, _ = get_fortschritt(db, spieler, id) db.close() if stufe < len(stufen): return redirect(f"/app/raum/{id}") body = """

Notsignal aktiviert

✓ ALLE DREI SYSTEME VERIFIZIERT

Ein greller Alarmton hallt durch die Gänge. Auf dem Hauptschirm leuchtet: „NOTSIGNAL GESENDET — RETTUNG UNTERWEGS.“

""" return render_page("Notsignal aktiviert", body, eyebrow="MISSION ERFÜLLT") @app.route("/reset") @app.route("/app/reset") def reset(): session.pop("spieler", None) return redirect("/app/") if __name__ == "__main__": app.run(host="0.0.0.0", port=9004)