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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
## Hello World — Example Server Plugin
|
||||
##
|
||||
## Demonstrates all available plugin hooks by logging each event.
|
||||
## Use this as a template for creating new plugins.
|
||||
|
||||
extends PluginBase
|
||||
class_name HelloWorldPlugin
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin state
|
||||
# ---------------------------------------------------------------------------
|
||||
var _player_count: int = 0
|
||||
var _round_count: int = 0
|
||||
var _tick_count: int = 0
|
||||
var _start_time: int = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
log_info("Hello World plugin initializing...")
|
||||
_start_time = Time.get_ticks_msec()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
log_info("Hello World plugin shutting down. Uptime: %.1f seconds" %
|
||||
[(Time.get_ticks_msec() - _start_time) / 1000.0])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server lifecycle hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
func on_server_start() -> void:
|
||||
log_info("on_server_start — Server is now online")
|
||||
_tick_count = 0
|
||||
|
||||
func on_server_stop() -> void:
|
||||
log_info("on_server_stop — Server is shutting down")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map lifecycle hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
func on_map_start(map_name: String) -> void:
|
||||
log_info("on_map_start — Loading map: %s" % map_name)
|
||||
_player_count = 0
|
||||
_round_count = 0
|
||||
|
||||
func on_map_end(map_name: String) -> void:
|
||||
log_info("on_map_end — Map ended: %s (rounds played: %d)" % [map_name, _round_count])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
func on_player_connect(id: int, name: String, steam_id: String) -> bool:
|
||||
log_info("on_player_connect — Player connecting: id=%d, name='%s', steam_id=%s" %
|
||||
[id, name, steam_id])
|
||||
# Return true to allow connection
|
||||
return true
|
||||
|
||||
func on_player_disconnect(id: int, reason: String) -> void:
|
||||
log_info("on_player_disconnect — Player disconnected: id=%d, reason='%s'" %
|
||||
[id, reason])
|
||||
|
||||
func on_player_say(id: int, msg: String) -> bool:
|
||||
log_info("on_player_say — Player %d says: \"%s\"" % [id, msg])
|
||||
# Return true to let the message through
|
||||
return true
|
||||
|
||||
func on_player_spawn(id: int) -> void:
|
||||
_player_count += 1
|
||||
log_info("on_player_spawn — Player %d spawned (total spawns: %d)" %
|
||||
[id, _player_count])
|
||||
|
||||
func on_player_killed(victim: int, killer: int, weapon: String) -> void:
|
||||
var desc: String
|
||||
if victim == killer:
|
||||
desc = "suicide"
|
||||
else:
|
||||
desc = "killed by %d" % killer
|
||||
log_info("on_player_killed — Player %d %s with '%s'" % [victim, desc, weapon])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
func on_round_start(number: int) -> void:
|
||||
_round_count = number
|
||||
log_info("on_round_start — Round %d begins" % number)
|
||||
|
||||
func on_round_end(winner_team: int, reason: String) -> void:
|
||||
var team_name: String = "Draw"
|
||||
match winner_team:
|
||||
1:
|
||||
team_name = "Team A"
|
||||
2:
|
||||
team_name = "Team B"
|
||||
log_info("on_round_end — Round %d ended: %s wins (%s)" %
|
||||
[_round_count, team_name, reason])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-tick hook
|
||||
# ---------------------------------------------------------------------------
|
||||
func on_tick(delta: float) -> void:
|
||||
_tick_count += 1
|
||||
# Log only every ~100 ticks to avoid spam (at 128 Hz ≈ every 0.78s)
|
||||
if _tick_count % 100 == 0:
|
||||
log_info("on_tick — Tick %d (delta=%.4f)" % [_tick_count, delta])
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_resource type="Resource" format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://server/scripts/plugin_api/plugin_manifest.gd" id="1_plugin_manifest"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_plugin_manifest")
|
||||
name = "Hello World"
|
||||
version = "1.0.0"
|
||||
author = "Tactical Shooter Team"
|
||||
script_path = "hello_world.gd"
|
||||
enabled = true
|
||||
min_api_version = "1.0.0"
|
||||
max_api_version = ""
|
||||
metadata = {
|
||||
"description": "Example plugin that logs all server events",
|
||||
"url": "https://gitea.ourpad.casa/shawn/tactical-shooter"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
## Plugin Base — Abstract plugin interface
|
||||
##
|
||||
## All server plugins must extend this class and may override any
|
||||
## of the virtual hook methods. Hooks that return a bool act as
|
||||
## filters/callbacks — return true to allow/accept the event,
|
||||
## false to block/reject it (where the hook definition supports it).
|
||||
##
|
||||
## The base class provides a `plugin_name` property that is
|
||||
## automatically populated at load time from the manifest, and a
|
||||
## `log(msg)` convenience method for structured logging.
|
||||
##
|
||||
## Usage:
|
||||
## extends PluginBase
|
||||
## func on_server_start():
|
||||
## log_info("My plugin started!")
|
||||
##
|
||||
## func on_player_say(id, msg) -> bool:
|
||||
## if "badword" in msg:
|
||||
## return false # block the message
|
||||
## return true
|
||||
|
||||
extends Node
|
||||
class_name PluginBase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public properties — populated by PluginManager at load time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## The canonical name of this plugin, set from the manifest at load time.
|
||||
var plugin_name: String = &""
|
||||
|
||||
## The version string from the manifest (e.g. "1.0.0").
|
||||
var plugin_version: String = &""
|
||||
|
||||
## The author string from the manifest.
|
||||
var plugin_author: String = &""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Log an informational message prefixed with the plugin name.
|
||||
func log_info(msg: String) -> void:
|
||||
print("[Plugin:%s] %s" % [plugin_name, msg])
|
||||
|
||||
## Log a warning message prefixed with the plugin name.
|
||||
func log_warn(msg: String) -> void:
|
||||
push_warning("[Plugin:%s] %s" % [plugin_name, msg])
|
||||
|
||||
## Log an error message prefixed with the plugin name.
|
||||
func log_err(msg: String) -> void:
|
||||
push_error("[Plugin:%s] %s" % [plugin_name, msg])
|
||||
|
||||
## Set metadata on this plugin from the manifest.
|
||||
## Called automatically by PluginManager during load.
|
||||
func set_plugin_meta(name: String, version: String, author: String) -> void:
|
||||
plugin_name = name
|
||||
plugin_version = version
|
||||
plugin_author = author
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server lifecycle hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called when the server starts (after all autoloads are ready).
|
||||
## Use this to initialise timers, connect signals, or register cvars.
|
||||
func on_server_start() -> void:
|
||||
pass
|
||||
|
||||
## Called when the server is shutting down.
|
||||
## Use this to flush state, save data, or disconnect from external services.
|
||||
func on_server_stop() -> void:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map lifecycle hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called when a map starts loading.
|
||||
## map_name: the resource path or identifier of the map.
|
||||
func on_map_start(map_name: String) -> void:
|
||||
pass
|
||||
|
||||
## Called when a map ends (all players left, next map loading).
|
||||
## map_name: the resource path or identifier of the map that ended.
|
||||
func on_map_end(map_name: String) -> void:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called when a player attempts to connect to the server.
|
||||
## id: the peer/connection id assigned by the network layer.
|
||||
## name: the player's display name.
|
||||
## steam_id: the player's unique platform identifier.
|
||||
##
|
||||
## Return true to allow the connection, false to reject it.
|
||||
func on_player_connect(id: int, name: String, steam_id: String) -> bool:
|
||||
return true
|
||||
|
||||
## Called when a player disconnects from the server.
|
||||
## id: the peer/connection id of the player.
|
||||
## reason: a string describing why the player left (optional).
|
||||
func on_player_disconnect(id: int, reason: String) -> void:
|
||||
pass
|
||||
|
||||
## Called when a player sends a chat message.
|
||||
## id: the peer/connection id of the sender.
|
||||
## msg: the plain-text chat message.
|
||||
##
|
||||
## Return true to allow the message through, false to suppress it.
|
||||
func on_player_say(id: int, msg: String) -> bool:
|
||||
return true
|
||||
|
||||
## Called when a player spawns into the game world.
|
||||
## id: the peer/connection id of the player.
|
||||
func on_player_spawn(id: int) -> void:
|
||||
pass
|
||||
|
||||
## Called when a player is killed.
|
||||
## victim: peer id of the killed player.
|
||||
## killer: peer id of the killer (may equal victim for suicide).
|
||||
## weapon: weapon name/identifier string (e.g. "rifle", "pistol").
|
||||
func on_player_killed(victim: int, killer: int, weapon: String) -> void:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called when a new round begins.
|
||||
## number: the round number (1-based, increments each round).
|
||||
func on_round_start(number: int) -> void:
|
||||
pass
|
||||
|
||||
## Called when a round ends.
|
||||
## winner_team: 0 = draw/stalemate, 1 = team A, 2 = team B.
|
||||
## reason: a string describing the end condition ("time_limit", "all_dead", etc.).
|
||||
func on_round_end(winner_team: int, reason: String) -> void:
|
||||
pass
|
||||
|
||||
## Called when the team scores change.
|
||||
## team_a_score and team_b_score reflect the current match state.
|
||||
func on_score_changed(team_a_score: int, team_b_score: int) -> void:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-tick hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called every physics tick (at the configured tick rate, typically 128 Hz).
|
||||
## delta: time elapsed since last tick in seconds (approximately 1/tickrate).
|
||||
##
|
||||
## WARNING: This is called at high frequency — keep implementations cheap
|
||||
## to avoid impacting server performance.
|
||||
func on_tick(delta: float) -> void:
|
||||
pass
|
||||
@@ -0,0 +1,575 @@
|
||||
## 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)
|
||||
@@ -0,0 +1,80 @@
|
||||
## Plugin Manifest — Custom Resource
|
||||
##
|
||||
## Each plugin directory must contain a plugin.tres file of this type.
|
||||
## The PluginManager scans for these files when discovering plugins.
|
||||
##
|
||||
## Example plugin.tres:
|
||||
## [gd_resource type="Resource" format=3]
|
||||
## [ext_resource type="Script" id="1" path="res://server/scripts/plugin_api/plugin_manifest.gd"]
|
||||
## [resource]
|
||||
## script = ExtResource("1")
|
||||
## name = "Hello World"
|
||||
## version = "1.0.0"
|
||||
## author = "Tactical Shooter Team"
|
||||
## script_path = "hello_world.gd"
|
||||
## enabled = true
|
||||
|
||||
extends Resource
|
||||
class_name PluginManifest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Human-readable plugin name (e.g. "Hello World").
|
||||
## This is used as the canonical identifier for load/unload/info commands.
|
||||
@export var name: String = &""
|
||||
|
||||
## Plugin version string (e.g. "1.0.0").
|
||||
## Follows semver convention for compatibility checking.
|
||||
@export var version: String = &"1.0.0"
|
||||
|
||||
## Author name, handle, or team name.
|
||||
@export var author: String = &""
|
||||
|
||||
## Path to the main GDScript file, relative to the plugin directory
|
||||
## (e.g. "hello_world.gd" for a script in the same directory).
|
||||
@export var script_path: String = &""
|
||||
|
||||
## Whether the plugin is enabled on initial load.
|
||||
## Set to false to keep the plugin discovered but not auto-loaded.
|
||||
@export var enabled: bool = true
|
||||
|
||||
## Minimum required PluginManager API version (semver string).
|
||||
## Leave empty if no minimum is required.
|
||||
@export var min_api_version: String = &""
|
||||
|
||||
## Maximum allowed PluginManager API version (semver string).
|
||||
## Leave empty if any version is accepted.
|
||||
@export var max_api_version: String = &""
|
||||
|
||||
## Arbitrary key-value metadata (e.g. description, homepage, dependencies).
|
||||
## Useful for plugin browsers and info commands.
|
||||
@export var metadata: Dictionary = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Returns true if the manifest fields are minimally valid and usable.
|
||||
##
|
||||
## A valid manifest must have:
|
||||
## - A non-empty name
|
||||
## - A non-empty script_path referencing the plugin's main script
|
||||
func is_valid() -> bool:
|
||||
return not name.is_empty() and not script_path.is_empty()
|
||||
|
||||
## Returns true if this plugin is compatible with the given API version.
|
||||
## Uses simple string comparison — extend with proper semver logic as needed.
|
||||
func is_api_compatible(api_version: String) -> bool:
|
||||
if not min_api_version.is_empty() and api_version < min_api_version:
|
||||
return false
|
||||
if not max_api_version.is_empty() and api_version > max_api_version:
|
||||
return false
|
||||
return true
|
||||
|
||||
## Returns a human-readable summary of the manifest.
|
||||
func to_summary() -> String:
|
||||
return "%s v%s by %s (script: %s, enabled: %s)" % [
|
||||
name, version, author, script_path, str(enabled)
|
||||
]
|
||||
@@ -11,6 +11,10 @@ extends Node
|
||||
# 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
|
||||
#
|
||||
@@ -51,6 +55,8 @@ func handle_command(cmd: String, args: PackedStringArray) -> String:
|
||||
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":
|
||||
return _cmd_dispatch(cmd, args)
|
||||
_:
|
||||
@@ -76,6 +82,7 @@ func _cmd_help(_args: PackedStringArray) -> String:
|
||||
" cvarlist [pattern] List cvars (optional filter)",
|
||||
" cvar_get <name> Get a cvar's value",
|
||||
" cvar_set <name> <value> Set a writable cvar",
|
||||
" plugin <subcommand> [args] Plugin management (list/load/unload/reload/info/rescan)",
|
||||
" quit Shut down the server gracefully",
|
||||
]
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
@@ -215,6 +222,20 @@ func _cmd_cvar_set(args: PackedStringArray) -> String:
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user