extends PanelContainer ## Admin panel for in-game server management using Nakama RPC ## Requires server-side tekton_admin.ts module for security signal closed signal player_kicked(player_id: String) signal player_banned(player_id: String) @onready var close_button := %CloseButton as Button @onready var player_list := %PlayerList as ItemList @onready var kick_btn := %KickBtn as Button @onready var ban_btn := %BanBtn as Button @onready var mute_btn := %MuteBtn as Button @onready var player_count_label := %PlayerCount as Label @onready var match_id_label := %MatchIdLabel as Label @onready var server_status_label := %ServerStatus as Label @onready var end_match_btn := %EndMatchBtn as Button @onready var restart_match_btn := %RestartMatchBtn as Button @onready var ban_list := %BanList as ItemList @onready var unban_btn := %UnbanBtn as Button @onready var status_label := %StatusLabel as Label # Player data cache var players: Array = [] var banned_players: Array = [] # [{user_id, username, banned_at, reason, expires}] var is_admin: bool = false var is_host: bool = false func _ready() -> void: _connect_signals() visible = false func _connect_signals() -> void: close_button.pressed.connect(_on_close_pressed) kick_btn.pressed.connect(_on_kick_pressed) ban_btn.pressed.connect(_on_ban_pressed) mute_btn.pressed.connect(_on_mute_pressed) end_match_btn.pressed.connect(_on_end_match_pressed) restart_match_btn.pressed.connect(_on_restart_match_pressed) unban_btn.pressed.connect(_on_unban_pressed) player_list.item_selected.connect(_on_player_selected) func show_panel() -> void: # Check permissions is_host = LobbyManager.is_host is_admin = await _check_admin_status() if not is_host and not is_admin: status_label.text = "Admin access required" status_label.add_theme_color_override("font_color", Color.RED) return # Show/hide admin-only features ban_btn.visible = is_admin unban_btn.visible = is_admin await _refresh_all_data() visible = true func _check_admin_status() -> bool: # Check with server if user has admin role var result := await _rpc_call("get_user_profile", {}) if result.has("role"): return result.role in ["admin", "moderator", "owner"] return false func _on_close_pressed() -> void: visible = false emit_signal("closed") # ============================================================================= # RPC Helpers # ============================================================================= func _rpc_call(rpc_name: String, payload: Dictionary) -> Dictionary: if not NakamaManager.client or not NakamaManager.session: push_error("[AdminPanel] Not connected to Nakama") return {"error": "Not connected"} var result = await NakamaManager.client.rpc_async( NakamaManager.session, rpc_name, JSON.stringify(payload) ) if result.is_exception(): var error: String = result.get_exception().message push_error("[AdminPanel] RPC Error: " + error) status_label.text = "Error: " + error status_label.add_theme_color_override("font_color", Color.RED) return {"error": error} if result.payload: return JSON.parse_string(result.payload) return {} # ============================================================================= # Data Refresh # ============================================================================= func _refresh_all_data() -> void: await _refresh_player_list() await _refresh_server_stats() if is_admin: await _refresh_ban_list() status_label.text = "" func _refresh_player_list() -> void: player_list.clear() players = LobbyManager.get_players() for player in players: var name: String = player.get("name", "Unknown") var id: int = player.get("id", 0) var user_id: String = player.get("user_id", "") var is_player_host := id == 1 var display := name if is_player_host: display += " (Host)" player_list.add_item(display) # Store user_id as metadata player_list.set_item_metadata(player_list.item_count - 1, { "user_id": user_id, "peer_id": id, "name": name }) _update_action_buttons() func _refresh_server_stats() -> void: var stats := await _rpc_call("admin_get_server_stats", { "match_id": NakamaManager.current_match_id }) if stats.has("error"): return player_count_label.text = str(stats.get("total_players", LobbyManager.get_players().size())) var match_id := NakamaManager.current_match_id if match_id.length() > 16: match_id = match_id.substr(0, 16) + "..." match_id_label.text = match_id if match_id else "N/A" server_status_label.text = "Running" server_status_label.add_theme_color_override("font_color", Color.GREEN) func _refresh_ban_list() -> void: ban_list.clear() var result := await _rpc_call("admin_get_ban_list", {}) if result.has("error"): return banned_players = result.get("bans", []) for ban in banned_players: var display := "%s - %s" % [ ban.get("username", "Unknown"), ban.get("reason", "No reason") ] if ban.get("expires"): display += " (until " + ban.expires + ")" ban_list.add_item(display) ban_list.set_item_metadata(ban_list.item_count - 1, ban) # ============================================================================= # Player Actions # ============================================================================= func _on_player_selected(_index: int) -> void: _update_action_buttons() func _update_action_buttons() -> void: var selected := player_list.get_selected_items() if selected.is_empty(): kick_btn.disabled = true ban_btn.disabled = true mute_btn.disabled = true return var idx: int = selected[0] var meta: Dictionary = player_list.get_item_metadata(idx) var is_player_host: bool = meta.get("peer_id", 0) == 1 # Can't kick/ban the host or yourself var is_self: bool = meta.get("user_id", "") == AuthManager.current_user.get("user_id", "") kick_btn.disabled = is_player_host or is_self ban_btn.disabled = is_player_host or is_self or not is_admin mute_btn.disabled = is_self func _on_kick_pressed() -> void: var selected := player_list.get_selected_items() if selected.is_empty(): return var meta: Dictionary = player_list.get_item_metadata(selected[0]) var user_id: String = meta.get("user_id", "") var player_name: String = meta.get("name", "Unknown") if user_id.is_empty(): status_label.text = "Cannot kick: Invalid player" return status_label.text = "Kicking player..." var result := await _rpc_call("admin_kick_player", { "match_id": NakamaManager.current_match_id, "user_id": user_id, "reason": "Kicked by " + ("admin" if is_admin else "host") }) if result.get("success", false): status_label.text = "Kicked: " + player_name status_label.add_theme_color_override("font_color", Color.GREEN) emit_signal("player_kicked", user_id) await get_tree().create_timer(0.5).timeout _refresh_player_list() else: status_label.text = "Failed to kick player" status_label.add_theme_color_override("font_color", Color.RED) func _on_ban_pressed() -> void: var selected := player_list.get_selected_items() if selected.is_empty(): return var meta: Dictionary = player_list.get_item_metadata(selected[0]) var user_id: String = meta.get("user_id", "") var player_name: String = meta.get("name", "Unknown") if user_id.is_empty(): status_label.text = "Cannot ban: Invalid player" return # Show ban dialog var dialog := _create_ban_dialog(user_id, player_name) add_child(dialog) dialog.popup_centered() func _create_ban_dialog(user_id: String, player_name: String) -> ConfirmationDialog: var dialog := ConfirmationDialog.new() dialog.title = "Ban Player" var vbox := VBoxContainer.new() var info_label := Label.new() info_label.text = "Ban player: " + player_name vbox.add_child(info_label) var reason_label := Label.new() reason_label.text = "Reason:" vbox.add_child(reason_label) var reason_input := LineEdit.new() reason_input.placeholder_text = "Enter ban reason" vbox.add_child(reason_input) var duration_label := Label.new() duration_label.text = "Duration (hours, 0 = permanent):" vbox.add_child(duration_label) var duration_input := SpinBox.new() duration_input.min_value = 0 duration_input.max_value = 8760 # 1 year duration_input.value = 24 # Default 24 hours vbox.add_child(duration_input) dialog.add_child(vbox) dialog.confirmed.connect(func(): _execute_ban(user_id, player_name, reason_input.text, int(duration_input.value)) dialog.queue_free() ) dialog.canceled.connect(func(): dialog.queue_free() ) return dialog func _execute_ban(user_id: String, player_name: String, reason: String, duration_hours: int) -> void: status_label.text = "Banning player..." var result := await _rpc_call("admin_ban_player", { "user_id": user_id, "reason": reason if reason else "No reason provided", "duration_hours": duration_hours, "match_id": NakamaManager.current_match_id }) if result.get("success", false): status_label.text = "Banned: " + player_name status_label.add_theme_color_override("font_color", Color.GREEN) emit_signal("player_banned", user_id) await get_tree().create_timer(0.5).timeout await _refresh_all_data() else: status_label.text = "Failed to ban: " + result.get("error", "Unknown error") status_label.add_theme_color_override("font_color", Color.RED) func _on_mute_pressed() -> void: var selected := player_list.get_selected_items() if selected.is_empty(): return var meta: Dictionary = player_list.get_item_metadata(selected[0]) var player_name: String = meta.get("name", "Unknown") # TODO: Implement mute via chat system RPC status_label.text = "Muted: " + player_name # ============================================================================= # Server Controls # ============================================================================= func _on_end_match_pressed() -> void: var dialog := ConfirmationDialog.new() dialog.dialog_text = "Are you sure you want to end this match?\nAll players will be returned to the lobby." add_child(dialog) dialog.popup_centered() dialog.confirmed.connect(func(): status_label.text = "Ending match..." var result := await _rpc_call("admin_end_match", { "match_id": NakamaManager.current_match_id, "reason": "Ended by " + ("admin" if is_admin else "host") }) if result.get("success", false): status_label.text = "Match ended" else: status_label.text = "Failed to end match" dialog.queue_free() ) dialog.canceled.connect(func(): dialog.queue_free()) func _on_restart_match_pressed() -> void: var dialog := ConfirmationDialog.new() dialog.dialog_text = "Restart the current match?" add_child(dialog) dialog.popup_centered() dialog.confirmed.connect(func(): # For restart, we'll use a different approach - reload scene locally # The actual match restart logic would depend on your game get_tree().reload_current_scene() dialog.queue_free() ) dialog.canceled.connect(func(): dialog.queue_free()) # ============================================================================= # Ban List Management # ============================================================================= func _on_unban_pressed() -> void: var selected := ban_list.get_selected_items() if selected.is_empty(): status_label.text = "Select a player to unban" return var meta: Dictionary = ban_list.get_item_metadata(selected[0]) var user_id: String = meta.get("user_id", "") var username: String = meta.get("username", "Unknown") if user_id.is_empty(): status_label.text = "Invalid ban entry" return status_label.text = "Unbanning..." var result := await _rpc_call("admin_unban_player", { "user_id": user_id }) if result.get("success", false): status_label.text = "Unbanned: " + username status_label.add_theme_color_override("font_color", Color.GREEN) await _refresh_ban_list() else: status_label.text = "Failed to unban" status_label.add_theme_color_override("font_color", Color.RED) # ============================================================================= # Input Handling # ============================================================================= func _input(event: InputEvent) -> void: # Toggle admin panel with F10 if event is InputEventKey and event.pressed and event.keycode == KEY_F10: if visible: _on_close_pressed() else: show_panel()