extends Node # RCON Admin Console — Command Handler # ============================================================================ # Processes RCON commands, returns formatted response strings. # Game-affecting commands (changelevel, kick, ban, say, players, etc.) # are dispatched via the `rcon_command` signal so game logic can handle them # without coupling RCON to game-specific systems. # # Commands handled inline (no game dependency): # help, echo/ping, status (engine-level stats), quit # cvarlist, cvar_get, cvar_set (via CvarRegistry singleton if available) # # Plugin commands are delegated to PluginManager singleton: # plugin list, plugin load, plugin unload, plugin reload, # plugin info, plugin rescan # # Commands dispatched via signal: # changelevel, kick, ban, unban, say, msg, players, exec # # Connection pattern for game logic: # func _ready(): # var rcon = get_node("/root/RconServer/RconCommandHandler") # if rcon: # rcon.rcon_command.connect(_on_rcon_command) # # func _on_rcon_command(command: String, args: PackedStringArray): # match command: # "changelevel": # $ServerManager.change_level(args[0]) # "kick": # $PlayerManager.kick_player(args[0]) # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- signal rcon_command(command: String, args: PackedStringArray) # --------------------------------------------------------------------------- # Public API — called by rcon_server.gd # --------------------------------------------------------------------------- func handle_command(cmd: String, args: PackedStringArray) -> String: match cmd: "help": return _cmd_help(args) "echo", "ping": return _cmd_echo(args) "status": return _cmd_status(args) "quit": return _cmd_quit(args) "cvarlist": return _cmd_cvarlist(args) "cvar_get": return _cmd_cvar_get(args) "cvar_set": return _cmd_cvar_set(args) "plugin": return _cmd_plugin(args) "say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec", "start_match", "end_round": return _cmd_dispatch(cmd, args) _: return "Unknown command: '" + cmd + "'. Type 'help' for available commands.\\r\\n" # --------------------------------------------------------------------------- # Built-in commands # --------------------------------------------------------------------------- func _cmd_help(_args: PackedStringArray) -> String: var lines: PackedStringArray = [ "=== RCON Commands ===", " help Show this help", " echo [text] Echo text back (ping test)", " status Show server engine status", " players List connected players", " say Broadcast chat to all players", " msg Send private message", " changelevel Load a new map", " start_match Start the match (exit warmup)", " end_round [reason] Force-end the current round", " kick [reason] Remove a player", " ban [duration] Ban a player from the server", " unban Remove a ban", " exec Execute a server config file", " cvarlist [pattern] List cvars (optional filter)", " cvar_get Get a cvar's value", " cvar_set Set a writable cvar", " plugin [args] Plugin management (list/load/unload/reload/info/rescan)", " quit Shut down the server gracefully", ] return "\r\n".join(lines) + "\r\n" func _cmd_echo(args: PackedStringArray) -> String: if args.is_empty(): return "Pong\r\n" return " ".join(args) + "\\r\\n" func _cmd_status(_args: PackedStringArray) -> String: var lines: PackedStringArray = [] lines.append("=== Server Status ===") # Engine version var vi: Dictionary = Engine.get_version_info() lines.append(" Engine: Godot " + str(vi.get("major", "?")) + "." + str(vi.get("minor", "?")) + "." + str(vi.get("patch", "?"))) # FPS lines.append(" FPS: " + str(Engine.get_frames_per_second())) # Physics tick rate lines.append(" Tickrate: " + str(Engine.physics_ticks_per_second) + " Hz") # Uptime var uptime_ms: int = Time.get_ticks_msec() var total_sec: int = uptime_ms / 1000 var h: int = total_sec / 3600 var m: int = (total_sec % 3600) / 60 var s: int = total_sec % 60 lines.append(" Uptime: " + str(h) + "h " + str(m) + "m " + str(s) + "s") # Server time var dt: Dictionary = Time.get_datetime_dict_from_system() lines.append(" Time: " + \ str(dt.get("year", 0)) + "-" + str(dt.get("month", 1)).pad_zeros(2) + "-" + str(dt.get("day", 1)).pad_zeros(2) + " " + \ str(dt.get("hour", 0)).pad_zeros(2) + ":" + str(dt.get("minute", 0)).pad_zeros(2) + ":" + str(dt.get("second", 0)).pad_zeros(2)) # Performance metrics lines.append(" Objects: " + str(Performance.get_monitor(Performance.OBJECT_COUNT))) var mem_kb: float = Performance.get_monitor(Performance.MEMORY_STATIC) lines.append(" Memory: " + str(snapped(mem_kb / 1024.0, 0.1)) + " MB") # Node count (via SceneTree) lines.append(" Nodes: " + str(get_tree().node_count)) # RCON info lines.append(" RCON: enabled on port ? (see rcon_server)") # Try to get server name from engine var server_name: String = ProjectSettings.get_setting("application/config/name", "Tactical Shooter") lines.append(" Server: " + server_name) return "\r\n".join(lines) + "\r\n" func _cmd_quit(_args: PackedStringArray) -> String: # Defer shutdown to next frame so the response can be sent get_tree().call_deferred("quit") return "Server shutting down...\r\n" func _cmd_cvarlist(args: PackedStringArray) -> String: var pattern: String = args[0] if args.size() > 0 else "" if not has_node("/root/CvarRegistry"): if pattern.is_empty() or "rcon" in pattern: return "rcon_enabled: bool\r\nrcon_port: int\r\nrcon_password: string [PW]\r\n(Cvar registry not available — RCON cvars shown)\r\n" return "Cvar registry not available. No cvars match '" + pattern + "'.\r\n" var registry = get_node("/root/CvarRegistry") if not registry or not registry.has_method("list_cvars"): return "Cvar registry available but incompatible.\r\n" var cvar_names: Array = registry.list_cvars() var lines: PackedStringArray = [] lines.append("=== Cvar List" + (" (filter: '" + pattern + "')" if pattern else "") + " ===") var found: int = 0 for cvar_name in cvar_names: if not pattern.is_empty() and pattern not in str(cvar_name): continue var val = registry.get(cvar_name) var flags: int = 0 var cvar_type: String = "?" if registry.has_method("get_flags"): flags = registry.get_flags(cvar_name) if registry.has_method("get_type"): cvar_type = str(registry.get_type(cvar_name)) var readonly: String = " [RO]" if (flags & 1) else "" var passwd: String = " [PW]" if str(cvar_name).find("password") >= 0 else "" lines.append(" " + str(cvar_name) + " = " + str(val) + " (" + cvar_type + ")" + readonly + passwd) found += 1 if found == 0: return "No cvars match '" + pattern + "'.\r\n" lines.append(str(found) + " cvars listed.") return "\r\n".join(lines) + "\r\n" func _cmd_cvar_get(args: PackedStringArray) -> String: if args.is_empty(): return "Usage: cvar_get \r\n" var name: String = args[0] if has_node("/root/CvarRegistry"): var registry = get_node("/root/CvarRegistry") if registry and registry.has_method("get"): var val = registry.get(name) if val != null: return name + " = " + str(val) + "\r\n" return "Cvar not found: " + name + "\r\n" return "Cvar registry not available.\r\n" func _cmd_cvar_set(args: PackedStringArray) -> String: if args.size() < 2: return "Usage: cvar_set \r\n" var name: String = args[0] var value: String = args[1] if has_node("/root/CvarRegistry"): var registry = get_node("/root/CvarRegistry") if registry and registry.has_method("set"): # Use Callable to avoid compile-time void return detection on dynamic object var fn = Callable(registry, "set") var err = fn.call(name, value) if err == OK: return name + " = " + value + "\r\n" match err: ERR_INVALID_PARAMETER: return "Cvar not found or read-only: " + name + "\r\n" ERR_INVALID_DATA: return "Invalid value for " + name + ": " + value + "\r\n" _: return "Failed to set " + name + " (error " + str(err) + ")\r\n" return "Cvar registry not available.\r\n" # --------------------------------------------------------------------------- # Plugin management — delegates to PluginManager singleton # --------------------------------------------------------------------------- func _cmd_plugin(args: PackedStringArray) -> String: # Try to find PluginManager singleton (autoload) var pm = get_node("/root/PluginManager") if not pm: return "PluginManager not available. Ensure PluginManager is registered as an autoload in project.godot.\r\n" if not pm.has_method("rcon_command"): return "PluginManager does not support RCON commands.\r\n" return pm.rcon_command(args) # --------------------------------------------------------------------------- # Delegated commands — emit signal for game logic to handle # --------------------------------------------------------------------------- func _cmd_dispatch(cmd: String, args: PackedStringArray) -> String: rcon_command.emit(cmd, args) return "Command '" + cmd + "' dispatched to server.\r\n" # --------------------------------------------------------------------------- # Plugin management commands — interfaces with PluginManager singleton # --------------------------------------------------------------------------- func _cmd_plugins(args: PackedStringArray) -> String: """Handle 'plugins' command with subcommands.""" if args.is_empty(): return _cmd_plugins_help() var subcmd: String = args[0].to_lower() match subcmd: "list": return _cmd_plugins_list() "load": if args.size() < 2: return "Usage: plugins load \r\n" return _cmd_plugins_load(args[1]) "unload": if args.size() < 2: return "Usage: plugins unload \r\n" return _cmd_plugins_unload(args[1]) "reload": if args.size() < 2: return "Usage: plugins reload \r\n" return _cmd_plugins_reload(args[1]) "info": if args.size() < 2: return "Usage: plugins info \r\n" return _cmd_plugins_info(args[1]) "scan", "rescan": return _cmd_plugins_rescan() _: return "Unknown plugins subcommand: '" + subcmd + "'\r\n" + _cmd_plugins_help() func _cmd_plugins_help() -> String: return "\r\n".join([ "=== Plugin Management ===", " plugins list List all loaded plugins", " plugins load Load a plugin by name from disk", " plugins unload Unload a running plugin", " plugins reload Reload a plugin (hotswap)", " plugins info Show metadata for a plugin", " plugins rescan Rescan plugin directory for new manifests", ]) + "\r\n" func _cmd_plugins_list() -> String: var mgr = _get_plugin_manager() if not mgr: return "Plugin system not available (no PluginManager singleton).\r\n" var plugins = mgr.list_plugins() if plugins.is_empty(): return "No plugins loaded.\r\n" var lines: PackedStringArray = [] lines.append("=== Loaded Plugins (" + str(plugins.size()) + ") ===") lines.append(" %-24s %-10s %s" % ["Name", "Version", "Author"]) lines.append(" " + "-".repeat(50)) for p in plugins: lines.append(" %-24s %-10s %s" % [p.name, p.version, p.author]) return "\r\n".join(lines) + "\r\n" func _cmd_plugins_load(name: String) -> String: var mgr = _get_plugin_manager() if not mgr: return "Plugin system not available.\r\n" if not mgr.has_method("discover_plugins"): return "PluginManager does not support targeted loading yet — use 'plugins rescan' to reload all.\r\n" if mgr.get_plugin(name): return "Plugin '" + name + "' is already loaded.\r\n" var result = mgr.discover_plugins() if mgr.get_plugin(name): return "Plugin '" + name + "' loaded successfully.\r\n" else: return "Failed to load '" + name + "'. Check that plugin.tres exists in user://plugins/" + name.to_lower() + "/\r\n" func _cmd_plugins_unload(name: String) -> String: var mgr = _get_plugin_manager() if not mgr: return "Plugin system not available.\r\n" if not mgr.has_method("unload_plugin"): return "PluginManager does not support unloading.\r\n" if mgr.unload_plugin(name): return "Plugin '" + name + "' unloaded.\r\n" else: return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n" func _cmd_plugins_reload(name: String) -> String: var mgr = _get_plugin_manager() if not mgr: return "Plugin system not available.\r\n" if not mgr.has_method("reload_plugin"): return "PluginManager does not support reloading.\r\n" if mgr.reload_plugin(name): return "Plugin '" + name + "' reloaded.\r\n" else: return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n" func _cmd_plugins_info(name: String) -> String: var mgr = _get_plugin_manager() if not mgr: return "Plugin system not available.\r\n" var plugin = mgr.get_plugin(name) if not plugin: return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n" var lines: PackedStringArray = [] lines.append("=== Plugin: " + plugin.plugin_name + " ===") lines.append(" Name: " + plugin.plugin_name) lines.append(" Version: " + plugin.plugin_version) lines.append(" Author: " + plugin.plugin_author) lines.append(" Description: " + plugin.plugin_description) lines.append(" Manifest: " + plugin.plugin_manifest_path) return "\r\n".join(lines) + "\r\n" func _cmd_plugins_rescan() -> String: var mgr = _get_plugin_manager() if not mgr: return "Plugin system not available.\r\n" var result = mgr.discover_plugins() return "Rescan complete — loaded: " + str(result.get("loaded", 0)) + \ ", failed: " + str(result.get("failed", 0)) + \ ", skipped: " + str(result.get("skipped", 0)) + "\r\n" # --------------------------------------------------------------------------- # Helper to locate PluginManager singleton # --------------------------------------------------------------------------- func _get_plugin_manager(): """Get the PluginManager singleton, or null if not available.""" if Engine.has_singleton("PluginManager"): return Engine.get_singleton("PluginManager") if has_node("/root/PluginManager"): return get_node("/root/PluginManager") return null