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 # 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 = "" 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() 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) 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) # ============================================================================= # 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() # ============================================================================= # 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 NakamaManager.client.rpc_async( NakamaManager.session, rpc_name, JSON.stringify(payload) ) if result.is_exception(): var err: String = result.get_exception().message _set_status(err, CLR_STATUS_ERR) return {"error": err} if result.payload: return JSON.parse_string(result.payload) return {} 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 all_users = res.get("users", []) 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 role: String = user.get("role", "player") var banned: bool = user.get("banned", false) var dialog := AcceptDialog.new() dialog.title = "Edit User: " + uname dialog.min_size = Vector2i(380, 260) 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 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, uname, new_role, ban_check.button_pressed, reason_input.text) dialog.queue_free() ) func _save_user_edit(uid: String, uname: String, new_role: String, new_banned: bool, reason: String) -> void: _set_status("Saving...") 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: " + uname, 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_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 lb_data = res.get("leaderboard", []) 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 _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() _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() 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 _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)