572 lines
No EOL
15 KiB
Python
572 lines
No EOL
15 KiB
Python
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'<span><span class="dot"></span>{html.escape(spieler)}</span>' if spieler else '<span><span class="dot"></span>NOTSTROM AKTIV</span>'
|
|
eyebrow_html = f'<p class="eyebrow">{html.escape(eyebrow)}</p>' if eyebrow else ""
|
|
|
|
return f"""<!doctype html>
|
|
<html lang=\"de\">
|
|
<head>
|
|
<meta charset=\"utf-8\">
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
|
|
<title>{html.escape(title)}</title>
|
|
<style>{BASE_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<main class=\"shell\">
|
|
<div class=\"console-strip\">
|
|
{status}
|
|
<span>SCHIFF-OS // NOTBETRIEB</span>
|
|
</div>
|
|
<section class=\"card\">
|
|
{eyebrow_html}
|
|
{body_html}
|
|
</section>
|
|
</main>
|
|
<script>{BASE_JS}</script>
|
|
</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"""
|
|
<div class="actions">
|
|
<a href="/app/raum/{s['raum_id']}">Weiterspielen</a>
|
|
<a class="secondary" href="/app/reset">Neues Abenteuer</a>
|
|
</div>
|
|
"""
|
|
db.close()
|
|
|
|
body = f"""
|
|
<h1>Verlassenes Raumschiff</h1>
|
|
<p>Du bist der einzige Überlebende an Bord. Erkunde das Schiff und aktiviere das Notsignal.</p>
|
|
{weiterspielen_html}
|
|
<form method="post">
|
|
<input name="name" placeholder="Spieler-ID eingeben" autocomplete="off">
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
<p class="status-line">{anzahl['n']} Abenteurer waren schon an Bord.</p>
|
|
"""
|
|
return render_page("Verlassenes Raumschiff", body, eyebrow="SCHIFF-OS v2.4 // BOOT")
|
|
|
|
|
|
@app.route("/raum/<int:id>", methods=["GET", "POST"])
|
|
@app.route("/app/raum/<int:id>", 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",
|
|
'<h1>Raum nicht gefunden</h1><p>Der angeforderte Raum existiert nicht.</p>'
|
|
'<div class="actions"><a class="secondary" href="/app/">Zurück zum Start</a></div>',
|
|
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'<div class="log-line done">✓ Stufe {i+1}: {html.escape(f)}</div>'
|
|
for i, (f, a) in enumerate(stufen[:stufe])
|
|
)
|
|
|
|
if stufe >= len(stufen):
|
|
body = f"""
|
|
<div class="terminal-log">{log_html}</div>
|
|
<p class="verify-ok">IDENTITÄT BESTÄTIGT — ALLE SYSTEME GRÜN</p>
|
|
<p>{html.escape(r['beschreibung'])}</p>
|
|
<div class="actions">
|
|
<a class="danger-btn" href="/app/raum/{id}/aktivieren">Notsignal auslösen</a>
|
|
</div>
|
|
"""
|
|
return render_page(html.escape(r["name"]), body, eyebrow=f"{eyebrow_basis} · VERIFIZIERT")
|
|
|
|
frage = stufen[stufe][0]
|
|
hinweis_html = f'<p class="hint">{html.escape(STUFE_HINWEISE[stufe])}</p>' if fehler >= 2 else ""
|
|
deny_html = f'<p class="denied">{deny_msg}</p>' if deny_msg else ""
|
|
dots = "".join(
|
|
f'<span class="dot-step{" filled" if i < stufe else ""}"></span>' for i in range(len(stufen))
|
|
)
|
|
|
|
body = f"""
|
|
<div class="terminal-log">{log_html}</div>
|
|
<p class="progress-dots">{dots}</p>
|
|
<p>{html.escape(r['beschreibung'])}</p>
|
|
<p class="frage">{html.escape(frage)}</p>
|
|
{deny_html}
|
|
<form method="post">
|
|
<input id="antwort" name="antwort" placeholder="Antwort eingeben" autocomplete="off">
|
|
<button type="submit">Prüfen</button>
|
|
</form>
|
|
{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"""
|
|
<h1>{html.escape(r['name'])}</h1>
|
|
<p>{html.escape(r['beschreibung'])}</p>
|
|
<div class="links">
|
|
"""
|
|
for a in ausgaenge:
|
|
body += f'<a class="secondary" href="/app/raum/{a["nach_raum"]}">{html.escape(a["richtung"])}</a>'
|
|
body += "</div>"
|
|
return render_page(html.escape(r["name"]), body, eyebrow=eyebrow_basis)
|
|
|
|
|
|
@app.route("/raum/<int:id>/aktivieren")
|
|
@app.route("/app/raum/<int:id>/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 = """
|
|
<h1>Notsignal aktiviert</h1>
|
|
<p class="verify-ok">✓ ALLE DREI SYSTEME VERIFIZIERT</p>
|
|
<p>Ein greller Alarmton hallt durch die Gänge. Auf dem Hauptschirm leuchtet: „NOTSIGNAL GESENDET — RETTUNG UNTERWEGS.“</p>
|
|
<div class="actions">
|
|
<a href="/app/">Zurück zum Start</a>
|
|
</div>
|
|
"""
|
|
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) |