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 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() 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()) # ============================================================================= # 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 == 4: await _load_mail() elif tab_index == 5: await _load_featured_banners() # ============================================================================= # 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 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_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 history for user...", CLR_STATUS_OK) var res = await _rpc("admin_get_user_history", {"user_id": uid}) if res.has("error"): _set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR) return _set_status("History loaded.", CLR_STATUS_OK) var h = res.get("history", {}) var text = "[b]=== USER HISTORY ===[/b]\n" text += "User ID: " + uid + "\n\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" 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", []) lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else [] 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) # ============================================================================= # 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()) 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() # ============================================================================= # 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)