From e222cc49ee7ea8ce92e278e229ec8d349cd8feb8 Mon Sep 17 00:00:00 2001 From: adtpdn Date: Wed, 8 Apr 2026 03:12:55 +0800 Subject: [PATCH] feat : update backend --- .../client/NakamaClient.gd | 1 - .../enhanced_gridmap/meshlibrary/default.tres | 2 +- scenes/ui/leaderboard_panel.tscn | 17 +- scenes/ui/login_screen.tscn | 220 ++++-- scenes/ui/profile_panel.tscn | 69 +- scripts/managers/user_profile_manager.gd | 56 +- scripts/ui/leaderboard_panel.gd | 90 ++- scripts/ui/login_screen.gd | 170 +---- scripts/ui/profile_panel.gd | 83 +++ server/nakama/tekton_admin.js | 147 +++- server/nakama/tekton_admin.ts | 699 ------------------ 11 files changed, 619 insertions(+), 935 deletions(-) delete mode 100644 server/nakama/tekton_admin.ts diff --git a/addons/com.heroiclabs.nakama/client/NakamaClient.gd b/addons/com.heroiclabs.nakama/client/NakamaClient.gd index 84ff608..5c54132 100644 --- a/addons/com.heroiclabs.nakama/client/NakamaClient.gd +++ b/addons/com.heroiclabs.nakama/client/NakamaClient.gd @@ -1081,4 +1081,3 @@ func write_tournament_record2_async(p_session : NakamaSession, "score": str(p_score), "subscore": str(p_subscore) })) - diff --git a/addons/enhanced_gridmap/meshlibrary/default.tres b/addons/enhanced_gridmap/meshlibrary/default.tres index 5c1d663..cdb133e 100644 --- a/addons/enhanced_gridmap/meshlibrary/default.tres +++ b/addons/enhanced_gridmap/meshlibrary/default.tres @@ -14,7 +14,7 @@ [ext_resource type="Texture2D" uid="uid://dpkx1a780pvwv" path="res://assets/textures/tile_diamond.png" id="10_sx8rm"] [ext_resource type="BoxMesh" uid="uid://fy4bhoeii40c" path="res://addons/enhanced_gridmap/meshlibrary/tile_safe_zone.tres" id="10_uwjsj"] [ext_resource type="BoxMesh" uid="uid://b5cc3prem52r6" path="res://addons/enhanced_gridmap/meshlibrary/tile_freeze.tres" id="11_pgnbl"] -[ext_resource type="BoxMesh" uid="uid://dcjdwbffgtutt" path="res://addons/enhanced_gridmap/meshlibrary/tile_non_walkable.tres" id="11_uwjsj"] +[ext_resource type="BoxMesh" path="res://addons/enhanced_gridmap/meshlibrary/tile_non_walkable.tres" id="11_uwjsj"] [sub_resource type="CompressedTexture2D" id="CompressedTexture2D_5d0gc"] load_path = "res://.godot/imported/tile_heart.png-deeef50755ca225f028608dfd16900e6.s3tc.ctex" diff --git a/scenes/ui/leaderboard_panel.tscn b/scenes/ui/leaderboard_panel.tscn index 1715d7f..28bf6be 100644 --- a/scenes/ui/leaderboard_panel.tscn +++ b/scenes/ui/leaderboard_panel.tscn @@ -63,13 +63,20 @@ layout_mode = 2 theme_override_fonts/font = ExtResource("3_font") text = "← BACK" -[node name="RefreshBtn" type="Button" parent="MainLayout/LeftPanel/LeftVBox/Header"] +[node name="RefreshBtn" type="Button" parent="MainLayout/LeftPanel/LeftVBox/Header" unique_id=993543919] unique_name_in_owner = true custom_minimum_size = Vector2(44, 44) layout_mode = 2 tooltip_text = "Refresh Data" text = "⟳" +[node name="SyncBtn" type="Button" parent="MainLayout/LeftPanel/LeftVBox/Header" unique_id=1452457095] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 44) +layout_mode = 2 +tooltip_text = "Sync your score to the global leaderboard" +text = "↑ Sync Score" + [node name="Title" type="Label" parent="MainLayout/LeftPanel/LeftVBox/Header" unique_id=1037998429] layout_mode = 2 size_flags_horizontal = 3 @@ -186,21 +193,21 @@ autoplay = &"animation-pack/idle" layout_mode = 2 size_flags_vertical = 8 -[node name="InfoMargin" type="MarginContainer" parent="MainLayout/RightPanel/SelectedPlayerInfo"] +[node name="InfoMargin" type="MarginContainer" parent="MainLayout/RightPanel/SelectedPlayerInfo" unique_id=882298034] layout_mode = 2 theme_override_constants/margin_left = 20 -theme_override_constants/margin_right = 20 theme_override_constants/margin_top = 15 +theme_override_constants/margin_right = 20 theme_override_constants/margin_bottom = 15 -[node name="InfoVBox" type="VBoxContainer" parent="MainLayout/RightPanel/SelectedPlayerInfo/InfoMargin"] +[node name="InfoVBox" type="VBoxContainer" parent="MainLayout/RightPanel/SelectedPlayerInfo/InfoMargin" unique_id=567154378] layout_mode = 2 theme_override_constants/separation = 4 [node name="SelectedNameLabel" type="Label" parent="MainLayout/RightPanel/SelectedPlayerInfo/InfoMargin/InfoVBox" unique_id=1940372038] unique_name_in_owner = true layout_mode = 2 -theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_color = Color(0.69803923, 0.5411765, 0.3647059, 1) theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 24 text = "PLAYER NAME" diff --git a/scenes/ui/login_screen.tscn b/scenes/ui/login_screen.tscn index 07b621d..72aac54 100644 --- a/scenes/ui/login_screen.tscn +++ b/scenes/ui/login_screen.tscn @@ -1,9 +1,9 @@ -[gd_scene load_steps=3 format=3 uid="uid://b00eef4tqt2ri"] +[gd_scene format=3 uid="uid://b00eef4tqt2ri"] [ext_resource type="Script" uid="uid://b1lcy1ikdkknq" path="res://scripts/ui/login_screen.gd" id="1"] [ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2"] -[node name="LoginScreen" type="Control"] +[node name="LoginScreen" type="Control" unique_id=1394670984] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -13,7 +13,7 @@ grow_vertical = 2 theme = ExtResource("2") script = ExtResource("1") -[node name="Background" type="ColorRect" parent="."] +[node name="Background" type="ColorRect" parent="." unique_id=1913867293] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -22,7 +22,7 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0.12, 0.1, 0.08, 1) -[node name="CenterContainer" type="CenterContainer" parent="."] +[node name="CenterContainer" type="CenterContainer" parent="." unique_id=706535114] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -30,177 +30,251 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -[node name="MainPanel" type="PanelContainer" parent="CenterContainer"] -custom_minimum_size = Vector2(400, 0) +[node name="LayoutVBox" type="VBoxContainer" parent="CenterContainer" unique_id=1110313476] layout_mode = 2 +theme_override_constants/separation = 12 -[node name="VBox" type="VBoxContainer" parent="CenterContainer/MainPanel"] +[node name="HeaderVBox" type="VBoxContainer" parent="CenterContainer/LayoutVBox" unique_id=851120967] +visible = false layout_mode = 2 -theme_override_constants/separation = 16 +theme_override_constants/separation = 2 -[node name="LogoLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +[node name="LogoLabel" type="Label" parent="CenterContainer/LayoutVBox/HeaderVBox" unique_id=73067558] layout_mode = 2 theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1) -theme_override_font_sizes/font_size = 48 +theme_override_font_sizes/font_size = 42 text = "TEKTON" horizontal_alignment = 1 -[node name="SubtitleLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +[node name="SubtitleLabel" type="Label" parent="CenterContainer/LayoutVBox/HeaderVBox" unique_id=240064697] layout_mode = 2 theme_override_colors/font_color = Color(0.992, 0.796, 0.047, 1) theme_override_font_sizes/font_size = 14 text = "Armageddon" horizontal_alignment = 1 -[node name="Spacer1" type="Control" parent="CenterContainer/MainPanel/VBox"] -custom_minimum_size = Vector2(0, 16) +[node name="ServerSelectionSection" type="VBoxContainer" parent="CenterContainer/LayoutVBox" unique_id=580109643] layout_mode = 2 +theme_override_constants/separation = 8 -[node name="GuestButton" type="Button" parent="CenterContainer/MainPanel/VBox"] -unique_name_in_owner = true -custom_minimum_size = Vector2(0, 52) -layout_mode = 2 -theme_override_font_sizes/font_size = 18 -text = "▶ PLAY AS GUEST" - -[node name="Spacer2" type="Control" parent="CenterContainer/MainPanel/VBox"] -custom_minimum_size = Vector2(0, 8) -layout_mode = 2 - -[node name="OrLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +[node name="ConnectionModeLabel" type="Label" parent="CenterContainer/LayoutVBox/ServerSelectionSection" unique_id=2002206900] layout_mode = 2 theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1) -text = "─────── or sign in ───────" +theme_override_font_sizes/font_size = 12 +text = "CONNECTION MODE" + +[node name="ServerOption" type="OptionButton" parent="CenterContainer/LayoutVBox/ServerSelectionSection" unique_id=975117024] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +selected = 3 +item_count = 4 +popup/item_0/text = "Nakama - Localhost (Testing)" +popup/item_0/id = 0 +popup/item_1/text = "Nakama - Remote Server (Host IP)" +popup/item_1/id = 1 +popup/item_2/text = "LAN Direct (No Server)" +popup/item_2/id = 2 +popup/item_3/text = "Nakama - Tekton Dash EU" +popup/item_3/id = 3 + +[node name="ServerIPInput" type="LineEdit" parent="CenterContainer/LayoutVBox/ServerSelectionSection" unique_id=1444265129] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "127.0.0.1" +placeholder_text = "Enter Nakama Server IP..." + +[node name="LANSection" type="VBoxContainer" parent="CenterContainer/LayoutVBox/ServerSelectionSection" unique_id=1917634402] +unique_name_in_owner = true +visible = false +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="LanInfo" type="Label" parent="CenterContainer/LayoutVBox/ServerSelectionSection/LANSection" unique_id=75077084] +layout_mode = 2 +theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) +theme_override_font_sizes/font_size = 12 +text = "Play over LAN without any server. +Firewall may need to allow port 7777." +autowrap_mode = 3 + +[node name="LANHostBtn" type="Button" parent="CenterContainer/LayoutVBox/ServerSelectionSection/LANSection" unique_id=751026831] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "HOST LAN GAME" + +[node name="LanSeparator" type="Label" parent="CenterContainer/LayoutVBox/ServerSelectionSection/LANSection" unique_id=2088668724] +layout_mode = 2 +theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1) +theme_override_font_sizes/font_size = 11 +text = "── or join a friend ──" horizontal_alignment = 1 -[node name="EmailInput" type="LineEdit" parent="CenterContainer/MainPanel/VBox"] +[node name="LANIPInput" type="LineEdit" parent="CenterContainer/LayoutVBox/ServerSelectionSection/LANSection" unique_id=1859021984] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "127.0.0.1" +placeholder_text = "Host IP (e.g. 192.168.1.10)" + +[node name="LANJoinBtn" type="Button" parent="CenterContainer/LayoutVBox/ServerSelectionSection/LANSection" unique_id=2009389522] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +text = "JOIN LAN GAME" + +[node name="HSeparator" type="HSeparator" parent="CenterContainer/LayoutVBox" unique_id=1424264812] +layout_mode = 2 +[node name="TabContainer" type="TabContainer" parent="CenterContainer/LayoutVBox" unique_id=2135112748] +unique_name_in_owner = true +custom_minimum_size = Vector2(420, 0) +layout_mode = 2 +current_tab = 0 + +[node name="Login" type="MarginContainer" parent="CenterContainer/LayoutVBox/TabContainer" unique_id=1294712927] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 +metadata/_tab_index = 0 + +[node name="VBox" type="VBoxContainer" parent="CenterContainer/LayoutVBox/TabContainer/Login" unique_id=1063599539] +layout_mode = 2 +theme_override_constants/separation = 12 + + +[node name="GuestButton" type="Button" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=2037993426] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "â–¶ PLAY AS GUEST" + +[node name="OrLabel" type="Label" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=1387412213] +layout_mode = 2 +theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1) +text = "─────── or sign in ───────" +horizontal_alignment = 1 + +[node name="EmailInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=323436289] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Email" -[node name="PasswordInput" type="LineEdit" parent="CenterContainer/MainPanel/VBox"] +[node name="PasswordInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=1098627484] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Password" secret = true -[node name="RememberMe" type="CheckBox" parent="CenterContainer/MainPanel/VBox"] +[node name="RememberMe" type="CheckBox" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=1013722117] unique_name_in_owner = true layout_mode = 2 button_pressed = true text = "Remember me" -[node name="LoginButton" type="Button" parent="CenterContainer/MainPanel/VBox"] +[node name="LoginButton" type="Button" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=945681236] unique_name_in_owner = true custom_minimum_size = Vector2(0, 48) layout_mode = 2 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" - -[node name="Spacer3" type="Control" parent="CenterContainer/MainPanel/VBox"] -custom_minimum_size = Vector2(0, 8) -layout_mode = 2 - -[node name="SocialLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +[node name="SocialLabel" type="Label" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=840440943] layout_mode = 2 theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1) -text = "─────── or continue with ───────" +text = "─────── or continue with ───────" horizontal_alignment = 1 -[node name="SocialButtons" type="HBoxContainer" parent="CenterContainer/MainPanel/VBox"] +[node name="SocialButtons" type="HBoxContainer" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=1899762021] layout_mode = 2 theme_override_constants/separation = 12 alignment = 1 -[node name="GoogleButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"] +[node name="GoogleButton" type="Button" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox/SocialButtons" unique_id=196743645] unique_name_in_owner = true custom_minimum_size = Vector2(100, 44) layout_mode = 2 text = "Google" -[node name="AppleButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"] +[node name="AppleButton" type="Button" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox/SocialButtons" unique_id=1174582184] unique_name_in_owner = true custom_minimum_size = Vector2(100, 44) layout_mode = 2 text = "Apple" -[node name="FacebookButton" type="Button" parent="CenterContainer/MainPanel/VBox/SocialButtons"] +[node name="FacebookButton" type="Button" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox/SocialButtons" unique_id=1252096120] unique_name_in_owner = true custom_minimum_size = Vector2(100, 44) layout_mode = 2 text = "Facebook" -[node name="StatusLabel" type="Label" parent="CenterContainer/MainPanel/VBox"] +[node name="StatusLabel" type="Label" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=1679807364] 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"] +[node name="LoadingSpinner" type="TextureProgressBar" parent="CenterContainer/LayoutVBox/TabContainer/Login/VBox" unique_id=1985748047] unique_name_in_owner = true visible = false custom_minimum_size = Vector2(40, 40) layout_mode = 2 size_flags_horizontal = 4 -[node name="RegistrationPanel" type="PanelContainer" parent="CenterContainer"] -unique_name_in_owner = true +[node name="Register" type="MarginContainer" parent="CenterContainer/LayoutVBox/TabContainer" unique_id=1373074681] visible = false -custom_minimum_size = Vector2(400, 0) layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 +metadata/_tab_index = 1 -[node name="VBox" type="VBoxContainer" parent="CenterContainer/RegistrationPanel"] +[node name="VBox" type="VBoxContainer" parent="CenterContainer/LayoutVBox/TabContainer/Register" unique_id=839364109] 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.647, 0.996, 0.224, 1) -theme_override_font_sizes/font_size = 24 -text = "Create Account" -horizontal_alignment = 1 - -[node name="RegEmailInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegEmailInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=1906331672] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Email" -[node name="RegUsernameInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegUsernameInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=853099369] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Username" -[node name="RegPasswordInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegPasswordInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=1603528515] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Password" secret = true -[node name="RegConfirmPasswordInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegConfirmPasswordInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=830465730] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Confirm Password" secret = true -[node name="PasswordStrength" type="ProgressBar" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="PasswordStrength" type="ProgressBar" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=2023454025] unique_name_in_owner = true custom_minimum_size = Vector2(0, 12) layout_mode = 2 max_value = 4.0 show_percentage = false -[node name="PasswordHint" type="Label" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="PasswordHint" type="Label" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=1402181329] unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1) @@ -208,43 +282,38 @@ theme_override_font_sizes/font_size = 11 text = "Min 8 characters, include number and symbol" horizontal_alignment = 1 -[node name="RegCaptchaContainer" type="VBoxContainer" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegCaptchaContainer" type="VBoxContainer" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=583603175] layout_mode = 2 theme_override_constants/separation = 4 -[node name="RegCaptchaQuestion" type="Label" parent="CenterContainer/RegistrationPanel/VBox/RegCaptchaContainer"] +[node name="RegCaptchaQuestion" type="Label" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox/RegCaptchaContainer" unique_id=1792478300] unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1) text = "Security Check: 5 + 7 = ?" horizontal_alignment = 1 -[node name="RegCaptchaInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox/RegCaptchaContainer"] +[node name="RegCaptchaInput" type="LineEdit" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox/RegCaptchaContainer" unique_id=1405718888] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 placeholder_text = "Answer" alignment = 1 -[node name="RegisterButton" type="Button" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegisterButton" type="Button" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=2040733064] unique_name_in_owner = true custom_minimum_size = Vector2(0, 48) layout_mode = 2 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" - -[node name="RegStatusLabel" type="Label" parent="CenterContainer/RegistrationPanel/VBox"] +[node name="RegStatusLabel" type="Label" parent="CenterContainer/LayoutVBox/TabContainer/Register/VBox" unique_id=1096594976] 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="."] +[node name="VersionLabel" type="Label" parent="." unique_id=481275614] layout_mode = 1 anchors_preset = 3 anchor_left = 1.0 @@ -261,3 +330,4 @@ theme_override_colors/font_color = Color(0.6, 0.6, 0.6, 0.6) theme_override_font_sizes/font_size = 11 text = "v0.9.0" horizontal_alignment = 2 + diff --git a/scenes/ui/profile_panel.tscn b/scenes/ui/profile_panel.tscn index 6cfee16..7fc994d 100644 --- a/scenes/ui/profile_panel.tscn +++ b/scenes/ui/profile_panel.tscn @@ -227,7 +227,7 @@ theme_override_colors/font_color = Color(0.57, 0.32090998, 0.1767, 1) theme_override_fonts/font = ExtResource("3_e13i6") text = "High Score: 0" -[node name="AccountSection" type="VBoxContainer" parent="MainLayout/LeftPanel/LeftVBox" unique_id=1730058560] +[node name="AccountSection" type="HBoxContainer" parent="MainLayout/LeftPanel/LeftVBox" unique_id=1267378790] layout_mode = 2 theme_override_constants/separation = 8 @@ -242,8 +242,8 @@ text = "Link Email (Keep Progress)" unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.4, 1) theme_override_fonts/font = ExtResource("3_e13i6") -theme_override_colors/font_color = Color(1.0, 0.4, 0.4, 1.0) text = "Server Admin Panel" [node name="LogoutBtn" type="Button" parent="MainLayout/LeftPanel/LeftVBox/AccountSection" unique_id=1741557407] @@ -271,7 +271,7 @@ unique_name_in_owner = true own_world_3d = true transparent_bg = true handle_input_locally = false -size = Vector2i(746, 742) +size = Vector2i(746, 720) render_target_update_mode = 4 [node name="WorldEnvironment" type="WorldEnvironment" parent="MainLayout/RightPanel/PreviewViewport" unique_id=1213356390] @@ -328,3 +328,66 @@ offset_top = 4.0 offset_right = 316.0 offset_bottom = 216.0 columns = 3 + +[node name="AccountSettingsDialog" type="AcceptDialog" parent="." unique_id=987000001] +unique_name_in_owner = true +title = "Account Settings" +size = Vector2i(360, 480) + +[node name="VBox" type="VBoxContainer" parent="AccountSettingsDialog" unique_id=987000002] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 352.0 +offset_bottom = 431.0 +theme_override_constants/separation = 12 + +[node name="CredTitle" type="Label" parent="AccountSettingsDialog/VBox" unique_id=987000003] +layout_mode = 2 +text = "Update Credentials" + +[node name="OldPassInput" type="LineEdit" parent="AccountSettingsDialog/VBox" unique_id=987000004] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Current Password" +secret = true + +[node name="NewEmailInput" type="LineEdit" parent="AccountSettingsDialog/VBox" unique_id=987000005] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "New Email" + +[node name="NewPassInput" type="LineEdit" parent="AccountSettingsDialog/VBox" unique_id=987000006] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "New Password" +secret = true + +[node name="SubmitCredBtn" type="Button" parent="AccountSettingsDialog/VBox" unique_id=987000007] +unique_name_in_owner = true +layout_mode = 2 +text = "Submit Credentials" + +[node name="HSeparator1" type="HSeparator" parent="AccountSettingsDialog/VBox" unique_id=987000008] +layout_mode = 2 + +[node name="TzLabel" type="Label" parent="AccountSettingsDialog/VBox" unique_id=987000009] +layout_mode = 2 +text = "Timezone" + +[node name="TzDropdown" type="OptionButton" parent="AccountSettingsDialog/VBox" unique_id=987000010] +unique_name_in_owner = true +layout_mode = 2 + +[node name="SaveTzBtn" type="Button" parent="AccountSettingsDialog/VBox" unique_id=987000011] +unique_name_in_owner = true +layout_mode = 2 +text = "Save Timezone" + +[node name="HSeparator2" type="HSeparator" parent="AccountSettingsDialog/VBox" unique_id=987000012] +layout_mode = 2 + +[node name="ResetStatsBtn" type="Button" parent="AccountSettingsDialog/VBox" unique_id=987000013] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0, 0, 1) +text = "RESET ALL GAME STATS" diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd index c8304eb..c4c7204 100644 --- a/scripts/managers/user_profile_manager.gd +++ b/scripts/managers/user_profile_manager.gd @@ -86,6 +86,11 @@ func load_profile() -> Dictionary: is_profile_loaded = true emit_signal("profile_loaded", profile) print("[UserProfileManager] Profile loaded: ", profile.display_name) + + # Auto-sync existing score to native Nakama leaderboard in background + if stats.get("high_score", 0) > 0 and NakamaManager.session and not AuthManager.is_guest: + _submit_to_leaderboard.call_deferred() + return profile func load_stats() -> Dictionary: @@ -135,15 +140,16 @@ func update_display_name(new_name: String) -> bool: emit_signal("profile_update_failed", "Name can only contain letters, numbers, and spaces") return false - # Allow guest name updates locally - if not NakamaManager.session or AuthManager.is_guest: + # Offline fallback (no session) + if not NakamaManager.session: profile["display_name"] = new_name emit_signal("profile_updated") return true + var formatted_username = new_name.replace(" ", "_").to_lower() var result: NakamaAsyncResult = await NakamaManager.client.update_account_async( NakamaManager.session, - null, # username (don't change) + formatted_username, # username (sync to display name) new_name # display_name ) @@ -168,10 +174,24 @@ func update_avatar(avatar_index: int) -> bool: profile["avatar_index"] = avatar_index profile["avatar_url"] = AVATARS[avatar_index] + + # Update native avatar URL for the leaderboard + var avatar_url = AVATARS[avatar_index] + var result = await NakamaManager.client.update_account_async( + NakamaManager.session, null, null, avatar_url + ) + if result.is_exception(): + emit_signal("profile_update_failed", "Failed to update native avatar") + return false + var success := await _save_profile_data() if success: - emit_signal("avatar_changed", AVATARS[avatar_index]) + emit_signal("avatar_changed", avatar_url) emit_signal("profile_updated") + + # Immediately update leaderboard with new avatar + if stats.get("high_score", 0) > 0: + _submit_to_leaderboard.call_deferred() return success @@ -255,6 +275,34 @@ func record_game_result(won: bool, score: int) -> void: stats["high_score"] = score await update_stats(stats) + + # Also submit to Nakama native leaderboard so global_high_score is populated + await _submit_to_leaderboard() + +func _submit_to_leaderboard() -> void: + """Submits the current high_score via server RPC (required for authoritative leaderboards).""" + if not NakamaManager.session: + return + if stats.get("high_score", 0) <= 0: + return + + var payload = JSON.stringify({ + "score": int(stats.get("high_score", 0)), + "games_played": int(stats.get("games_played", 0)), + "games_won": int(stats.get("games_won", 0)), + "avatar_url": profile.get("avatar_url", "") + }) + + var result = await NakamaManager.client.rpc_async( + NakamaManager.session, + "submit_score", + payload + ) + + if result.is_exception(): + push_warning("[UserProfileManager] Leaderboard RPC failed: ", result.get_exception().message) + else: + print("[UserProfileManager] Leaderboard score submitted: ", stats.get("high_score", 0)) # ============================================================================= # Getters diff --git a/scripts/ui/leaderboard_panel.gd b/scripts/ui/leaderboard_panel.gd index 8cdb8f5..2f09ede 100644 --- a/scripts/ui/leaderboard_panel.gd +++ b/scripts/ui/leaderboard_panel.gd @@ -1,5 +1,5 @@ extends Control -## Leaderboard panel — full-scene layout. +## Leaderboard panel — reads from Nakama native leaderboard (global_high_score). ## Left: sortable leaderboard list. ## Right: 3D SubViewport character preview of the selected/top-ranked player. @@ -10,6 +10,7 @@ signal closed # ------------------------------------------------------------------------- @onready var back_btn := %BackBtn as Button @onready var refresh_btn := %RefreshBtn as Button +@onready var sync_btn := %SyncBtn as Button @onready var sort_score_btn := %SortScoreBtn as Button @onready var sort_win_rate_btn := %SortWinRateBtn as Button @onready var sort_games_btn := %SortGamesBtn as Button @@ -41,6 +42,7 @@ const AVATAR_TO_CHAR: Array[String] = ["Pip", "Gatot", "Dabro", "Copper"] func _ready() -> void: back_btn.pressed.connect(_on_close_pressed) refresh_btn.pressed.connect(_fetch_leaderboard_data) + sync_btn.pressed.connect(_on_sync_pressed) sort_score_btn.pressed.connect(func(): _sort_by("high_score")) sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate")) sort_games_btn.pressed.connect(func(): _sort_by("games_played")) @@ -52,12 +54,31 @@ func _ready() -> void: # ------------------------------------------------------------------------- func show_panel() -> void: show() + status_label.text = "Syncing scores..." + # Bulk-sync all users' storage stats to native leaderboard (server-side operation) + if NakamaManager.session: + var sync_result = await NakamaManager.client.rpc_async(NakamaManager.session, "sync_leaderboard", "{}") + if sync_result.is_exception(): + push_error("[Leaderboard] sync_leaderboard RPC failed: ", sync_result.get_exception().message) + else: + print("[Leaderboard] Server sync finished: ", sync_result.payload) _fetch_leaderboard_data() func _on_close_pressed() -> void: hide() emit_signal("closed") +func _on_sync_pressed() -> void: + """Push the current player's stored stats up to the native Nakama leaderboard.""" + if not NakamaManager.session or AuthManager.is_guest: + status_label.text = "Must be logged in to sync" + return + status_label.text = "Syncing your score..." + await UserProfileManager._submit_to_leaderboard() + status_label.text = "Synced! Refreshing..." + await get_tree().create_timer(0.5).timeout + _fetch_leaderboard_data() + # ------------------------------------------------------------------------- # Data # ------------------------------------------------------------------------- @@ -66,11 +87,59 @@ func _fetch_leaderboard_data() -> void: status_label.text = "Not connected to Nakama" return - status_label.text = "Fetching Global Records..." + status_label.text = "Fetching Leaderboard..." for child in leaderboard_list.get_children(): child.queue_free() - # Calls the updated RPC that returns ONLY native global_high_score records + # Try native Nakama leaderboard first (fastest, ranked already) + var native_data = await _fetch_native_leaderboard() + + if native_data.size() > 0: + leaderboard_data = native_data + _calculate_win_rates() + status_label.text = "" + _sort_by(current_sort_key) + if leaderboard_data.size() > 0: + _show_entry_preview(0) + else: + # Fallback: try the server RPC (reads same native leaderboard) + await _fetch_via_rpc() + +func _fetch_native_leaderboard() -> Array: + """Use the Nakama client API to list native leaderboard records directly.""" + var result = await NakamaManager.client.list_leaderboard_records_async( + NakamaManager.session, + "global_high_score", + [], # no specific owner filter + null, # expiry = null (no filter) + 100 # limit + ) + + if result.is_exception(): + push_warning("[Leaderboard] Native API failed: ", result.get_exception().message) + return [] + + var data: Array = [] + for record in result.records: + var meta: Dictionary = {} + if record.metadata and not record.metadata.is_empty(): + var parsed = JSON.parse_string(record.metadata) + if parsed is Dictionary: + meta = parsed + + data.append({ + "user_id": record.owner_id, + "display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown", + "avatar_url": meta.get("avatar_url", ""), + "high_score": int(record.score), + "games_played": int(meta.get("games_played", 0)), + "games_won": int(meta.get("games_won", 0)), + "rank": int(record.rank) + }) + return data + +func _fetch_via_rpc() -> void: + """Fallback: call server RPC which reads the same native leaderboard.""" var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_leaderboard_stats", "{}") if result.is_exception(): @@ -78,20 +147,19 @@ func _fetch_leaderboard_data() -> void: push_error("[Leaderboard] RPC failed: ", result.get_exception().message) return - var json = JSON.new() - var error = json.parse(result.payload) - if error == OK: + var json := JSON.new() + if json.parse(result.payload) == OK: var data = json.get_data() - if data.has("leaderboard"): + if data.has("leaderboard") and data.leaderboard.size() > 0: leaderboard_data = data.leaderboard _calculate_win_rates() status_label.text = "" _sort_by(current_sort_key) - # Show top player's character in 3D preview if leaderboard_data.size() > 0: _show_entry_preview(0) else: - status_label.text = "No records found" + # No records exist yet — show a helpful hint + status_label.text = "No scores recorded yet.\nPlay a match to appear here!" else: status_label.text = "Error parsing server data" @@ -122,7 +190,7 @@ func _populate_list() -> void: child.queue_free() if leaderboard_data.size() == 0: - status_label.text = "No players found" + status_label.text = "No players found.\nPlay a match to appear here!" return for i in range(leaderboard_data.size()): @@ -143,7 +211,7 @@ func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void: item.add_theme_stylebox_override("panel", style) var hbox = HBoxContainer.new() - hbox.theme_override_constants.separation = 16 + hbox.add_theme_constant_override("separation", 16) item.add_child(hbox) # Rank diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 01ca001..630b9b0 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -7,7 +7,6 @@ extends Control @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 @@ -15,7 +14,7 @@ extends Control @onready var loading_spinner := %LoadingSpinner as TextureProgressBar # Registration panel elements -@onready var registration_panel := %RegistrationPanel as PanelContainer +@onready var tab_container := %TabContainer as TabContainer @onready var reg_email_input := %RegEmailInput as LineEdit @onready var reg_username_input := %RegUsernameInput as LineEdit @onready var reg_password_input := %RegPasswordInput as LineEdit @@ -25,25 +24,33 @@ extends Control @onready var reg_captcha_question := %RegCaptchaQuestion as Label @onready var reg_captcha_input := %RegCaptchaInput as LineEdit @onready var register_button := %RegisterButton as Button -@onready var back_to_login_link := %BackToLoginLink as LinkButton @onready var reg_status_label := %RegStatusLabel as Label var current_captcha_answer: int = 0 -# Main panel reference -@onready var main_panel := $CenterContainer/MainPanel as PanelContainer - var is_loading: bool = false # Server Selection Controls -var server_option: OptionButton -var server_ip_input: LineEdit -var lan_section: VBoxContainer # LAN-specific controls +@onready var server_option := %ServerOption as OptionButton +@onready var server_ip_input := %ServerIPInput as LineEdit +@onready var lan_section := %LANSection as VBoxContainer +@onready var lan_host_btn := %LANHostBtn as Button +@onready var lan_ip := %LANIPInput as LineEdit +@onready var lan_join_btn := %LANJoinBtn as Button func _ready() -> void: _connect_signals() _setup_ui() - _setup_server_config_ui() + + # Initialize connection mode view + if NakamaManager.nakama_host == "localhost": + server_option.selected = 0 + elif NakamaManager.nakama_host == "tektondash.vps.webdock.cloud": + server_option.selected = 3 + else: + server_option.selected = 1 + server_ip_input.text = NakamaManager.nakama_host if NakamaManager.nakama_host != "localhost" else "127.0.0.1" + _on_server_option_selected(server_option.selected) # Check if already authenticated if AuthManager.is_logged_in(): @@ -53,7 +60,6 @@ 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) @@ -62,7 +68,6 @@ func _connect_signals() -> void: # 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) @@ -73,6 +78,13 @@ func _connect_signals() -> void: AuthManager.auth_failed.connect(_on_auth_failed) AuthManager.session_restored.connect(_on_session_restored) + tab_container.tab_changed.connect(_on_tab_changed) + server_option.item_selected.connect(_on_server_option_selected) + server_ip_input.text_submitted.connect(_on_server_ip_submitted) + server_ip_input.focus_exited.connect(func(): _on_server_ip_submitted(server_ip_input.text)) + lan_host_btn.pressed.connect(_on_lan_host_pressed) + lan_join_btn.pressed.connect(func(): _on_lan_join_pressed(lan_ip.text)) + # 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()) @@ -81,8 +93,7 @@ func _setup_ui() -> void: status_label.text = "" reg_status_label.text = "" loading_spinner.visible = false - registration_panel.visible = false - main_panel.visible = true + tab_container.current_tab = 0 # Hide social buttons on platforms where they're not supported _configure_social_buttons() @@ -102,12 +113,14 @@ func _configure_social_buttons() -> void: # Panel Switching # ============================================================================= -func _show_registration() -> void: - main_panel.visible = false - registration_panel.visible = true - reg_status_label.text = "" - _generate_captcha() - reg_email_input.grab_focus() +func _on_tab_changed(tab: int) -> void: + if tab == 0: + status_label.text = "" + email_input.grab_focus() + elif tab == 1: + reg_status_label.text = "" + _generate_captcha() + reg_email_input.grab_focus() func _generate_captcha() -> void: var num1 := randi_range(1, 10) @@ -116,12 +129,6 @@ func _generate_captcha() -> void: reg_captcha_question.text = "Security Check: %d + %d = ?" % [num1, num2] reg_captcha_input.text = "" -func _show_login() -> void: - registration_panel.visible = false - main_panel.visible = true - status_label.text = "" - email_input.grab_focus() - # ============================================================================= # Login Handlers # ============================================================================= @@ -183,112 +190,7 @@ func _on_facebook_pressed() -> void: # When you have the access token from Facebook SDK: # AuthManager.login_with_facebook(access_token) -func _setup_server_config_ui() -> void: - """Inject server configuration controls into MainPanel.""" - if not main_panel: return - - var vbox = main_panel.get_node_or_null("VBox") - if not vbox: return - - # Find where to insert (before GuestButton) - var insert_pos = 3 # Default position - if guest_button: - insert_pos = guest_button.get_index() - - # Create Server Section - var server_section = VBoxContainer.new() - server_section.name = "ServerSelectionSection" - server_section.add_theme_constant_override("separation", 10) - vbox.add_child(server_section) - vbox.move_child(server_section, insert_pos) - - # Server Label - var label = Label.new() - label.text = "CONNECTION MODE" - label.add_theme_color_override("font_color", Color(0.69, 0.529, 0.357, 1)) - label.add_theme_font_size_override("font_size", 13) - server_section.add_child(label) - - # Server OptionButton - server_option = OptionButton.new() - server_option.name = "ServerOption" - server_option.custom_minimum_size = Vector2(0, 44) - server_option.add_item("Nakama - Localhost (Testing)") - server_option.add_item("Nakama - Remote Server (Host IP)") - server_option.add_item("LAN Direct (No Server)") - server_option.add_item("Nakama - Tekton Dash EU") - - # Set initial state based on NakamaManager - if NakamaManager.nakama_host == "localhost": - server_option.selected = 0 - elif NakamaManager.nakama_host == "tektondash.vps.webdock.cloud": - server_option.selected = 3 - else: - server_option.selected = 1 - - server_option.item_selected.connect(_on_server_option_selected) - server_section.add_child(server_option) - - # Nakama Server IP Input - server_ip_input = LineEdit.new() - server_ip_input.name = "ServerIPInput" - server_ip_input.custom_minimum_size = Vector2(0, 44) - server_ip_input.placeholder_text = "Enter Nakama Server IP..." - server_ip_input.text = NakamaManager.nakama_host if NakamaManager.nakama_host != "localhost" else "127.0.0.1" - server_ip_input.visible = server_option.selected == 1 - server_ip_input.text_submitted.connect(_on_server_ip_submitted) - server_ip_input.focus_exited.connect(func(): _on_server_ip_submitted(server_ip_input.text)) - server_section.add_child(server_ip_input) - - # --- LAN Section --- - lan_section = VBoxContainer.new() - lan_section.name = "LANSection" - lan_section.add_theme_constant_override("separation", 8) - lan_section.visible = false - server_section.add_child(lan_section) - - var lan_info = Label.new() - lan_info.text = "Play over LAN without any server.\nFirewall may need to allow port 7777." - lan_info.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1)) - lan_info.add_theme_font_size_override("font_size", 12) - lan_info.autowrap_mode = TextServer.AUTOWRAP_WORD - lan_section.add_child(lan_info) - - # Host LAN button - var lan_host_btn = Button.new() - lan_host_btn.name = "LANHostBtn" - lan_host_btn.text = "HOST LAN GAME" - lan_host_btn.custom_minimum_size = Vector2(0, 44) - lan_host_btn.pressed.connect(_on_lan_host_pressed) - lan_section.add_child(lan_host_btn) - - var lan_sep = Label.new() - lan_sep.text = "── or join a friend ──" - lan_sep.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - lan_sep.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1)) - lan_sep.add_theme_font_size_override("font_size", 11) - lan_section.add_child(lan_sep) - - # LAN Host IP input - var lan_ip = LineEdit.new() - lan_ip.name = "LANIPInput" - lan_ip.custom_minimum_size = Vector2(0, 44) - lan_ip.placeholder_text = "Host IP (e.g. 192.168.1.10)" - lan_ip.text = "127.0.0.1" - lan_section.add_child(lan_ip) - - # Join LAN button - var lan_join_btn = Button.new() - lan_join_btn.name = "LANJoinBtn" - lan_join_btn.text = "JOIN LAN GAME" - lan_join_btn.custom_minimum_size = Vector2(0, 44) - lan_join_btn.pressed.connect(func(): _on_lan_join_pressed(lan_ip.text)) - lan_section.add_child(lan_join_btn) - - # Add a separator after the section - var separator = HSeparator.new() - vbox.add_child(separator) - vbox.move_child(separator, insert_pos + 1) + func _on_server_option_selected(index: int) -> void: if index == 0: @@ -322,7 +224,7 @@ func _on_lan_host_pressed() -> void: player_name = "Host" LobbyManager.local_player_name = player_name - var ok = await LobbyManager.create_room_lan() + var ok = LobbyManager.create_room_lan() if ok: _go_to_lobby() else: @@ -490,7 +392,7 @@ func _on_auth_failed(error: String) -> void: loading_spinner.visible = false _set_inputs_enabled(true) - if registration_panel.visible: + if tab_container.current_tab == 1: _show_reg_error(error) else: _show_error(error) diff --git a/scripts/ui/profile_panel.gd b/scripts/ui/profile_panel.gd index cdb5fa3..4ea075d 100644 --- a/scripts/ui/profile_panel.gd +++ b/scripts/ui/profile_panel.gd @@ -25,6 +25,16 @@ signal profile_updated @onready var avatar_popup := %AvatarSelectionPopup as PopupPanel @onready var avatar_grid := %GridContainer as GridContainer +# Account Settings refs +@onready var acc_settings_dialog := %AccountSettingsDialog as AcceptDialog +@onready var old_pass_input := %OldPassInput as LineEdit +@onready var new_email_input := %NewEmailInput as LineEdit +@onready var new_pass_input := %NewPassInput as LineEdit +@onready var submit_cred_btn := %SubmitCredBtn as Button +@onready var tz_dropdown := %TzDropdown as OptionButton +@onready var save_tz_btn := %SaveTzBtn as Button +@onready var reset_stats_btn := %ResetStatsBtn as Button + # Loadout refs @onready var char_left_btn := %CharLeftBtn as Button @onready var char_right_btn := %CharRightBtn as Button @@ -55,6 +65,7 @@ func _ready() -> void: _connect_signals() _load_profile_data() _setup_avatar_grid() + _setup_account_settings_ui() _load_loadout() _setup_3d_preview() @@ -71,6 +82,17 @@ func _connect_signals() -> void: UserProfileManager.profile_updated.connect(_on_profile_updated) UserProfileManager.profile_update_failed.connect(_on_profile_update_failed) + + # Dynamically inject Account Settings button + var acc_settings_btn = Button.new() + acc_settings_btn.text = "Account Settings" + acc_settings_btn.custom_minimum_size = Vector2(0, 44) + acc_settings_btn.add_theme_font_override("font", load("res://assets/fonts/Nougat-ExtraBlack.ttf")) + acc_settings_btn.pressed.connect(_open_account_settings) + # Insert it before Logout button + var logout_idx = logout_btn.get_index() + logout_btn.get_parent().add_child(acc_settings_btn) + logout_btn.get_parent().move_child(acc_settings_btn, logout_idx) # ------------------------------------------------------------------------- # Profile @@ -279,6 +301,63 @@ func _on_link_account_pressed() -> void: dialog.queue_free() ) +func _setup_account_settings_ui() -> void: + # Populate Timezone dropdown + tz_dropdown.clear() + for i in range(-12, 15): + var prefix = "+" if i >= 0 else "" + tz_dropdown.add_item("GMT " + prefix + str(i)) + + # Connect buttons + submit_cred_btn.pressed.connect(func(): + status_label.text = "Updating credentials..." + var payload = { + "current_password": old_pass_input.text, + "new_email": new_email_input.text, + "new_password": new_pass_input.text + } + var result = await NakamaManager.client.rpc_async(NakamaManager.session, "change_credentials", JSON.stringify(payload)) + if result.is_exception(): + status_label.text = "Failed: " + result.get_exception().message + else: + status_label.text = "Credentials updated successfully!" + acc_settings_dialog.hide() + ) + + save_tz_btn.pressed.connect(func(): + var selected_text = tz_dropdown.get_item_text(tz_dropdown.selected) + var res = await NakamaManager.client.update_account_async(NakamaManager.session, null, null, null, null, null, selected_text) + if res.is_exception(): + status_label.text = "TZ Failed: " + res.get_exception().message + else: + status_label.text = "Timezone saved!" + ) + + reset_stats_btn.pressed.connect(func(): + var conf = ConfirmationDialog.new() + conf.dialog_text = "Are you SURE you want to irreversibly wipe all your stats to 0?" + add_child(conf) + conf.popup_centered() + conf.confirmed.connect(func(): + var r = await NakamaManager.client.rpc_async(NakamaManager.session, "reset_stats", "{}") + if not r.is_exception(): + UserProfileManager.stats = { + "games_played": 0, "games_won": 0, "games_lost": 0, "total_score": 0, "high_score": 0, "play_time_minutes": 0 + } + _load_profile_data() + status_label.text = "Stats wiped completely." + conf.queue_free() + acc_settings_dialog.hide() + ) + ) + +func _open_account_settings() -> void: + old_pass_input.visible = not AuthManager.is_guest + old_pass_input.text = "" + new_email_input.text = "" + new_pass_input.text = "" + acc_settings_dialog.popup_centered() + func _on_logout_pressed() -> void: AuthManager.logout() get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") @@ -301,6 +380,10 @@ func show_panel() -> void: _load_loadout() _check_admin_visibility() show() + + if AuthManager.is_guest: + _on_link_account_pressed() + status_label.text = "Please link an email to save your progress permanently!" func _check_admin_visibility() -> void: admin_panel_btn.hide() diff --git a/server/nakama/tekton_admin.js b/server/nakama/tekton_admin.js index ee119c9..113174b 100644 --- a/server/nakama/tekton_admin.js +++ b/server/nakama/tekton_admin.js @@ -28,10 +28,15 @@ function InitModule(ctx, logger, nk, initializer) { initializer.registerRpc("admin_update_stats", rpcAdminUpdateStats); initializer.registerRpc("admin_delete_stats", rpcAdminDeleteStats); initializer.registerRpc("admin_sync_leaderboard", rpcAdminSyncLeaderboard); + // Client-accessible score submission (authoritative leaderboard requires server-side writes) + initializer.registerRpc("submit_score", rpcSubmitScore); + initializer.registerRpc("sync_leaderboard", rpcSyncLeaderboard); + initializer.registerRpc("change_credentials", rpcChangeCredentials); + initializer.registerRpc("reset_stats", rpcResetStats); // Create default native leaderboard // id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None - nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); + try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch(e) {} logger.info("Tekton admin module loaded"); } @@ -477,6 +482,144 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) { } } +// Any authenticated user can submit their own score (server writes on their behalf) +function rpcSubmitScore(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("Not authenticated"); + try { + var request = JSON.parse(payload || "{}"); + var score = parseInt(request.score) || 0; + var account = nk.accountGetId(ctx.userId); + var metadata = { + games_played: request.games_played || 0, + games_won: request.games_won || 0, + avatar_url: account.user.avatarUrl || request.avatar_url || "" + }; + nk.leaderboardRecordWrite( + "global_high_score", + ctx.userId, + account.user.username, + score, + 0, + metadata + ); + logger.info("Score submitted for user " + ctx.userId + ": " + score); + return JSON.stringify({ success: true }); + } catch (e) { + logger.error("Failed to submit score for " + ctx.userId + ": " + e); + throw new Error("Failed to submit score: " + e); + } +} + +// Any authenticated user can trigger a bulk sync (reads all public stats, populates leaderboard) +function rpcSyncLeaderboard(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("Not authenticated"); + try { + var result = nk.storageList(null, "stats", 100, ""); + var statsObjects = result.objects || []; + var userGroup = {}; + + for (var i = 0; i < statsObjects.length; i++) { + var obj = statsObjects[i]; + var userId = obj.userId; + var value; + try { + // obj.value may already be an object or a string depending on Nakama version + value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value; + } catch (e) { continue; } + if (!value) continue; + + if (!userGroup[userId]) { + userGroup[userId] = { + high_score: value.high_score || 0, + games_played: value.games_played || 0, + games_won: value.games_won || 0, + avatar_url: value.avatar_url || "" + }; + } else { + userGroup[userId].high_score = Math.max(userGroup[userId].high_score, value.high_score || 0); + userGroup[userId].games_played = Math.max(userGroup[userId].games_played, value.games_played || 0); + userGroup[userId].games_won = Math.max(userGroup[userId].games_won, value.games_won || 0); + } + } + + var count = 0; + var debugLogs = []; + for (var uid in userGroup) { + try { + var stats = userGroup[uid]; + var account = nk.accountGetId(uid); + var meta = { + games_played: stats.games_played || 0, + games_won: stats.games_won || 0, + avatar_url: stats.avatar_url || account.user.avatarUrl || "" + }; + nk.leaderboardRecordWrite("global_high_score", uid, account.user.username, stats.high_score, 0, meta); + count++; + } catch (inner) { + debugLogs.push("Error user " + uid + ": " + inner); + logger.error("Failed to sync record for " + uid + ": " + inner); + } + } + logger.info("Synced " + count + " records to leaderboard by user " + ctx.userId); + return JSON.stringify({ success: true, synced: count, objects_found: statsObjects.length, debug: debugLogs }); + } catch (e) { + logger.error("Leaderboard sync failed: " + e); + throw new Error("Sync failed: " + e); + } +} + +// Change Email / Password securely +function rpcChangeCredentials(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("Not authenticated"); + var req = {}; + try { req = JSON.parse(payload || "{}"); } catch (e) {} + + var account = nk.accountGetId(ctx.userId); + + // If not a guest (has email), verify current password and unlink + if (account.email) { + if (!req.current_password) throw new Error("Current password required"); + try { + nk.authenticateEmail(account.email, req.current_password, false); + } catch (e) { + throw new Error("Incorrect current password."); + } + nk.unlinkEmail(ctx.userId, account.email, req.current_password); + } + + try { + nk.linkEmail(ctx.userId, req.new_email, req.new_password); + } catch (e) { + // Safe rollback + if (account.email) nk.linkEmail(ctx.userId, account.email, req.current_password); + throw new Error("Failed to set new credentials: " + e.message); + } + + return JSON.stringify({ success: true }); +} + +// Reset Game Stats +function rpcResetStats(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("Not authenticated"); + var account = nk.accountGetId(ctx.userId); + + // Delete native leaderboard rank + try { nk.leaderboardRecordDelete("global_high_score", ctx.userId); } catch (e) {} + + // Wipe storage stats + var zeros = { games_played: 0, games_won: 0, high_score: 0, total_kills: 0, total_deaths: 0 }; + nk.storageWrite([{ + collection: "stats", + key: "game_stats", + userId: ctx.userId, + value: zeros, + permissionRead: 2, + permissionWrite: 1 + }]); + + return JSON.stringify({ success: true }); +} + // ============================================================================= // Admin User Management RPCs // ============================================================================= @@ -680,7 +823,7 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) { avatar_url: stats.avatar_url || account.user.avatarUrl || "" }; - nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, JSON.stringify(metadata)); + nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, metadata); count++; } catch (inner) { logger.error("Failed to sync merged record for " + userId + ": " + inner); diff --git a/server/nakama/tekton_admin.ts b/server/nakama/tekton_admin.ts deleted file mode 100644 index 9d871f3..0000000 --- a/server/nakama/tekton_admin.ts +++ /dev/null @@ -1,699 +0,0 @@ -/** - * 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); - initializer.registerRpc("admin_list_users", rpcAdminListUsers); - initializer.registerRpc("admin_delete_users", rpcAdminDeleteUsers); - - // User management RPCs - initializer.registerRpc("get_user_profile", rpcGetUserProfile); - initializer.registerRpc("update_user_profile", rpcUpdateUserProfile); - - // Leaderboard RPCs - initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats); - - 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"); - } -} - -// ============================================================================= -// Leaderboard RPCs -// ============================================================================= - -function rpcGetLeaderboardStats( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - try { - // Query the "stats" collection to get all user stats - // Warning: For a large production game this should be indexed using Nakama's actual Leaderboard system, - // but this works for listing all users' custom storage stats. - const limit = 100; - const result = nk.storageList(null, "stats", limit, ""); - - const statsObjects = result.objects || []; - const leaderboardData: any[] = []; - - for (const obj of statsObjects) { - try { - const stats = JSON.parse(obj.value); - const userId = obj.userId; - - // Get the user's profile to retrieve their display name - let displayName = "Unknown"; - let avatarUrl = ""; - try { - const account = nk.accountGetId(userId); - displayName = account.user.displayName || account.user.username; - avatarUrl = account.user.avatarUrl; - } catch (e) { - // Ignore if account fetch fails - } - - leaderboardData.push({ - user_id: userId, - display_name: displayName, - avatar_url: avatarUrl, - games_played: stats.games_played || 0, - games_won: stats.games_won || 0, - high_score: stats.high_score || 0 - }); - } catch (e) { - logger.error(`Error parsing stats for object: ${obj.key}`); - } - } - - return JSON.stringify({ leaderboard: leaderboardData }); - } catch (e) { - logger.error(`Failed to get leaderboard stats: ${e}`); - throw new Error("Failed to get leaderboard stats"); - } -} - -// ============================================================================= -// Admin User Management RPCs -// ============================================================================= - -function rpcAdminListUsers( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - requireAdmin(ctx, nk); - - try { - const users: any[] = []; - let cursor = ""; - for (let page = 0; page < 10; page++) { - const result = nk.usersList("", "", 100, cursor); - if (!result || !result.users || result.users.length === 0) break; - for (const u of result.users) { - let metadata: UserMetadata = {}; - try { metadata = JSON.parse(u.metadata || "{}"); } catch(e) {} - users.push({ - user_id: u.id, - username: u.username || "", - display_name: u.displayName || u.username || "", - create_time: u.createTime, - role: metadata.role || "player", - banned: metadata.banned || false, - ban_reason: metadata.ban_reason || "" - }); - } - if (!result.cursor) break; - cursor = result.cursor; - } - return JSON.stringify({ users, count: users.length }); - } catch (e) { - logger.error(`Failed to list users: ${e}`); - throw new Error(`Failed to list users: ${e}`); - } -} - -interface DeleteUsersRequest { - user_ids: string[]; -} - -function rpcAdminDeleteUsers( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - requireAdmin(ctx, nk); - - const request = JSON.parse(payload) as DeleteUsersRequest; - const userIds = request.user_ids || []; - - if (userIds.length === 0) { - throw new Error("No user IDs provided"); - } - - for (const uid of userIds) { - if (uid === ctx.userId) { - throw new Error("Cannot delete your own account"); - } - } - - const deleted: string[] = []; - const failed: { user_id: string; reason: string }[] = []; - - for (const uid of userIds) { - try { - const account = nk.accountGetId(uid); - const meta = JSON.parse(account.user.metadata || "{}") as UserMetadata; - if (ADMIN_ROLES.includes(meta.role || "")) { - failed.push({ user_id: uid, reason: "Cannot delete admin account" }); - continue; - } - nk.accountDeleteId(uid, false); - deleted.push(uid); - logger.warn(`User ${uid} deleted by ${ctx.userId}`); - } catch (e) { - failed.push({ user_id: uid, reason: `${e}` }); - } - } - - return JSON.stringify({ success: true, deleted, failed }); -} - -// 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; -}