Files
tekton/scripts/ui/admin_panel.gd
T

1662 lines
59 KiB
GDScript

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