Files
tactical-shooter/server/scripts/plugin_api/plugin_manager.gd
T
shawn 159c554a86 t_p6_api: build plugin scripting system
- Add PluginBase (plugin_base.gd) — abstract base class with 12 virtual hooks
- Add PluginManifest (plugin_manifest.gd) — custom Resource for plugin metadata
- Add PluginManager (plugin_manager.gd) — autoload singleton: directory scan,
  hook dispatch, lifecycle (load/unload/reload/rescan), cvar integration
- Add hello_world example plugin with all hooks demonstrating usage
- Register PluginManager autoload in project.godot
- Extend rcon_command_handler.gd with 'plugin' subcommand (list/load/unload/
  reload/info/rescan) delegating to PluginManager
2026-07-01 18:36:23 -04:00

576 lines
20 KiB
GDScript

## 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 <name> — load a discovered plugin by name
## plugin unload <name> — unload a running plugin
## plugin reload <name> — reload a plugin (re-compiles its script)
## plugin info <name> — show detailed information about a plugin
## plugin rescan — re-scan the plugins directory
extends Node
class_name PluginManager
# ---------------------------------------------------------------------------
# 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: PluginManifest = 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 not manifest is PluginManifest:
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: PluginManifest = 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: PluginManifest) -> 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 not instance is PluginBase:
log_warn("Plugin '%s' does not extend PluginBase — some features may be unavailable" % name)
# Inject manifest metadata into the instance
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: PluginManifest = 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 <name> Load a discovered plugin by name",
" plugin unload <name> Unload a running plugin",
" plugin reload <name> Reload a plugin (re-compiles script)",
" plugin info <name> 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: PluginManifest = 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 <name>\\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 <name>\\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 <name>\\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 <name>\\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: PluginManifest = 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)