diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6109ea3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+# SCOicide Squadâ·
+
+
+
+> A python Flask based role play game app đȘ
+
+â [Live Page](https://example.com/)
+
+â [Game rules](rules.md)
\ No newline at end of file
diff --git a/application.py b/application.py
new file mode 100644
index 0000000..f7e78f1
--- /dev/null
+++ b/application.py
@@ -0,0 +1,636 @@
+import sqlite3
+import string
+import secrets
+import io
+import zoneinfo
+import os
+import markdown
+from datetime import datetime, timezone
+from passlib.hash import argon2
+import pyotp
+import qrcode
+import qrcode.image.svg
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.date import DateTrigger
+from flask import Flask, request, session, redirect, render_template, url_for, abort, send_from_directory
+from flask_socketio import SocketIO, emit, join_room
+
+application = Flask(__name__)
+application.secret_key = secrets.token_hex(64)
+websocket_manager = SocketIO(application, manage_session=False)
+scheduler_instance = BackgroundScheduler(timezone="Europe/Berlin")
+european_timezone = zoneinfo.ZoneInfo("Europe/Berlin")
+
+IDENTIFIER_LENGTH = 5
+
+def get_database_connection():
+ connection = sqlite3.connect("game_database.sqlite")
+ connection.row_factory = sqlite3.Row
+ return connection
+
+def initialize_database():
+ connection = get_database_connection()
+ connection.execute("""
+ CREATE TABLE IF NOT EXISTS game_configuration (
+ configuration_key TEXT PRIMARY KEY,
+ configuration_value TEXT
+ )
+ """)
+ connection.execute("""
+ CREATE TABLE IF NOT EXISTS player (
+ player_identifier TEXT PRIMARY KEY,
+ password_hash TEXT,
+ display_name TEXT,
+ is_alive INTEGER DEFAULT 1,
+ score INTEGER DEFAULT 0,
+ target_identifier TEXT,
+ reset_token TEXT,
+ pending_attacker_identifier TEXT,
+ is_approved INTEGER DEFAULT 0,
+ pending_display_name TEXT,
+ late_joiner INTEGER DEFAULT 0
+ )
+ """)
+ connection.execute("""
+ CREATE TABLE IF NOT EXISTS administrator (
+ administrator_identifier INTEGER PRIMARY KEY,
+ password_hash TEXT,
+ two_factor_secret TEXT,
+ two_factor_enabled INTEGER DEFAULT 0
+ )
+ """)
+ connection.execute("""
+ CREATE TABLE IF NOT EXISTS kill_log (
+ kill_identifier INTEGER PRIMARY KEY AUTOINCREMENT,
+ killer_identifier TEXT,
+ victim_identifier TEXT,
+ victim_previous_target TEXT,
+ kill_timestamp TEXT
+ )
+ """)
+ try:
+ connection.execute("ALTER TABLE administrator ADD COLUMN two_factor_enabled INTEGER DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ connection.execute("ALTER TABLE player ADD COLUMN is_approved INTEGER DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ connection.execute("ALTER TABLE player ADD COLUMN pending_display_name TEXT")
+ except sqlite3.OperationalError:
+ pass
+ try:
+ connection.execute("ALTER TABLE player ADD COLUMN late_joiner INTEGER DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass
+ cursor = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'")
+ if not cursor.fetchone():
+ connection.execute("INSERT INTO game_configuration (configuration_key, configuration_value) VALUES ('game_status', 'pending')")
+ connection.execute("INSERT INTO game_configuration (configuration_key, configuration_value) VALUES ('start_time', '')")
+ connection.commit()
+ connection.close()
+
+def require_player_authentication(function):
+ def wrapper(*arguments, **keyword_arguments):
+ if "player_identifier" not in session:
+ return redirect(url_for("handle_login"))
+ return function(*arguments, **keyword_arguments)
+ wrapper.__name__ = function.__name__
+ return wrapper
+
+def require_administrator_authentication(function):
+ def wrapper(*arguments, **keyword_arguments):
+ if not session.get("administrator_authenticated"):
+ return redirect(url_for("handle_administrator"))
+ return function(*arguments, **keyword_arguments)
+ wrapper.__name__ = function.__name__
+ return wrapper
+
+def get_markdown_rules():
+ if os.path.exists("rules.md"):
+ with open("rules.md", "r", encoding="utf-8") as markdown_file:
+ return markdown.markdown(markdown_file.read())
+ return "
Regelwerk nicht gefunden "
+
+def shuffle_player_targets():
+ connection = get_database_connection()
+ players = connection.execute("SELECT player_identifier FROM player WHERE is_alive = 1 AND is_approved = 1").fetchall()
+ if len(players) > 1:
+ identifiers = [player_record["player_identifier"] for player_record in players]
+ secure_random_generator = secrets.SystemRandom()
+ secure_random_generator.shuffle(identifiers)
+ for index, identifier in enumerate(identifiers):
+ target = identifiers[(index + 1) % len(identifiers)]
+ connection.execute("UPDATE player SET target_identifier = ? WHERE player_identifier = ?", (target, identifier))
+ connection.commit()
+ connection.close()
+ websocket_manager.emit("game_state_changed")
+
+def process_administrator_action(action_type, form_data):
+ connection = get_database_connection()
+ has_game_state_changed = False
+
+ if action_type == "start_game":
+ alive_count_record = connection.execute("SELECT COUNT(*) as alive_count FROM player WHERE is_approved = 1").fetchone()
+ if alive_count_record["alive_count"] >= 2:
+ connection.execute("UPDATE game_configuration SET configuration_value = 'running' WHERE configuration_key = 'game_status'")
+ connection.execute("UPDATE game_configuration SET configuration_value = ? WHERE configuration_key = 'start_time'", (datetime.now(european_timezone).isoformat(),))
+ connection.commit()
+ connection.close()
+ shuffle_player_targets()
+ websocket_manager.emit("admin_state_changed", to="administrator_room")
+ return
+
+ elif action_type == "stop_game":
+ connection.execute("UPDATE game_configuration SET configuration_value = 'finished' WHERE configuration_key = 'game_status'")
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "pause_game":
+ connection.execute("UPDATE game_configuration SET configuration_value = 'paused' WHERE configuration_key = 'game_status'")
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "resume_game":
+ connection.execute("UPDATE game_configuration SET configuration_value = 'running' WHERE configuration_key = 'game_status'")
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "reset_game":
+ connection.execute("UPDATE game_configuration SET configuration_value = 'pending' WHERE configuration_key = 'game_status'")
+ connection.execute("UPDATE game_configuration SET configuration_value = '' WHERE configuration_key = 'start_time'")
+ connection.execute("UPDATE player SET is_alive = 1, late_joiner = 0, score = 0, target_identifier = NULL, pending_attacker_identifier = NULL")
+ connection.execute("DELETE FROM kill_log")
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "create_player":
+ display_name = form_data.get("display_name")
+ new_identifier = ''.join(secrets.choice(string.ascii_uppercase) for _ in range(IDENTIFIER_LENGTH))
+ temporary_password = secrets.token_urlsafe(16)
+ hashed_password = argon2.hash(temporary_password)
+ connection.execute("INSERT INTO player (player_identifier, password_hash, display_name, is_approved) VALUES (?, ?, ?, 0)", (new_identifier, hashed_password, display_name))
+ connection.commit()
+
+ elif action_type == "approve_player":
+ target_identifier = form_data.get("player_identifier")
+ status_record = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()
+ is_late_joiner = 1 if status_record["configuration_value"] in ['running', 'paused'] else 0
+ is_alive_status = 0 if is_late_joiner else 1
+ connection.execute("UPDATE player SET is_approved = 1, is_alive = ?, late_joiner = ? WHERE player_identifier = ?", (is_alive_status, is_late_joiner, target_identifier))
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "edit_name":
+ target_identifier = form_data.get("player_identifier")
+ new_name = form_data.get("new_display_name")
+ connection.execute("UPDATE player SET display_name = ? WHERE player_identifier = ?", (new_name, target_identifier))
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "approve_name_change":
+ target_identifier = form_data.get("player_identifier")
+ connection.execute("UPDATE player SET display_name = pending_display_name, pending_display_name = NULL WHERE player_identifier = ?", (target_identifier,))
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "reject_name_change":
+ target_identifier = form_data.get("player_identifier")
+ connection.execute("UPDATE player SET pending_display_name = NULL WHERE player_identifier = ?", (target_identifier,))
+ connection.commit()
+ websocket_manager.emit("game_state_changed", to=target_identifier)
+
+ elif action_type == "delete_player":
+ target_identifier = form_data.get("player_identifier")
+ connection.execute("DELETE FROM player WHERE player_identifier = ?", (target_identifier,))
+ status_record = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()
+ if status_record["configuration_value"] == 'running':
+ alive_count_record = connection.execute("SELECT COUNT(*) as alive_count FROM player WHERE is_alive = 1 AND is_approved = 1").fetchone()
+ if alive_count_record["alive_count"] <= 1:
+ connection.execute("UPDATE game_configuration SET configuration_value = 'finished' WHERE configuration_key = 'game_status'")
+ if alive_count_record["alive_count"] == 1:
+ connection.execute("UPDATE player SET score = score + 3 WHERE is_alive = 1 AND is_approved = 1")
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "revive_player":
+ target_identifier = form_data.get("player_identifier")
+ player_to_revive = connection.execute("SELECT * FROM player WHERE player_identifier = ?", (target_identifier,)).fetchone()
+ if player_to_revive and player_to_revive["is_alive"] == 0:
+ alive_players = connection.execute("SELECT * FROM player WHERE is_alive = 1 AND is_approved = 1").fetchall()
+ if len(alive_players) > 0:
+ random_living_player = secrets.choice(alive_players)
+ random_living_identifier = random_living_player["player_identifier"]
+ attacker = connection.execute("SELECT player_identifier FROM player WHERE target_identifier = ?", (random_living_identifier,)).fetchone()
+ if attacker:
+ connection.execute("UPDATE player SET target_identifier = ? WHERE player_identifier = ?", (target_identifier, attacker["player_identifier"]))
+ connection.execute("UPDATE player SET is_alive = 1, late_joiner = 0, target_identifier = ? WHERE player_identifier = ?", (random_living_identifier, target_identifier))
+ else:
+ connection.execute("UPDATE player SET is_alive = 1, late_joiner = 0, target_identifier = NULL WHERE player_identifier = ?", (target_identifier,))
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "eliminate_player":
+ target_identifier = form_data.get("player_identifier")
+ victim = connection.execute("SELECT * FROM player WHERE player_identifier = ?", (target_identifier,)).fetchone()
+ if victim and victim["is_alive"] == 1:
+ attacker = connection.execute("SELECT player_identifier FROM player WHERE target_identifier = ?", (target_identifier,)).fetchone()
+ new_target = victim["target_identifier"]
+ connection.execute("UPDATE player SET is_alive = 0, target_identifier = NULL, pending_attacker_identifier = NULL WHERE player_identifier = ?", (target_identifier,))
+
+ attacker_identifier = None
+ if attacker:
+ attacker_identifier = attacker["player_identifier"]
+ connection.execute("UPDATE player SET target_identifier = ? WHERE player_identifier = ?", (new_target, attacker_identifier))
+
+ timestamp = datetime.now(european_timezone).isoformat()
+ connection.execute("INSERT INTO kill_log (killer_identifier, victim_identifier, victim_previous_target, kill_timestamp) VALUES (?, ?, ?, ?)", (attacker_identifier, target_identifier, new_target, timestamp))
+
+ alive_count_record = connection.execute("SELECT COUNT(*) as alive_count FROM player WHERE is_alive = 1 AND is_approved = 1").fetchone()
+ if alive_count_record["alive_count"] <= 1:
+ status_record = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()
+ current_status = status_record["configuration_value"] if status_record else "unknown"
+ connection.execute("UPDATE game_configuration SET configuration_value = 'finished' WHERE configuration_key = 'game_status'")
+
+ if alive_count_record["alive_count"] == 1 and current_status != 'finished':
+ connection.execute("UPDATE player SET score = score + 3 WHERE is_alive = 1 AND is_approved = 1")
+
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "edit_kill":
+ kill_identifier = form_data.get("kill_identifier")
+ new_killer_identifier = form_data.get("new_killer_identifier")
+ if new_killer_identifier == "":
+ new_killer_identifier = None
+ new_timestamp = form_data.get("kill_timestamp")
+
+ old_kill = connection.execute("SELECT * FROM kill_log WHERE kill_identifier = ?", (kill_identifier,)).fetchone()
+ if old_kill:
+ old_killer_identifier = old_kill["killer_identifier"]
+ if old_killer_identifier != new_killer_identifier:
+ if old_killer_identifier:
+ connection.execute("UPDATE player SET score = MAX(0, score - 1) WHERE player_identifier = ?", (old_killer_identifier,))
+ if new_killer_identifier:
+ connection.execute("UPDATE player SET score = score + 1 WHERE player_identifier = ?", (new_killer_identifier,))
+
+ connection.execute("UPDATE kill_log SET killer_identifier = ?, kill_timestamp = ? WHERE kill_identifier = ?", (new_killer_identifier, new_timestamp, kill_identifier))
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "revoke_kill":
+ kill_identifier = form_data.get("kill_identifier")
+ kill_record = connection.execute("SELECT * FROM kill_log WHERE kill_identifier = ?", (kill_identifier,)).fetchone()
+
+ if kill_record:
+ if kill_record["killer_identifier"]:
+ connection.execute("UPDATE player SET score = MAX(0, score - 1) WHERE player_identifier = ?", (kill_record["killer_identifier"],))
+
+ victim_identifier = kill_record["victim_identifier"]
+ target_to_restore = kill_record["victim_previous_target"]
+
+ player_pointing_to_target = connection.execute("SELECT player_identifier FROM player WHERE target_identifier = ?", (target_to_restore,)).fetchone()
+
+ connection.execute("UPDATE player SET is_alive = 1, target_identifier = ? WHERE player_identifier = ?", (target_to_restore, victim_identifier))
+ if player_pointing_to_target:
+ connection.execute("UPDATE player SET target_identifier = ? WHERE player_identifier = ?", (victim_identifier, player_pointing_to_target["player_identifier"]))
+
+ connection.execute("DELETE FROM kill_log WHERE kill_identifier = ?", (kill_identifier,))
+
+ alive_count = connection.execute("SELECT COUNT(*) as alive_count FROM player WHERE is_alive = 1 AND is_approved = 1").fetchone()["alive_count"]
+ if alive_count > 1:
+ status = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()["configuration_value"]
+ if status == 'finished':
+ connection.execute("UPDATE game_configuration SET configuration_value = 'running' WHERE configuration_key = 'game_status'")
+ connection.execute("UPDATE player SET score = MAX(0, score - 3) WHERE is_alive = 1 AND is_approved = 1 AND player_identifier != ?", (victim_identifier,))
+
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "change_target":
+ player_identifier = form_data.get("player_identifier")
+ new_target_identifier = form_data.get("new_target_identifier")
+ if player_identifier and new_target_identifier and player_identifier != new_target_identifier:
+ player_record = connection.execute("SELECT target_identifier FROM player WHERE player_identifier = ?", (player_identifier,)).fetchone()
+ other_player_record = connection.execute("SELECT player_identifier FROM player WHERE target_identifier = ?", (new_target_identifier,)).fetchone()
+ if player_record and other_player_record:
+ connection.execute("UPDATE player SET target_identifier = ? WHERE player_identifier = ?", (new_target_identifier, player_identifier))
+ connection.execute("UPDATE player SET target_identifier = ? WHERE player_identifier = ?", (player_record["target_identifier"], other_player_record["player_identifier"]))
+ connection.commit()
+ has_game_state_changed = True
+
+ elif action_type == "generate_reset":
+ target_identifier = form_data.get("player_identifier")
+ reset_token = secrets.token_urlsafe(32)
+ connection.execute("UPDATE player SET reset_token = ? WHERE player_identifier = ?", (reset_token, target_identifier))
+ connection.commit()
+
+ elif action_type == "update_credentials":
+ new_password = form_data.get("password_string")
+ password_confirmation = form_data.get("password_string_confirmation")
+ if new_password and new_password == password_confirmation:
+ hashed_password = argon2.hash(new_password)
+ connection.execute("UPDATE administrator SET password_hash = ? WHERE administrator_identifier = 1", (hashed_password,))
+ connection.commit()
+
+ elif action_type == "enable_two_factor":
+ two_factor_input = form_data.get("two_factor_code")
+ administrator_record = connection.execute("SELECT two_factor_secret FROM administrator WHERE administrator_identifier = 1").fetchone()
+ time_based_one_time_password_validator = pyotp.TOTP(administrator_record["two_factor_secret"])
+ if time_based_one_time_password_validator.verify(two_factor_input):
+ connection.execute("UPDATE administrator SET two_factor_enabled = 1 WHERE administrator_identifier = 1")
+ connection.commit()
+
+ elif action_type == "disable_two_factor":
+ connection.execute("UPDATE administrator SET two_factor_enabled = 0 WHERE administrator_identifier = 1")
+ connection.commit()
+
+ connection.close()
+
+ if has_game_state_changed:
+ websocket_manager.emit("game_state_changed")
+ else:
+ websocket_manager.emit("admin_state_changed", to="administrator_room")
+
+def scheduled_action_wrapper(action_type, form_data):
+ with application.app_context():
+ process_administrator_action(action_type, form_data)
+
+@application.route("/service_worker.js")
+def serve_service_worker():
+ return send_from_directory(application.static_folder, "service_worker.js")
+
+@application.route("/")
+@require_player_authentication
+def handle_index():
+ connection = get_database_connection()
+ player = connection.execute("SELECT * FROM player WHERE player_identifier = ?", (session["player_identifier"],)).fetchone()
+ if not player:
+ session.clear()
+ return redirect(url_for("handle_login"))
+ status_row = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()
+ game_status = status_row["configuration_value"]
+ time_row = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'start_time'").fetchone()
+ start_time = time_row["configuration_value"]
+
+ target_player = None
+ if player["target_identifier"]:
+ target_player = connection.execute("SELECT display_name FROM player WHERE player_identifier = ?", (player["target_identifier"],)).fetchone()
+
+ pending_attacker_name = None
+ if player["pending_attacker_identifier"]:
+ attacker = connection.execute("SELECT display_name FROM player WHERE player_identifier = ?", (player["pending_attacker_identifier"],)).fetchone()
+ if attacker:
+ pending_attacker_name = attacker["display_name"]
+
+ all_players = connection.execute("SELECT * FROM player WHERE is_approved = 1 ORDER BY score DESC").fetchall()
+ total_eliminations = sum(player_record["score"] for player_record in all_players)
+ alive_count = sum(1 for player_record in all_players if player_record["is_alive"] == 1)
+ connection.close()
+
+ document_markup = get_markdown_rules()
+
+ return render_template(
+ "game.html",
+ player=player,
+ game_status=game_status,
+ start_time=start_time,
+ target_player=target_player,
+ pending_attacker_name=pending_attacker_name,
+ all_players=all_players,
+ total_eliminations=total_eliminations,
+ alive_count=alive_count,
+ rules_markup=document_markup
+ )
+
+@application.route("/register", methods=["GET", "POST"])
+def handle_registration():
+ if request.method == "POST":
+ display_name = request.form.get("display_name")
+ password_input = request.form.get("password_string")
+ password_confirmation = request.form.get("password_string_confirmation")
+ if password_input and password_input == password_confirmation:
+ new_identifier = ''.join(secrets.choice(string.ascii_uppercase) for _ in range(IDENTIFIER_LENGTH))
+ hashed_password = argon2.hash(password_input)
+ connection = get_database_connection()
+ connection.execute("INSERT INTO player (player_identifier, password_hash, display_name, is_approved) VALUES (?, ?, ?, 0)", (new_identifier, hashed_password, display_name))
+ connection.commit()
+ connection.close()
+ websocket_manager.emit("admin_state_changed", to="administrator_room")
+ return render_template("login.html", identifier_length=IDENTIFIER_LENGTH, registration_success=True, new_identifier=new_identifier)
+ return render_template("login.html", identifier_length=IDENTIFIER_LENGTH, register_mode=True)
+
+@application.route("/login", methods=["GET", "POST"])
+def handle_login():
+ if request.method == "POST":
+ identifier_input = request.form.get("player_identifier", "").strip().upper()
+ password_input = request.form.get("password_string", "")
+ connection = get_database_connection()
+ player = connection.execute("SELECT password_hash FROM player WHERE player_identifier = ?", (identifier_input,)).fetchone()
+ connection.close()
+ if player and argon2.verify(password_input, player["password_hash"]):
+ session["player_identifier"] = identifier_input
+ return redirect(url_for("handle_index"))
+ return render_template("login.html", identifier_length=IDENTIFIER_LENGTH)
+
+@application.route("/reset_password", methods=["GET", "POST"])
+def handle_password_reset():
+ token = request.args.get("token") or request.form.get("token")
+ if not token:
+ abort(400)
+ connection = get_database_connection()
+ player = connection.execute("SELECT player_identifier FROM player WHERE reset_token = ?", (token,)).fetchone()
+ if not player:
+ connection.close()
+ abort(403)
+ if request.method == "POST":
+ new_password = request.form.get("password_string")
+ password_confirmation = request.form.get("password_string_confirmation")
+ if new_password and new_password == password_confirmation:
+ hashed_password = argon2.hash(new_password)
+ connection.execute("UPDATE player SET password_hash = ?, reset_token = NULL WHERE reset_token = ?", (hashed_password, token))
+ connection.commit()
+ connection.close()
+ return redirect(url_for("handle_login"))
+ connection.close()
+ return render_template("login.html", identifier_length=IDENTIFIER_LENGTH, reset_mode=True, token=token)
+
+@application.route("/player/action", methods=["POST"])
+@require_player_authentication
+def handle_player_action():
+ action_type = request.form.get("action_type")
+ connection = get_database_connection()
+ if action_type == "request_name_change":
+ pending_name = request.form.get("pending_display_name")
+ connection.execute("UPDATE player SET pending_display_name = ? WHERE player_identifier = ?", (pending_name, session["player_identifier"]))
+ connection.commit()
+ websocket_manager.emit("admin_state_changed", to="administrator_room")
+ connection.close()
+ return redirect(url_for("handle_index"))
+
+@application.route("/logout")
+def handle_logout():
+ session.clear()
+ return redirect(url_for("handle_login"))
+
+@application.route("/admin", methods=["GET", "POST"])
+def handle_administrator():
+ connection = get_database_connection()
+ administrator_record = connection.execute("SELECT * FROM administrator WHERE administrator_identifier = 1").fetchone()
+
+ if not administrator_record:
+ if request.method == "POST":
+ password_input = request.form.get("password_string")
+ password_confirmation = request.form.get("password_string_confirmation")
+ if password_input and password_input == password_confirmation:
+ hashed_password = argon2.hash(password_input)
+ two_factor_secret = pyotp.random_base32()
+ connection.execute("INSERT INTO administrator (administrator_identifier, password_hash, two_factor_secret, two_factor_enabled) VALUES (1, ?, ?, 0)", (hashed_password, two_factor_secret))
+ connection.commit()
+ session["administrator_authenticated"] = True
+ connection.close()
+ return redirect(url_for("handle_administrator"))
+ connection.close()
+ return render_template("administrator.html", setup_required=True)
+
+ if not session.get("administrator_authenticated"):
+ if request.method == "POST":
+ password_input = request.form.get("password_string")
+ if argon2.verify(password_input, administrator_record["password_hash"]):
+ if administrator_record["two_factor_enabled"]:
+ two_factor_input = request.form.get("two_factor_code")
+ time_based_one_time_password_validator = pyotp.TOTP(administrator_record["two_factor_secret"])
+ if time_based_one_time_password_validator.verify(two_factor_input):
+ session["administrator_authenticated"] = True
+ connection.close()
+ return redirect(url_for("handle_administrator"))
+ else:
+ session["administrator_authenticated"] = True
+ connection.close()
+ return redirect(url_for("handle_administrator"))
+ connection.close()
+ return render_template("administrator.html", login_required=True, two_factor_enabled=bool(administrator_record["two_factor_enabled"]))
+
+ status_row = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()
+ game_status = status_row["configuration_value"]
+ all_players = connection.execute("SELECT * FROM player ORDER BY is_approved ASC, display_name ASC").fetchall()
+ alive_count_record = connection.execute("SELECT COUNT(*) as alive_count FROM player WHERE is_approved = 1").fetchone()
+ can_start = alive_count_record["alive_count"] >= 2
+
+ kill_logs = connection.execute("""
+ SELECT k.*,
+ killer.display_name as killer_name,
+ victim.display_name as victim_name
+ FROM kill_log k
+ LEFT JOIN player killer ON k.killer_identifier = killer.player_identifier
+ LEFT JOIN player victim ON k.victim_identifier = victim.player_identifier
+ ORDER BY k.kill_timestamp DESC
+ """).fetchall()
+
+ two_factor_uri = pyotp.totp.TOTP(administrator_record["two_factor_secret"]).provisioning_uri(name="Administrator", issuer_name="AssassinGame")
+ qr_factory = qrcode.image.svg.SvgPathImage
+ qr_image = qrcode.make(two_factor_uri, image_factory=qr_factory)
+ qr_stream = io.BytesIO()
+ qr_image.save(qr_stream)
+ qr_code_svg = qr_stream.getvalue().decode("utf-8")
+
+ connection.close()
+
+ return render_template(
+ "administrator.html",
+ dashboard_active=True,
+ game_status=game_status,
+ can_start=can_start,
+ all_players=all_players,
+ kill_logs=kill_logs,
+ two_factor_secret=administrator_record["two_factor_secret"],
+ two_factor_enabled=bool(administrator_record["two_factor_enabled"]),
+ qr_code_svg=qr_code_svg
+ )
+
+@application.route("/admin/action", methods=["POST"])
+@require_administrator_authentication
+def handle_administrator_action():
+ form_data = request.form.to_dict()
+ action_type = form_data.get("action_type")
+ execution_time_string = form_data.get("execution_time")
+
+ if execution_time_string:
+ execution_datetime = datetime.strptime(execution_time_string[:16], "%Y-%m-%dT%H:%M").replace(tzinfo=european_timezone)
+ scheduler_instance.add_job(scheduled_action_wrapper, DateTrigger(run_date=execution_datetime), args=[action_type, form_data])
+ else:
+ process_administrator_action(action_type, form_data)
+
+ return redirect(url_for("handle_administrator"))
+
+@websocket_manager.on("connect")
+def handle_websocket_connection():
+ if "player_identifier" in session:
+ join_room(session["player_identifier"])
+ if session.get("administrator_authenticated"):
+ join_room("administrator_room")
+
+@websocket_manager.on("request_elimination")
+def handle_elimination_request():
+ attacker_identifier = session.get("player_identifier")
+ if not attacker_identifier:
+ return
+ connection = get_database_connection()
+ attacker = connection.execute("SELECT target_identifier FROM player WHERE player_identifier = ?", (attacker_identifier,)).fetchone()
+ if attacker and attacker["target_identifier"]:
+ victim_identifier = attacker["target_identifier"]
+ connection.execute("UPDATE player SET pending_attacker_identifier = ? WHERE player_identifier = ?", (attacker_identifier, victim_identifier))
+ connection.commit()
+ emit("elimination_verification_requested", to=victim_identifier)
+ connection.close()
+
+@websocket_manager.on("confirm_elimination")
+def handle_elimination_confirmation():
+ victim_identifier = session.get("player_identifier")
+ if not victim_identifier:
+ return
+ connection = get_database_connection()
+ victim = connection.execute("SELECT pending_attacker_identifier, target_identifier FROM player WHERE player_identifier = ?", (victim_identifier,)).fetchone()
+ if victim and victim["pending_attacker_identifier"]:
+ attacker_identifier = victim["pending_attacker_identifier"]
+ new_target = victim["target_identifier"]
+
+ connection.execute("UPDATE player SET is_alive = 0, target_identifier = NULL, pending_attacker_identifier = NULL WHERE player_identifier = ?", (victim_identifier,))
+ connection.execute("UPDATE player SET target_identifier = ?, score = score + 1 WHERE player_identifier = ?", (new_target, attacker_identifier))
+
+ timestamp = datetime.now(european_timezone).isoformat()
+ connection.execute("INSERT INTO kill_log (killer_identifier, victim_identifier, victim_previous_target, kill_timestamp) VALUES (?, ?, ?, ?)", (attacker_identifier, victim_identifier, new_target, timestamp))
+
+ alive_count_record = connection.execute("SELECT COUNT(*) as alive_count FROM player WHERE is_alive = 1 AND is_approved = 1").fetchone()
+ if alive_count_record["alive_count"] <= 1:
+ status_record = connection.execute("SELECT configuration_value FROM game_configuration WHERE configuration_key = 'game_status'").fetchone()
+ current_status = status_record["configuration_value"] if status_record else "unknown"
+ connection.execute("UPDATE game_configuration SET configuration_value = 'finished' WHERE configuration_key = 'game_status'")
+
+ if alive_count_record["alive_count"] == 1 and current_status != 'finished':
+ connection.execute("UPDATE player SET score = score + 3 WHERE is_alive = 1 AND is_approved = 1")
+
+ connection.commit()
+ websocket_manager.emit("game_state_changed")
+ connection.close()
+
+@websocket_manager.on("reject_elimination")
+def handle_elimination_rejection():
+ victim_identifier = session.get("player_identifier")
+ if not victim_identifier:
+ return
+ connection = get_database_connection()
+ victim = connection.execute("SELECT pending_attacker_identifier FROM player WHERE player_identifier = ?", (victim_identifier,)).fetchone()
+ if victim and victim["pending_attacker_identifier"]:
+ attacker_identifier = victim["pending_attacker_identifier"]
+ connection.execute("UPDATE player SET pending_attacker_identifier = NULL WHERE player_identifier = ?", (victim_identifier,))
+ connection.commit()
+ emit("elimination_rejected", to=attacker_identifier)
+ connection.close()
+
+if __name__ == "__main__":
+ initialize_database()
+ scheduler_instance.start()
+ websocket_manager.run(application, host="0.0.0.0", port=5000)
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..094e08a
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+flask
+flask-socketio
+passlib
+argon2-cffi
+pyotp
+apscheduler
+qrcode
+markdown
\ No newline at end of file
diff --git a/rules.md b/rules.md
new file mode 100644
index 0000000..f7eb5ff
--- /dev/null
+++ b/rules.md
@@ -0,0 +1,91 @@
+# *SCO*icide Squad
+_Ein Spiel am Science College Overbach_
+
+## Dein Ziel
+_Du hast immer genau eine Person zu erwischen._
+
+- Auf deinem Dashboard steht dein aktuelles Ziel. Es ist immer genau eine Person.
+
+- Gleichzeitig jagt dich jemand. Wer das ist, erfÀhrst du erst, wenn es passiert.
+
+- Erwischst du dein Ziel, ĂŒbernimmst du automatisch dessen Ziel. Die Kette bleibt geschlossen.
+
+- Ăber dein Ziel wird nicht geredet. Wer sein Ziel verrĂ€t oder gemeinsam mit anderen Ziele abgleicht, nimmt allen den SpaĂ.
+
+## Wie ein Kill zÀhlt
+_Ăbergeben eines Gegenstandes, direkt und ungesehen_
+
+- Du killst dein Ziel, indem du ihm einen beliebigen Gegenstand ĂŒbergibst.
+
+- Das Ziel muss den Gegenstand direkt in die Hand gegeben bekommen. Unvorbereitetes Abwerfen gilt nicht. Im Zweifel gilt sonst der Kill nicht. Achtet auf Fairness!
+
+- Sind andere lebende Personen unmittelbar dabei und sehen den Mord, zÀhlt der Versuch nicht.
+
+- Wehrt sich dein Ziel erfolgreich, hast du Pech gehabt. Ein weiterer Versuch ist jederzeit erlaubt.
+
+## Melden
+_Der TÀter meldet, das Opfer bestÀtigt._
+
+- Wer sein Ziel erwischt, meldet das selbst unter dem Reiter „Ziel“ in der App.
+
+- Die Meldung wird geprĂŒft: Das Ziel bestĂ€tigt, ob du es erwischt hast.
+
+- Melde zĂŒgig: Bis zur Meldung lĂ€uft die Mordkette unverĂ€ndert weiter.
+
+- Stimmt etwas nicht, korrigiert die Spielleitung die Meldung.
+
+## Punkte
+_Jeder Kill zÀhlt, gekillt werden kostet keine Punkte._
+
+- FĂŒr jeden Kill gibt es einen Punkt. Deine Punkte bleiben dir auch, wenn du gekillt wirst.
+
+- Die letzte ĂŒberlebende Person bekommt zusĂ€tzlich drei Punkte fĂŒr's Ăberleben.
+
+- Der Punktestand ist fĂŒr alle einsehbar und aktualisiert sich nach jeder Meldung.
+
+## GeschĂŒtzte Orte und Zeiten
+_Es gibt Bereiche, in denen nicht gespielt wird._
+
+- GeschĂŒtzt sind nur Zimmernachbarn in ihren jeweiligen Zimmern. Dein Zimmernachbar darf dich nicht umbringen, wenn ihr alleine im Zimmer seid.
+
+- Wer krank ist oder eine Pause braucht, sagt bescheid. Das wird respektiert.
+
+## Was nicht geht
+_Keine Gewalt, kein Zwang, kein fremdes Eigentum_
+
+- Kein körperliche Gewalt und kein Festhalten.
+
+- Achtet fremdes Eigentum und die PrivatsphÀre anderer.
+
+- Nichts beschÀdigen und niemanden in eine unangenehme Lage bringen.
+
+- Ein Nein ist ein Nein, auch mitten im Spiel.
+
+## Ablauf
+_Das Spiel lĂ€uft ĂŒber mehrere Tage bis nur noch eine Person ĂŒbrig ist._
+
+- Das Spiel startet, sobald die Spielleitung auslost.
+
+- Die Spielleitung kann jederzeit pausieren. Dann sind keine Eliminierungen gĂŒltig.
+
+- Das Spiel endet, wenn nur noch eine Person ĂŒbrig ist oder die Spielleitung es beendet.
+
+## Dein Zugang
+_FĂŒnfstellige ID und dein Passwort bringen dich immer zurĂŒck._
+
+- Deine ID steht oben. Sie Àndert sich nie.
+
+- Mit ID und Passwort kommst du auf jedem GerÀt wieder in deinen Account.
+
+- Auf diesem GerÀt bleibst du angemeldet, bist du dich abmeldest.
+
+- Passwort vergessen? Die Spielleitung kann dir einen Link zum ZurĂŒcksetzen geben.
+
+## Spielleitung
+_Bei Streit entscheidet die Spielleitung._
+
+- Die Spielleitung sieht die vollstÀndige Kette und alle Meldungen.
+
+- Die Spielleitung kann ZugÀnge und das Spiel verwalten.
+
+- Bei Unklarheiten entscheidet die Spielleitung.
\ No newline at end of file
diff --git a/static/behavior.js b/static/behavior.js
new file mode 100644
index 0000000..36ccec9
--- /dev/null
+++ b/static/behavior.js
@@ -0,0 +1,335 @@
+let websocket_connection;
+let pending_administrator_form = null;
+let stopwatch_interval_identifier = null;
+
+function initialize_lucide_icons() {
+ if (typeof lucide !== "undefined") {
+ lucide.createIcons();
+ }
+}
+
+function update_game_stopwatch() {
+ const stopwatch_element = document.getElementById("game-stopwatch");
+ if (!stopwatch_element) {
+ return;
+ }
+
+ const start_time_string = stopwatch_element.getAttribute("data-start-time");
+ if (!start_time_string) {
+ return;
+ }
+
+ const start_date = new Date(start_time_string);
+ if (isNaN(start_date.getTime())) {
+ return;
+ }
+
+ const current_date = new Date();
+ const difference_in_milliseconds = current_date.getTime() - start_date.getTime();
+
+ if (difference_in_milliseconds < 0) {
+ stopwatch_element.textContent = "00:00 Minuten";
+ return;
+ }
+
+ const total_seconds = Math.floor(difference_in_milliseconds / 1000);
+ const days_passed = Math.floor(total_seconds / 86400);
+ const hours_passed = Math.floor((total_seconds % 86400) / 3600);
+ const minutes_passed = Math.floor((total_seconds % 3600) / 60);
+ const seconds_passed = total_seconds % 60;
+
+ let output_string = "";
+
+ if (days_passed === 1) {
+ output_string += "1 Tag und ";
+ } else if (days_passed > 1) {
+ output_string += days_passed + " Tage und ";
+ }
+
+ const padded_minutes = String(minutes_passed).padStart(2, "0");
+ const padded_seconds = String(seconds_passed).padStart(2, "0");
+
+ if (hours_passed === 1) {
+ output_string += "1:" + padded_minutes + ":" + padded_seconds + " Stunde";
+ } else if (hours_passed > 1) {
+ output_string += hours_passed + ":" + padded_minutes + ":" + padded_seconds + " Stunden";
+ } else {
+ output_string += padded_minutes + ":" + padded_seconds + " Minuten";
+ }
+
+ stopwatch_element.textContent = output_string;
+}
+
+function initialize_application_behavior() {
+ initialize_lucide_icons();
+
+ if (stopwatch_interval_identifier) {
+ clearInterval(stopwatch_interval_identifier);
+ }
+ stopwatch_interval_identifier = setInterval(update_game_stopwatch, 1000);
+ update_game_stopwatch();
+
+ const tab_buttons = document.querySelectorAll(".tab-button");
+ tab_buttons.forEach(function(current_button) {
+ current_button.addEventListener("click", function() {
+ const target_identifier = current_button.getAttribute("data-target");
+ const parent_container = current_button.closest(".application-content");
+
+ parent_container.querySelectorAll(".tab-button").forEach(function(button_element) {
+ button_element.classList.remove("active-tab");
+ });
+ parent_container.querySelectorAll(".tab-content").forEach(function(content_element) {
+ content_element.classList.remove("active-content");
+ });
+
+ current_button.classList.add("active-tab");
+ const target_panel = document.getElementById(target_identifier);
+ if (target_panel) {
+ target_panel.classList.add("active-content");
+ }
+ });
+ });
+
+ const player_forms = document.querySelectorAll(".asynchronous-form");
+ player_forms.forEach(function(current_form) {
+ current_form.addEventListener("submit", function(form_submission_event) {
+ form_submission_event.preventDefault();
+ execute_form_submission(current_form);
+ });
+ });
+
+ const administrator_forms = document.querySelectorAll(".admin-asynchronous-form");
+ administrator_forms.forEach(function(current_form) {
+ current_form.addEventListener("submit", function(form_submission_event) {
+ form_submission_event.preventDefault();
+ pending_administrator_form = current_form;
+ const administrator_modal = document.getElementById("admin-confirmation-modal");
+ if (administrator_modal) {
+ const time_input = document.getElementById("modal_execution_time");
+ if (time_input) {
+ time_input.value = "";
+ }
+ administrator_modal.classList.add("active-modal");
+ }
+ });
+ });
+
+ const confirm_administrator_button = document.getElementById("confirm-admin-action");
+ if (confirm_administrator_button) {
+ confirm_administrator_button.addEventListener("click", function() {
+ if (pending_administrator_form) {
+ const time_input = document.getElementById("modal_execution_time");
+ if (time_input && time_input.value) {
+ let hidden_time_input = pending_administrator_form.querySelector("input[name='execution_time']");
+ if (!hidden_time_input) {
+ hidden_time_input = document.createElement("input");
+ hidden_time_input.type = "hidden";
+ hidden_time_input.name = "execution_time";
+ pending_administrator_form.appendChild(hidden_time_input);
+ }
+ hidden_time_input.value = time_input.value;
+ } else {
+ let hidden_time_input = pending_administrator_form.querySelector("input[name='execution_time']");
+ if (hidden_time_input) {
+ hidden_time_input.remove();
+ }
+ }
+ execute_form_submission(pending_administrator_form);
+ pending_administrator_form = null;
+ const administrator_modal = document.getElementById("admin-confirmation-modal");
+ if (administrator_modal) {
+ administrator_modal.classList.remove("active-modal");
+ }
+ }
+ });
+ }
+
+ const cancel_administrator_button = document.getElementById("cancel-admin-action");
+ if (cancel_administrator_button) {
+ cancel_administrator_button.addEventListener("click", function() {
+ pending_administrator_form = null;
+ const administrator_modal = document.getElementById("admin-confirmation-modal");
+ if (administrator_modal) {
+ administrator_modal.classList.remove("active-modal");
+ }
+ });
+ }
+
+ const trigger_elimination_button = document.getElementById("trigger-elimination");
+ if (trigger_elimination_button) {
+ trigger_elimination_button.addEventListener("click", function() {
+ const elimination_modal = document.getElementById("elimination-confirm-modal");
+ if (elimination_modal) {
+ elimination_modal.classList.add("active-modal");
+ }
+ });
+ }
+
+ const proceed_elimination_button = document.getElementById("proceed-elimination");
+ if (proceed_elimination_button) {
+ proceed_elimination_button.addEventListener("click", function() {
+ websocket_connection.emit("request_elimination");
+ const elimination_modal = document.getElementById("elimination-confirm-modal");
+ if (elimination_modal) {
+ elimination_modal.classList.remove("active-modal");
+ }
+ const waiting_modal = document.getElementById("waiting-modal");
+ if (waiting_modal) {
+ waiting_modal.classList.add("active-modal");
+ }
+ });
+ }
+
+ const cancel_elimination_button = document.getElementById("cancel-elimination");
+ if (cancel_elimination_button) {
+ cancel_elimination_button.addEventListener("click", function() {
+ const elimination_modal = document.getElementById("elimination-confirm-modal");
+ if (elimination_modal) {
+ elimination_modal.classList.remove("active-modal");
+ }
+ });
+ }
+
+ const confirm_button = document.getElementById("confirm-elimination");
+ if (confirm_button) {
+ confirm_button.addEventListener("click", function() {
+ websocket_connection.emit("confirm_elimination");
+ const modal_element = document.getElementById("verification-modal");
+ if (modal_element) {
+ modal_element.classList.remove("active-modal");
+ }
+ });
+ }
+
+ const reject_button = document.getElementById("reject-elimination");
+ if (reject_button) {
+ reject_button.addEventListener("click", function() {
+ websocket_connection.emit("reject_elimination");
+ const modal_element = document.getElementById("verification-modal");
+ if (modal_element) {
+ modal_element.classList.remove("active-modal");
+ }
+ });
+ }
+
+ const acknowledge_rejection_button = document.getElementById("acknowledge-rejection");
+ if (acknowledge_rejection_button) {
+ acknowledge_rejection_button.addEventListener("click", function() {
+ const rejection_modal = document.getElementById("rejection-modal");
+ if (rejection_modal) {
+ rejection_modal.classList.remove("active-modal");
+ }
+ });
+ }
+
+ const open_name_modal_button = document.getElementById("trigger-name-change-modal");
+ if (open_name_modal_button) {
+ open_name_modal_button.addEventListener("click", function() {
+ const name_modal = document.getElementById("name-change-modal");
+ if (name_modal) {
+ name_modal.classList.add("active-modal");
+ }
+ });
+ }
+
+ const close_name_modal_button = document.getElementById("cancel-name-change");
+ if (close_name_modal_button) {
+ close_name_modal_button.addEventListener("click", function() {
+ const name_modal = document.getElementById("name-change-modal");
+ if (name_modal) {
+ name_modal.classList.remove("active-modal");
+ }
+ });
+ }
+}
+
+function execute_form_submission(target_form) {
+ const active_tab_element = document.querySelector(".active-tab");
+ let active_target = "control-panel";
+ if (active_tab_element) {
+ active_target = active_tab_element.getAttribute("data-target");
+ }
+
+ fetch(target_form.action, {
+ method: target_form.method,
+ body: new FormData(target_form)
+ })
+ .then(function(network_response) { return network_response.text(); })
+ .then(function(markup_text) {
+ replace_main_content(markup_text);
+ const new_tab_button = document.querySelector(".tab-button[data-target='" + active_target + "']");
+ if (new_tab_button) {
+ new_tab_button.click();
+ }
+ });
+}
+
+function replace_main_content(markup_text) {
+ const document_parser = new DOMParser();
+ const parsed_document = document_parser.parseFromString(markup_text, "text/html");
+ const current_container = document.querySelector(".application-content");
+ const new_container = parsed_document.querySelector(".application-content");
+ if (current_container && new_container) {
+ current_container.innerHTML = new_container.innerHTML;
+ initialize_application_behavior();
+ }
+}
+
+document.addEventListener("DOMContentLoaded", function() {
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/service_worker.js");
+ }
+
+ if (typeof io !== "undefined") {
+ websocket_connection = io();
+
+ websocket_connection.on("game_state_changed", function() {
+ if (document.querySelector(".game-section") || document.querySelector(".administrator-section")) {
+ fetch(window.location.href)
+ .then(function(network_response) { return network_response.text(); })
+ .then(function(markup_text) {
+ replace_main_content(markup_text);
+ });
+ }
+ });
+
+ websocket_connection.on("admin_state_changed", function() {
+ if (document.querySelector(".administrator-section")) {
+ fetch(window.location.href)
+ .then(function(network_response) { return network_response.text(); })
+ .then(function(markup_text) {
+ replace_main_content(markup_text);
+ });
+ }
+ });
+
+ websocket_connection.on("elimination_verification_requested", function() {
+ const modal_element = document.getElementById("verification-modal");
+ if (modal_element) {
+ modal_element.classList.add("active-modal");
+ } else {
+ fetch(window.location.href)
+ .then(function(network_response) { return network_response.text(); })
+ .then(function(markup_text) {
+ replace_main_content(markup_text);
+ const new_modal_element = document.getElementById("verification-modal");
+ if (new_modal_element) {
+ new_modal_element.classList.add("active-modal");
+ }
+ });
+ }
+ });
+
+ websocket_connection.on("elimination_rejected", function() {
+ const waiting_modal = document.getElementById("waiting-modal");
+ if (waiting_modal) {
+ waiting_modal.classList.remove("active-modal");
+ }
+ const rejection_modal = document.getElementById("rejection-modal");
+ if (rejection_modal) {
+ rejection_modal.classList.add("active-modal");
+ }
+ });
+ }
+ initialize_application_behavior();
+});
\ No newline at end of file
diff --git a/static/logo.svg b/static/logo.svg
new file mode 100644
index 0000000..b2f2548
--- /dev/null
+++ b/static/logo.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/static/manifest.json b/static/manifest.json
new file mode 100644
index 0000000..eeff66f
--- /dev/null
+++ b/static/manifest.json
@@ -0,0 +1,15 @@
+{
+ "name": "SCOicide Squad",
+ "short_name": "SCOicide",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#f0f2f5",
+ "theme_color": "#1a1e29",
+ "icons": [
+ {
+ "src": "/static/logo.svg",
+ "sizes": "any",
+ "type": "image/svg+xml"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/static/service_worker.js b/static/service_worker.js
new file mode 100644
index 0000000..ccef124
--- /dev/null
+++ b/static/service_worker.js
@@ -0,0 +1,23 @@
+const cache_identifier = "scoicide-squad-cache-version-1";
+
+self.addEventListener("install", function(installation_event) {
+ installation_event.waitUntil(
+ caches.open(cache_identifier).then(function(cache_object) {
+ return cache_object.addAll([
+ "/",
+ "/static/stylesheet.css",
+ "/static/behavior.js",
+ "/static/logo.svg",
+ "/static/manifest.json"
+ ]);
+ })
+ );
+});
+
+self.addEventListener("fetch", function(network_fetch_event) {
+ network_fetch_event.respondWith(
+ caches.match(network_fetch_event.request).then(function(cached_response) {
+ return cached_response || fetch(network_fetch_event.request);
+ })
+ );
+});
\ No newline at end of file
diff --git a/static/stylesheet.css b/static/stylesheet.css
new file mode 100644
index 0000000..e66e0df
--- /dev/null
+++ b/static/stylesheet.css
@@ -0,0 +1,443 @@
+:root {
+ --lightness-primary: 0.35;
+ --chroma-primary: 0.15;
+ --hue-primary: 15;
+
+ --lightness-background: 0.9;
+ --chroma-background: 0.01;
+ --hue-background: var(--hue-primary);
+
+ --lightness-surface: 1;
+ --chroma-surface: 0;
+ --hue-surface: 0;
+
+ --lightness-text: 0.15;
+ --chroma-text: 0.05;
+ --hue-text: 250;
+
+ --lightness-success: 0.60;
+ --chroma-success: 0.18;
+ --hue-success: 140;
+
+ --lightness-danger: 0.50;
+ --chroma-danger: 0.22;
+ --hue-danger: 25;
+
+ --lightness-warning: 0.75;
+ --chroma-warning: 0.18;
+ --hue-warning: 70;
+
+ --dimension-multiplier: 1;
+ --spacing-small: calc(0.5rem * var(--dimension-multiplier));
+ --spacing-medium: calc(1rem * var(--dimension-multiplier));
+ --spacing-large: calc(1.5rem * var(--dimension-multiplier));
+
+ --border-radius-small: calc(0.5rem * var(--dimension-multiplier));
+ --border-radius-large: calc(1.25rem * var(--dimension-multiplier));
+ --border-radius-pill: calc(999rem * var(--dimension-multiplier));
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --lightness-background: 0.2;
+ --chroma-background: 0.02;
+
+ --lightness-surface: 0.18;
+ --chroma-surface: 0.02;
+
+ --lightness-text: 0.90;
+ --chroma-text: 0.02;
+
+ --lightness-primary: 0.65;
+ --chroma-primary: 0.20;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition-duration: 0s !important;
+ animation-duration: 0s !important;
+ }
+}
+
+body {
+ background-color: oklch(var(--lightness-background) var(--chroma-background) var(--hue-background));
+ color: oklch(var(--lightness-text) var(--chroma-text) var(--hue-text));
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: calc(1rem * var(--dimension-multiplier));
+ line-height: 1.6;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.application-header {
+ background-color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+ color: oklch(1 0 0);
+ padding: var(--spacing-medium) var(--spacing-large);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.brand-container {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-small);
+}
+
+.brand-logo {
+ width: calc(2rem * var(--dimension-multiplier));
+ height: calc(2rem * var(--dimension-multiplier));
+ color: currentColor;
+}
+
+.application-title {
+ margin: 0;
+ font-size: calc(1.25rem * var(--dimension-multiplier));
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.application-content {
+ flex: 1;
+ padding: var(--spacing-large);
+ width: 100%;
+ max-width: calc(48rem * var(--dimension-multiplier));
+ margin: 0 auto;
+ box-sizing: border-box;
+}
+
+.content-card {
+ background-color: oklch(var(--lightness-surface) var(--chroma-surface) var(--hue-surface));
+ padding: var(--spacing-large);
+ border-radius: var(--border-radius-large);
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
+ margin-bottom: var(--spacing-large);
+}
+
+.section-title {
+ margin-top: 0;
+ margin-bottom: var(--spacing-medium);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-small);
+}
+
+.card-subtitle {
+ margin-top: 0;
+ margin-bottom: var(--spacing-small);
+ opacity: 0.7;
+ text-transform: uppercase;
+ font-size: calc(0.875rem * var(--dimension-multiplier));
+ letter-spacing: 0.05em;
+}
+
+.input-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-medium);
+}
+
+.inline-form {
+ display: inline-block;
+}
+
+.flexible_form_container {
+ display: flex;
+ gap: calc(0.5rem * var(--dimension-multiplier));
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: calc(0.25rem * var(--dimension-multiplier));
+}
+
+.input-label {
+ font-weight: bold;
+ font-size: calc(0.875rem * var(--dimension-multiplier));
+}
+
+.text-input {
+ padding: var(--spacing-medium);
+ border: 2px solid oklch(0.5 0 0 / 0.2);
+ border-radius: var(--border-radius-small);
+ background-color: oklch(var(--lightness-background) var(--chroma-background) var(--hue-background));
+ color: inherit;
+ font-size: inherit;
+ transition: border-color 0.2s;
+}
+
+.text-input:focus {
+ outline: none;
+ border-color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+}
+
+.small-input {
+ width: calc(12rem * var(--dimension-multiplier));
+ padding: var(--spacing-small);
+}
+
+.time-input {
+ width: calc(13rem * var(--dimension-multiplier));
+}
+
+.action-button {
+ padding: var(--spacing-medium) var(--spacing-large);
+ border: none;
+ border-radius: var(--border-radius-pill);
+ font-size: inherit;
+ font-weight: bold;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-small);
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ color: oklch(1 0 0);
+}
+
+.action-button:hover, .action-button:focus {
+ filter: brightness(1.1);
+ transform: translateY(-1px);
+}
+
+.icon-button {
+ padding: calc(0.5rem * var(--dimension-multiplier));
+ border-radius: var(--border-radius-small);
+}
+
+.primary-button {
+ background-color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+ box-shadow: 0 4px 12px oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary) / 0.3);
+}
+
+.success-button {
+ background-color: oklch(var(--lightness-success) var(--chroma-success) var(--hue-success));
+}
+
+.danger-button {
+ background-color: oklch(var(--lightness-danger) var(--chroma-danger) var(--hue-danger));
+}
+
+.warning-button {
+ background-color: oklch(var(--lightness-warning) var(--chroma-warning) var(--hue-warning));
+ color: oklch(0.2 0 0);
+}
+
+.secondary-button {
+ background-color: transparent;
+ color: oklch(var(--lightness-text) var(--chroma-text) var(--hue-text));
+ border: 2px solid oklch(var(--lightness-text) var(--chroma-text) var(--hue-text) / 0.2);
+}
+
+.large-button {
+ padding: var(--spacing-large) calc(2.5rem * var(--dimension-multiplier));
+ font-size: calc(1.125rem * var(--dimension-multiplier));
+ width: 100%;
+}
+
+.small-button {
+ padding: calc(0.25rem * var(--dimension-multiplier)) calc(0.75rem * var(--dimension-multiplier));
+ font-size: calc(0.875rem * var(--dimension-multiplier));
+}
+
+.tab-container {
+ display: flex;
+ background-color: oklch(var(--lightness-surface) var(--chroma-surface) var(--hue-surface));
+ border-radius: var(--border-radius-pill);
+ padding: calc(0.375rem * var(--dimension-multiplier));
+ gap: calc(0.25rem * var(--dimension-multiplier));
+ box-shadow: 0 4px 12px rgba(0,0,0,0.05);
+ margin-bottom: var(--spacing-large);
+ overflow-x: auto;
+}
+
+.tab-button {
+ flex: 1;
+ border-radius: var(--border-radius-pill);
+ padding: calc(0.625rem * var(--dimension-multiplier)) var(--spacing-medium);
+ border: none;
+ background: transparent;
+ color: inherit;
+ font-size: inherit;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: calc(0.5rem * var(--dimension-multiplier));
+ white-space: nowrap;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.active-tab {
+ background-color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+ color: oklch(1 0 0);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+}
+
+.tab-content {
+ display: none;
+}
+
+.active-content {
+ display: block;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: calc(0.375rem * var(--dimension-multiplier));
+ padding: calc(0.25rem * var(--dimension-multiplier)) calc(0.875rem * var(--dimension-multiplier));
+ border-radius: var(--border-radius-pill);
+ font-size: calc(0.875rem * var(--dimension-multiplier));
+ font-weight: bold;
+ color: oklch(1 0 0);
+ margin-left: var(--spacing-small);
+}
+
+.warning-pill {
+ background-color: oklch(var(--lightness-warning) var(--chroma-warning) var(--hue-warning));
+ color: oklch(0.2 0 0);
+}
+
+.success-pill {
+ background-color: oklch(var(--lightness-success) var(--chroma-success) var(--hue-success));
+}
+
+.danger-pill {
+ background-color: oklch(var(--lightness-danger) var(--chroma-danger) var(--hue-danger));
+}
+
+.notice-card {
+ border-left: 6px solid oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+}
+
+.danger-card {
+ border-left-color: oklch(var(--lightness-danger) var(--chroma-danger) var(--hue-danger));
+}
+
+.success-card {
+ border-left-color: oklch(var(--lightness-success) var(--chroma-success) var(--hue-success));
+}
+
+.warning-card {
+ border-left-color: oklch(var(--lightness-warning) var(--chroma-warning) var(--hue-warning));
+}
+
+.target-name {
+ font-size: calc(2.5rem * var(--dimension-multiplier));
+ text-align: center;
+ margin: var(--spacing-large) 0;
+ color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+ font-weight: 800;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.data-table th, .data-table td {
+ padding: var(--spacing-medium);
+ border-bottom: 1px solid oklch(0.5 0 0 / 0.1);
+ text-align: left;
+}
+
+.data-table th {
+ font-weight: 800;
+ text-transform: uppercase;
+ font-size: calc(0.75rem * var(--dimension-multiplier));
+ letter-spacing: 0.05em;
+ opacity: 0.7;
+}
+
+.highlighted-row {
+ background-color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary) / 0.1);
+}
+
+.highlighted-row td {
+ font-weight: bold;
+}
+
+.unregistered-row {
+ opacity: 0.6;
+}
+
+.markdown-container h1, .markdown-container h2, .markdown-container h3 {
+ color: oklch(var(--lightness-primary) var(--chroma-primary) var(--hue-primary));
+}
+
+.markdown-container ul {
+ padding-left: var(--spacing-large);
+}
+
+.modal-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0,0,0,0.6);
+ backdrop-filter: blur(4px);
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.active-modal {
+ display: flex;
+}
+
+.modal-dialog {
+ background-color: oklch(var(--lightness-surface) var(--chroma-surface) var(--hue-surface));
+ padding: var(--spacing-large);
+ border-radius: var(--border-radius-large);
+ max-width: calc(32rem * var(--dimension-multiplier));
+ width: 90%;
+ box-shadow: 0 20px 40px rgba(0,0,0,0.2);
+}
+
+.modal-title {
+ margin-top: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-small);
+}
+
+.modal-actions {
+ display: flex;
+ gap: var(--spacing-medium);
+ justify-content: flex-end;
+ margin-top: var(--spacing-large);
+}
+
+.qr-code-container {
+ background-color: oklch(1 0 0);
+ padding: var(--spacing-medium);
+ border-radius: var(--border-radius-large);
+ display: inline-block;
+ margin-bottom: var(--spacing-medium);
+ box-shadow: inset 0 0 0 2px oklch(0.5 0 0 / 0.1);
+}
+
+.qr-code-container svg {
+ display: block;
+ width: calc(14rem * var(--dimension-multiplier));
+ height: calc(14rem * var(--dimension-multiplier));
+}
+
+.monospace-identifier {
+ font-family: monospace;
+}
\ No newline at end of file
diff --git a/templates/administrator.html b/templates/administrator.html
new file mode 100644
index 0000000..a6a9006
--- /dev/null
+++ b/templates/administrator.html
@@ -0,0 +1,333 @@
+{% extends "layout.html" %}
+{% block content %}
+
+ {% if setup_required %}
+
+
Administrator Einrichtung
+
+
+ {% elif login_required %}
+
+
Administrator Anmeldung
+
+
+ {% elif dashboard_active %}
+
+ Steuerung
+ Spieler
+ Kills
+
+
+
+
+
+ Spielstatus:
+ {% if game_status == 'pending' %}
+ Ausstehend
+ {% elif game_status == 'running' %}
+ LĂ€uft
+ {% elif game_status == 'paused' %}
+ Pausiert
+ {% elif game_status == 'finished' %}
+ Beendet
+ {% endif %}
+
+
+ {% if game_status == 'pending' %}
+ {% if can_start %}
+
+ {% else %}
+
+
Es werden mindestens zwei freigegebene Spieler benötigt, um das Spiel zu starten.
+
+ {% endif %}
+ {% elif game_status == 'running' %}
+
+
+ {% elif game_status == 'paused' %}
+
+
+ {% elif game_status == 'finished' %}
+
+ {% endif %}
+
+
+
+
Sicherheit
+
+ {% if two_factor_enabled %}
+
+
Zwei-Faktor-Authentifizierung ist aktiv.
+
+
+ {% else %}
+
+
Zwei-Faktor-Authentifizierung ist inaktiv. Bitte scanne den QR-Code und bestÀtige zur Aktivierung.
+
Manuelle Eingabe: {{ two_factor_secret }}
+
+ {{ qr_code_svg | safe }}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
Spieler manuell erstellen
+
+
+
+
+
+
+
+ Kennung
+ Name & Ănderung
+ Status
+ Ziel
+ Aktionen
+
+
+
+ {% for current_player in all_players %}
+
+
+ {{ current_player.player_identifier }}
+ {% if not current_player.is_approved %}
+ Wartet
+ {% elif current_player.late_joiner %}
+ SpÀteinstieg
+ {% endif %}
+
+
+
+ {% if current_player.pending_display_name %}
+
+
+ WĂŒnscht: {{ current_player.pending_display_name }}
+
+
+
+
+ {% endif %}
+
+
+ {% if not current_player.is_approved %}
+
+ {% else %}
+ {% if current_player.is_alive %}
+ Lebend
+
+ {% else %}
+ Tot
+
+ {% endif %}
+ {% endif %}
+
+
+ {% if current_player.target_identifier %}
+ {{ current_player.target_identifier }}
+
+ {% else %}
+ -
+ {% endif %}
+
+
+
+ {% if current_player.reset_token %}
+
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+ Zeitpunkt
+ Killer
+ Opfer
+ Aktionen
+
+
+
+ {% for kill in kill_logs %}
+
+
+
+
+
+ Keiner (Admin)
+ {% for p in all_players %}
+ {{ p.display_name }} ({{ p.player_identifier }})
+ {% endfor %}
+
+
+
+ {{ kill.victim_name or 'Unbekannt' }} ({{ kill.victim_identifier }})
+
+
+
+
+
+
+
+
+ {% else %}
+
+ Bisher keine Kills im System.
+
+ {% endfor %}
+
+
+
+
+
+
+
+
Aktion planen
+
FĂŒhre die Aktion sofort aus oder plane sie fĂŒr die Zukunft.
+
+ Zeitpunkt (Optional)
+
+
+
+ BestÀtigen
+ Abbrechen
+
+
+
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/game.html b/templates/game.html
new file mode 100644
index 0000000..52283e1
--- /dev/null
+++ b/templates/game.html
@@ -0,0 +1,187 @@
+{% extends "layout.html" %}
+{% block content %}
+
+
+
Willkommen, {{ player.display_name }} ({{ player.player_identifier }})!
+
+
+ {% if not player.is_approved %}
+
+
Konto wird geprĂŒft…
+
+ {% elif game_status == 'pending' %}
+
+
Startet bald…
+
+ {% elif game_status == 'finished' %}
+
+
Spiel beendet – Rangliste
+
+
+
+ Platz
+ Name
+ Punkte
+
+
+
+ {% for current_player in all_players %}
+
+ {{ loop.index }}
+ {{ current_player.display_name }}
+ {{ current_player.score }}
+
+ {% endfor %}
+
+
+
+ {% elif game_status in ['running', 'paused'] %}
+
+ Ziel
+ Punkte
+ Regeln
+
+
+
+ {% if game_status == 'paused' %}
+
+
Spiel pausiert
+
Das Spiel wurde pausiert. Eliminierungen sind aktuell nicht möglich.
+
+ {% endif %}
+
+ {% if player.is_alive %}
+
+
Dein Ziel
+ {{ target_player.display_name if target_player else 'Niemand' }}
+ {% if game_status == 'running' %}
+ Kill melden
+ {% else %}
+ Pausiert
+ {% endif %}
+
+ {% elif player.late_joiner %}
+
+
Warte auf Einstieg…
+
Du bist dem Spiel wÀhrend einer laufenden Runde beigetreten. Du wirst aktiv, sobald du eingewechselt wirst.
+
+ {% else %}
+
+
Eliminiert
+
+ {% endif %}
+
+
+
SpielĂŒbersicht
+
Ăberlebende: {{ alive_count }}
+
Deine Kills: {{ player.score }}
+
Gesamte Kills: {{ total_eliminations }}
+
Laufzeit: Wird berechnet...
+
+
+
+
Profil
+ {% if player.pending_display_name %}
+
+
Der Name {{ player.pending_display_name }} wird geprĂŒft…
+
+ {% else %}
+
Name Àndern
+ {% endif %}
+
+
+
+
+
+
+
+
+ Platz
+ Name
+ Punkte
+
+
+
+ {% for current_player in all_players %}
+
+ {{ loop.index }}
+ {{ current_player.display_name }}
+ {{ current_player.score }}
+
+ {% endfor %}
+
+
+
+
+
+
+
+ {{ rules_markup | safe }}
+
+
+
+
+
+
Kill
+
Möchtest du melden, dass du dein Ziel gekillt hast?
+
+ Ja, melden
+ Abbrechen
+
+
+
+
+
+
+
Bitte warten
+
Warte auf BestÀtigung vom Ziel...
+
+
+
+
+
+
Abgelehnt
+
Das Ziel hat deinen Kill abgelehnt.
+
+ Verstanden
+
+
+
+
+
+
+
Wurdest du eliminiert?
+
+ {% if pending_attacker_name %}
+ {{ pending_attacker_name }} hat gemeldet, dass er/sie dich gekillt hat.
+ {% else %}
+ Dein Angreifer hat gemeldet, dass er dich gekillt hat.
+ {% endif %}
+
+
+ BestÀtigen
+ Ablehnen
+
+
+
+
+
+
+
Name Àndern
+
Dein neuer Name muss erst geprĂŒft werden.
+
+
+
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..a28e45f
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ SCOicide Squad
+
+
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
\ No newline at end of file
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..af4cef1
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,72 @@
+{% extends "layout.html" %}
+{% block content %}
+
+ {% if reset_mode %}
+
+
Passwort zurĂŒcksetzen
+
+
+ {% elif register_mode %}
+
+
Neues Konto erstellen
+
+
+
+ {% elif registration_success %}
+
+
Registrierung erfolgreich
+
Deine ID fĂŒr die Anmeldung lautet:
+
{{ new_identifier }}
+
Bitte speichere oder merke sie dir!
+
+
+ {% else %}
+
+
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file