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:
2026-07-01 18:36:23 -04:00
parent ffa72a8f24
commit 159c554a86
7 changed files with 987 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Tactical Shooter"
config/description="Phase 0 — Headless Dedicated Server"
run/main_scene="res://scenes/server/server_main.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
[autoload]
NetworkManager="*res://scripts/network/network_manager.gd"
ServerConfig="*res://scripts/config/server_config.gd"
PluginManager="*res://server/scripts/plugin_api/plugin_manager.gd"
[physics]
common/physics_ticks_per_second=128
common/enable_pause_aware_picking=true
[rendering]
renderer/rendering_method="vulkan"
environment/default_clear_color=Color(0.1, 0.1, 0.15, 1)
+104
View File
@@ -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])
+17
View File
@@ -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"
}
+158
View File
@@ -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
+575
View File
@@ -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)
]
+21
View File
@@ -11,6 +11,10 @@ extends Node
# help, echo/ping, status (engine-level stats), quit # help, echo/ping, status (engine-level stats), quit
# cvarlist, cvar_get, cvar_set (via CvarRegistry singleton if available) # 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: # Commands dispatched via signal:
# changelevel, kick, ban, unban, say, msg, players, exec # 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) return _cmd_cvar_get(args)
"cvar_set": "cvar_set":
return _cmd_cvar_set(args) return _cmd_cvar_set(args)
"plugin":
return _cmd_plugin(args)
"say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec": "say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec":
return _cmd_dispatch(cmd, args) return _cmd_dispatch(cmd, args)
_: _:
@@ -76,6 +82,7 @@ func _cmd_help(_args: PackedStringArray) -> String:
" cvarlist [pattern] List cvars (optional filter)", " cvarlist [pattern] List cvars (optional filter)",
" cvar_get <name> Get a cvar's value", " cvar_get <name> Get a cvar's value",
" cvar_set <name> <value> Set a writable cvar", " 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", " quit Shut down the server gracefully",
] ]
return "\r\n".join(lines) + "\r\n" 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" 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 # Delegated commands — emit signal for game logic to handle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------