extends Node # ═══════════════════════════════════════════════════════════════════════════ # PluginManager — Server plugin lifecycle & hook dispatch # # Architecture (SourceMod-inspired): # Plugins extend PluginBase and provide hook overrides. The PluginManager # discovers .tres manifests in the plugin directory, loads the referenced # GDScripts, and dispatches hook events to all loaded plugins. # # Setup — Add as an autoload in project.godot: # [autoload] # PluginManager="*res://server/scripts/plugin_manager.gd" # # Game Integration — Connect signals to forward game events: # PluginManager.signal_map_start.connect(_on_map_start) # --- After calling your game logic, dispatch to plugins: # PluginManager.dispatch_map_start("de_dust2") # # Hook Dispatch Flow: # Game loop → (calls plugin_manager.dispatch_*) → PluginManager iterates # loaded plugins → calls virtual method on each → collects results # # Thread Safety: # Hook dispatch runs on the main thread. Plugin _ready() and _exit_tree() # also run on the main thread. Plugins must not create threads without # proper mutex handling. # ═══════════════════════════════════════════════════════════════════════════ # ─── Signals (for game-logic integration) ──────────────────────────────── # Game logic connects these to learn when plugins request actions. ## Emitted when a plugin wants to broadcast a chat message. signal plugin_broadcast_chat(message: String) ## Emitted when a plugin requests a player be kicked. signal plugin_request_kick(peer_id: int, reason: String) ## Emitted when a plugin explicitly asks the server to change map. signal plugin_request_changelevel(map_name: String) # ─── Exports / Cvars ───────────────────────────────────────────────────── ## Master enable/disable. When false, no plugins are loaded. @export var sv_plugin_enabled: bool = true: set = set_plugin_enabled ## Directory to scan for plugin .tres manifests. ## Relative to user:// for runtime discovery (admins can FTP plugins in). ## Set to "res://plugins" for development / built-in plugins. @export var sv_plugin_dir: String = "user://plugins" ## Path separator pattern — subdirectories under sv_plugin_dir are scanned. ## Each subdirectory should contain a plugin.tres file. @export var sv_plugin_rescan_on_map: bool = false # ─── Internal State ────────────────────────────────────────────────────── ## Loaded plugins keyed by plugin_name (lowercase). var loaded_plugins: Dictionary = {} # "plugin_name_lower" → PluginBase ## Registration order (for deterministic dispatch). var _load_order: Array[PluginBase] = [] ## Whether the server is currently running (set by dispatch_server_start). var _server_running: bool = false ## Reference to the CvarRegistry singleton (cached). var _cvar_registry = null # ═══════════════════════════════════════════════════════════════════════════ # Initialization & Lifecycle # ═══════════════════════════════════════════════════════════════════════════ func _ready() -> void: """Called when the PluginManager is added to the scene tree (autoload).""" # Cache CvarRegistry reference if Engine.has_singleton("CvarRegistry"): _cvar_registry = Engine.get_singleton("CvarRegistry") # Read cvar overrides _apply_cvar_overrides() # Discover and load plugins if sv_plugin_enabled: _discover_plugins() else: print("[PluginManager] Plugin system disabled (sv_plugin_enabled=false)") func _exit_tree() -> void: """Clean shutdown — dispatch server_stop if not already done.""" if _server_running: dispatch_server_stop() # ═══════════════════════════════════════════════════════════════════════════ # Cvar Integration # ═══════════════════════════════════════════════════════════════════════════ func _apply_cvar_overrides() -> void: """Read overrides from the CvarRegistry (if available).""" if not _cvar_registry: return if _cvar_registry.has_method("get"): var val = _cvar_registry.get("sv_plugin_enabled") if val != null: sv_plugin_enabled = bool(val) val = _cvar_registry.get("sv_plugin_dir") if val != null and not str(val).is_empty(): sv_plugin_dir = str(val) func set_plugin_enabled(new_val: bool) -> void: """Setter for sv_plugin_enabled — triggers load/unload if state changes.""" if new_val == sv_plugin_enabled: return sv_plugin_enabled = new_val if sv_plugin_enabled: print("[PluginManager] Enabling plugin system") _discover_plugins() else: print("[PluginManager] Disabling plugin system — unloading all") _unload_all() # ═══════════════════════════════════════════════════════════════════════════ # Plugin Discovery & Loading # ═══════════════════════════════════════════════════════════════════════════ func discover_plugins() -> Dictionary: """Force a re-scan of the plugin directory. Returns {loaded, failed, skipped} counts.""" return _discover_plugins() func _discover_plugins() -> Dictionary: """Scan sv_plugin_dir for plugin.tres manifests and load new plugins. Returns a dict with: loaded: int — plugins successfully loaded failed: int — manifests found but failed to load skipped: int — manifests skipped (already loaded, disabled, or invalid) """ var result = {"loaded": 0, "failed": 0, "skipped": 0} # Resolve the plugin directory path var dir_path = _resolve_plugin_dir() if dir_path.is_empty(): push_warning("[PluginManager] Could not resolve plugin directory: " + sv_plugin_dir) return result var dir = DirAccess.open(dir_path) if not dir: push_warning("[PluginManager] Plugin directory not found: " + dir_path) return result # List subdirectories (each subdir = one plugin) dir.list_dir_begin() var entry = dir.get_next() while entry: if entry != "." and entry != ".." and dir.current_is_dir(): var sub_path = dir_path.path_join(entry) var manifest_path = sub_path.path_join("plugin.tres") if FileAccess.file_exists(manifest_path): var load_result = _load_plugin(manifest_path) if load_result == OK: result["loaded"] += 1 elif load_result == ERR_SKIP: result["skipped"] += 1 else: result["failed"] += 1 else: result["skipped"] += 1 entry = dir.get_next() dir.list_dir_end() print("[PluginManager] Discovery complete — loaded: " + str(result["loaded"]) + \ ", failed: " + str(result["failed"]) + ", skipped: " + str(result["skipped"])) return result func _load_plugin(manifest_path: String) -> int: """Load a single plugin from its manifest path. Returns: OK — loaded successfully ERR_SKIP — skipped (already loaded, disabled, or invalid manifest) ERR_FILE_CORRUPT — manifest couldn't be loaded ERR_SCRIPT_FAILED — script failed to instantiate """ # Load the manifest Resource var res = ResourceLoader.load(manifest_path, "Resource", ResourceLoader.CACHE_MODE_IGNORE) if not res: push_error("[PluginManager] Failed to load manifest: " + manifest_path) return ERR_FILE_CORRUPT # The manifest is a PluginManifest if we loaded a custom Resource, or a # plain Resource with export properties. var manifest = res as PluginManifest # Validate if not manifest or not manifest.is_valid(): push_warning("[PluginManager] Skipping invalid manifest: " + manifest_path) return ERR_SKIP if not manifest.plugin_enabled: print("[PluginManager] Plugin '" + manifest.plugin_name + "' is disabled — skipping") return ERR_SKIP var name_key = manifest.plugin_name.to_lower() # Check if already loaded if loaded_plugins.has(name_key): print("[PluginManager] Plugin '" + manifest.plugin_name + "' already loaded — skipping") return ERR_SKIP # Check version compatibility if not _check_version_compatibility(manifest): push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \ "' version requirement not met — skipping") return ERR_SKIP # Load the plugin script if manifest.plugin_script_path.is_empty(): push_error("[PluginManager] Plugin '" + manifest.plugin_name + "' has no script path") return ERR_FILE_CORRUPT # Resolve the script path relative to the plugin directory var script_path = manifest.plugin_script_path if not script_path.begins_with("res://") and not script_path.begins_with("user://"): # If relative, resolve relative to manifest location var manifest_dir = manifest_path.get_base_dir() script_path = manifest_dir.path_join(script_path) if not ResourceLoader.exists(script_path): push_error("[PluginManager] Plugin script not found: " + script_path) return ERR_FILE_CORRUPT var script_res = load(script_path) if not script_res or not (script_res is GDScript): push_error("[PluginManager] Plugin script is not a valid GDScript: " + script_path) return ERR_SCRIPT_FAILED # Instantiate the plugin var plugin_instance = script_res.new() if not plugin_instance or not (plugin_instance is PluginBase): push_error("[PluginManager] Plugin script must extend PluginBase: " + script_path) if plugin_instance: plugin_instance.free() return ERR_SCRIPT_FAILED # Apply metadata from manifest plugin_instance.plugin_name = manifest.plugin_name plugin_instance.plugin_version = manifest.plugin_version plugin_instance.plugin_author = manifest.plugin_author plugin_instance.plugin_description = manifest.plugin_description plugin_instance.plugin_manifest_path = manifest_path # Register loaded_plugins[name_key] = plugin_instance _load_order.append(plugin_instance) # Add to scene tree to enable _ready(), _process(), etc. add_child(plugin_instance) print("[PluginManager] Loaded plugin: " + manifest.plugin_name + " v" + manifest.plugin_version) return OK func unload_plugin(name: String) -> bool: """Unload a specific plugin by name. Returns true on success.""" var name_key = name.to_lower() if not loaded_plugins.has(name_key): return false var plugin = loaded_plugins[name_key] as PluginBase # Remove from tracking first so dispatch doesn't hit it mid-unload loaded_plugins.erase(name_key) var idx = _load_order.find(plugin) if idx >= 0: _load_order.remove_at(idx) # Save state (call on_server_stop if server is running) if _server_running: plugin.on_server_stop() # Remove from scene tree remove_child(plugin) plugin.queue_free() print("[PluginManager] Unloaded plugin: " + plugin.plugin_name) return true func reload_plugin(name: String) -> bool: """Reload a plugin by unloading it, then re-loading from its manifest. Returns true if both unload and load succeeded. """ var name_key = name.to_lower() if not loaded_plugins.has(name_key): return false var plugin = loaded_plugins[name_key] as PluginBase var manifest_path = plugin.plugin_manifest_path if not unload_plugin(name): return false if manifest_path.is_empty(): push_warning("[PluginManager] No manifest path to reload from for '" + name + "'") return false var result = _load_plugin(manifest_path) if result != OK: push_error("[PluginManager] Failed to reload plugin '" + name + "'") return false # If server is running, call on_server_start on the new instance if _server_running: var new_plugin = loaded_plugins.get(name_key) if new_plugin: new_plugin.on_server_start() return true func _unload_all() -> void: """Unload every loaded plugin.""" for plugin in _load_order.duplicate(): unload_plugin(plugin.plugin_name) func get_plugin(name: String) -> PluginBase: """Get a loaded plugin by name. Returns null if not found.""" return loaded_plugins.get(name.to_lower()) func get_plugin_count() -> int: """Number of currently loaded plugins.""" return _load_order.size() func list_plugins() -> Array[Dictionary]: """Return metadata for all loaded plugins (for admin display).""" var result: Array[Dictionary] = [] for plugin in _load_order: result.append({ "name": plugin.plugin_name, "version": plugin.plugin_version, "author": plugin.plugin_author, "description": plugin.plugin_description, "manifest_path": plugin.plugin_manifest_path, }) return result # ═══════════════════════════════════════════════════════════════════════════ # Hook Dispatch Methods # ═══════════════════════════════════════════════════════════════════════════ # Each dispatch_* method iterates all loaded plugins and calls the # corresponding virtual hook method. For hooks that return a bool, # the method collects return values and decides: if ANY plugin returns # true, the action is considered blocked. # # The game loop should call these at the appropriate points. func dispatch_server_start() -> void: """Notify all plugins that the server has started.""" _server_running = true for plugin in _load_order: plugin.on_server_start() func dispatch_server_stop() -> void: """Notify all plugins that the server is shutting down.""" _server_running = false # Iterate in reverse for clean shutdown for i in range(_load_order.size() - 1, -1, -1): _load_order[i].on_server_stop() func dispatch_map_start(map_name: String) -> void: """Notify plugins a new map has loaded.""" for plugin in _load_order: plugin.on_map_start(map_name) func dispatch_map_end(map_name: String) -> void: """Notify plugins the current map is ending.""" for plugin in _load_order: plugin.on_map_end(map_name) func dispatch_player_connect(peer_id: int, name: String, steam_id: String) -> bool: """Ask plugins if a player connection should be blocked. Returns true if ANY plugin blocked the connection. """ var blocked: bool = false for plugin in _load_order: if plugin.on_player_connect(peer_id, name, steam_id): blocked = true return blocked func dispatch_player_disconnect(peer_id: int, reason: String) -> void: """Notify plugins a player has disconnected.""" for plugin in _load_order: plugin.on_player_disconnect(peer_id, reason) func dispatch_player_say(peer_id: int, message: String) -> bool: """Ask plugins if a chat message should be blocked. Returns true if ANY plugin blocked the message. """ var blocked: bool = false for plugin in _load_order: if plugin.on_player_say(peer_id, message): blocked = true return blocked func dispatch_player_spawn(peer_id: int) -> void: """Notify plugins a player has spawned.""" for plugin in _load_order: plugin.on_player_spawn(peer_id) func dispatch_player_killed(victim_id: int, killer_id: int, weapon: String) -> void: """Notify plugins a player has been killed.""" for plugin in _load_order: plugin.on_player_killed(victim_id, killer_id, weapon) func dispatch_round_start(round_number: int) -> void: """Notify plugins a new round has started.""" for plugin in _load_order: plugin.on_round_start(round_number) func dispatch_round_end(winner_team: int, reason: String) -> void: """Notify plugins the current round has ended.""" for plugin in _load_order: plugin.on_round_end(winner_team, reason) func dispatch_tick(delta: float) -> void: """Dispatch per-tick hook. Call from _physics_process. This is potentially expensive with many plugins. Consider using a pooling/tick budget approach if you have >20 plugins. """ for plugin in _load_order: plugin.on_tick(delta) # ═══════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════ func _resolve_plugin_dir() -> String: """Resolve the plugin directory path, handling res:// and user:// prefixes.""" var path = sv_plugin_dir if path.begins_with("res://"): # Convert to absolute filesystem path (DirAccess needs absolute paths) return ProjectSettings.globalize_path(path) elif path.begins_with("user://"): return ProjectSettings.globalize_path(path) else: # Assume relative — prepend user:// path = "user://" + path.trim_prefix("/") return ProjectSettings.globalize_path(path) func _check_version_compatibility(manifest: PluginManifest) -> bool: """Check if this server version satisfies the plugin's version requirements.""" # Get server version var server_version = ProjectSettings.get_setting("application/config/version", "0.0.0") if not manifest.min_server_version.is_empty(): if not _version_gte(server_version, manifest.min_server_version): push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \ "' requires server >= " + manifest.min_server_version + \ " (server: " + server_version + ")") return false if not manifest.max_server_version.is_empty(): if not _version_lte(server_version, manifest.max_server_version): push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \ "' requires server <= " + manifest.max_server_version + \ " (server: " + server_version + ")") return false return true static func _version_gte(version: String, min_version: String) -> bool: """Simple semver comparison: version >= min_version.""" var v_parts = version.split(".") var m_parts = min_version.split(".") for i in range(max(v_parts.size(), m_parts.size())): var v = int(v_parts[i]) if i < v_parts.size() else 0 var m = int(m_parts[i]) if i < m_parts.size() else 0 if v > m: return true if v < m: return false return true static func _version_lte(version: String, max_version: String) -> bool: """Simple semver comparison: version <= max_version.""" var v_parts = version.split(".") var m_parts = max_version.split(".") for i in range(max(v_parts.size(), m_parts.size())): var v = int(v_parts[i]) if i < v_parts.size() else 0 var m = int(m_parts[i]) if i < m_parts.size() else 0 if v < m: return true if v > m: return false return true