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

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

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ZurĂŒck zur Anmeldung +
+ {% elif registration_success %} +
+

Registrierung erfolgreich

+

Deine ID fĂŒr die Anmeldung lautet:

+

{{ new_identifier }}

+

Bitte speichere oder merke sie dir!

+
+ Jetzt anmelden +
+
+ {% else %} +
+

Anmeldung

+
+
+ + +
+
+ + +
+ +
+
+
+ Noch kein Konto? Hier registrieren +
+ {% endif %} +
+{% endblock %} \ No newline at end of file