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,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)
|
||||||
@@ -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
|
# 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user