refactor: enhance test framework with automated resource tracking and scripted error capture capabilities

This commit is contained in:
2026-06-26 09:40:17 +08:00
parent 948a99cf90
commit 00f9d98f4b
58 changed files with 3594 additions and 1289 deletions
+285 -61
View File
@@ -197,14 +197,15 @@ func _setup_columns() -> void:
_mail_root = mail_tree.create_item()
# Chat Storage
chat_tree.set_column_title(0, "Sender")
chat_tree.set_column_title(1, "Content")
chat_tree.set_column_title(2, "Date")
chat_tree.set_column_title(3, "ID")
chat_tree.set_column_custom_minimum_width(0, 100)
chat_tree.set_column_expand(1, true)
chat_tree.set_column_custom_minimum_width(2, 120)
chat_tree.set_column_custom_minimum_width(3, 100)
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:
@@ -256,8 +257,8 @@ func _connect_signals() -> void:
# Chat Storage actions
load_messages_btn.pressed.connect(_on_load_chat_messages)
refresh_chat_btn.pressed.connect(_on_load_chat_messages)
delete_selected_btn.pressed.connect(_on_delete_chat_message)
refresh_chat_btn.pressed.connect(_on_load_more_chat_messages)
delete_selected_btn.pressed.connect(_on_delete_selected_chat_messages)
# =============================================================================
# Core Panel Logic
@@ -281,6 +282,8 @@ func _on_tab_changed(tab_index: int) -> void:
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:
@@ -371,12 +374,15 @@ func _on_user_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: i
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(380, 260)
dialog.min_size = Vector2i(460, 420)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 8)
@@ -385,6 +391,25 @@ func _show_edit_user_dialog(user: Dictionary) -> void:
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: "
@@ -419,18 +444,31 @@ func _show_edit_user_dialog(user: Dictionary) -> void:
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)
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, new_role: String, new_banned: bool, reason: String) -> void:
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: " + uname, CLR_STATUS_OK)
_set_status("Saved: " + display_name, CLR_STATUS_OK)
await _load_users()
# =============================================================================
@@ -529,19 +567,47 @@ func _on_history_pressed() -> void:
return
var uid = selected_data[0].get("user_id", "")
_set_status("Fetching history for user...", CLR_STATUS_OK)
_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 res.has("error"):
_set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR)
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("History loaded.", CLR_STATUS_OK)
_set_status("User details loaded.", CLR_STATUS_OK)
var h = res.get("history", {})
var text = "[b]=== USER HISTORY ===[/b]\n"
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", [])
@@ -575,6 +641,17 @@ func _on_history_pressed() -> void:
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()
@@ -614,7 +691,15 @@ func _load_leaderboard() -> void:
return
var raw_lb = res.get("leaderboard", [])
lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else []
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))
@@ -631,6 +716,37 @@ func _load_leaderboard() -> void:
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))
@@ -729,6 +845,7 @@ func _load_daily_rewards_config() -> void:
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:
@@ -737,6 +854,7 @@ func _on_dr_month_selected(index: int) -> void:
_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
@@ -784,6 +902,11 @@ func _build_dr_grid() -> void:
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...")
@@ -802,7 +925,15 @@ func _on_add_reward_pressed() -> void:
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())
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()
@@ -927,6 +1058,7 @@ func _on_send_mail() -> void:
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
@@ -1245,6 +1377,7 @@ func _save_featured_banners() -> void:
# =============================================================================
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)
@@ -1344,18 +1477,19 @@ func _on_save_chat_config() -> void:
func _on_load_chat_messages() -> void:
var channel_id := chat_channel_id_edit.text.strip_edges()
if channel_id.is_empty():
_set_status("Enter a Channel ID first.", CLR_STATUS_ERR)
return
# Default to the global lobby room rather than erroring out.
channel_id = "social_global"
chat_channel_id_edit.text = channel_id
# Auto-resolve "social_global" to the actual Nakama Channel ID if the admin is in the lobby
# 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 lobby = get_tree().get_first_node_in_group("Lobby")
if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"):
channel_id = lobby.chat._chat_channel.id
chat_channel_id_edit.text = channel_id # Update UI so admin sees the real ID
else:
_set_status("Cannot resolve social_global. Join chat first.", CLR_STATUS_ERR)
return
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 = ""
@@ -1364,6 +1498,35 @@ func _on_load_chat_messages() -> void:
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 := {
@@ -1380,19 +1543,42 @@ func _fetch_chat_messages_batch() -> void:
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 msg in msgs:
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_text(0, msg.get("username", msg.get("sender_id", "?").substr(0, 8)))
item.set_text(1, msg.get("content", ""))
item.set_text(2, msg.get("create_time", "").substr(0, 19).replace("T", " "))
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_text(3, mid)
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
@@ -1401,37 +1587,75 @@ func _fetch_chat_messages_batch() -> void:
_chat_cursor = ""
_set_status("All messages loaded.", CLR_STATUS_OK)
func _on_delete_chat_message() -> void:
var item = chat_tree.get_selected()
if not item:
_set_status("Select a message to delete.", CLR_STATUS_ERR)
return
func _format_chat_storage_content(content) -> String:
if typeof(content) == TYPE_DICTIONARY:
return str(content.get("msg", content))
var msg = item.get_metadata(0)
if not msg:
return
var text := str(content)
var parsed = JSON.parse_string(text)
if typeof(parsed) == TYPE_DICTIONARY:
return str(parsed.get("msg", text))
return text
var msg_id = msg.get("message_id", "")
if msg_id.is_empty():
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 Message?"
confirm.dialog_text = "Permanently delete message from " + msg.get("username", "?") + "?"
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 message...")
var res = await _rpc("admin_delete_channel_message", {
"channel_id": _chat_channel_id,
"message_id": msg_id
})
if res.get("success", false):
_set_status("Message deleted!", CLR_STATUS_OK)
chat_tree.get_root().remove_child(item)
item.free()
count_label.text = "%d messages loaded" % _chat_messages_data.size()
else:
_set_status("Failed: " + str(res.get("error", "")), CLR_STATUS_ERR)
_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