## Plugin Manager — Autoload Singleton ## ## Scans the server/plugins/ directory for plugin manifests ## (plugin.tres), loads enabled plugins, and dispatches engine ## events to every loaded plugin via their hook methods. ## ## This singleton must be registered in project.godot under [autoload]: ## PluginManager="*res://server/scripts/plugin_api/plugin_manager.gd" ## ## Usage from game code: ## var pm: PluginManager = get_node("/root/PluginManager") ## pm.load_plugin("res://server/plugins/my_plugin/") ## pm.dispatch_hook("on_server_start") ## ## RCON integration (commands handled via PluginManager.rcon_command()): ## plugin list — list all discovered and loaded plugins ## plugin load — load a discovered plugin by name ## plugin unload — unload a running plugin ## plugin reload — reload a plugin (re-compiles its script) ## plugin info — show detailed information about a plugin ## plugin rescan — re-scan the plugins directory extends Node ## Duplicate class_name removed — already registered as autoload singleton. ## class_name PluginManager # Force dependency scripts to load (headless compat) const _pm_manifest_ref = preload("res://server/scripts/plugin_api/plugin_manifest.gd") const _pm_base_ref = preload("res://server/scripts/plugin_api/plugin_base.gd") # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when a plugin is successfully loaded. signal plugin_loaded(name: String) ## Emitted when a plugin is unloaded. signal plugin_unloaded(name: String) ## Emitted when a plugin is reloaded (load after unload). signal plugin_reloaded(name: String) ## Emitted when a plugin hook call fails with an error. signal plugin_error(name: String, hook: String, message: String) # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- ## Directory under res:// where plugin subdirectories live. ## Each subdirectory should contain a plugin.tres manifest + .gd script(s). @export var plugins_root: String = "res://server/plugins" ## When true, the manager periodically re-scans the plugins directory ## for new or removed plugins. Enable for development convenience; ## disable in production to avoid filesystem overhead. @export var auto_rescan: bool = false ## Interval in seconds between auto-rescan passes ## (only applies when auto_rescan is true). @export var rescan_interval: float = 10.0 # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- ## Current API version of the PluginManager. ## Bump the MAJOR version when breaking hook signature changes are made. ## Bump the MINOR version when new hooks are added (non-breaking). ## Bump the PATCH version for bug fixes and internal changes. const API_VERSION: String = "1.0.0" # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## Dictionary of loaded plugins: plugin_name (String) -> Node (plugin instance). var loaded_plugins: Dictionary = {} ## Dictionary of all discovered manifests: plugin_name -> PluginManifest resource. var discovered_manifests: Dictionary = {} ## Internal metadata store: plugin_name -> {path, manifest, tres_path, ...}. var _plugin_info: Dictionary = {} ## Timer accumulator for auto-rescan. var _rescan_timer: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: # Discover and load all enabled plugins from the plugins directory. rescan() log_info("PluginManager ready — %d plugin(s) loaded, %d discovered total" % [loaded_plugins.size(), discovered_manifests.size()]) log_info("PluginManager API version: %s" % API_VERSION) func _exit_tree() -> void: # Gracefully shut down all plugins before the tree exits. unload_all() func _process(delta: float) -> void: if not auto_rescan: return _rescan_timer += delta if _rescan_timer >= rescan_interval: _rescan_timer = 0.0 rescan() # --------------------------------------------------------------------------- # Discovery — scan filesystem for plugin manifests # --------------------------------------------------------------------------- ## Scan the plugins_root directory for plugin.tres files. ## Populates discovered_manifests and automatically loads enabled ones. ## Returns the total number of manifests found. func rescan() -> int: var previous_count: int = discovered_manifests.size() discovered_manifests.clear() _plugin_info.clear() var root: String = plugins_root if not ResourceLoader.exists(root): log_warn("Plugins root does not exist: %s" % root) return 0 var dir: DirAccess = DirAccess.open(root) if not dir: log_err("Cannot open plugins root directory: %s" % root) return 0 # Walk one level deep (plugin directories) dir.list_dir_begin() var entry: String = dir.get_next() while entry != &"": if entry.begins_with("."): entry = dir.get_next() continue if dir.current_is_dir(): _process_plugin_directory(root, entry) entry = dir.get_next() dir.list_dir_end() # Auto-load newly discovered enabled plugins that aren't already loaded. for pname in discovered_manifests: if pname not in loaded_plugins: var manifest = discovered_manifests[pname] if manifest.enabled: _load_plugin_from_manifest(pname, manifest) var new_count: int = discovered_manifests.size() var delta_count: int = new_count - previous_count log_info("Rescan complete: %d manifest(s) found (+%d new)" % [new_count, delta_count]) return new_count ## Inspect a single subdirectory for a plugin.tres file and, ## if valid, record it in the discovered manifests. func _process_plugin_directory(root: String, dir_name: String) -> void: var tres_path: String = root + "/" + dir_name + "/plugin.tres" if not ResourceLoader.exists(tres_path): return var manifest: Resource = ResourceLoader.load(tres_path) if manifest == null or manifest.get_script() == null or not manifest.has_method(&"is_valid"): log_warn("Invalid manifest (not a PluginManifest resource): %s" % tres_path) return if not manifest.is_valid(): log_warn("Invalid manifest (missing required fields 'name' or 'script_path'): %s" % tres_path) return # Check API compatibility if not manifest.is_api_compatible(API_VERSION): log_warn("Plugin '%s' requires API version %s-%s, but current API is %s" % [manifest.name, manifest.min_api_version, manifest.max_api_version, API_VERSION]) return var pname: String = manifest.name discovered_manifests[pname] = manifest _plugin_info[pname] = { "path": root + "/" + dir_name + "/", "manifest": manifest, "tres_path": tres_path, "dir_name": dir_name } log_info("Discovered plugin: %s v%s at %s" % [pname, manifest.version, tres_path]) # --------------------------------------------------------------------------- # Loading / Unloading # --------------------------------------------------------------------------- ## Load a specific plugin by name (from discovered manifests). ## Returns true on success, false on failure. func load_plugin(name: String) -> bool: if name in loaded_plugins: log_warn("Plugin already loaded: %s" % name) return false if name not in discovered_manifests: log_err("Plugin not found: %s. Use 'plugin rescan' to scan for new plugins." % name) return false var manifest = discovered_manifests[name] return _load_plugin_from_manifest(name, manifest) ## Internal: instantiate a plugin from its manifest and add it to the tree. ## Handles resource loading, instance creation, and metadata injection. func _load_plugin_from_manifest(name: String, manifest) -> bool: var info: Dictionary = _plugin_info.get(name, {}) var plugin_dir: String = info.get("path", plugins_root + "/" + name + "/") var script_path: String = plugin_dir + manifest.script_path if not ResourceLoader.exists(script_path): log_err("Script not found for plugin '%s': %s" % [name, script_path]) return false var script_res: Resource = ResourceLoader.load(script_path) if not script_res is GDScript: log_err("Script resource is not a GDScript for plugin '%s': got %s" % [name, typeof(script_res)]) return false # Attempt to instantiate the script var script_class = load(script_path) var instance: Node var ok: bool = false # Try normal Node.new() pattern if script_class.has_method(&"new"): instance = script_class.new() ok = true else: log_err("Cannot instantiate script for plugin '%s' — no 'new' method" % name) return false if not instance: log_err("Failed to create instance for plugin '%s'" % name) return false # Warn if the plugin doesn't extend PluginBase (it'll still work, # just won't have convenience methods). if instance == null or not instance.has_method(&"log_info"): log_warn("Plugin '%s' does not extend PluginBase — some features may be unavailable" % name) # Inject manifest metadata into the instance if not instance: push_error("[PluginManager] Plugin '%s' failed to instantiate (null)" % name) return false if instance.has_method(&"set_plugin_meta"): instance.set_plugin_meta(name, manifest.version, manifest.author) elif instance.has_method(&"set") or true: # Fallback: assign properties directly if "plugin_name" in instance: instance.plugin_name = name if "plugin_version" in instance: instance.plugin_version = manifest.version if "plugin_author" in instance: instance.plugin_author = manifest.author instance.name = "Plugin_%s" % name add_child(instance) loaded_plugins[name] = instance # Notify the plugin that it's now active (server lifecycle) if instance.has_method(&"on_server_start"): _call_plugin_hook(name, instance, "on_server_start", []) plugin_loaded.emit(name) log_info("Loaded plugin: %s v%s by %s" % [name, manifest.version, manifest.author]) return true ## Unload a plugin by name. ## Removes it from the tree and clears internal state. ## Returns true on success, false if the plugin wasn't loaded. func unload_plugin(name: String) -> bool: if name not in loaded_plugins: log_warn("Plugin not loaded: %s" % name) return false var instance: Node = loaded_plugins[name] # Notify the plugin of impending shutdown if instance.has_method(&"on_server_stop"): _call_plugin_hook(name, instance, "on_server_stop", []) # Remove from tree and free remove_child(instance) instance.queue_free() loaded_plugins.erase(name) plugin_unloaded.emit(name) log_info("Unloaded plugin: %s" % name) return true ## Reload a plugin: unload, recompile script, then load again. ## Useful for development iteration without restarting the server. ## Returns true on success. func reload_plugin(name: String) -> bool: if name not in loaded_plugins: log_warn("Cannot reload — plugin not loaded: %s" % name) return false log_info("Reloading plugin: %s" % name) unload_plugin(name) # Re-acquire manifest info var manifest = discovered_manifests.get(name) if not manifest: log_err("Manifest lost for plugin '%s' during reload" % name) return false var info: Dictionary = _plugin_info.get(name, {}) var plugin_dir: String = info.get("path", plugins_root + "/" + name + "/") var script_path: String = plugin_dir + manifest.script_path # Force GDScript recompile from disk if ResourceLoader.exists(script_path): var gdscript: GDScript = load(script_path) if gdscript and gdscript.has_method(&"reload"): gdscript.reload() var ok: bool = _load_plugin_from_manifest(name, manifest) if ok: plugin_reloaded.emit(name) log_info("Reloaded plugin: %s" % name) else: log_err("Failed to reload plugin: %s" % name) return ok ## Unload all currently loaded plugins. Called automatically on exit. func unload_all() -> void: var names: Array = loaded_plugins.keys() for name in names: unload_plugin(name) if names.size() > 0: log_info("All plugins unloaded (%d total)" % names.size()) # --------------------------------------------------------------------------- # Hook dispatch — call a named method on every loaded plugin # --------------------------------------------------------------------------- ## Dispatch a hook to all loaded plugins. ## Returns an array of return values (one per plugin that returned non-null). func dispatch_hook(hook_name: String, args: Array = []) -> Array: var results: Array = [] for name in loaded_plugins: var instance: Node = loaded_plugins[name] if instance and instance.has_method(hook_name): var result = _call_plugin_hook(name, instance, hook_name, args) if result != null: results.append(result) return results ## Dispatch a filter hook (bool-returning) and return true only if ALL ## plugins returned true. Short-circuits on the first false. ## This is used for on_player_connect and on_player_say. func dispatch_filter_hook(hook_name: String, args: Array = []) -> bool: for name in loaded_plugins: var instance: Node = loaded_plugins[name] if instance and instance.has_method(hook_name): var result = _call_plugin_hook(name, instance, hook_name, args) if result is bool and not result: return false return true ## Safely call a hook on a single plugin instance, catching errors. ## If the call fails, a plugin_error signal is emitted and null is returned, ## allowing other plugins to continue processing. func _call_plugin_hook(name: String, instance: Node, hook: String, args: Array): if not instance or not is_instance_id_valid(instance.get_instance_id()): return null var callable: Callable = Callable(instance, hook) if callable.is_null(): return null # Use callv for variadic argument passing var result result = callable.callv(args) return result # --------------------------------------------------------------------------- # Convenience dispatchers — called by game code (server_main.gd, etc.) # --------------------------------------------------------------------------- func on_server_start() -> void: dispatch_hook("on_server_start") func on_server_stop() -> void: dispatch_hook("on_server_stop") func on_map_start(map_name: String) -> void: dispatch_hook("on_map_start", [map_name]) func on_map_end(map_name: String) -> void: dispatch_hook("on_map_end", [map_name]) func on_player_connect(id: int, name: String, steam_id: String) -> bool: return dispatch_filter_hook("on_player_connect", [id, name, steam_id]) func on_player_disconnect(id: int, reason: String) -> void: dispatch_hook("on_player_disconnect", [id, reason]) func on_player_say(id: int, msg: String) -> bool: return dispatch_filter_hook("on_player_say", [id, msg]) func on_player_spawn(id: int) -> void: dispatch_hook("on_player_spawn", [id]) func on_player_killed(victim: int, killer: int, weapon: String) -> void: dispatch_hook("on_player_killed", [victim, killer, weapon]) func on_round_start(number: int) -> void: dispatch_hook("on_round_start", [number]) func on_round_end(winner_team: int, reason: String) -> void: dispatch_hook("on_round_end", [winner_team, reason]) func on_tick(delta: float) -> void: dispatch_hook("on_tick", [delta]) # --------------------------------------------------------------------------- # Cvar integration — register plugin state as server cvars # --------------------------------------------------------------------------- ## Register cvars so rcon/in-game can inspect plugin state. ## Called automatically when a CvarRegistry singleton is detected. func _register_plugin_cvars() -> void: if not has_node("/root/CvarRegistry"): return var registry = get_node("/root/CvarRegistry") if not registry or not registry.has_method(&"register"): return # Register a cvar for each loaded plugin indicating its load status for pname in loaded_plugins: var cvar_name: String = "plugin_" + pname.to_lower().replace(" ", "_") + "_loaded" if registry.has_method(&"register"): registry.register(cvar_name, true, TYPE_BOOL, "Plugin '%s' loaded status" % pname) # Register API version for external inspection if registry.has_method(&"register"): registry.register("plugin_api_version", API_VERSION, TYPE_STRING, "PluginManager API version") # --------------------------------------------------------------------------- # RCON command handler — subcommands for the rcon console # --------------------------------------------------------------------------- ## Handle a plugin-related RCON command. ## Matches the subcommands added to rcon_command_handler.gd. ## Returns a formatted response string suitable for RCON output. func rcon_command(args: PackedStringArray) -> String: if args.is_empty(): return _rcon_help() var subcmd: String = args[0].to_lower() var subargs: PackedStringArray = args.slice(1) match subcmd: "list": return _rcon_list(subargs) "load": return _rcon_load(subargs) "unload": return _rcon_unload(subargs) "reload": return _rcon_reload(subargs) "info": return _rcon_info(subargs) "rescan": return _rcon_rescan(subargs) "help": return _rcon_help() _: return "Unknown plugin subcommand: '%s'. Use 'plugin help'.\\r\\n" % subcmd func _rcon_help() -> String: var lines: PackedStringArray = [ "=== Plugin Commands ===", " plugin list List all loaded/available plugins", " plugin load Load a discovered plugin by name", " plugin unload Unload a running plugin", " plugin reload Reload a plugin (re-compiles script)", " plugin info Show detailed info about a plugin", " plugin rescan Re-scan the plugins directory for changes", ] return "\\r\\n".join(lines) + "\\r\\n" func _rcon_list(_args: PackedStringArray) -> String: var lines: PackedStringArray = [] lines.append("=== Plugins (%d loaded, %d discovered) ===" % [loaded_plugins.size(), discovered_manifests.size()]) lines.append(&"") var all_names: Array = discovered_manifests.keys() all_names.sort() if all_names.is_empty(): lines.append(" No plugins discovered. Use 'plugin rescan' to scan.") else: for pname in all_names: var manifest = discovered_manifests[pname] var status: String if pname in loaded_plugins: status = "LOADED" elif not manifest.enabled: status = "DISABLED" else: status = "AVAILABLE" lines.append(" %s v%s [%s]" % [pname, manifest.version, status]) return "\\r\\n".join(lines) + "\\r\\n" func _rcon_load(args: PackedStringArray) -> String: if args.is_empty(): return "Usage: plugin load \\r\\n" var name: String = args[0] if load_plugin(name): return "Loaded plugin: %s\\r\\n" % name return "Failed to load plugin: %s. Check server logs for details.\\r\\n" % name func _rcon_unload(args: PackedStringArray) -> String: if args.is_empty(): return "Usage: plugin unload \\r\\n" var name: String = args[0] if unload_plugin(name): return "Unloaded plugin: %s\\r\\n" % name return "Failed to unload plugin: %s. Check server logs for details.\\r\\n" % name func _rcon_reload(args: PackedStringArray) -> String: if args.is_empty(): return "Usage: plugin reload \\r\\n" var name: String = args[0] if reload_plugin(name): return "Reloaded plugin: %s\\r\\n" % name return "Failed to reload plugin: %s. Check server logs for details.\\r\\n" % name func _rcon_info(args: PackedStringArray) -> String: if args.is_empty(): return "Usage: plugin info \\r\\n" var name: String = args[0] if name not in discovered_manifests: return "Plugin not found: %s. Use 'plugin list' to see available plugins.\\r\\n" % name var manifest = discovered_manifests[name] var info: Dictionary = _plugin_info.get(name, {}) var full_script_path: String = info.get("path", "") + manifest.script_path var lines: PackedStringArray = [] lines.append("=== Plugin Info: %s ===" % name) lines.append(" Name: %s" % name) lines.append(" Version: %s" % manifest.version) lines.append(" Author: %s" % manifest.author) lines.append(" Script: %s" % full_script_path) lines.append(" Enabled: %s" % str(manifest.enabled)) lines.append(" Loaded: %s" % str(name in loaded_plugins)) lines.append(" Min API: %s" % (manifest.min_api_version if manifest.min_api_version else "(none)")) lines.append(" Max API: %s" % (manifest.max_api_version if manifest.max_api_version else "(none)")) lines.append(" Metadata: %s" % str(manifest.metadata)) return "\\r\\n".join(lines) + "\\r\\n" func _rcon_rescan(_args: PackedStringArray) -> String: var count: int = rescan() return "Rescan complete: %d manifest(s) found, %d plugin(s) loaded.\\r\\n" % [count, loaded_plugins.size()] # --------------------------------------------------------------------------- # Utility / logging # --------------------------------------------------------------------------- func log_info(msg: String) -> void: print("[PluginManager] %s" % msg) func log_warn(msg: String) -> void: push_warning("[PluginManager] %s" % msg) func log_err(msg: String) -> void: push_error("[PluginManager] %s" % msg)