extends Panel ## Server Admin Panel — Management for Users and Leaderboards. ## Features: User BAN/DELETE/ROLE management, Leaderboard score modification. signal closed # -- UI refs -- @onready var tabs := %Tabs as TabContainer @onready var title_label := %Title as Label @onready var count_label := %CountLabel as Label @onready var refresh_btn := %RefreshBtn as Button @onready var close_btn := %CloseBtn as Button @onready var status_label := %StatusLabel as Label # Tab: Users @onready var user_tree := %UserTree as Tree @onready var select_all_btn := %SelectAllBtn as Button @onready var deselect_btn := %DeselectBtn as Button @onready var selected_label := %SelectedLabel as Label @onready var ban_btn := %BanBtn as Button @onready var unban_btn := %UnbanBtn as Button @onready var delete_btn := %DeleteBtn as Button @onready var history_btn := %HistoryBtn as Button @onready var history_dialog := %HistoryDialog as AcceptDialog @onready var history_text := %HistoryText as RichTextLabel # Tab: Leaderboards @onready var lb_tree := %LeaderboardTree as Tree @onready var sync_lb_btn := %SyncLeaderboardBtn as Button @onready var reset_lb_btn := %ResetLBBtn as Button # Tab: Daily Rewards @onready var month_option_btn := %MonthOptionBtn as OptionButton @onready var days_grid := %DaysGrid as GridContainer @onready var day_config_template := %DayConfigTemplate as PanelContainer @onready var load_dr_btn := %LoadDRConfigBtn as Button @onready var save_dr_btn := %SaveDRConfigBtn as Button var _daily_reward_config_data: Dictionary = {} var _current_dr_month: String = "" # Tab: Announcements @onready var target_user_edit := %TargetUserEdit as LineEdit @onready var find_user_btn := %FindUserBtn as Button @onready var resolved_id_label := %ResolvedIdLabel as Label @onready var title_edit := %TitleEdit as LineEdit @onready var content_edit := %ContentEdit as TextEdit @onready var start_date_edit := %StartDatePicker as Button @onready var end_date_edit := %EndDatePicker as Button @onready var add_reward_btn := %AddRewardBtn as Button @onready var rewards_list := %RewardsList as VBoxContainer @onready var reward_row_template := %RewardRowTemplate as HBoxContainer @onready var send_mail_btn := %SendMailBtn as Button var _resolved_user_id: String = "" # Tab: Mail Manager @onready var mail_tree := %MailTree as Tree @onready var refresh_mail_btn := %RefreshMailBtn as Button @onready var edit_mail_btn := %EditMailBtn as Button @onready var end_mail_btn := %EndMailBtn as Button @onready var delete_mail_server_btn := %DeleteMailServerBtn as Button var _mail_root: TreeItem var _all_server_mails: Array = [] # Tab: Shop (Featured Banners) @onready var slots_vbox := %SlotsVBox as VBoxContainer @onready var load_banners_btn := %LoadBannersBtn as Button @onready var save_banners_btn := %SaveBannersBtn as Button # Tab: Lobby Chat @onready var chat_prefix_edit := %PrefixEdit as LineEdit @onready var chat_max_msg_spin := %MaxMsgSpin as SpinBox @onready var chat_max_age_spin := %MaxAgeSpin as SpinBox @onready var chat_wipe_btn := %WipeChatBtn as Button @onready var chat_purge_btn := %PurgeOldBtn as Button @onready var chat_save_btn := %SaveConfigBtn as Button @onready var chat_status_label := %ChatStatusLabel as Label # Tab: Chat Storage @onready var chat_channel_id_edit := %ChannelIdEdit as LineEdit @onready var load_messages_btn := %LoadMessagesBtn as Button @onready var chat_tree := %ChatTree as Tree @onready var refresh_chat_btn := %RefreshChatBtn as Button @onready var delete_selected_btn := %DeleteSelectedBtn as Button var _chat_tree_root: TreeItem var _chat_channel_id: String = "" var _chat_cursor: String = "" var _chat_messages_data: Array = [] const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] # -- Data -- var all_users: Array = [] var lb_data: Array = [] var _user_root: TreeItem var _lb_root: TreeItem # -- Colors -- const CLR_BG := Color(0.1, 0.1, 0.12) const CLR_HEADER := Color(0.85, 0.55, 0.2) const CLR_TITLE := Color(0.95, 0.35, 0.35) const CLR_TEXT := Color(0.82, 0.82, 0.82) const CLR_DIM := Color(0.5, 0.5, 0.5) const CLR_BANNED := Color(1.0, 0.3, 0.3) const CLR_ADMIN := Color(0.4, 0.75, 1.0) const CLR_MOD := Color(0.5, 0.9, 0.4) const CLR_BTN_BAN := Color(1.0, 0.6, 0.2) const CLR_BTN_UNBAN := Color(0.4, 0.8, 0.4) const CLR_BTN_DEL := Color(1.0, 0.3, 0.3) const CLR_STATUS_OK := Color(0.4, 0.9, 0.4) const CLR_STATUS_ERR := Color(1.0, 0.3, 0.3) func _ready() -> void: visible = false _apply_plain_style() _setup_columns() _connect_signals() # ============================================================================= # Plain style (overrides any inherited theme) # ============================================================================= func _apply_plain_style() -> void: title_label.add_theme_color_override("font_color", CLR_TITLE) title_label.add_theme_font_size_override("font_size", 22) count_label.add_theme_color_override("font_color", CLR_HEADER) status_label.add_theme_color_override("font_color", CLR_DIM) for btn: Button in [refresh_btn, close_btn, select_all_btn, deselect_btn, load_dr_btn, save_dr_btn]: _style_button(btn, Color(0.2, 0.2, 0.24), CLR_TEXT) _style_button(ban_btn, Color(0.3, 0.2, 0.1), CLR_BTN_BAN) _style_button(unban_btn, Color(0.1, 0.22, 0.1), CLR_BTN_UNBAN) _style_button(delete_btn, Color(0.28, 0.1, 0.1), CLR_BTN_DEL) _style_button(sync_lb_btn, Color(0.2, 0.2, 0.3), CLR_ADMIN) var tree_bg := StyleBoxFlat.new() tree_bg.bg_color = Color(0.12, 0.12, 0.14) tree_bg.set_corner_radius_all(4) for t: Tree in [user_tree, lb_tree]: t.add_theme_stylebox_override("panel", tree_bg) t.add_theme_color_override("font_color", CLR_TEXT) t.add_theme_color_override("title_button_color", CLR_HEADER) func _style_button(btn: Button, bg_color: Color, text_color: Color) -> void: var sb := StyleBoxFlat.new() sb.bg_color = bg_color sb.set_corner_radius_all(4) sb.set_content_margin_all(6) btn.add_theme_stylebox_override("normal", sb) var sb_hover := sb.duplicate() sb_hover.bg_color = bg_color.lightened(0.15) btn.add_theme_stylebox_override("hover", sb_hover) btn.add_theme_color_override("font_color", text_color) # ============================================================================= # Columns Setup # ============================================================================= func _setup_columns() -> void: # Users user_tree.set_column_title(0, "☑") user_tree.set_column_title(1, "User ID") user_tree.set_column_title(2, "Username") user_tree.set_column_title(3, "Display Name") user_tree.set_column_title(4, "Created") user_tree.set_column_title(5, "Edit") user_tree.set_column_custom_minimum_width(0, 40) user_tree.set_column_expand(0, false) _user_root = user_tree.create_item() # Leaderboards lb_tree.set_column_title(0, "Rank") lb_tree.set_column_title(1, "Player") lb_tree.set_column_title(2, "High Score") lb_tree.set_column_title(3, "Wins") lb_tree.set_column_title(4, "Games") lb_tree.set_column_title(5, "Edit") lb_tree.set_column_expand(0, false) lb_tree.set_column_custom_minimum_width(0, 60) lb_tree.set_column_expand(5, false) lb_tree.set_column_custom_minimum_width(5, 60) _lb_root = lb_tree.create_item() # Mail Manager mail_tree.set_column_title(0, "Type") mail_tree.set_column_title(1, "Title") mail_tree.set_column_title(2, "Sender") mail_tree.set_column_title(3, "Start") mail_tree.set_column_title(4, "Expires") mail_tree.set_column_title(5, "Status") mail_tree.set_column_custom_minimum_width(0, 100) mail_tree.set_column_expand(0, false) mail_tree.set_column_custom_minimum_width(5, 80) mail_tree.set_column_expand(5, false) _mail_root = mail_tree.create_item() # Chat Storage chat_tree.set_column_title(0, "Select") chat_tree.set_column_title(1, "Sender") chat_tree.set_column_title(2, "Content") chat_tree.set_column_title(3, "Date / ID") chat_tree.set_column_custom_minimum_width(0, 70) chat_tree.set_column_expand(0, false) chat_tree.set_column_custom_minimum_width(1, 100) chat_tree.set_column_expand(2, true) chat_tree.set_column_custom_minimum_width(3, 180) _chat_tree_root = chat_tree.create_item() func _connect_signals() -> void: close_btn.pressed.connect(_on_close) refresh_btn.pressed.connect(_on_refresh) tabs.tab_changed.connect(_on_tab_changed) # User actions select_all_btn.pressed.connect(_select_all) deselect_btn.pressed.connect(_deselect_all) ban_btn.pressed.connect(_on_ban) unban_btn.pressed.connect(_on_unban) delete_btn.pressed.connect(_on_delete) history_btn.pressed.connect(_on_history_pressed) user_tree.item_edited.connect(_on_user_tree_item_edited) user_tree.button_clicked.connect(_on_user_tree_button_clicked) # LB actions lb_tree.button_clicked.connect(_on_lb_tree_button_clicked) sync_lb_btn.pressed.connect(_on_sync_leaderboard) # DR actions load_dr_btn.pressed.connect(_load_daily_rewards_config) save_dr_btn.pressed.connect(_save_daily_rewards_config) month_option_btn.item_selected.connect(_on_dr_month_selected) # Announcement actions send_mail_btn.pressed.connect(_on_send_mail) add_reward_btn.pressed.connect(_on_add_reward_pressed) find_user_btn.pressed.connect(_on_find_user) target_user_edit.text_changed.connect(func(_t): _resolved_user_id = ""; resolved_id_label.text = "") # Mail Manager actions refresh_mail_btn.pressed.connect(func(): await _load_mail()) mail_tree.item_selected.connect(_on_mail_item_selected) edit_mail_btn.pressed.connect(_on_edit_mail_pressed) end_mail_btn.pressed.connect(_on_end_mail_pressed) delete_mail_server_btn.pressed.connect(_on_delete_mail_server_pressed) _update_mail_action_btns(null) # Shop actions load_banners_btn.pressed.connect(func(): await _load_featured_banners()) save_banners_btn.pressed.connect(func(): await _save_featured_banners()) # Chat actions chat_wipe_btn.pressed.connect(_on_wipe_chat) chat_purge_btn.pressed.connect(_on_purge_old_chat) chat_save_btn.pressed.connect(_on_save_chat_config) # Chat Storage actions load_messages_btn.pressed.connect(_on_load_chat_messages) refresh_chat_btn.pressed.connect(_on_load_more_chat_messages) delete_selected_btn.pressed.connect(_on_delete_selected_chat_messages) # ============================================================================= # Core Panel Logic # ============================================================================= func show_panel() -> void: visible = true _on_tab_changed(tabs.current_tab) func _on_close() -> void: visible = false emit_signal("closed") func _on_refresh() -> void: _on_tab_changed(tabs.current_tab) func _on_tab_changed(tab_index: int) -> void: _set_status("") if tab_index == 0: await _load_users() elif tab_index == 1: await _load_leaderboard() elif tab_index == 2: await _load_daily_rewards_config() elif tab_index == 3: _update_announcement_count() elif tab_index == 4: await _load_mail() elif tab_index == 5: await _load_featured_banners() elif tab_index == 6: await _load_chat_config() elif tab_index == 7: await _on_load_chat_messages() # ============================================================================= # RPC Helper # ============================================================================= func _rpc(rpc_name: String, payload: Dictionary) -> Dictionary: if not NakamaManager.client or not NakamaManager.session: return {"error": "Not connected"} var result = await BackendService.api_rpc_async(rpc_name, JSON.stringify(payload)) if result.get("success", false) == false: var err: String = str(result.get("message", "Unknown error")) _set_status(err, CLR_STATUS_ERR) return {"error": err} return result.get("data", {}) func _set_status(msg: String, color: Color = CLR_DIM) -> void: status_label.text = msg status_label.add_theme_color_override("font_color", color) # ============================================================================= # TAB 1: USER MANAGEMENT # ============================================================================= func _load_users() -> void: _clear_tree(user_tree, _user_root) _set_status("Loading users...") var res := await _rpc("admin_list_users", {}) if res.has("error"): _set_status("Failed: " + str(res.error), CLR_STATUS_ERR) return var raw_users = res.get("users", []) all_users = raw_users if typeof(raw_users) == TYPE_ARRAY else [] count_label.text = "%d users" % all_users.size() for user in all_users: var uid: String = user.get("user_id", "") var uname: String = user.get("username", "") var dname: String = user.get("display_name", uname) var role: String = user.get("role", "player") var banned: bool = user.get("banned", false) var created: String = str(user.get("create_time", "")) var item := _user_root.create_child() item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) item.set_editable(0, true) var short_id := uid.substr(0, 8) + "..." item.set_text(1, short_id) item.set_tooltip_text(1, uid) var uname_display := uname if role != "player": uname_display += " [%s]" % role.to_upper() if banned: uname_display += " (BANNED)" item.set_text(2, uname_display) item.set_text(3, dname) item.set_text(4, created.substr(0, 10) if created.length() > 10 else created) item.add_button(5, _get_edit_icon(), 0, false, "Edit User") if banned: item.set_custom_color(2, CLR_BANNED) elif role in ["admin", "owner"]: item.set_custom_color(2, CLR_ADMIN) item.set_metadata(0, user) _update_selection_count() _set_status("") func _on_user_tree_item_edited() -> void: _update_selection_count() func _update_selection_count() -> void: var count := _get_checked_items().size() selected_label.text = "%d selected" % count ban_btn.disabled = count == 0 unban_btn.disabled = count == 0 delete_btn.disabled = count == 0 func _on_user_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void: _show_edit_user_dialog(item.get_metadata(0)) func _show_edit_user_dialog(user: Dictionary) -> void: var uid: String = user.get("user_id", "") var uname: String = user.get("username", "") var display_name: String = user.get("display_name", uname) var role: String = user.get("role", "player") var banned: bool = user.get("banned", false) var detail := await _rpc("admin_get_user_detail", {"user_id": uid}) var detail_user: Dictionary = detail.get("user", {}) if not detail.has("error") else {} var dialog := AcceptDialog.new() dialog.title = "Edit User: " + uname dialog.min_size = Vector2i(460, 420) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 8) var id_lbl := Label.new() id_lbl.text = "User ID: " + uid id_lbl.add_theme_color_override("font_color", CLR_DIM) vbox.add_child(id_lbl) var email_lbl := Label.new() var email = detail_user.get("email", "") var verified = detail_user.get("email_verified", false) email_lbl.text = "Email: %s (%s)" % [email if not str(email).is_empty() else "none", "verified" if verified else "unverified"] email_lbl.add_theme_color_override("font_color", CLR_DIM) vbox.add_child(email_lbl) var name_grid := GridContainer.new() name_grid.columns = 2 name_grid.add_theme_constant_override("h_separation", 8) name_grid.add_theme_constant_override("v_separation", 8) var username_lbl := Label.new(); username_lbl.text = "Username:"; name_grid.add_child(username_lbl) var username_input := LineEdit.new(); username_input.text = detail_user.get("username", uname); name_grid.add_child(username_input) var display_lbl := Label.new(); display_lbl.text = "Display Name:"; name_grid.add_child(display_lbl) var display_input := LineEdit.new(); display_input.text = detail_user.get("display_name", display_name); name_grid.add_child(display_input) var password_lbl := Label.new(); password_lbl.text = "New Password:"; name_grid.add_child(password_lbl) var password_input := LineEdit.new(); password_input.placeholder_text = "Leave empty to keep"; password_input.secret = true; name_grid.add_child(password_input) vbox.add_child(name_grid) var role_hbox := HBoxContainer.new() var role_lbl := Label.new() role_lbl.text = "Role: " role_hbox.add_child(role_lbl) var role_option := OptionButton.new() var roles := ["player", "moderator", "admin"] for r in roles: role_option.add_item(r) role_option.selected = roles.find(role) role_hbox.add_child(role_option) vbox.add_child(role_hbox) var ban_check := CheckButton.new() ban_check.text = "Banned" ban_check.button_pressed = banned vbox.add_child(ban_check) var reason_input := LineEdit.new() reason_input.placeholder_text = "Ban reason..." reason_input.text = user.get("ban_reason", "") reason_input.visible = banned vbox.add_child(reason_input) ban_check.toggled.connect(func(on): reason_input.visible = on) var save_btn := Button.new() save_btn.text = "Save Changes" save_btn.custom_minimum_size.y = 36 vbox.add_child(save_btn) dialog.add_child(vbox) add_child(dialog) dialog.popup_centered() save_btn.pressed.connect(func(): var new_role: String = roles[role_option.selected] await _save_user_edit(uid, username_input.text.strip_edges(), display_input.text.strip_edges(), password_input.text, new_role, ban_check.button_pressed, reason_input.text) dialog.queue_free() ) func _save_user_edit(uid: String, uname: String, display_name: String, new_password: String, new_role: String, new_banned: bool, reason: String) -> void: _set_status("Saving...") var identity_res := await _rpc("admin_update_user_identity", { "user_id": uid, "username": uname, "display_name": display_name }) if identity_res.has("error"): _set_status("Identity save failed: " + str(identity_res.error), CLR_STATUS_ERR) return if not new_password.strip_edges().is_empty(): var password_res := await _rpc("admin_set_user_password", {"user_id": uid, "password": new_password}) if password_res.has("error"): _set_status("Password save failed: " + str(password_res.error), CLR_STATUS_ERR) return await _rpc("admin_set_user_role", {"user_id": uid, "role": new_role}) if new_banned: await _rpc("admin_ban_player", {"user_id": uid, "reason": reason, "duration_hours": 0}) else: await _rpc("admin_unban_player", {"user_id": uid}) _set_status("Saved: " + display_name, CLR_STATUS_OK) await _load_users() # ============================================================================= # TAB 1: USER SELECTION BATCH ACTIONS # ============================================================================= func _select_all() -> void: if not _user_root: return var item := _user_root.get_first_child() while item: item.set_checked(0, true) item = item.get_next() _update_selection_count() func _deselect_all() -> void: if not _user_root: return var item := _user_root.get_first_child() while item: item.set_checked(0, false) item = item.get_next() _update_selection_count() func _get_checked_items() -> Array[TreeItem]: var items: Array[TreeItem] = [] if not _user_root: return items var item := _user_root.get_first_child() while item: if item.is_checked(0): items.append(item) item = item.get_next() return items func _get_checked_user_data() -> Array: var data: Array = [] for item in _get_checked_items(): data.append(item.get_metadata(0)) return data func _on_ban() -> void: var users := _get_checked_user_data() if users.is_empty(): return var to_ban: Array = [] for u in users: if u.get("banned", false): continue if u.get("role", "") in ["admin", "owner"]: continue to_ban.append(u) if to_ban.is_empty(): _set_status("No eligible users to ban", CLR_STATUS_ERR) return var dialog := ConfirmationDialog.new() dialog.title = "Ban %d user(s)" % to_ban.size() var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 8) var list_names: Array = [] for i in range(min(to_ban.size(), 10)): list_names.append(to_ban[i].get("username", "")) var name_str := ", ".join(list_names) if to_ban.size() > 10: name_str += "... and %d others" % (to_ban.size() - 10) var lbl := Label.new() lbl.text = "Ban following users:\n" + name_str lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART lbl.custom_minimum_size = Vector2(350, 0) vbox.add_child(lbl) var reason_input := LineEdit.new() reason_input.placeholder_text = "Ban reason" vbox.add_child(reason_input) var dur := SpinBox.new() dur.min_value = 0; dur.max_value = 8760; dur.value = 24; dur.prefix = "Hours: " vbox.add_child(dur) dialog.add_child(vbox) add_child(dialog) dialog.popup_centered() dialog.confirmed.connect(func(): var ok := 0 for u in to_ban: var r := await _rpc("admin_ban_player", {"user_id": u.user_id, "reason": reason_input.text, "duration_hours": int(dur.value)}) if r.get("success", false): ok += 1 _set_status("Banned %d/%d" % [ok, to_ban.size()], CLR_STATUS_OK) await _load_users() dialog.queue_free() ) func _on_unban() -> void: var users := _get_checked_user_data() var to_unban := []; for u in users: if u.get("banned", false): to_unban.append(u) if to_unban.is_empty(): return var ok := 0 for u in to_unban: var r := await _rpc("admin_unban_player", {"user_id": u.user_id}) if r.get("success", false): ok += 1 _set_status("Unbanned %d/%d" % [ok, to_unban.size()], CLR_STATUS_OK) await _load_users() func _on_history_pressed() -> void: var selected_data = _get_checked_user_data() if selected_data.size() != 1: _set_status("Please select exactly ONE user to view history.", CLR_STATUS_ERR) return var uid = selected_data[0].get("user_id", "") _set_status("Fetching user details...", CLR_STATUS_OK) var detail_res = await _rpc("admin_get_user_detail", {"user_id": uid}) var res = await _rpc("admin_get_user_history", {"user_id": uid}) if detail_res.has("error") and res.has("error"): _set_status("Failed to get user details: " + str(detail_res.error), CLR_STATUS_ERR) return _set_status("User details loaded.", CLR_STATUS_OK) var h = res.get("history", {}) if not res.has("error") else {} var details = detail_res if not detail_res.has("error") else {} var detail_user: Dictionary = details.get("user", {}) var text = "[b]=== USER DETAIL ===[/b]\n" text += "User ID: " + uid + "\n\n" text += "[b]-- Account --[/b]\n" text += "Username: %s\n" % detail_user.get("username", "") text += "Display Name: %s\n" % detail_user.get("display_name", "") text += "Email: %s (%s)\n" % [detail_user.get("email", "none"), "verified" if detail_user.get("email_verified", false) else "unverified"] text += "Created: %s\n" % str(detail_user.get("create_time", "")) text += "Wallet: %s\n" % str(detail_user.get("wallet", {})) text += "Subscription: %s\n\n" % str(details.get("subscription", {})) text += "[b]-- Friends --[/b]\n" var friends = details.get("friends", []) if friends.is_empty(): text += "No friends found.\n" else: for f in friends: text += "- %s (%s) state=%s\n" % [f.get("username", ""), f.get("user_id", ""), str(f.get("state", ""))] text += "\n" text += "[b]-- Purchase History / Receipts --[/b]\n" var purchases = details.get("purchases", []) if purchases.is_empty(): text += "No purchases found.\n" else: for p in purchases: text += "- %s: %s\n" % [p.get("key", ""), str(p.get("value", {}))] text += "\n" # Logins text += "[b]-- Recent Logins --[/b]\n" var logins = h.get("logins", []) if logins.is_empty(): text += "No recent logins found.\n" else: for l in logins: var time_str = Time.get_datetime_string_from_unix_time(int(l.get("time", 0))) text += "- %s (IP: %s)\n" % [time_str, l.get("ip", "unknown")] text += "\n" # Wallet Ledger text += "[b]-- Economy / Wallet Ledger --[/b]\n" var ledger = h.get("wallet_ledger", []) if ledger.is_empty(): text += "No transactions found.\n" else: for item in ledger: var changeset = str(item.get("changeset", {})) var c_time = item.get("create_time", "") text += "- [%s] %s\n" % [c_time.left(19).replace("T", " "), changeset] text += "\n" # Matches text += "[b]-- Matches --[/b]\n" var matches = h.get("matches", []) if matches.is_empty(): text += "No match history found.\n" else: for m in matches: text += "- " + str(m) + "\n" text += "\n[b]-- Storage Objects --[/b]\n" var storage = details.get("storage", {}) if storage.is_empty(): text += "No storage objects found.\n" else: for collection in storage.keys(): var objects = storage[collection] text += "\n[b]%s[/b] (%d)\n" % [collection, objects.size()] for obj in objects: text += "- %s: %s\n" % [obj.get("key", ""), str(obj.get("value", {}))] history_text.text = text history_dialog.popup_centered() func _on_delete() -> void: var users := _get_checked_user_data() if users.is_empty(): return var ids := []; var names := [] for u in users: if u.get("role", "") in ["admin", "owner"]: continue ids.append(u.user_id); names.append(u.username) if ids.is_empty(): return var dialog := ConfirmationDialog.new() dialog.title = "DELETE %d account(s)" % ids.size() var vbox := VBoxContainer.new(); vbox.add_theme_constant_override("separation", 12) var list_names = []; for i in range(min(names.size(), 10)): list_names.append(names[i]) var name_str = ", ".join(list_names); if names.size() > 10: name_str += "... and %d others" % (names.size() - 10) var text_lbl = Label.new(); text_lbl.text = "PERMANENTLY DELETE:\n" + name_str + "\n\nThis CANNOT be undone!"; text_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; text_lbl.custom_minimum_size = Vector2(400, 0) vbox.add_child(text_lbl); dialog.add_child(vbox); add_child(dialog); dialog.popup_centered() dialog.confirmed.connect(func(): var r := await _rpc("admin_delete_users", {"user_ids": ids}) _set_status("Deleted %d accounts" % r.get("deleted", []).size(), CLR_STATUS_OK) await _load_users() dialog.queue_free() ) # ============================================================================= # TAB 2: LEADERBOARD MANAGEMENT # ============================================================================= func _load_leaderboard() -> void: _clear_tree(lb_tree, _lb_root) _set_status("Loading leaderboards...") var res := await _rpc("get_leaderboard_stats", {}) if res.has("error"): _set_status("Failed to load scores", CLR_STATUS_ERR) return var raw_lb = res.get("leaderboard", []) if typeof(raw_lb) == TYPE_ARRAY: lb_data = raw_lb elif typeof(raw_lb) == TYPE_DICTIONARY: lb_data = raw_lb.values() else: lb_data = [] if lb_data.is_empty(): lb_data = await _fetch_native_leaderboard_for_admin() count_label.text = "%d records" % lb_data.size() lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0)) var rank = 1 for entry in lb_data: var item := _lb_root.create_child() item.set_text(0, "#%d" % rank) item.set_text(1, entry.get("display_name", "Unknown")) item.set_text(2, str(entry.get("high_score", 0))) item.set_text(3, str(entry.get("games_won", 0))) item.set_text(4, str(entry.get("games_played", 0))) item.add_button(5, _get_edit_icon(), 0, false, "Edit Score") item.set_metadata(0, entry) rank += 1 _set_status("") func _fetch_native_leaderboard_for_admin() -> Array: var result = await NakamaManager.client.list_leaderboard_records_async( NakamaManager.session, "global_high_score", [], null, 100 ) if result.is_exception(): push_warning("[AdminPanel] Native leaderboard load 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, "username": record.username, "display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown", "avatar_url": meta.get("avatar_url", ""), "loadout_character": meta.get("loadout_character", "Copper"), "high_score": int(record.score), "games_played": int(meta.get("games_played", 0)), "games_won": int(meta.get("games_won", 0)) }) return data func _on_lb_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void: _show_edit_score_dialog(item.get_metadata(0)) func _show_edit_score_dialog(entry: Dictionary) -> void: var uid: String = entry.get("user_id", "") var name: String = entry.get("display_name", "Unknown") var dialog := AcceptDialog.new(); dialog.title = "Edit Score: " + name; dialog.min_size = Vector2i(350, 280) var vbox := VBoxContainer.new(); vbox.add_theme_constant_override("separation", 10) var info_lbl := Label.new(); info_lbl.text = "Manage player stats for: " + name; info_lbl.add_theme_color_override("font_color", CLR_HEADER); vbox.add_child(info_lbl) var grid := GridContainer.new(); grid.columns = 2 var fields = {"high_score": SpinBox.new(), "games_won": SpinBox.new(), "games_played": SpinBox.new()} for key in fields: var lbl = Label.new() lbl.text = key.capitalize() + ":" grid.add_child(lbl) var spin = fields[key] as SpinBox; spin.max_value = 1000000; spin.value = entry.get(key, 0); spin.custom_minimum_size.x = 120; grid.add_child(spin) vbox.add_child(grid); var btn_vbox = VBoxContainer.new(); btn_vbox.add_theme_constant_override("separation", 5) var save_btn = Button.new(); save_btn.text = "UPDATE SCORE"; save_btn.custom_minimum_size.y = 40; btn_vbox.add_child(save_btn) var reset_btn = Button.new(); reset_btn.text = "RESET / DELETE RECORD"; reset_btn.add_theme_color_override("font_color", CLR_BTN_DEL); btn_vbox.add_child(reset_btn) vbox.add_child(btn_vbox); dialog.add_child(vbox); add_child(dialog); dialog.popup_centered() save_btn.pressed.connect(func(): var stats = {}; for k in fields: stats[k] = int(fields[k].value) await _save_score_edit(uid, stats); dialog.queue_free() ) reset_btn.pressed.connect(func(): _confirm_reset_score(uid, name); dialog.queue_free()) func _confirm_reset_score(uid: String, name: String) -> void: var confirm := ConfirmationDialog.new(); confirm.title = "Reset Score?"; confirm.dialog_text = "Are you sure?" add_child(confirm); confirm.popup_centered(); confirm.confirmed.connect(func(): var res := await _rpc("admin_delete_stats", {"user_id": uid}) if res.has("success"): await _load_leaderboard() ) func _save_score_edit(uid: String, stats: Dictionary) -> void: _set_status("Updating score...") var res := await _rpc("admin_update_stats", {"user_id": uid, "stats": stats}) if res.has("success"): _set_status("Updated", CLR_STATUS_OK); await _load_leaderboard() func _on_sync_leaderboard() -> void: _set_status("Syncing all records to native leaderboard...", CLR_HEADER) var res := await _rpc("admin_sync_leaderboard", {}) if res.has("success"): var count = res.get("synced", 0) _set_status("Successfully synced %d records!" % count, CLR_STATUS_OK) await _load_leaderboard() else: _set_status("Sync failed: " + str(res.get("error", "Unknown")), CLR_STATUS_ERR) # ============================================================================= # Common Helpers # ============================================================================= func _clear_tree(tree: Tree, root: TreeItem) -> void: if root: var child := root.get_first_child() while child: var next := child.get_next() root.remove_child(child) child = next func _get_edit_icon() -> Texture2D: var img := Image.create(16, 16, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) for i in range(4, 12): img.set_pixel(i, 11 - i + 4, CLR_TEXT) img.set_pixel(i, 12 - i + 4, CLR_TEXT) return ImageTexture.create_from_image(img) # ============================================================================= # TAB 3: DAILY REWARDS # ============================================================================= func _load_daily_rewards_config() -> void: _set_status("Loading Daily Rewards Config...") var res := await _rpc("get_daily_reward_config_admin", {}) if res.has("error"): _set_status("Failed to load DR config", CLR_STATUS_ERR) return var config = res.get("config", {}) if config.is_empty(): for m in range(1, 13): var m_str = "%02d" % m var arr = [] for d in range(30): arr.append({"type": "star", "amount": min(10 + d*5, 100)}) config[m_str] = arr _daily_reward_config_data = config month_option_btn.clear() for i in range(1, 13): month_option_btn.add_item(MONTH_NAMES[i - 1]) month_option_btn.set_item_metadata(i - 1, "%02d" % i) if not _daily_reward_config_data.is_empty(): _current_dr_month = "01" month_option_btn.select(0) _build_dr_grid() _update_daily_reward_count() _set_status("Config Loaded", CLR_STATUS_OK) func _on_dr_month_selected(index: int) -> void: # Save current grid values into the dictionary before switching _save_current_grid_to_dict() _current_dr_month = month_option_btn.get_item_metadata(index) _build_dr_grid() _update_daily_reward_count() func _save_current_grid_to_dict() -> void: if _current_dr_month.is_empty(): return var arr = [] for child in days_grid.get_children(): if child != day_config_template: var opt = child.get_node("Margin/VBox/TypeOptionBtn") as OptionButton var spin = child.get_node("Margin/VBox/AmountSpinBox") as SpinBox var item_type = opt.get_item_text(opt.selected) if opt.selected >= 0 else "star" arr.append({"type": item_type, "amount": int(spin.value)}) if not arr.is_empty(): _daily_reward_config_data[_current_dr_month] = arr func _build_dr_grid() -> void: for child in days_grid.get_children(): if child != day_config_template: child.queue_free() var arr = _daily_reward_config_data.get(_current_dr_month, []) for i in range(arr.size()): var slot = day_config_template.duplicate() slot.visible = true days_grid.add_child(slot) var lbl = slot.get_node("Margin/VBox/DayLabel") as Label var opt = slot.get_node("Margin/VBox/TypeOptionBtn") as OptionButton var spin = slot.get_node("Margin/VBox/AmountSpinBox") as SpinBox lbl.text = "Day " + str(i + 1) var rdata = arr[i] if typeof(rdata) == TYPE_DICTIONARY: spin.value = rdata.get("amount", 0) var type_str = rdata.get("type", "star") var found = false for j in range(opt.item_count): if opt.get_item_text(j) == type_str: opt.select(j) found = true break if not found: opt.add_item(type_str) opt.select(opt.item_count - 1) else: # Fallback for old int format spin.value = int(rdata) opt.select(0) func _update_daily_reward_count() -> void: var rewards = _daily_reward_config_data.get(_current_dr_month, []) var count: int = rewards.size() if typeof(rewards) == TYPE_ARRAY else 0 count_label.text = "%d reward days" % count func _save_daily_rewards_config() -> void: _save_current_grid_to_dict() _set_status("Saving config...") var req = { "config": _daily_reward_config_data } var res = await _rpc("set_daily_reward_config", req) if res.has("error"): _set_status("Save failed: " + res.get("error"), CLR_STATUS_ERR) else: _set_status("Config saved successfully!", CLR_STATUS_OK) # ============================================================================= # TAB 4: ANNOUNCEMENTS # ============================================================================= func _on_add_reward_pressed() -> void: var row = reward_row_template.duplicate() row.visible = true rewards_list.add_child(row) var remove_btn = row.get_node("RemoveBtn") as Button remove_btn.pressed.connect(func(): row.queue_free(); _update_announcement_count()) _update_announcement_count() func _update_announcement_count() -> void: var count := 0 for child in rewards_list.get_children(): if child.visible: count += 1 count_label.text = "%d rewards attached" % count func _on_find_user() -> void: var input = target_user_edit.text.strip_edges() if input.is_empty(): _resolved_user_id = "" resolved_id_label.text = "(Global — all users)" return _set_status("Looking up user...") var uid = await _resolve_target_user_id(input) if uid.is_empty(): resolved_id_label.text = "NOT FOUND" resolved_id_label.add_theme_color_override("font_color", CLR_STATUS_ERR) _set_status("User not found: " + input, CLR_STATUS_ERR) else: _resolved_user_id = uid resolved_id_label.text = "ID: " + uid.substr(0, 12) + "..." resolved_id_label.add_theme_color_override("font_color", CLR_STATUS_OK) _set_status("Found user: " + uid, CLR_STATUS_OK) func _resolve_target_user_id(input: String) -> String: """Resolve a username, display_name, or user_id to a user_id. Returns empty string if not found.""" if input.is_empty(): return "" # If it looks like a UUID already, return as-is if input.length() >= 32 and "-" in input: return input # Search in cached all_users first for u in all_users: var uname: String = u.get("username", "") var dname: String = u.get("display_name", "") var uid: String = u.get("user_id", "") if uname.to_lower() == input.to_lower() or dname.to_lower() == input.to_lower(): return uid # Cache miss — fetch from server var res := await _rpc("admin_list_users", {}) if res.has("error"): return "" all_users = res.get("users", []) for u in all_users: var uname: String = u.get("username", "") var dname: String = u.get("display_name", "") var uid: String = u.get("user_id", "") if uname.to_lower() == input.to_lower() or dname.to_lower() == input.to_lower(): return uid return "" func _on_send_mail() -> void: var input = target_user_edit.text.strip_edges() var title = title_edit.text.strip_edges() var content = content_edit.text.strip_edges() var start_date = start_date_edit.get_date_iso() var end_date = end_date_edit.get_date_iso() if title.is_empty() or content.is_empty(): _set_status("Title and content cannot be empty", CLR_STATUS_ERR) return # Resolve target var target_uid := "" if not input.is_empty(): if not _resolved_user_id.is_empty(): target_uid = _resolved_user_id else: _set_status("Resolving user...") target_uid = await _resolve_target_user_id(input) if target_uid.is_empty(): _set_status("User not found: " + input + ". Use Find button.", CLR_STATUS_ERR) return _resolved_user_id = target_uid resolved_id_label.text = "ID: " + target_uid.substr(0, 12) + "..." resolved_id_label.add_theme_color_override("font_color", CLR_STATUS_OK) var rewards_arr = [] for child in rewards_list.get_children(): if not child.visible: continue # skip template var type_opt = child.get_node("TypeOption") as OptionButton var id_edit = child.get_node("IdEdit") as LineEdit var amount_spin = child.get_node("AmountSpin") as SpinBox var type_str = type_opt.get_item_text(type_opt.selected) var r_id = id_edit.text.strip_edges() rewards_arr.append({ "type": type_str, "id": r_id, "amount": int(amount_spin.value) }) send_mail_btn.disabled = true _set_status("Sending mail...") var payload = { "target_user_id": target_uid, "title": title, "content": content, "start_date": start_date, "end_date": end_date, "rewards": rewards_arr } var res = await _rpc("admin_send_mail", payload) send_mail_btn.disabled = false if res.has("error"): _set_status("Failed to send mail: " + res.get("error", "Unknown"), CLR_STATUS_ERR) else: _set_status("Mail sent successfully!", CLR_STATUS_OK) target_user_edit.text = "" _resolved_user_id = "" resolved_id_label.text = "" title_edit.text = "" content_edit.text = "" if start_date_edit.has_method("clear_date"): start_date_edit.clear_date() if end_date_edit.has_method("clear_date"): end_date_edit.clear_date() for child in rewards_list.get_children(): if child.visible: child.queue_free() _update_announcement_count() # ============================================================================= # TAB 5: MAIL MANAGER # ============================================================================= func _load_mail() -> void: _clear_tree(mail_tree, _mail_root) _set_status("Loading mails...") var res := await _rpc("admin_list_mail", {}) if res.has("error"): _set_status("Failed: " + str(res.error), CLR_STATUS_ERR) return var raw_mails = res.get("mails", []) _all_server_mails = raw_mails if typeof(raw_mails) == TYPE_ARRAY else [] count_label.text = "%d mails" % _all_server_mails.size() var now_str = Time.get_datetime_string_from_system(true) for mail in _all_server_mails: var item := _mail_root.create_child() var mail_type: String = mail.get("type", "global") var mail_title: String = mail.get("title", "No Title") var sender: String = mail.get("sender", "SYSTEM") var start_date: String = mail.get("start_date", "") var expiry: String = mail.get("expiry_date", "") # Type column item.set_text(0, mail_type.to_upper()) if mail_type == "personal": item.set_custom_color(0, CLR_ADMIN) else: item.set_custom_color(0, CLR_MOD) # Title item.set_text(1, mail_title) # Sender item.set_text(2, sender) # Start date item.set_text(3, start_date.substr(0, 10) if start_date.length() >= 10 else start_date) # Expiry var expiry_short = expiry.substr(0, 10) if expiry.length() >= 10 else expiry item.set_text(4, expiry_short) # Status var end_date: String = mail.get("end_date", "") var status := "ACTIVE" if not end_date.is_empty() and now_str > end_date: status = "ENDED" item.set_custom_color(5, CLR_BTN_DEL) elif not expiry.is_empty() and now_str > expiry: status = "EXPIRED" item.set_custom_color(5, CLR_BTN_DEL) else: item.set_custom_color(5, CLR_STATUS_OK) item.set_text(5, status) item.set_metadata(0, mail) _update_mail_action_btns(null) _set_status("") func _on_mail_item_selected() -> void: var item = mail_tree.get_selected() _update_mail_action_btns(item) func _update_mail_action_btns(item) -> void: var has_sel = item != null edit_mail_btn.disabled = not has_sel end_mail_btn.disabled = not has_sel delete_mail_server_btn.disabled = not has_sel func _get_selected_mail() -> Dictionary: var item = mail_tree.get_selected() if item: return item.get_metadata(0) return {} func _on_edit_mail_pressed() -> void: var mail = _get_selected_mail() if mail.is_empty(): return _show_edit_mail_dialog(mail) func _show_edit_mail_dialog(mail: Dictionary) -> void: var mail_id: String = mail.get("id", "") var mail_type: String = mail.get("type", "global") var target_uid: String = mail.get("target_user_id", "") var dialog := AcceptDialog.new() dialog.title = "Edit Mail: " + mail.get("title", "") dialog.min_size = Vector2i(480, 360) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 10) var id_lbl := Label.new() id_lbl.text = "ID: " + mail_id + " | Type: " + mail_type id_lbl.add_theme_color_override("font_color", CLR_DIM) vbox.add_child(id_lbl) var grid := GridContainer.new() grid.columns = 2 grid.add_theme_constant_override("h_separation", 8) grid.add_theme_constant_override("v_separation", 8) var title_lbl := Label.new(); title_lbl.text = "Title:"; grid.add_child(title_lbl) var title_input := LineEdit.new(); title_input.text = mail.get("title", ""); title_input.custom_minimum_size.x = 300; grid.add_child(title_input) var content_lbl := Label.new(); content_lbl.text = "Content:"; grid.add_child(content_lbl) var content_input := TextEdit.new(); content_input.text = mail.get("content", ""); content_input.custom_minimum_size = Vector2(300, 100); grid.add_child(content_input) var end_lbl := Label.new(); end_lbl.text = "End Date:"; grid.add_child(end_lbl) var end_picker: Button = load("res://scenes/ui/date_picker.tscn").instantiate() end_picker.custom_minimum_size.x = 200 grid.add_child(end_picker) # Pre-populate if existing end_date var existing_end: String = mail.get("end_date", "") if not existing_end.is_empty(): var parts = existing_end.substr(0, 10).split("-") if parts.size() == 3: end_picker.current_date = {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2])} end_picker.view_date = end_picker.current_date.duplicate() end_picker.text = existing_end.substr(0, 10) # Recipient row var recip_lbl := Label.new(); recip_lbl.text = "Recipient:"; grid.add_child(recip_lbl) var recip_hbox := HBoxContainer.new() recip_hbox.add_theme_constant_override("separation", 6) var recip_input := LineEdit.new() recip_input.custom_minimum_size.x = 180 recip_input.placeholder_text = "Username or ID (empty = global)" if mail_type == "personal" and not target_uid.is_empty(): recip_input.text = target_uid.substr(0, 12) + "..." recip_input.tooltip_text = target_uid recip_hbox.add_child(recip_input) var recip_find_btn := Button.new() recip_find_btn.text = "Find" recip_find_btn.custom_minimum_size.x = 60 recip_hbox.add_child(recip_find_btn) var recip_resolved_lbl := Label.new() recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_OK) recip_hbox.add_child(recip_resolved_lbl) grid.add_child(recip_hbox) var _edit_resolved_uid := target_uid recip_find_btn.pressed.connect(func(): var inp = recip_input.text.strip_edges() if inp.is_empty(): _edit_resolved_uid = "" recip_resolved_lbl.text = "(Global)" recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_OK) return var uid = await _resolve_target_user_id(inp) if uid.is_empty(): recip_resolved_lbl.text = "NOT FOUND" recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_ERR) else: _edit_resolved_uid = uid recip_resolved_lbl.text = uid.substr(0, 12) + "..." recip_resolved_lbl.add_theme_color_override("font_color", CLR_STATUS_OK) ) vbox.add_child(grid) var save_btn := Button.new() save_btn.text = "Save Changes" save_btn.custom_minimum_size.y = 40 vbox.add_child(save_btn) dialog.add_child(vbox) add_child(dialog) dialog.popup_centered() save_btn.pressed.connect(func(): # If user typed something but didn't click Find, auto-resolve var recip_text = recip_input.text.strip_edges() var final_target = _edit_resolved_uid if not recip_text.is_empty() and _edit_resolved_uid == target_uid: # Text changed but not resolved yet var resolved = await _resolve_target_user_id(recip_text) if not resolved.is_empty(): final_target = resolved elif recip_text.is_empty(): final_target = "" _set_status("Updating mail...") var payload = { "mail_id": mail_id, "type": mail_type, "target_user_id": target_uid, "new_target_user_id": final_target, "title": title_input.text, "content": content_input.text, "end_date": end_picker.get_date_iso() } var res = await _rpc("admin_update_mail", payload) if res.has("success"): _set_status("Mail updated!", CLR_STATUS_OK) await _load_mail() dialog.queue_free() ) func _on_end_mail_pressed() -> void: var mail = _get_selected_mail() if mail.is_empty(): return var mail_id: String = mail.get("id", "") var mail_type: String = mail.get("type", "global") var target_uid: String = mail.get("target_user_id", "") var confirm := ConfirmationDialog.new() confirm.title = "End Mail Now?" confirm.dialog_text = "Set end_date to NOW for:\n" + mail.get("title", "Unknown") add_child(confirm) confirm.popup_centered() confirm.confirmed.connect(func(): var now_iso = Time.get_datetime_string_from_system(true) var res = await _rpc("admin_update_mail", { "mail_id": mail_id, "type": mail_type, "target_user_id": target_uid, "end_date": now_iso }) if res.has("success"): _set_status("Mail ended", CLR_STATUS_OK) await _load_mail() confirm.queue_free() ) func _on_delete_mail_server_pressed() -> void: var mail = _get_selected_mail() if mail.is_empty(): return var mail_id: String = mail.get("id", "") var mail_type: String = mail.get("type", "global") var target_uid: String = mail.get("target_user_id", "") var confirm := ConfirmationDialog.new() confirm.title = "PERMANENTLY Delete Mail?" confirm.dialog_text = "Remove from server storage:\n" + mail.get("title", "Unknown") + "\n\nThis cannot be undone!" add_child(confirm) confirm.popup_centered() confirm.confirmed.connect(func(): var res = await _rpc("admin_delete_mail_server", { "mail_id": mail_id, "type": mail_type, "target_user_id": target_uid }) if res.has("success"): _set_status("Mail deleted from server", CLR_STATUS_OK) await _load_mail() confirm.queue_free() ) # ============================================================================= # TAB 6: SHOP — FEATURED BANNERS # ============================================================================= var _slot_nodes: Array = [] # cached references to the 3 slot HBoxContainers func _get_slot_nodes() -> Array: if _slot_nodes.is_empty(): for child in slots_vbox.get_children(): if child is HBoxContainer: _slot_nodes.append(child) return _slot_nodes func _load_featured_banners() -> void: _set_status("Loading banners...") var res := await _rpc("admin_get_featured_banners", {}) if res.has("error"): _set_status("Failed: " + str(res.error), CLR_STATUS_ERR) return var raw_banners = res.get("banners", []) var banners: Array = raw_banners if typeof(raw_banners) == TYPE_ARRAY else [] var slots := _get_slot_nodes() for i in range(slots.size()): var slot: HBoxContainer = slots[i] var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit if i < banners.size(): var b: Dictionary = banners[i] if banners[i] is Dictionary else {} id_edit.text = b.get("item_id", "") lbl_edit.text = b.get("label", "") else: id_edit.text = "" lbl_edit.text = "" count_label.text = "%d banners configured" % banners.size() _set_status("Banners loaded", CLR_STATUS_OK) func _save_featured_banners() -> void: var banners: Array = [] var slots := _get_slot_nodes() for slot in slots: var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit var item_id: String = id_edit.text.strip_edges() var label: String = lbl_edit.text.strip_edges() if not item_id.is_empty(): banners.append({"item_id": item_id, "label": label}) _set_status("Saving banners...") var res := await _rpc("admin_set_featured_banners", {"banners": banners}) if res.has("error"): _set_status("Save failed: " + str(res.error), CLR_STATUS_ERR) elif res.has("success"): _set_status("Banners saved! (%d slots)" % banners.size(), CLR_STATUS_OK) # ============================================================================= # TAB 7: LOBBY CHAT # ============================================================================= func _load_chat_config() -> void: chat_status_label.text = "Loading config..." count_label.text = "chat config" var res := await _rpc("admin_get_chat_config", {}) if res.has("error"): chat_status_label.text = "Failed: " + str(res.error) return var config: Dictionary = res.get("config", {}) chat_prefix_edit.text = config.get("prefix", "") chat_max_msg_spin.value = config.get("max_messages", 50) chat_max_age_spin.value = config.get("max_age_days", 0) chat_status_label.text = "" func _on_wipe_chat() -> void: var confirm := ConfirmationDialog.new() confirm.title = "Wipe Entire Lobby Chat?" confirm.dialog_text = "This will delete ALL messages in the global lobby chat for everyone. Continue?" add_child(confirm) confirm.popup_centered() confirm.confirmed.connect(func(): chat_status_label.text = "Wiping chat..." var target_channel_id = "" var lobby = get_tree().get_first_node_in_group("Lobby") if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"): target_channel_id = lobby.chat._chat_channel.id if target_channel_id.is_empty(): chat_status_label.text = "Error: Not connected to global chat." confirm.queue_free() return var payload = JSON.stringify({"channel_id": target_channel_id}) var result = await BackendService.admin_clear_global_chat(payload) if result.get("success", false): chat_status_label.text = "Chat wiped!" if lobby.has_method("admin_wipe_chat"): # Just update UI locally lobby.chat._chat_messages.clear() lobby.chat._refresh_chat_display() lobby.chat._inject_local_message("[SYSTEM] : Global chat cleared by admin.") else: chat_status_label.text = "Wipe failed: " + str(result.get("message", "")) confirm.queue_free() ) func _on_purge_old_chat() -> void: var max_age: int = int(chat_max_age_spin.value) if max_age <= 0: chat_status_label.text = "Set 'Delete older than' to > 0 days first." return var confirm := ConfirmationDialog.new() confirm.title = "Purge Old Messages?" confirm.dialog_text = "Delete all messages older than %d days?" % max_age add_child(confirm) confirm.popup_centered() confirm.confirmed.connect(func(): chat_status_label.text = "Purging old messages..." var target_channel_id = "" var lobby = get_tree().get_first_node_in_group("Lobby") if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"): target_channel_id = lobby.chat._chat_channel.id if target_channel_id.is_empty(): chat_status_label.text = "Error: Not connected to global chat." confirm.queue_free() return var result = await BackendService.admin_purge_old_messages(target_channel_id, max_age) if result.has("error"): chat_status_label.text = "Purge failed: " + str(result.get("error", "")) else: var deleted: int = result.get("deleted", 0) chat_status_label.text = "Purged %d old messages." % deleted confirm.queue_free() ) func _on_save_chat_config() -> void: chat_status_label.text = "Saving..." var config := { "prefix": chat_prefix_edit.text.strip_edges(), "max_messages": int(chat_max_msg_spin.value), "max_age_days": int(chat_max_age_spin.value) } var res := await _rpc("admin_set_chat_config", config) if res.has("error"): chat_status_label.text = "Failed: " + str(res.error) else: chat_status_label.text = "Chat config saved!" # ============================================================================= # TAB 8: CHAT STORAGE # ============================================================================= func _on_load_chat_messages() -> void: var channel_id := chat_channel_id_edit.text.strip_edges() if channel_id.is_empty(): # Default to the global lobby room rather than erroring out. channel_id = "social_global" chat_channel_id_edit.text = channel_id # Best-effort: resolve "social_global" to the real hashed Nakama Channel ID so # the admin sees it in the UI. If resolution fails (not in lobby / socket # down), fall through with the room name — the server resolves it # authoritatively via nk.channel_id_build. if channel_id == "social_global": var resolved := await _resolve_global_chat_channel_id() if not resolved.is_empty(): channel_id = resolved chat_channel_id_edit.text = channel_id # show the admin the real ID _chat_channel_id = channel_id _chat_cursor = "" _chat_messages_data.clear() _clear_tree(chat_tree, _chat_tree_root) await _fetch_chat_messages_batch() func _resolve_global_chat_channel_id() -> String: # Nakama Room channel IDs are deterministically hashed from the type and room name. # For type=2 (Room) and name="social_global", the ID format is always: # "2." + uri_encoded_room_name + "." # no domain needed for rooms. # But Nakama's format often just uses "2.RoomName." - let's ensure we try the exact determinism if socket fails. var lobby = get_tree().get_first_node_in_group("Lobby") if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"): return lobby.chat._chat_channel.id var socket = NakamaManager.socket if socket and socket.is_connected_to_host(): var result = await socket.join_chat_async("social_global", NakamaSocket.ChannelType.Room, true, false) if not result.is_exception(): return result.id # Fallback if no socket or join failed: construct the exact ID the Web UI expects. # Type 2 (Room), Name "social_global" return "2." + "social_global".uri_encode() + "." func _on_load_more_chat_messages() -> void: if _chat_channel_id.is_empty(): await _on_load_chat_messages() return if _chat_cursor.is_empty(): _set_status("No more messages to load.", CLR_STATUS_OK) return await _fetch_chat_messages_batch() func _fetch_chat_messages_batch() -> void: _set_status("Loading messages...") var payload := { "channel_id": _chat_channel_id, "limit": 50, "cursor": _chat_cursor, "forward": false } var res := await _rpc("admin_list_channel_messages", payload) if res.has("error"): _set_status("Failed: " + str(res.error), CLR_STATUS_ERR) return var msgs = res.get("messages", []) var next_cursor = res.get("next_cursor", "") if (typeof(msgs) == TYPE_DICTIONARY and msgs.is_empty()) or (typeof(msgs) == TYPE_ARRAY and msgs.is_empty()): var fallback = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel_id, 50, false, _chat_cursor) if not fallback.is_exception(): msgs = fallback.messages if fallback.messages else [] next_cursor = fallback.next_cursor else: _set_status("Failed: " + fallback.get_exception().message, CLR_STATUS_ERR) return if typeof(msgs) == TYPE_DICTIONARY: msgs = msgs.values() elif typeof(msgs) != TYPE_ARRAY: msgs = [] var added_count := 0 for raw_msg in msgs: var msg := _normalize_chat_storage_message(raw_msg) if msg.is_empty(): continue _chat_messages_data.append(msg) var item := _chat_tree_root.create_child() item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) item.set_editable(0, true) item.set_text(1, msg.get("username", msg.get("sender_id", "?").substr(0, 8))) item.set_text(2, _format_chat_storage_content(msg.get("content", ""))) item.set_text(3, msg.get("create_time", "").substr(0, 19).replace("T", " ")) var mid = msg.get("message_id", "") item.set_tooltip_text(3, mid) item.set_metadata(0, msg) added_count += 1 count_label.text = "%d messages loaded" % _chat_messages_data.size() chat_tree.queue_redraw() if added_count == 0: _set_status("No stored messages returned for channel: " + _chat_channel_id, CLR_STATUS_ERR) return if not next_cursor.is_empty(): _chat_cursor = next_cursor _set_status("Loaded page. Click Refresh to load more.", CLR_STATUS_OK) else: _chat_cursor = "" _set_status("All messages loaded.", CLR_STATUS_OK) func _format_chat_storage_content(content) -> String: if typeof(content) == TYPE_DICTIONARY: return str(content.get("msg", content)) var text := str(content) var parsed = JSON.parse_string(text) if typeof(parsed) == TYPE_DICTIONARY: return str(parsed.get("msg", text)) return text func _normalize_chat_storage_message(raw_msg) -> Dictionary: if typeof(raw_msg) == TYPE_DICTIONARY: return raw_msg if typeof(raw_msg) != TYPE_OBJECT: return {} return { "message_id": raw_msg.message_id, "sender_id": raw_msg.sender_id, "username": raw_msg.username, "content": raw_msg.content, "create_time": raw_msg.create_time, "update_time": raw_msg.update_time, "channel_id": raw_msg.channel_id } func _on_delete_selected_chat_messages() -> void: var items := _get_checked_chat_items() if items.is_empty(): var selected = chat_tree.get_selected() if selected: items.append(selected) if items.is_empty(): _set_status("Select one or more messages to delete.", CLR_STATUS_ERR) return var confirm := ConfirmationDialog.new() confirm.title = "Delete %d Message(s)?" % items.size() confirm.dialog_text = "Permanently delete selected chat messages?" add_child(confirm) confirm.popup_centered() confirm.confirmed.connect(func(): _set_status("Deleting %d message(s)..." % items.size()) var deleted := 0 for item in items: var msg = item.get_metadata(0) if typeof(msg) != TYPE_DICTIONARY: continue var msg_id = msg.get("message_id", "") if msg_id.is_empty(): continue var res = await _rpc("admin_delete_channel_message", { "channel_id": _chat_channel_id, "message_id": msg_id }) if res.get("success", false): deleted += 1 _chat_messages_data.erase(msg) _chat_tree_root.remove_child(item) item.free() count_label.text = "%d messages loaded" % _chat_messages_data.size() _set_status("Deleted %d message(s)" % deleted, CLR_STATUS_OK if deleted > 0 else CLR_STATUS_ERR) confirm.queue_free() ) func _get_checked_chat_items() -> Array: var items: Array = [] var child = _chat_tree_root.get_first_child() if _chat_tree_root else null while child: if child.is_checked(0): items.append(child) child = child.get_next() return items