diff --git a/project.godot b/project.godot index bd2fadd..9487292 100644 --- a/project.godot +++ b/project.godot @@ -28,6 +28,8 @@ GoalManager="*res://scripts/managers/goal_manager.gd" GameStateManager="*res://scripts/managers/game_state_manager.gd" LobbyManager="*res://scripts/managers/lobby_manager.gd" GameUpdateManager="*res://scripts/managers/game_update_manager.gd" +AuthManager="*res://scripts/managers/auth_manager.gd" +UserProfileManager="*res://scripts/managers/user_profile_manager.gd" [display] diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 9679917..c72bdb0 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -28,10 +28,27 @@ extends Control # UI References - Status @onready var connection_status = $StatusBar/ConnectionStatus +# UI References - User Profile Bar (will be added to scene) +var user_profile_bar: Control +var profile_panel_instance: Control + # Store current match ID for copy function var current_match_id: String = "" func _ready(): + # Check if user is authenticated + if not AuthManager.is_logged_in(): + # Redirect to login screen + get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") + return + + # Initialize user profile bar + _setup_user_profile_bar() + + # Set player name from profile + if player_name_input: + player_name_input.text = UserProfileManager.get_display_name() + # Connect button signals create_room_btn.pressed.connect(_on_create_room_pressed) browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed) @@ -59,6 +76,98 @@ func _ready(): # Show main menu initially _show_panel("main_menu") + _update_profile_bar() + +# ============================================================================= +# User Profile Bar +# ============================================================================= + +func _setup_user_profile_bar() -> void: + # Create profile bar dynamically (or get reference if in scene) + user_profile_bar = _create_profile_bar() + add_child(user_profile_bar) + +func _create_profile_bar() -> Control: + var bar := HBoxContainer.new() + bar.name = "UserProfileBar" + bar.set_anchors_preset(Control.PRESET_TOP_WIDE) + bar.offset_top = 5 + bar.offset_bottom = 45 + bar.offset_left = 10 + bar.offset_right = -10 + + # Avatar + var avatar := TextureRect.new() + avatar.name = "Avatar" + avatar.custom_minimum_size = Vector2(35, 35) + avatar.expand_mode = TextureRect.EXPAND_FIT_WIDTH + avatar.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + bar.add_child(avatar) + + # Display name + var name_label := Label.new() + name_label.name = "DisplayName" + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_label.add_theme_color_override("font_color", Color(0, 0.831, 1)) + bar.add_child(name_label) + + # Account type badge + var badge := Label.new() + badge.name = "AccountBadge" + badge.add_theme_font_size_override("font_size", 10) + badge.add_theme_color_override("font_color", Color(0.5, 0.5, 0.6)) + bar.add_child(badge) + + # Profile button + var profile_btn := Button.new() + profile_btn.name = "ProfileBtn" + profile_btn.text = "Profile" + profile_btn.pressed.connect(_on_profile_btn_pressed) + bar.add_child(profile_btn) + + # Logout button + var logout_btn := Button.new() + logout_btn.name = "LogoutBtn" + logout_btn.text = "Logout" + logout_btn.pressed.connect(_on_logout_pressed) + bar.add_child(logout_btn) + + return bar + +func _update_profile_bar() -> void: + if not user_profile_bar: + return + + var name_label := user_profile_bar.get_node_or_null("DisplayName") as Label + if name_label: + name_label.text = UserProfileManager.get_display_name() + + var badge := user_profile_bar.get_node_or_null("AccountBadge") as Label + if badge: + badge.text = "[Guest]" if AuthManager.is_guest else "[Registered]" + + var avatar := user_profile_bar.get_node_or_null("Avatar") as TextureRect + if avatar: + var avatar_url := UserProfileManager.get_avatar_url() + if ResourceLoader.exists(avatar_url): + avatar.texture = load(avatar_url) + +func _on_profile_btn_pressed() -> void: + # Show profile panel + if not profile_panel_instance: + var profile_panel_scene := load("res://scenes/ui/profile_panel.tscn") + profile_panel_instance = profile_panel_scene.instantiate() + profile_panel_instance.closed.connect(func(): profile_panel_instance.hide()) + profile_panel_instance.profile_updated.connect(_update_profile_bar) + add_child(profile_panel_instance) + + profile_panel_instance.show_panel() + # Center the panel + profile_panel_instance.position = (get_viewport_rect().size - profile_panel_instance.size) / 2 + +func _on_logout_pressed() -> void: + AuthManager.logout() + get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") # ============================================================================= # Panel Management diff --git a/scenes/ui/admin_panel.tscn b/scenes/ui/admin_panel.tscn new file mode 100644 index 0000000..73c6eaf --- /dev/null +++ b/scenes/ui/admin_panel.tscn @@ -0,0 +1,136 @@ +[gd_scene load_steps=2 format=3 uid="uid://admin_panel"] + +[ext_resource type="Script" path="res://scripts/ui/admin_panel.gd" id="1"] + +[node name="AdminPanel" type="PanelContainer"] +anchors_preset = 0 +offset_right = 450.0 +offset_bottom = 500.0 +script = ExtResource("1") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Header" type="HBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="Title" type="Label" parent="VBox/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 0.4, 0.4, 1) +theme_override_font_sizes/font_size = 20 +text = "⚙ Admin Panel" + +[node name="CloseButton" type="Button" parent="VBox/Header"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(30, 30) +text = "✕" + +[node name="TabContainer" type="TabContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Players" type="VBoxContainer" parent="VBox/TabContainer"] +layout_mode = 2 +metadata/_tab_index = 0 + +[node name="PlayerList" type="ItemList" parent="VBox/TabContainer/Players"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +custom_minimum_size = Vector2(0, 150) + +[node name="PlayerActions" type="HBoxContainer" parent="VBox/TabContainer/Players"] +layout_mode = 2 + +[node name="KickBtn" type="Button" parent="VBox/TabContainer/Players/PlayerActions"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Kick" + +[node name="BanBtn" type="Button" parent="VBox/TabContainer/Players/PlayerActions"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Ban" + +[node name="MuteBtn" type="Button" parent="VBox/TabContainer/Players/PlayerActions"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Mute" + +[node name="Server" type="VBoxContainer" parent="VBox/TabContainer"] +visible = false +layout_mode = 2 +metadata/_tab_index = 1 + +[node name="StatsGrid" type="GridContainer" parent="VBox/TabContainer/Server"] +layout_mode = 2 +columns = 2 + +[node name="Label1" type="Label" parent="VBox/TabContainer/Server/StatsGrid"] +layout_mode = 2 +text = "Connected Players:" + +[node name="PlayerCount" type="Label" parent="VBox/TabContainer/Server/StatsGrid"] +unique_name_in_owner = true +layout_mode = 2 +text = "0" + +[node name="Label2" type="Label" parent="VBox/TabContainer/Server/StatsGrid"] +layout_mode = 2 +text = "Match ID:" + +[node name="MatchIdLabel" type="Label" parent="VBox/TabContainer/Server/StatsGrid"] +unique_name_in_owner = true +layout_mode = 2 +text = "N/A" + +[node name="Label3" type="Label" parent="VBox/TabContainer/Server/StatsGrid"] +layout_mode = 2 +text = "Server Status:" + +[node name="ServerStatus" type="Label" parent="VBox/TabContainer/Server/StatsGrid"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0, 1, 0.5, 1) +text = "Running" + +[node name="ServerActions" type="VBoxContainer" parent="VBox/TabContainer/Server"] +layout_mode = 2 + +[node name="EndMatchBtn" type="Button" parent="VBox/TabContainer/Server/ServerActions"] +unique_name_in_owner = true +layout_mode = 2 +text = "End Match" + +[node name="RestartMatchBtn" type="Button" parent="VBox/TabContainer/Server/ServerActions"] +unique_name_in_owner = true +layout_mode = 2 +text = "Restart Match" + +[node name="Bans" type="VBoxContainer" parent="VBox/TabContainer"] +visible = false +layout_mode = 2 +metadata/_tab_index = 2 + +[node name="BanList" type="ItemList" parent="VBox/TabContainer/Bans"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +custom_minimum_size = Vector2(0, 150) + +[node name="UnbanBtn" type="Button" parent="VBox/TabContainer/Bans"] +unique_name_in_owner = true +layout_mode = 2 +text = "Unban Selected" + +[node name="StatusLabel" type="Label" parent="VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 12 +horizontal_alignment = 1 diff --git a/scenes/ui/login_screen.tscn b/scenes/ui/login_screen.tscn new file mode 100644 index 0000000..19e6a4b --- /dev/null +++ b/scenes/ui/login_screen.tscn @@ -0,0 +1,236 @@ +[gd_scene load_steps=2 format=3 uid="uid://login_screen"] + +[ext_resource type="Script" path="res://scripts/ui/login_screen.gd" id="1"] + +[node name="LoginScreen" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.039, 0.039, 0.102, 1) + +[node name="CenterContainer" type="CenterContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="MainPanel" type="PanelContainer" parent="CenterContainer"] +layout_mode = 2 +custom_minimum_size = Vector2(400, 0) + +[node name="VBox" type="VBoxContainer" parent="CenterContainer/MainPanel"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="LogoLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0, 0.831, 1, 1) +theme_override_font_sizes/font_size = 48 +text = "TEKTON" +horizontal_alignment = 1 + +[node name="SubtitleLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1) +theme_override_font_sizes/font_size = 14 +text = "Tactical Multiplayer" +horizontal_alignment = 1 + +[node name="Spacer1" type="Control" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 20) + +[node name="GuestButton" type="Button" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 50) +theme_override_font_sizes/font_size = 18 +text = "▶ PLAY AS GUEST" + +[node name="Spacer2" type="Control" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 10) + +[node name="OrLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.4, 0.4, 0.5, 1) +text = "─────── or sign in ───────" +horizontal_alignment = 1 + +[node name="EmailInput" type="LineEdit" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +placeholder_text = "Email" + +[node name="PasswordInput" type="LineEdit" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +placeholder_text = "Password" +secret = true + +[node name="RememberMe" type="CheckBox" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +button_pressed = true +text = "Remember me" + +[node name="LoginButton" type="Button" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 45) +text = "Sign In" + +[node name="RegisterLink" type="LinkButton" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +text = "Don't have an account? Register" +horizontal_alignment = 1 + +[node name="Spacer3" type="Control" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 10) + +[node name="SocialLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.4, 0.4, 0.5, 1) +text = "─────── or continue with ───────" +horizontal_alignment = 1 + +[node name="SocialButtons" type="HBoxContainer" parent="CenterContainer/MainPanel/VBox"] +layout_mode = 2 +alignment = 1 + +[node name="GoogleButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(100, 40) +text = "Google" + +[node name="AppleButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(100, 40) +text = "Apple" + +[node name="FacebookButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(100, 40) +text = "Facebook" + +[node name="StatusLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.4, 1) +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="LoadingSpinner" type="TextureProgressBar" parent="CenterContainer/MainPanel/VBox"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +custom_minimum_size = Vector2(40, 40) +size_flags_horizontal = 4 + +[node name="RegistrationPanel" type="PanelContainer" parent="CenterContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +custom_minimum_size = Vector2(400, 0) + +[node name="VBox" type="VBoxContainer" parent="CenterContainer/RegistrationPanel"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="CenterContainer/RegistrationPanel/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0, 0.831, 1, 1) +theme_override_font_sizes/font_size = 24 +text = "Create Account" +horizontal_alignment = 1 + +[node name="RegEmailInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +placeholder_text = "Email" + +[node name="RegUsernameInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +placeholder_text = "Username" + +[node name="RegPasswordInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +placeholder_text = "Password" +secret = true + +[node name="RegConfirmPasswordInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +placeholder_text = "Confirm Password" +secret = true + +[node name="PasswordStrength" type="ProgressBar" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 8) +max_value = 4.0 +show_percentage = false + +[node name="PasswordHint" type="Label" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1) +theme_override_font_sizes/font_size = 11 +text = "Min 8 characters, include number and symbol" +horizontal_alignment = 1 + +[node name="RegisterButton" type="Button" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(0, 45) +text = "Create Account" + +[node name="BackToLoginLink" type="LinkButton" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +text = "Already have an account? Sign In" +horizontal_alignment = 1 + +[node name="RegStatusLabel" type="Label" parent="CenterContainer/RegistrationPanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.4, 1) +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="VersionLabel" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -100.0 +offset_top = -25.0 +offset_right = -10.0 +offset_bottom = -10.0 +theme_override_colors/font_color = Color(0.3, 0.3, 0.4, 1) +theme_override_font_sizes/font_size = 11 +text = "v1.0.0" +horizontal_alignment = 2 diff --git a/scenes/ui/profile_panel.tscn b/scenes/ui/profile_panel.tscn new file mode 100644 index 0000000..cf54386 --- /dev/null +++ b/scenes/ui/profile_panel.tscn @@ -0,0 +1,138 @@ +[gd_scene load_steps=2 format=3 uid="uid://profile_panel"] + +[ext_resource type="Script" path="res://scripts/ui/profile_panel.gd" id="1"] + +[node name="ProfilePanel" type="PanelContainer"] +anchors_preset = 0 +offset_right = 350.0 +offset_bottom = 400.0 +script = ExtResource("1") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Header" type="HBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="Title" type="Label" parent="VBox/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0, 0.831, 1, 1) +theme_override_font_sizes/font_size = 20 +text = "Profile" + +[node name="CloseButton" type="Button" parent="VBox/Header"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(30, 30) +text = "✕" + +[node name="AvatarSection" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +alignment = 1 + +[node name="AvatarDisplay" type="TextureRect" parent="VBox/AvatarSection"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(80, 80) +expand_mode = 1 +stretch_mode = 5 + +[node name="AvatarButtons" type="VBoxContainer" parent="VBox/AvatarSection"] +layout_mode = 2 + +[node name="ChangeAvatarBtn" type="Button" parent="VBox/AvatarSection/AvatarButtons"] +unique_name_in_owner = true +layout_mode = 2 +text = "Change Avatar" + +[node name="DisplayNameSection" type="VBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="Label" type="Label" parent="VBox/DisplayNameSection"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1) +text = "Display Name" + +[node name="HBox" type="HBoxContainer" parent="VBox/DisplayNameSection"] +layout_mode = 2 + +[node name="DisplayNameInput" type="LineEdit" parent="VBox/DisplayNameSection/HBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +custom_minimum_size = Vector2(0, 35) +max_length = 50 + +[node name="SaveNameBtn" type="Button" parent="VBox/DisplayNameSection/HBox"] +unique_name_in_owner = true +layout_mode = 2 +text = "Save" + +[node name="Separator1" type="HSeparator" parent="VBox"] +layout_mode = 2 + +[node name="StatsSection" type="VBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="StatsTitle" type="Label" parent="VBox/StatsSection"] +layout_mode = 2 +theme_override_colors/font_color = Color(0, 0.831, 1, 1) +text = "Statistics" + +[node name="GamesPlayed" type="Label" parent="VBox/StatsSection"] +unique_name_in_owner = true +layout_mode = 2 +text = "Games Played: 0" + +[node name="WinRate" type="Label" parent="VBox/StatsSection"] +unique_name_in_owner = true +layout_mode = 2 +text = "Win Rate: 0%" + +[node name="HighScore" type="Label" parent="VBox/StatsSection"] +unique_name_in_owner = true +layout_mode = 2 +text = "High Score: 0" + +[node name="Separator2" type="HSeparator" parent="VBox"] +layout_mode = 2 + +[node name="AccountSection" type="VBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="AccountType" type="Label" parent="VBox/AccountSection"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.533, 0.533, 0.6, 1) +text = "Account: Guest" + +[node name="LinkAccountBtn" type="Button" parent="VBox/AccountSection"] +unique_name_in_owner = true +layout_mode = 2 +text = "Link Email (Keep Progress)" + +[node name="LogoutBtn" type="Button" parent="VBox/AccountSection"] +unique_name_in_owner = true +layout_mode = 2 +text = "Logout" + +[node name="StatusLabel" type="Label" parent="VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 12 +horizontal_alignment = 1 + +[node name="AvatarSelectionPopup" type="PopupPanel" parent="."] +unique_name_in_owner = true +title = "Select Avatar" +size = Vector2i(300, 200) + +[node name="GridContainer" type="GridContainer" parent="AvatarSelectionPopup"] +unique_name_in_owner = true +offset_left = 10.0 +offset_top = 10.0 +offset_right = 290.0 +offset_bottom = 190.0 +columns = 3 diff --git a/scripts/managers/auth_manager.gd b/scripts/managers/auth_manager.gd new file mode 100644 index 0000000..6b8fc04 --- /dev/null +++ b/scripts/managers/auth_manager.gd @@ -0,0 +1,426 @@ +extends Node +## AuthManager - Centralized authentication handling for Nakama +## Supports: Guest (device ID), Email/Password, Social (Google, Apple, Facebook) + +# Signals +signal auth_started +signal auth_completed(success: bool, user_data: Dictionary) +signal auth_failed(error: String) +signal session_restored +signal logged_out + +# Auth modes +enum AuthMode { GUEST, EMAIL, GOOGLE, APPLE, FACEBOOK, CUSTOM } + +# User data +var current_user: Dictionary = {} +var is_authenticated: bool = false +var is_guest: bool = false +var auth_mode: AuthMode = AuthMode.GUEST + +# Session persistence +const SESSION_FILE := "user://auth_session.dat" +const CREDENTIALS_FILE := "user://auth_credentials.dat" + +# Encryption key for session storage (replace with your own!) +const ENCRYPTION_KEY := "tekton_secret_key_change_me_123" + +func _ready() -> void: + # Try to restore session on startup + call_deferred("_try_restore_session") + +# ============================================================================= +# Session Persistence +# ============================================================================= + +func _try_restore_session() -> void: + if not FileAccess.file_exists(SESSION_FILE): + print("[AuthManager] No saved session found") + return + + var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.READ, ENCRYPTION_KEY) + if not file: + print("[AuthManager] Could not open session file") + return + + var session_data := file.get_var() + file.close() + + if not session_data or not session_data is Dictionary: + return + + var token: String = session_data.get("token", "") + var refresh_token: String = session_data.get("refresh_token", "") + var saved_auth_mode: int = session_data.get("auth_mode", AuthMode.GUEST) + + if token.is_empty(): + return + + print("[AuthManager] Attempting to restore session...") + + # Try to restore the session + var session := NakamaClient.restore_session(token) + + if session.is_expired(): + # Try to refresh + if refresh_token: + var refreshed := await NakamaManager.client.session_refresh_async(session) + if not refreshed.is_exception(): + session = refreshed + _save_session(session, saved_auth_mode) + else: + print("[AuthManager] Session refresh failed, need to re-login") + return + else: + print("[AuthManager] Session expired, need to re-login") + return + + # Session valid, connect + NakamaManager.session = session + auth_mode = saved_auth_mode as AuthMode + is_guest = auth_mode == AuthMode.GUEST + + var socket_success := await _connect_socket() + if socket_success: + await _load_user_profile() + is_authenticated = true + emit_signal("session_restored") + emit_signal("auth_completed", true, current_user) + print("[AuthManager] Session restored successfully") + +func _save_session(session: NakamaSession, mode: AuthMode) -> void: + var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.WRITE, ENCRYPTION_KEY) + if file: + file.store_var({ + "token": session.token, + "refresh_token": session.refresh_token, + "auth_mode": mode, + "user_id": session.user_id + }) + file.close() + +func clear_session() -> void: + if FileAccess.file_exists(SESSION_FILE): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_FILE)) + if FileAccess.file_exists(CREDENTIALS_FILE): + DirAccess.remove_absolute(ProjectSettings.globalize_path(CREDENTIALS_FILE)) + +# ============================================================================= +# Guest Authentication +# ============================================================================= + +func login_as_guest() -> bool: + emit_signal("auth_started") + auth_mode = AuthMode.GUEST + is_guest = true + + # Use device ID for guest auth + var device_id := _get_device_id() + print("[AuthManager] Guest login with device: ", device_id.substr(0, 8) + "...") + + var session := await NakamaManager.client.authenticate_device_async(device_id, null, true) + + if session.is_exception(): + var error: String = session.get_exception().message + emit_signal("auth_failed", error) + return false + + NakamaManager.session = session + _save_session(session, AuthMode.GUEST) + + var socket_success := await _connect_socket() + if not socket_success: + emit_signal("auth_failed", "Failed to connect to game server") + return false + + await _load_user_profile() + is_authenticated = true + + emit_signal("auth_completed", true, current_user) + return true + +func _get_device_id() -> String: + # Try to load saved device ID for consistent guest identity + var id_file := "user://device_id.txt" + if FileAccess.file_exists(id_file): + var file := FileAccess.open(id_file, FileAccess.READ) + if file: + var saved_id := file.get_as_text().strip_edges() + file.close() + if not saved_id.is_empty(): + return saved_id + + # Generate new device ID + var device_id := OS.get_unique_id() + if device_id.is_empty(): + device_id = str(randi()) + str(Time.get_ticks_msec()) + + # Save for future use + var file := FileAccess.open(id_file, FileAccess.WRITE) + if file: + file.store_string(device_id) + file.close() + + return device_id + +# ============================================================================= +# Email/Password Authentication +# ============================================================================= + +func login_with_email(email: String, password: String, remember: bool = true) -> bool: + emit_signal("auth_started") + auth_mode = AuthMode.EMAIL + is_guest = false + + print("[AuthManager] Email login: ", email) + + var session := await NakamaManager.client.authenticate_email_async(email, password, null, false) + + if session.is_exception(): + var error: String = session.get_exception().message + emit_signal("auth_failed", error) + return false + + NakamaManager.session = session + + if remember: + _save_session(session, AuthMode.EMAIL) + + var socket_success := await _connect_socket() + if not socket_success: + emit_signal("auth_failed", "Failed to connect to game server") + return false + + await _load_user_profile() + is_authenticated = true + + emit_signal("auth_completed", true, current_user) + return true + +func register_with_email(email: String, password: String, username: String = "") -> bool: + emit_signal("auth_started") + auth_mode = AuthMode.EMAIL + is_guest = false + + print("[AuthManager] Registering: ", email) + + # Create account (true = create if not exists) + var session := await NakamaManager.client.authenticate_email_async(email, password, username, true) + + if session.is_exception(): + var error: String = session.get_exception().message + emit_signal("auth_failed", error) + return false + + NakamaManager.session = session + _save_session(session, AuthMode.EMAIL) + + var socket_success := await _connect_socket() + if not socket_success: + emit_signal("auth_failed", "Failed to connect to game server") + return false + + await _load_user_profile() + is_authenticated = true + + emit_signal("auth_completed", true, current_user) + return true + +# ============================================================================= +# Social Authentication +# ============================================================================= + +func login_with_google(id_token: String) -> bool: + emit_signal("auth_started") + auth_mode = AuthMode.GOOGLE + is_guest = false + + print("[AuthManager] Google login...") + + var session := await NakamaManager.client.authenticate_google_async(id_token, null, true) + + if session.is_exception(): + var error: String = session.get_exception().message + emit_signal("auth_failed", error) + return false + + NakamaManager.session = session + _save_session(session, AuthMode.GOOGLE) + + var socket_success := await _connect_socket() + if not socket_success: + emit_signal("auth_failed", "Failed to connect to game server") + return false + + await _load_user_profile() + is_authenticated = true + + emit_signal("auth_completed", true, current_user) + return true + +func login_with_apple(id_token: String) -> bool: + emit_signal("auth_started") + auth_mode = AuthMode.APPLE + is_guest = false + + print("[AuthManager] Apple login...") + + var session := await NakamaManager.client.authenticate_apple_async(id_token, null, true) + + if session.is_exception(): + var error: String = session.get_exception().message + emit_signal("auth_failed", error) + return false + + NakamaManager.session = session + _save_session(session, AuthMode.APPLE) + + var socket_success := await _connect_socket() + if not socket_success: + emit_signal("auth_failed", "Failed to connect to game server") + return false + + await _load_user_profile() + is_authenticated = true + + emit_signal("auth_completed", true, current_user) + return true + +func login_with_facebook(access_token: String) -> bool: + emit_signal("auth_started") + auth_mode = AuthMode.FACEBOOK + is_guest = false + + print("[AuthManager] Facebook login...") + + var session := await NakamaManager.client.authenticate_facebook_async(access_token, null, true) + + if session.is_exception(): + var error: String = session.get_exception().message + emit_signal("auth_failed", error) + return false + + NakamaManager.session = session + _save_session(session, AuthMode.FACEBOOK) + + var socket_success := await _connect_socket() + if not socket_success: + emit_signal("auth_failed", "Failed to connect to game server") + return false + + await _load_user_profile() + is_authenticated = true + + emit_signal("auth_completed", true, current_user) + return true + +# ============================================================================= +# Account Linking (Convert Guest to Full Account) +# ============================================================================= + +func link_email(email: String, password: String) -> bool: + if not is_authenticated or not NakamaManager.session: + return false + + print("[AuthManager] Linking email to guest account...") + + var result := await NakamaManager.client.link_email_async(NakamaManager.session, email, password) + + if result.is_exception(): + push_error("[AuthManager] Link failed: " + result.get_exception().message) + return false + + is_guest = false + auth_mode = AuthMode.EMAIL + _save_session(NakamaManager.session, AuthMode.EMAIL) + + print("[AuthManager] Email linked successfully!") + return true + +func link_google(id_token: String) -> bool: + if not is_authenticated or not NakamaManager.session: + return false + + var result := await NakamaManager.client.link_google_async(NakamaManager.session, id_token) + + if result.is_exception(): + return false + + is_guest = false + auth_mode = AuthMode.GOOGLE + _save_session(NakamaManager.session, AuthMode.GOOGLE) + return true + +# ============================================================================= +# Logout +# ============================================================================= + +func logout() -> void: + print("[AuthManager] Logging out...") + + if NakamaManager.socket: + NakamaManager.socket.close() + + clear_session() + + current_user = {} + is_authenticated = false + is_guest = false + NakamaManager.session = null + + emit_signal("logged_out") + +# ============================================================================= +# Helper Functions +# ============================================================================= + +func _connect_socket() -> bool: + if NakamaManager.socket and NakamaManager.socket.is_connected_to_host(): + return true + + NakamaManager.socket = Nakama.create_socket_from(NakamaManager.client) + var result := await NakamaManager.socket.connect_async(NakamaManager.session) + + if result.is_exception(): + push_error("[AuthManager] Socket connection failed: " + result.get_exception().message) + return false + + # Initialize multiplayer bridge + NakamaManager.bridge = NakamaMultiplayerBridge.new(NakamaManager.socket) + NakamaManager.bridge.match_joined.connect(NakamaManager._on_bridge_match_joined) + NakamaManager.bridge.match_join_error.connect(NakamaManager._on_bridge_match_join_error) + multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer) + + return true + +func _load_user_profile() -> void: + if not NakamaManager.session: + return + + var account := await NakamaManager.client.get_account_async(NakamaManager.session) + + if account.is_exception(): + push_error("[AuthManager] Failed to load account") + return + + current_user = { + "user_id": account.user.id, + "username": account.user.username, + "display_name": account.user.display_name if account.user.display_name else account.user.username, + "avatar_url": account.user.avatar_url, + "email": account.email, + "is_guest": is_guest, + "auth_mode": auth_mode, + "created_at": account.user.create_time + } + + print("[AuthManager] User profile loaded: ", current_user.display_name) + +func get_display_name() -> String: + return current_user.get("display_name", "Guest") + +func get_user_id() -> String: + return current_user.get("user_id", "") + +func is_logged_in() -> bool: + return is_authenticated diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd new file mode 100644 index 0000000..e089d75 --- /dev/null +++ b/scripts/managers/user_profile_manager.gd @@ -0,0 +1,270 @@ +extends Node +## UserProfileManager - Manages user profile data with Nakama storage + +signal profile_loaded(profile: Dictionary) +signal profile_updated +signal profile_update_failed(error: String) +signal avatar_changed(url: String) + +# Profile data +var profile: Dictionary = {} +var stats: Dictionary = {} + +# Nakama storage collection names +const PROFILE_COLLECTION := "profiles" +const STATS_COLLECTION := "stats" + +# Available avatars (predefined) +const AVATARS := [ + "res://assets/avatars/avatar_default.png", + "res://assets/avatars/avatar_warrior.png", + "res://assets/avatars/avatar_mage.png", + "res://assets/avatars/avatar_rogue.png", + "res://assets/avatars/avatar_tank.png", + "res://assets/avatars/avatar_healer.png", +] + +func _ready() -> void: + # Connect to auth signals + if has_node("/root/AuthManager"): + var auth := get_node("/root/AuthManager") + auth.auth_completed.connect(_on_auth_completed) + auth.logged_out.connect(_on_logged_out) + +func _on_auth_completed(_success: bool, user_data: Dictionary) -> void: + if _success: + await load_profile() + +func _on_logged_out() -> void: + profile = {} + stats = {} + +# ============================================================================= +# Profile Loading +# ============================================================================= + +func load_profile() -> Dictionary: + if not NakamaManager.session: + push_error("[UserProfileManager] No session available") + return {} + + # First get basic account info + var account := await NakamaManager.client.get_account_async(NakamaManager.session) + + if account.is_exception(): + push_error("[UserProfileManager] Failed to load account") + return {} + + profile = { + "user_id": account.user.id, + "username": account.user.username, + "display_name": account.user.display_name if account.user.display_name else account.user.username, + "avatar_url": account.user.avatar_url, + "avatar_index": 0, + "email": account.email, + "created_at": account.user.create_time, + "online": account.user.online, + "bio": "", + "country": "", + "language": "en" + } + + # Load custom profile data from storage + var storage_result := await NakamaManager.client.read_storage_objects_async( + NakamaManager.session, + [NakamaStorageObjectId.new(PROFILE_COLLECTION, "profile", account.user.id)] + ) + + if not storage_result.is_exception() and storage_result.objects.size() > 0: + var stored_data = JSON.parse_string(storage_result.objects[0].value) + if stored_data: + profile.merge(stored_data, true) + + # Load stats + await load_stats() + + emit_signal("profile_loaded", profile) + print("[UserProfileManager] Profile loaded: ", profile.display_name) + return profile + +func load_stats() -> Dictionary: + if not NakamaManager.session: + return {} + + var user_id := NakamaManager.session.user_id + + var storage_result := await NakamaManager.client.read_storage_objects_async( + NakamaManager.session, + [NakamaStorageObjectId.new(STATS_COLLECTION, "game_stats", user_id)] + ) + + if not storage_result.is_exception() and storage_result.objects.size() > 0: + var stored_data = JSON.parse_string(storage_result.objects[0].value) + if stored_data: + stats = stored_data + else: + # Initialize default stats + stats = { + "games_played": 0, + "games_won": 0, + "games_lost": 0, + "total_score": 0, + "high_score": 0, + "play_time_minutes": 0 + } + + return stats + +# ============================================================================= +# Profile Updates +# ============================================================================= + +func update_display_name(new_name: String) -> bool: + if not NakamaManager.session: + emit_signal("profile_update_failed", "Not authenticated") + return false + + if new_name.strip_edges().is_empty(): + emit_signal("profile_update_failed", "Display name cannot be empty") + return false + + if new_name.length() > 50: + emit_signal("profile_update_failed", "Display name too long (max 50 characters)") + return false + + var result := await NakamaManager.client.update_account_async( + NakamaManager.session, + null, # username (don't change) + new_name # display_name + ) + + if result.is_exception(): + emit_signal("profile_update_failed", result.get_exception().message) + return false + + profile["display_name"] = new_name + emit_signal("profile_updated") + return true + +func update_avatar(avatar_index: int) -> bool: + if avatar_index < 0 or avatar_index >= AVATARS.size(): + emit_signal("profile_update_failed", "Invalid avatar index") + return false + + if not NakamaManager.session: + emit_signal("profile_update_failed", "Not authenticated") + return false + + # Store avatar in custom profile data + profile["avatar_index"] = avatar_index + profile["avatar_url"] = AVATARS[avatar_index] + + var success := await _save_profile_data() + if success: + emit_signal("avatar_changed", AVATARS[avatar_index]) + emit_signal("profile_updated") + + return success + +func update_bio(new_bio: String) -> bool: + if new_bio.length() > 200: + emit_signal("profile_update_failed", "Bio too long (max 200 characters)") + return false + + profile["bio"] = new_bio + return await _save_profile_data() + +func _save_profile_data() -> bool: + if not NakamaManager.session: + return false + + var custom_data := { + "avatar_index": profile.get("avatar_index", 0), + "bio": profile.get("bio", ""), + "country": profile.get("country", ""), + "language": profile.get("language", "en") + } + + var write_obj := NakamaWriteStorageObject.new( + PROFILE_COLLECTION, + "profile", + 2, # Public read + 1, # Owner write + JSON.stringify(custom_data), + "" # Version (empty = overwrite) + ) + + var result := await NakamaManager.client.write_storage_objects_async( + NakamaManager.session, + [write_obj] + ) + + if result.is_exception(): + emit_signal("profile_update_failed", result.get_exception().message) + return false + + emit_signal("profile_updated") + return true + +# ============================================================================= +# Stats Management +# ============================================================================= + +func update_stats(new_stats: Dictionary) -> bool: + stats.merge(new_stats, true) + + if not NakamaManager.session: + return false + + var write_obj := NakamaWriteStorageObject.new( + STATS_COLLECTION, + "game_stats", + 2, # Public read + 1, # Owner write + JSON.stringify(stats), + "" + ) + + var result := await NakamaManager.client.write_storage_objects_async( + NakamaManager.session, + [write_obj] + ) + + return not result.is_exception() + +func record_game_result(won: bool, score: int) -> void: + stats["games_played"] = stats.get("games_played", 0) + 1 + + if won: + stats["games_won"] = stats.get("games_won", 0) + 1 + else: + stats["games_lost"] = stats.get("games_lost", 0) + 1 + + stats["total_score"] = stats.get("total_score", 0) + score + + if score > stats.get("high_score", 0): + stats["high_score"] = score + + await update_stats(stats) + +# ============================================================================= +# Getters +# ============================================================================= + +func get_display_name() -> String: + return profile.get("display_name", "Guest") + +func get_avatar_url() -> String: + var index: int = profile.get("avatar_index", 0) + if index >= 0 and index < AVATARS.size(): + return AVATARS[index] + return AVATARS[0] + +func get_stats() -> Dictionary: + return stats + +func get_win_rate() -> float: + var played: int = stats.get("games_played", 0) + if played == 0: + return 0.0 + return float(stats.get("games_won", 0)) / float(played) * 100.0 diff --git a/scripts/ui/admin_panel.gd b/scripts/ui/admin_panel.gd new file mode 100644 index 0000000..4ed4906 --- /dev/null +++ b/scripts/ui/admin_panel.gd @@ -0,0 +1,403 @@ +extends PanelContainer +## Admin panel for in-game server management using Nakama RPC +## Requires server-side tekton_admin.ts module for security + +signal closed +signal player_kicked(player_id: String) +signal player_banned(player_id: String) + +@onready var close_button := %CloseButton as Button +@onready var player_list := %PlayerList as ItemList +@onready var kick_btn := %KickBtn as Button +@onready var ban_btn := %BanBtn as Button +@onready var mute_btn := %MuteBtn as Button +@onready var player_count_label := %PlayerCount as Label +@onready var match_id_label := %MatchIdLabel as Label +@onready var server_status_label := %ServerStatus as Label +@onready var end_match_btn := %EndMatchBtn as Button +@onready var restart_match_btn := %RestartMatchBtn as Button +@onready var ban_list := %BanList as ItemList +@onready var unban_btn := %UnbanBtn as Button +@onready var status_label := %StatusLabel as Label + +# Player data cache +var players: Array = [] +var banned_players: Array = [] # [{user_id, username, banned_at, reason, expires}] +var is_admin: bool = false +var is_host: bool = false + +func _ready() -> void: + _connect_signals() + visible = false + +func _connect_signals() -> void: + close_button.pressed.connect(_on_close_pressed) + kick_btn.pressed.connect(_on_kick_pressed) + ban_btn.pressed.connect(_on_ban_pressed) + mute_btn.pressed.connect(_on_mute_pressed) + end_match_btn.pressed.connect(_on_end_match_pressed) + restart_match_btn.pressed.connect(_on_restart_match_pressed) + unban_btn.pressed.connect(_on_unban_pressed) + player_list.item_selected.connect(_on_player_selected) + +func show_panel() -> void: + # Check permissions + is_host = LobbyManager.is_host + is_admin = await _check_admin_status() + + if not is_host and not is_admin: + status_label.text = "Admin access required" + status_label.add_theme_color_override("font_color", Color.RED) + return + + # Show/hide admin-only features + ban_btn.visible = is_admin + unban_btn.visible = is_admin + + await _refresh_all_data() + visible = true + +func _check_admin_status() -> bool: + # Check with server if user has admin role + var result := await _rpc_call("get_user_profile", {}) + if result.has("role"): + return result.role in ["admin", "moderator", "owner"] + return false + +func _on_close_pressed() -> void: + visible = false + emit_signal("closed") + +# ============================================================================= +# RPC Helpers +# ============================================================================= + +func _rpc_call(rpc_name: String, payload: Dictionary) -> Dictionary: + if not NakamaManager.client or not NakamaManager.session: + push_error("[AdminPanel] Not connected to Nakama") + return {"error": "Not connected"} + + var result := await NakamaManager.client.rpc_async( + NakamaManager.session, + rpc_name, + JSON.stringify(payload) + ) + + if result.is_exception(): + var error: String = result.get_exception().message + push_error("[AdminPanel] RPC Error: " + error) + status_label.text = "Error: " + error + status_label.add_theme_color_override("font_color", Color.RED) + return {"error": error} + + if result.payload: + return JSON.parse_string(result.payload) + return {} + +# ============================================================================= +# Data Refresh +# ============================================================================= + +func _refresh_all_data() -> void: + await _refresh_player_list() + await _refresh_server_stats() + if is_admin: + await _refresh_ban_list() + status_label.text = "" + +func _refresh_player_list() -> void: + player_list.clear() + players = LobbyManager.get_players() + + for player in players: + var name: String = player.get("name", "Unknown") + var id: int = player.get("id", 0) + var user_id: String = player.get("user_id", "") + var is_player_host := id == 1 + + var display := name + if is_player_host: + display += " (Host)" + + player_list.add_item(display) + # Store user_id as metadata + player_list.set_item_metadata(player_list.item_count - 1, { + "user_id": user_id, + "peer_id": id, + "name": name + }) + + _update_action_buttons() + +func _refresh_server_stats() -> void: + var stats := await _rpc_call("admin_get_server_stats", { + "match_id": NakamaManager.current_match_id + }) + + if stats.has("error"): + return + + player_count_label.text = str(stats.get("total_players", LobbyManager.get_players().size())) + + var match_id := NakamaManager.current_match_id + if match_id.length() > 16: + match_id = match_id.substr(0, 16) + "..." + match_id_label.text = match_id if match_id else "N/A" + + server_status_label.text = "Running" + server_status_label.add_theme_color_override("font_color", Color.GREEN) + +func _refresh_ban_list() -> void: + ban_list.clear() + + var result := await _rpc_call("admin_get_ban_list", {}) + + if result.has("error"): + return + + banned_players = result.get("bans", []) + + for ban in banned_players: + var display := "%s - %s" % [ + ban.get("username", "Unknown"), + ban.get("reason", "No reason") + ] + if ban.get("expires"): + display += " (until " + ban.expires + ")" + + ban_list.add_item(display) + ban_list.set_item_metadata(ban_list.item_count - 1, ban) + +# ============================================================================= +# Player Actions +# ============================================================================= + +func _on_player_selected(_index: int) -> void: + _update_action_buttons() + +func _update_action_buttons() -> void: + var selected := player_list.get_selected_items() + if selected.is_empty(): + kick_btn.disabled = true + ban_btn.disabled = true + mute_btn.disabled = true + return + + var idx: int = selected[0] + var meta: Dictionary = player_list.get_item_metadata(idx) + var is_player_host := meta.get("peer_id", 0) == 1 + + # Can't kick/ban the host or yourself + var is_self := meta.get("user_id", "") == AuthManager.current_user.get("user_id", "") + + kick_btn.disabled = is_player_host or is_self + ban_btn.disabled = is_player_host or is_self or not is_admin + mute_btn.disabled = is_self + +func _on_kick_pressed() -> void: + var selected := player_list.get_selected_items() + if selected.is_empty(): + return + + var meta: Dictionary = player_list.get_item_metadata(selected[0]) + var user_id: String = meta.get("user_id", "") + var player_name: String = meta.get("name", "Unknown") + + if user_id.is_empty(): + status_label.text = "Cannot kick: Invalid player" + return + + status_label.text = "Kicking player..." + + var result := await _rpc_call("admin_kick_player", { + "match_id": NakamaManager.current_match_id, + "user_id": user_id, + "reason": "Kicked by " + ("admin" if is_admin else "host") + }) + + if result.get("success", false): + status_label.text = "Kicked: " + player_name + status_label.add_theme_color_override("font_color", Color.GREEN) + emit_signal("player_kicked", user_id) + await get_tree().create_timer(0.5).timeout + _refresh_player_list() + else: + status_label.text = "Failed to kick player" + status_label.add_theme_color_override("font_color", Color.RED) + +func _on_ban_pressed() -> void: + var selected := player_list.get_selected_items() + if selected.is_empty(): + return + + var meta: Dictionary = player_list.get_item_metadata(selected[0]) + var user_id: String = meta.get("user_id", "") + var player_name: String = meta.get("name", "Unknown") + + if user_id.is_empty(): + status_label.text = "Cannot ban: Invalid player" + return + + # Show ban dialog + var dialog := _create_ban_dialog(user_id, player_name) + add_child(dialog) + dialog.popup_centered() + +func _create_ban_dialog(user_id: String, player_name: String) -> ConfirmationDialog: + var dialog := ConfirmationDialog.new() + dialog.title = "Ban Player" + + var vbox := VBoxContainer.new() + + var info_label := Label.new() + info_label.text = "Ban player: " + player_name + vbox.add_child(info_label) + + var reason_label := Label.new() + reason_label.text = "Reason:" + vbox.add_child(reason_label) + + var reason_input := LineEdit.new() + reason_input.placeholder_text = "Enter ban reason" + vbox.add_child(reason_input) + + var duration_label := Label.new() + duration_label.text = "Duration (hours, 0 = permanent):" + vbox.add_child(duration_label) + + var duration_input := SpinBox.new() + duration_input.min_value = 0 + duration_input.max_value = 8760 # 1 year + duration_input.value = 24 # Default 24 hours + vbox.add_child(duration_input) + + dialog.add_child(vbox) + + dialog.confirmed.connect(func(): + _execute_ban(user_id, player_name, reason_input.text, int(duration_input.value)) + dialog.queue_free() + ) + + dialog.canceled.connect(func(): + dialog.queue_free() + ) + + return dialog + +func _execute_ban(user_id: String, player_name: String, reason: String, duration_hours: int) -> void: + status_label.text = "Banning player..." + + var result := await _rpc_call("admin_ban_player", { + "user_id": user_id, + "reason": reason if reason else "No reason provided", + "duration_hours": duration_hours, + "match_id": NakamaManager.current_match_id + }) + + if result.get("success", false): + status_label.text = "Banned: " + player_name + status_label.add_theme_color_override("font_color", Color.GREEN) + emit_signal("player_banned", user_id) + await get_tree().create_timer(0.5).timeout + await _refresh_all_data() + else: + status_label.text = "Failed to ban: " + result.get("error", "Unknown error") + status_label.add_theme_color_override("font_color", Color.RED) + +func _on_mute_pressed() -> void: + var selected := player_list.get_selected_items() + if selected.is_empty(): + return + + var meta: Dictionary = player_list.get_item_metadata(selected[0]) + var player_name: String = meta.get("name", "Unknown") + + # TODO: Implement mute via chat system RPC + status_label.text = "Muted: " + player_name + +# ============================================================================= +# Server Controls +# ============================================================================= + +func _on_end_match_pressed() -> void: + var dialog := ConfirmationDialog.new() + dialog.dialog_text = "Are you sure you want to end this match?\nAll players will be returned to the lobby." + add_child(dialog) + dialog.popup_centered() + + dialog.confirmed.connect(func(): + status_label.text = "Ending match..." + + var result := await _rpc_call("admin_end_match", { + "match_id": NakamaManager.current_match_id, + "reason": "Ended by " + ("admin" if is_admin else "host") + }) + + if result.get("success", false): + status_label.text = "Match ended" + else: + status_label.text = "Failed to end match" + + dialog.queue_free() + ) + + dialog.canceled.connect(func(): dialog.queue_free()) + +func _on_restart_match_pressed() -> void: + var dialog := ConfirmationDialog.new() + dialog.dialog_text = "Restart the current match?" + add_child(dialog) + dialog.popup_centered() + + dialog.confirmed.connect(func(): + # For restart, we'll use a different approach - reload scene locally + # The actual match restart logic would depend on your game + get_tree().reload_current_scene() + dialog.queue_free() + ) + + dialog.canceled.connect(func(): dialog.queue_free()) + +# ============================================================================= +# Ban List Management +# ============================================================================= + +func _on_unban_pressed() -> void: + var selected := ban_list.get_selected_items() + if selected.is_empty(): + status_label.text = "Select a player to unban" + return + + var meta: Dictionary = ban_list.get_item_metadata(selected[0]) + var user_id: String = meta.get("user_id", "") + var username: String = meta.get("username", "Unknown") + + if user_id.is_empty(): + status_label.text = "Invalid ban entry" + return + + status_label.text = "Unbanning..." + + var result := await _rpc_call("admin_unban_player", { + "user_id": user_id + }) + + if result.get("success", false): + status_label.text = "Unbanned: " + username + status_label.add_theme_color_override("font_color", Color.GREEN) + await _refresh_ban_list() + else: + status_label.text = "Failed to unban" + status_label.add_theme_color_override("font_color", Color.RED) + +# ============================================================================= +# Input Handling +# ============================================================================= + +func _input(event: InputEvent) -> void: + # Toggle admin panel with F10 + if event is InputEventKey and event.pressed and event.keycode == KEY_F10: + if visible: + _on_close_pressed() + else: + show_panel() diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd new file mode 100644 index 0000000..0fb6629 --- /dev/null +++ b/scripts/ui/login_screen.gd @@ -0,0 +1,340 @@ +extends Control +## Login screen controller - handles authentication UI + +# Login panel elements +@onready var guest_button := %GuestButton as Button +@onready var email_input := %EmailInput as LineEdit +@onready var password_input := %PasswordInput as LineEdit +@onready var remember_me := %RememberMe as CheckBox +@onready var login_button := %LoginButton as Button +@onready var register_link := %RegisterLink as LinkButton +@onready var google_button := %GoogleButton as Button +@onready var apple_button := %AppleButton as Button +@onready var facebook_button := %FacebookButton as Button +@onready var status_label := %StatusLabel as Label +@onready var loading_spinner := %LoadingSpinner as TextureProgressBar + +# Registration panel elements +@onready var registration_panel := %RegistrationPanel as PanelContainer +@onready var reg_email_input := %RegEmailInput as LineEdit +@onready var reg_username_input := %RegUsernameInput as LineEdit +@onready var reg_password_input := %RegPasswordInput as LineEdit +@onready var reg_confirm_password_input := %RegConfirmPasswordInput as LineEdit +@onready var password_strength := %PasswordStrength as ProgressBar +@onready var password_hint := %PasswordHint as Label +@onready var register_button := %RegisterButton as Button +@onready var back_to_login_link := %BackToLoginLink as LinkButton +@onready var reg_status_label := %RegStatusLabel as Label + +# Main panel reference +@onready var main_panel := $CenterContainer/MainPanel as PanelContainer + +var is_loading: bool = false + +func _ready() -> void: + _connect_signals() + _setup_ui() + + # Check if already authenticated + if AuthManager.is_logged_in(): + _go_to_lobby() + +func _connect_signals() -> void: + # Login buttons + guest_button.pressed.connect(_on_guest_pressed) + login_button.pressed.connect(_on_login_pressed) + register_link.pressed.connect(_show_registration) + + # Social buttons + google_button.pressed.connect(_on_google_pressed) + apple_button.pressed.connect(_on_apple_pressed) + facebook_button.pressed.connect(_on_facebook_pressed) + + # Registration buttons + register_button.pressed.connect(_on_register_pressed) + back_to_login_link.pressed.connect(_show_login) + + # Password strength checker + reg_password_input.text_changed.connect(_check_password_strength) + + # Auth manager signals + AuthManager.auth_started.connect(_on_auth_started) + AuthManager.auth_completed.connect(_on_auth_completed) + AuthManager.auth_failed.connect(_on_auth_failed) + AuthManager.session_restored.connect(_on_session_restored) + + # Enter key to submit + password_input.text_submitted.connect(func(_t): _on_login_pressed()) + reg_confirm_password_input.text_submitted.connect(func(_t): _on_register_pressed()) + +func _setup_ui() -> void: + status_label.text = "" + reg_status_label.text = "" + loading_spinner.visible = false + registration_panel.visible = false + main_panel.visible = true + + # Hide social buttons on platforms where they're not supported + _configure_social_buttons() + +func _configure_social_buttons() -> void: + # Google - available on all platforms + google_button.visible = true + + # Apple - iOS and macOS only (or hide if not configured) + var os := OS.get_name() + apple_button.visible = os in ["iOS", "macOS"] + + # Facebook - available on all platforms + facebook_button.visible = true + +# ============================================================================= +# Panel Switching +# ============================================================================= + +func _show_registration() -> void: + main_panel.visible = false + registration_panel.visible = true + reg_status_label.text = "" + reg_email_input.grab_focus() + +func _show_login() -> void: + registration_panel.visible = false + main_panel.visible = true + status_label.text = "" + email_input.grab_focus() + +# ============================================================================= +# Login Handlers +# ============================================================================= + +func _on_guest_pressed() -> void: + if is_loading: + return + AuthManager.login_as_guest() + +func _on_login_pressed() -> void: + if is_loading: + return + + var email := email_input.text.strip_edges() + var password := password_input.text + + if email.is_empty(): + _show_error("Please enter your email") + return + + if password.is_empty(): + _show_error("Please enter your password") + return + + if not _is_valid_email(email): + _show_error("Please enter a valid email address") + return + + AuthManager.login_with_email(email, password, remember_me.button_pressed) + +func _on_google_pressed() -> void: + if is_loading: + return + + # Note: Actual Google Sign-In requires platform-specific implementation + # This is a placeholder - you need to integrate Google Sign-In SDK + _show_error("Google Sign-In requires SDK integration") + + # When you have the ID token from Google SDK: + # AuthManager.login_with_google(id_token) + +func _on_apple_pressed() -> void: + if is_loading: + return + + # Note: Apple Sign-In requires platform-specific implementation + _show_error("Apple Sign-In requires SDK integration") + + # When you have the ID token from Apple: + # AuthManager.login_with_apple(id_token) + +func _on_facebook_pressed() -> void: + if is_loading: + return + + # Note: Facebook Login requires platform-specific implementation + _show_error("Facebook Login requires SDK integration") + + # When you have the access token from Facebook SDK: + # AuthManager.login_with_facebook(access_token) + +# ============================================================================= +# Registration Handlers +# ============================================================================= + +func _on_register_pressed() -> void: + if is_loading: + return + + var email := reg_email_input.text.strip_edges() + var username := reg_username_input.text.strip_edges() + var password := reg_password_input.text + var confirm_password := reg_confirm_password_input.text + + # Validation + if email.is_empty(): + _show_reg_error("Please enter your email") + return + + if not _is_valid_email(email): + _show_reg_error("Please enter a valid email address") + return + + if username.is_empty(): + _show_reg_error("Please enter a username") + return + + if username.length() < 3: + _show_reg_error("Username must be at least 3 characters") + return + + if password.is_empty(): + _show_reg_error("Please enter a password") + return + + if password.length() < 8: + _show_reg_error("Password must be at least 8 characters") + return + + if password != confirm_password: + _show_reg_error("Passwords do not match") + return + + if _calculate_password_strength(password) < 2: + _show_reg_error("Password is too weak. Add numbers or symbols.") + return + + AuthManager.register_with_email(email, password, username) + +func _check_password_strength(password: String) -> void: + var strength := _calculate_password_strength(password) + password_strength.value = strength + + # Color based on strength + var color: Color + match strength: + 0, 1: + color = Color.RED + password_hint.text = "Weak - add more characters" + 2: + color = Color.ORANGE + password_hint.text = "Fair - add numbers or symbols" + 3: + color = Color.YELLOW_GREEN + password_hint.text = "Good" + 4: + color = Color.GREEN + password_hint.text = "Strong!" + + # Apply color to progress bar + var style := StyleBoxFlat.new() + style.bg_color = color + password_strength.add_theme_stylebox_override("fill", style) + +func _calculate_password_strength(password: String) -> int: + var strength := 0 + + if password.length() >= 8: + strength += 1 + if password.length() >= 12: + strength += 1 + + var has_upper := false + var has_lower := false + var has_digit := false + var has_special := false + + for c in password: + if c.to_upper() != c.to_lower(): + if c == c.to_upper(): + has_upper = true + else: + has_lower = true + elif c.is_valid_int(): + has_digit = true + else: + has_special = true + + if has_upper and has_lower: + strength += 1 + if has_digit: + strength += 0.5 + if has_special: + strength += 0.5 + + return mini(int(strength), 4) + +# ============================================================================= +# Auth Manager Callbacks +# ============================================================================= + +func _on_auth_started() -> void: + is_loading = true + loading_spinner.visible = true + _set_inputs_enabled(false) + status_label.text = "" + reg_status_label.text = "" + +func _on_auth_completed(success: bool, _user_data: Dictionary) -> void: + is_loading = false + loading_spinner.visible = false + _set_inputs_enabled(true) + + if success: + _go_to_lobby() + +func _on_auth_failed(error: String) -> void: + is_loading = false + loading_spinner.visible = false + _set_inputs_enabled(true) + + if registration_panel.visible: + _show_reg_error(error) + else: + _show_error(error) + +func _on_session_restored() -> void: + _go_to_lobby() + +# ============================================================================= +# Helper Functions +# ============================================================================= + +func _show_error(message: String) -> void: + status_label.text = message + status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4)) + +func _show_reg_error(message: String) -> void: + reg_status_label.text = message + reg_status_label.add_theme_color_override("font_color", Color(1, 0.4, 0.4)) + +func _set_inputs_enabled(enabled: bool) -> void: + guest_button.disabled = not enabled + login_button.disabled = not enabled + register_button.disabled = not enabled + google_button.disabled = not enabled + apple_button.disabled = not enabled + facebook_button.disabled = not enabled + email_input.editable = enabled + password_input.editable = enabled + reg_email_input.editable = enabled + reg_username_input.editable = enabled + reg_password_input.editable = enabled + reg_confirm_password_input.editable = enabled + +func _is_valid_email(email: String) -> bool: + # Simple email validation + var regex := RegEx.new() + regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") + return regex.search(email) != null + +func _go_to_lobby() -> void: + # Navigate to lobby scene + get_tree().change_scene_to_file("res://assets/models/meshes/lobby.tscn") diff --git a/scripts/ui/profile_panel.gd b/scripts/ui/profile_panel.gd new file mode 100644 index 0000000..ea7dbdf --- /dev/null +++ b/scripts/ui/profile_panel.gd @@ -0,0 +1,194 @@ +extends PanelContainer +## Profile panel controller - displays and edits user profile + +signal closed +signal profile_updated + +@onready var close_button := %CloseButton as Button +@onready var avatar_display := %AvatarDisplay as TextureRect +@onready var change_avatar_btn := %ChangeAvatarBtn as Button +@onready var display_name_input := %DisplayNameInput as LineEdit +@onready var save_name_btn := %SaveNameBtn as Button +@onready var games_played_label := %GamesPlayed as Label +@onready var win_rate_label := %WinRate as Label +@onready var high_score_label := %HighScore as Label +@onready var account_type_label := %AccountType as Label +@onready var link_account_btn := %LinkAccountBtn as Button +@onready var logout_btn := %LogoutBtn as Button +@onready var status_label := %StatusLabel as Label +@onready var avatar_popup := %AvatarSelectionPopup as PopupPanel +@onready var avatar_grid := %GridContainer as GridContainer + +func _ready() -> void: + _connect_signals() + _load_profile_data() + _setup_avatar_grid() + +func _connect_signals() -> void: + close_button.pressed.connect(_on_close_pressed) + change_avatar_btn.pressed.connect(_on_change_avatar_pressed) + save_name_btn.pressed.connect(_on_save_name_pressed) + link_account_btn.pressed.connect(_on_link_account_pressed) + logout_btn.pressed.connect(_on_logout_pressed) + + UserProfileManager.profile_updated.connect(_on_profile_updated) + UserProfileManager.profile_update_failed.connect(_on_profile_update_failed) + +func _load_profile_data() -> void: + var profile := UserProfileManager.profile + var stats := UserProfileManager.stats + + # Display name + display_name_input.text = profile.get("display_name", "Guest") + + # Avatar + var avatar_url: String = UserProfileManager.get_avatar_url() + if ResourceLoader.exists(avatar_url): + avatar_display.texture = load(avatar_url) + + # Stats + games_played_label.text = "Games Played: %d" % stats.get("games_played", 0) + win_rate_label.text = "Win Rate: %.1f%%" % UserProfileManager.get_win_rate() + high_score_label.text = "High Score: %d" % stats.get("high_score", 0) + + # Account type + if AuthManager.is_guest: + account_type_label.text = "Account: Guest" + link_account_btn.visible = true + link_account_btn.text = "Link Email (Keep Progress)" + else: + var mode_name := _get_auth_mode_name(AuthManager.auth_mode) + account_type_label.text = "Account: %s" % mode_name + link_account_btn.visible = false + + status_label.text = "" + +func _get_auth_mode_name(mode: int) -> String: + match mode: + AuthManager.AuthMode.EMAIL: + return "Email" + AuthManager.AuthMode.GOOGLE: + return "Google" + AuthManager.AuthMode.APPLE: + return "Apple" + AuthManager.AuthMode.FACEBOOK: + return "Facebook" + _: + return "Guest" + +func _setup_avatar_grid() -> void: + # Clear existing + for child in avatar_grid.get_children(): + child.queue_free() + + # Add avatar buttons + for i in range(UserProfileManager.AVATARS.size()): + var avatar_path: String = UserProfileManager.AVATARS[i] + var btn := Button.new() + btn.custom_minimum_size = Vector2(64, 64) + + if ResourceLoader.exists(avatar_path): + var tex := load(avatar_path) as Texture2D + btn.icon = tex + btn.expand_icon = true + else: + btn.text = str(i + 1) + + btn.pressed.connect(_on_avatar_selected.bind(i)) + avatar_grid.add_child(btn) + +func _on_close_pressed() -> void: + hide() + emit_signal("closed") + +func _on_change_avatar_pressed() -> void: + avatar_popup.popup_centered() + +func _on_avatar_selected(index: int) -> void: + avatar_popup.hide() + status_label.text = "Saving avatar..." + + var success := await UserProfileManager.update_avatar(index) + if success: + var avatar_url: String = UserProfileManager.get_avatar_url() + if ResourceLoader.exists(avatar_url): + avatar_display.texture = load(avatar_url) + status_label.text = "Avatar updated!" + else: + status_label.text = "Failed to update avatar" + +func _on_save_name_pressed() -> void: + var new_name := display_name_input.text.strip_edges() + + if new_name.is_empty(): + status_label.text = "Name cannot be empty" + return + + status_label.text = "Saving..." + save_name_btn.disabled = true + + var success := await UserProfileManager.update_display_name(new_name) + + save_name_btn.disabled = false + + if success: + status_label.text = "Name updated!" + emit_signal("profile_updated") + else: + status_label.text = "Failed to update name" + +func _on_link_account_pressed() -> void: + # Show link account dialog + # For now, just show a simple popup + var dialog := AcceptDialog.new() + dialog.title = "Link Email" + dialog.dialog_text = "Enter your email and password to link this guest account.\nYour progress will be preserved!" + + var vbox := VBoxContainer.new() + var email_input := LineEdit.new() + email_input.placeholder_text = "Email" + var password_input := LineEdit.new() + password_input.placeholder_text = "Password" + password_input.secret = true + + vbox.add_child(email_input) + vbox.add_child(password_input) + dialog.add_child(vbox) + + add_child(dialog) + dialog.popup_centered() + + dialog.confirmed.connect(func(): + var email := email_input.text.strip_edges() + var password := password_input.text + + if email.is_empty() or password.is_empty(): + status_label.text = "Please fill in all fields" + return + + status_label.text = "Linking account..." + var success := await AuthManager.link_email(email, password) + + if success: + status_label.text = "Account linked successfully!" + link_account_btn.visible = false + account_type_label.text = "Account: Email" + else: + status_label.text = "Failed to link account" + + dialog.queue_free() + ) + +func _on_logout_pressed() -> void: + AuthManager.logout() + get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") + +func _on_profile_updated() -> void: + _load_profile_data() + +func _on_profile_update_failed(error: String) -> void: + status_label.text = error + +func show_panel() -> void: + _load_profile_data() + show() diff --git a/server/nakama/README.md b/server/nakama/README.md new file mode 100644 index 0000000..b5f2be7 --- /dev/null +++ b/server/nakama/README.md @@ -0,0 +1,121 @@ +# Nakama Server Setup for Tekton + +This guide explains how to deploy the admin module to your Nakama server. + +## Files + +- `tekton_admin.ts` - TypeScript server runtime module + +## Prerequisites + +1. Nakama server installed and running +2. Node.js and TypeScript for compilation (if using TypeScript) + +## Deployment Steps + +### Option 1: TypeScript (Recommended) + +1. **Install dependencies:** + ```bash + npm install -g typescript + ``` + +2. **Compile TypeScript to JavaScript:** + ```bash + cd server/nakama + tsc tekton_admin.ts --outDir dist --lib ES2020 --types nakama-runtime + ``` + +3. **Copy to Nakama modules directory:** + ```bash + cp dist/tekton_admin.js /path/to/nakama/data/modules/ + ``` + +4. **Restart Nakama server** + +### Option 2: Convert to Lua + +If you prefer Lua, the TypeScript can be converted. See Nakama docs. + +## Configuration + +In your `nakama.yml` or `nakama-docker.yml`: + +```yaml +runtime: + js_entrypoint: "tekton_admin.js" +``` + +Or for multiple modules: + +```yaml +runtime: + js_entrypoint: "index.js" +``` + +## Role System + +User roles are stored in account metadata: + +```json +{ + "role": "player" | "moderator" | "admin" | "owner" +} +``` + +### Setting Initial Admin + +Run this in Nakama console or via API: + +```sql +UPDATE users +SET metadata = '{"role": "owner"}' +WHERE username = 'your_admin_username'; +``` + +Or via Nakama HTTP API: +```bash +curl -X PUT "http://localhost:7350/v2/console/account/{user_id}" \ + -H "Authorization: Bearer {admin_token}" \ + -d '{"metadata": "{\"role\": \"owner\"}"}' +``` + +## RPC Endpoints + +| Endpoint | Required Role | Description | +|----------|--------------|-------------| +| `admin_kick_player` | Host or Admin | Kick from match | +| `admin_ban_player` | Admin only | Ban user | +| `admin_unban_player` | Admin only | Unban user | +| `admin_get_ban_list` | Admin only | Get all bans | +| `admin_get_server_stats` | Host or Admin | Server statistics | +| `admin_end_match` | Host or Admin | End current match | +| `admin_set_user_role` | Owner only | Change user roles | + +## Security Notes + +1. **Never trust the client** - All admin operations are validated server-side +2. **Rate limiting** - Consider adding rate limits for RPC calls +3. **Logging** - All admin actions are logged with user ID +4. **Ban storage** - Bans are stored in Nakama storage, not local files + +## Testing + +1. Create a test user with admin role +2. Login with the admin user +3. Press F10 in-game to open admin panel +4. Verify kick/ban operations work + +## Troubleshooting + +### RPC Not Found +- Check module is loaded: `nakama --help` or check logs +- Verify js_entrypoint in config + +### Permission Denied +- Check user has correct role in metadata +- Verify session is valid + +### Ban Not Working +- Check Nakama storage permissions +- Verify user_id is correct format (UUID) diff --git a/server/nakama/tekton_admin.ts b/server/nakama/tekton_admin.ts new file mode 100644 index 0000000..ea7b1a9 --- /dev/null +++ b/server/nakama/tekton_admin.ts @@ -0,0 +1,550 @@ +/** + * Tekton Nakama Server Runtime Module + * + * This module provides secure admin operations via RPC calls. + * Deploy this to your Nakama server's runtime directory. + * + * For TypeScript modules, compile to JavaScript and place in: + * - data/modules/ (for Nakama Docker) + * - Or configure in nakama config.yml + */ + +// Initialize RPC endpoints +function InitModule( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + initializer: nkruntime.Initializer +) { + // Admin RPCs + initializer.registerRpc("admin_kick_player", rpcAdminKickPlayer); + initializer.registerRpc("admin_ban_player", rpcAdminBanPlayer); + initializer.registerRpc("admin_unban_player", rpcAdminUnbanPlayer); + initializer.registerRpc("admin_get_ban_list", rpcAdminGetBanList); + initializer.registerRpc("admin_get_server_stats", rpcAdminGetServerStats); + initializer.registerRpc("admin_get_player_list", rpcAdminGetPlayerList); + initializer.registerRpc("admin_end_match", rpcAdminEndMatch); + initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole); + + // User management RPCs + initializer.registerRpc("get_user_profile", rpcGetUserProfile); + initializer.registerRpc("update_user_profile", rpcUpdateUserProfile); + + logger.info("Tekton admin module loaded"); +} + +// ============================================================================= +// Authorization Helpers +// ============================================================================= + +interface UserMetadata { + role?: string; + banned?: boolean; + ban_reason?: string; + ban_expires?: string; +} + +const ADMIN_ROLES = ["admin", "moderator", "owner"]; + +function isAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): boolean { + if (!ctx.userId) return false; + + try { + const account = nk.accountGetId(ctx.userId); + const metadata = JSON.parse(account.user.metadata || "{}") as UserMetadata; + return ADMIN_ROLES.includes(metadata.role || ""); + } catch (e) { + return false; + } +} + +function isMatchHost( + ctx: nkruntime.Context, + nk: nkruntime.Nakama, + matchId: string +): boolean { + if (!ctx.userId || !matchId) return false; + + try { + // Get match state to check host + const match = nk.matchGet(matchId); + if (!match) return false; + + // The first user to join (presence) is typically the host + // This logic may need adjustment based on your match handler + const state = JSON.parse(match.state || "{}"); + return state.hostUserId === ctx.userId; + } catch (e) { + return false; + } +} + +function requireAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): void { + if (!isAdmin(ctx, nk)) { + throw new Error("Admin privileges required"); + } +} + +function requireAdminOrHost( + ctx: nkruntime.Context, + nk: nkruntime.Nakama, + matchId: string +): void { + if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) { + throw new Error("Admin or host privileges required"); + } +} + +// ============================================================================= +// Admin RPCs +// ============================================================================= + +interface KickPlayerRequest { + match_id: string; + user_id: string; + reason?: string; +} + +function rpcAdminKickPlayer( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload) as KickPlayerRequest; + + requireAdminOrHost(ctx, nk, request.match_id); + + // Can't kick yourself + if (request.user_id === ctx.userId) { + throw new Error("Cannot kick yourself"); + } + + try { + // Signal the match to kick the player + nk.matchSignal(request.match_id, JSON.stringify({ + action: "kick", + user_id: request.user_id, + reason: request.reason || "Kicked by admin" + })); + + logger.info(`Player ${request.user_id} kicked from match ${request.match_id} by ${ctx.userId}`); + + return JSON.stringify({ success: true }); + } catch (e) { + logger.error(`Failed to kick player: ${e}`); + throw new Error("Failed to kick player"); + } +} + +interface BanPlayerRequest { + user_id: string; + reason?: string; + duration_hours?: number; // 0 = permanent + match_id?: string; // Optional: also kick from current match +} + +function rpcAdminBanPlayer( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload) as BanPlayerRequest; + + // Only full admins can ban (not just match hosts) + requireAdmin(ctx, nk); + + if (request.user_id === ctx.userId) { + throw new Error("Cannot ban yourself"); + } + + try { + // Get target user's account + const targetAccount = nk.accountGetId(request.user_id); + const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata; + + // Don't allow banning other admins + if (ADMIN_ROLES.includes(metadata.role || "")) { + throw new Error("Cannot ban an admin"); + } + + // Set ban in metadata + const banExpires = request.duration_hours && request.duration_hours > 0 + ? new Date(Date.now() + request.duration_hours * 60 * 60 * 1000).toISOString() + : null; + + metadata.banned = true; + metadata.ban_reason = request.reason || "Banned by admin"; + if (banExpires) { + metadata.ban_expires = banExpires; + } + + nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata)); + + // Also kick from current match if specified + if (request.match_id) { + nk.matchSignal(request.match_id, JSON.stringify({ + action: "kick", + user_id: request.user_id, + reason: "Banned: " + (request.reason || "") + })); + } + + // Store in ban list (for quick lookup) + const banRecord = { + user_id: request.user_id, + username: targetAccount.user.username, + banned_by: ctx.userId, + banned_at: new Date().toISOString(), + reason: request.reason, + expires: banExpires + }; + + nk.storageWrite([{ + collection: "bans", + key: request.user_id, + userId: "00000000-0000-0000-0000-000000000000", // System-owned + value: banRecord, + permissionRead: 2, // Public read + permissionWrite: 0 // No one can write (except server) + }]); + + logger.warn(`Player ${request.user_id} banned by ${ctx.userId}. Reason: ${request.reason}`); + + return JSON.stringify({ success: true, ban: banRecord }); + } catch (e) { + logger.error(`Failed to ban player: ${e}`); + throw new Error(`Failed to ban player: ${e}`); + } +} + +interface UnbanPlayerRequest { + user_id: string; +} + +function rpcAdminUnbanPlayer( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload) as UnbanPlayerRequest; + + requireAdmin(ctx, nk); + + try { + // Get target user's account + const targetAccount = nk.accountGetId(request.user_id); + const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata; + + // Remove ban + delete metadata.banned; + delete metadata.ban_reason; + delete metadata.ban_expires; + + nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata)); + + // Remove from ban list + nk.storageDelete([{ + collection: "bans", + key: request.user_id, + userId: "00000000-0000-0000-0000-000000000000" + }]); + + logger.info(`Player ${request.user_id} unbanned by ${ctx.userId}`); + + return JSON.stringify({ success: true }); + } catch (e) { + logger.error(`Failed to unban player: ${e}`); + throw new Error("Failed to unban player"); + } +} + +function rpcAdminGetBanList( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + requireAdmin(ctx, nk); + + try { + const result = nk.storageList( + "00000000-0000-0000-0000-000000000000", + "bans", + 100, + "" + ); + + const bans = result.objects?.map(obj => obj.value) || []; + + return JSON.stringify({ bans }); + } catch (e) { + logger.error(`Failed to get ban list: ${e}`); + return JSON.stringify({ bans: [] }); + } +} + +interface GetServerStatsRequest { + match_id?: string; +} + +function rpcAdminGetServerStats( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload || "{}") as GetServerStatsRequest; + + if (request.match_id) { + requireAdminOrHost(ctx, nk, request.match_id); + } else { + requireAdmin(ctx, nk); + } + + try { + // Get server-wide stats + const matches = nk.matchList(100, true, null, null, null, null); + const activeMatchCount = matches?.length || 0; + + let totalPlayers = 0; + if (matches) { + for (const match of matches) { + totalPlayers += match.size || 0; + } + } + + const stats = { + active_matches: activeMatchCount, + total_players: totalPlayers, + server_time: new Date().toISOString() + }; + + // If specific match requested, include match details + if (request.match_id) { + try { + const match = nk.matchGet(request.match_id); + if (match) { + (stats as any).match = { + id: match.matchId, + size: match.size, + tick_rate: match.tickRate, + authoritative: match.authoritative + }; + } + } catch (e) { + // Match not found + } + } + + return JSON.stringify(stats); + } catch (e) { + logger.error(`Failed to get server stats: ${e}`); + throw new Error("Failed to get server stats"); + } +} + +interface GetPlayerListRequest { + match_id: string; +} + +function rpcAdminGetPlayerList( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload) as GetPlayerListRequest; + + requireAdminOrHost(ctx, nk, request.match_id); + + try { + const match = nk.matchGet(request.match_id); + if (!match) { + throw new Error("Match not found"); + } + + // Get player details + const players: any[] = []; + + // Note: In actual implementation, you'd need to track presences + // This is a simplified version - adjust based on your match handler + + return JSON.stringify({ players }); + } catch (e) { + logger.error(`Failed to get player list: ${e}`); + throw new Error("Failed to get player list"); + } +} + +interface EndMatchRequest { + match_id: string; + reason?: string; +} + +function rpcAdminEndMatch( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload) as EndMatchRequest; + + requireAdminOrHost(ctx, nk, request.match_id); + + try { + // Signal match to end + nk.matchSignal(request.match_id, JSON.stringify({ + action: "end_match", + reason: request.reason || "Ended by admin" + })); + + logger.info(`Match ${request.match_id} ended by ${ctx.userId}`); + + return JSON.stringify({ success: true }); + } catch (e) { + logger.error(`Failed to end match: ${e}`); + throw new Error("Failed to end match"); + } +} + +interface SetUserRoleRequest { + user_id: string; + role: string; // "player", "moderator", "admin" +} + +function rpcAdminSetUserRole( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload) as SetUserRoleRequest; + + // Only owner/super-admin can set roles + const callerAccount = nk.accountGetId(ctx.userId!); + const callerMetadata = JSON.parse(callerAccount.user.metadata || "{}"); + + if (callerMetadata.role !== "owner") { + throw new Error("Only owners can modify user roles"); + } + + const validRoles = ["player", "moderator", "admin"]; + if (!validRoles.includes(request.role)) { + throw new Error("Invalid role"); + } + + try { + const targetAccount = nk.accountGetId(request.user_id); + const metadata = JSON.parse(targetAccount.user.metadata || "{}"); + + metadata.role = request.role; + + nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata)); + + logger.info(`User ${request.user_id} role set to ${request.role} by ${ctx.userId}`); + + return JSON.stringify({ success: true, role: request.role }); + } catch (e) { + logger.error(`Failed to set user role: ${e}`); + throw new Error("Failed to set user role"); + } +} + +// ============================================================================= +// User Profile RPCs +// ============================================================================= + +function rpcGetUserProfile( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const request = JSON.parse(payload || "{}"); + const targetUserId = request.user_id || ctx.userId; + + try { + const account = nk.accountGetId(targetUserId!); + const metadata = JSON.parse(account.user.metadata || "{}"); + + // Check if banned + if (metadata.banned && targetUserId === ctx.userId) { + // Check if ban expired + if (metadata.ban_expires) { + const expiresAt = new Date(metadata.ban_expires); + if (expiresAt <= new Date()) { + // Ban expired, remove it + delete metadata.banned; + delete metadata.ban_reason; + delete metadata.ban_expires; + nk.accountUpdateId(targetUserId!, null, null, null, null, null, null, JSON.stringify(metadata)); + } else { + throw new Error(`Account banned until ${metadata.ban_expires}. Reason: ${metadata.ban_reason}`); + } + } else { + throw new Error(`Account permanently banned. Reason: ${metadata.ban_reason}`); + } + } + + return JSON.stringify({ + user_id: account.user.id, + username: account.user.username, + display_name: account.user.displayName, + avatar_url: account.user.avatarUrl, + create_time: account.user.createTime, + role: metadata.role || "player" + }); + } catch (e) { + throw e; + } +} + +interface UpdateProfileRequest { + display_name?: string; + avatar_url?: string; +} + +function rpcUpdateUserProfile( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + if (!ctx.userId) { + throw new Error("Not authenticated"); + } + + const request = JSON.parse(payload) as UpdateProfileRequest; + + try { + nk.accountUpdateId( + ctx.userId, + null, // username + request.display_name || null, + null, // timezone + null, // location + null, // lang + request.avatar_url || null, + null // metadata + ); + + return JSON.stringify({ success: true }); + } catch (e) { + logger.error(`Failed to update profile: ${e}`); + throw new Error("Failed to update profile"); + } +} + +// Before login hook to check ban status +function beforeAuthenticateEmail( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + data: nkruntime.AuthenticateEmailRequest +): nkruntime.AuthenticateEmailRequest { + // Can't check ban before auth, so we check in afterAuthenticate + return data; +}