From a6761d172e0e2725c4c941eae18cd745249cea83 Mon Sep 17 00:00:00 2001 From: MeBeBruno Date: Sun, 9 Aug 2026 16:03:27 +0200 Subject: [PATCH] Initial Push --- README.md | 11 +- application.py | 636 +++++++++++++++++++++++++++++++++++ game_database.sqlite | Bin 0 -> 32768 bytes requirements.txt | 8 + rules.md | 91 +++++ static/behavior.js | 335 ++++++++++++++++++ static/logo.svg | 2 + static/manifest.json | 15 + static/service_worker.js | 23 ++ static/stylesheet.css | 443 ++++++++++++++++++++++++ templates/administrator.html | 333 ++++++++++++++++++ templates/game.html | 187 ++++++++++ templates/layout.html | 33 ++ templates/login.html | 72 ++++ 14 files changed, 2187 insertions(+), 2 deletions(-) create mode 100644 application.py create mode 100644 game_database.sqlite create mode 100644 requirements.txt create mode 100644 rules.md create mode 100644 static/behavior.js create mode 100644 static/logo.svg create mode 100644 static/manifest.json create mode 100644 static/service_worker.js create mode 100644 static/stylesheet.css create mode 100644 templates/administrator.html create mode 100644 templates/game.html create mode 100644 templates/layout.html create mode 100644 templates/login.html diff --git a/README.md b/README.md index 1d3c734..6109ea3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,9 @@ -# SCOicideSquad -A python Flask based role play game app đŸ”Ș +# 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/game_database.sqlite b/game_database.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..97c143a52bfcedd960d8e9d3322f4baee64bc0c2 GIT binary patch literal 32768 zcmeI*&u`jR8~|_|H~h$&c$dwpqDZca)K;Vk#y~=)XoJL%gkLc@q2%H@*nksbgKfwU zZIfuFO*`z;e`UA*2fOYcn6#dDn6~xO-L{7*Ee6_bQroH8_bFie`T6;MKkq@pGmtW$ zQ*DK=8)i$gX%_ns!*T3Wn#M3}3ElkYHcHpf%HTFi_-N%d%Oz~J_ss(NC+3^~7F+y_ z{O^cc_0*b3#hg{R}faF4p&TZtO9|1Fj1zb z)@-%)kIJ-7rKcKQOSC1kq1g0vWlnohb&6$6t+rF(MT744KcDkaw{PQ*)*TQ@ZmGIz z*`{P0rfb9NqH*n-@Nw-Mk6{Lso2D&U)|p|-VpFo3Ga|9i46!cNP}9&bsF^dP9V?^g zgK;Cj=%8&bojk}1bogcXk3J)O)QubX*Go?LZB4pR%;EhtSHQ!(iAaZ;;~@_8#|Of{ zi%(XqL6zcQxXuV)wM0o%d&*QmjQccJ%`m46h24dQ!)3DV?lPugDYj@Er;0wKIc-Ik zRlOlfwk_38uNu;W#<$z1(VNvF?$eGczJf!O(9^+*q3UCg!5Li~y5^-mxq)M>TF}u2sW^?9IR37qYjQj)L zK>`5~009sH0T2KI5C8!X009sH0T6gk1r~{$_{(Vm&mwUvFg6XaK=v_W@e(7yB0XpU z2?Rg@1V8`;KmY_l00ck)1VG?z3Vi8Vp!!8BfKwP2!2Ck4SSSb4$2$x?qRPSECX)!Z zHsi4v8(*Doc(yxjNT!xQ=`j6x?o+~c-{D^3_b>FDWvHfkr1;_HdXdODQdu+M^_ z(=A0Wum{zhjMWG+Y5C$oGrHA_73;afqLE?Sv19J!_@c=aHd5%4v%%+hUT^~J&k=)0 z+zy~d45D`ST+xWGE*CrXio!*A&(6&zYigBFf)8#V9Tu8dC6(!}Nx7O9H7lXy5u4+p z`)aJT&#tcvCy`clKV2^InkreVnyl6hRjxbj_~JQ!;2q)Tx65j^EVWva6EPZBbN%yS zfA|M)$$z)~F}VIukWVo33HdAe>}?Kms2c=800ck)1V8`;KmY_l00ck)1VG>|3IqrW zccvPM!J0GY;qenxz?tqG1Ogxc0w4eaAOHd&00JNY0w4eaAn-;7`aax? z^^1A`74LU2(xuMj(W$^UPEy6J95(gC?m8c|ItA`)VED#P#pdyJ7kkdNPJ zh)@{_fB*=900@8p2!H?xfB*=900@Ak?3lIjk0lec-Zjz|5J>7`kE%eE)W0#5C8!X009sH0T2KI W5C8!X0D<>R;5tq$ 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

+
+
+ + +
+ {% if two_factor_enabled %} +
+ + +
+ {% endif %} + +
+
+ {% elif dashboard_active %} +
+ + + +
+ +
+
+

+ 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

+
+ +
+ + +
+ +
+
+ +
+ + + + + + + + + + + + {% for current_player in all_players %} + + + + + + + + {% endfor %} + +
KennungName & ÄnderungStatusZielAktionen
+ {{ 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 %} +
+ + + +
+
+
+
+ +
+
+ + + + + + + + + + + {% for kill in kill_logs %} + + + + + + + {% else %} + + + + {% endfor %} + +
ZeitpunktKillerOpferAktionen
+
+ + + +
+ + + {{ kill.victim_name or 'Unbekannt' }} ({{ kill.victim_identifier }}) + + + + +
+ + + +
+
Bisher keine Kills im System.
+
+
+ + + {% 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 %} +
+ +
+ {% elif game_status == 'pending' %} +
+ +
+ {% elif game_status == 'finished' %} +
+

Spiel beendet – Rangliste

+ + + + + + + + + + {% for current_player in all_players %} + + + + + + {% endfor %} + +
PlatzNamePunkte
{{ loop.index }}{{ current_player.display_name }}{{ current_player.score }}
+
+ {% elif game_status in ['running', 'paused'] %} +
+ + + +
+ +
+ {% if game_status == 'paused' %} +
+ +

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' %} + + {% else %} + + {% endif %} +
+ {% elif player.late_joiner %} +
+ +

Du bist dem Spiel wÀhrend einer laufenden Runde beigetreten. Du wirst aktiv, sobald du eingewechselt wirst.

+
+ {% else %} +
+ +
+ {% 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 %} + + {% endif %} +
+
+ +
+
+ + + + + + + + + + {% for current_player in all_players %} + + + + + + {% endfor %} + +
PlatzNamePunkte
{{ loop.index }}{{ current_player.display_name }}{{ current_player.score }}
+
+
+ +
+
+ {{ rules_markup | safe }} +
+
+ + + + + + + + + + + {% 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 + + + + + + + + + +
+
+ +

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 %} +
+

Anmeldung

+
+
+ + +
+
+ + +
+ +
+
+ + {% endif %} +
+{% endblock %} \ No newline at end of file