From 159c554a86f37194f2b34f17ce02dcec2b3c7eae Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 18:36:23 -0400 Subject: [PATCH] t_p6_api: build plugin scripting system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- project.godot | 32 ++ server/plugins/hello_world/hello_world.gd | 104 ++++ server/plugins/hello_world/plugin.tres | 17 + server/scripts/plugin_api/plugin_base.gd | 158 +++++ server/scripts/plugin_api/plugin_manager.gd | 575 +++++++++++++++++++ server/scripts/plugin_api/plugin_manifest.gd | 80 +++ server/scripts/rcon_command_handler.gd | 21 + 7 files changed, 987 insertions(+) create mode 100644 project.godot create mode 100644 server/plugins/hello_world/hello_world.gd create mode 100644 server/plugins/hello_world/plugin.tres create mode 100644 server/scripts/plugin_api/plugin_base.gd create mode 100644 server/scripts/plugin_api/plugin_manager.gd create mode 100644 server/scripts/plugin_api/plugin_manifest.gd diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..824b2fd --- /dev/null +++ b/project.godot @@ -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) diff --git a/server/plugins/hello_world/hello_world.gd b/server/plugins/hello_world/hello_world.gd new file mode 100644 index 0000000..d3f301e --- /dev/null +++ b/server/plugins/hello_world/hello_world.gd @@ -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]) diff --git a/server/plugins/hello_world/plugin.tres b/server/plugins/hello_world/plugin.tres new file mode 100644 index 0000000..3053001 --- /dev/null +++ b/server/plugins/hello_world/plugin.tres @@ -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" +} diff --git a/server/scripts/plugin_api/plugin_base.gd b/server/scripts/plugin_api/plugin_base.gd new file mode 100644 index 0000000..fbbdd03 --- /dev/null +++ b/server/scripts/plugin_api/plugin_base.gd @@ -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 diff --git a/server/scripts/plugin_api/plugin_manager.gd b/server/scripts/plugin_api/plugin_manager.gd new file mode 100644 index 0000000..c1170bb --- /dev/null +++ b/server/scripts/plugin_api/plugin_manager.gd @@ -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 — load a discovered plugin by name +## plugin unload — unload a running plugin +## plugin reload — reload a plugin (re-compiles its script) +## plugin info — 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 Load a discovered plugin by name", + " plugin unload Unload a running plugin", + " plugin reload Reload a plugin (re-compiles script)", + " plugin info 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 \\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 \\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 \\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 \\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) diff --git a/server/scripts/plugin_api/plugin_manifest.gd b/server/scripts/plugin_api/plugin_manifest.gd new file mode 100644 index 0000000..ae691ac --- /dev/null +++ b/server/scripts/plugin_api/plugin_manifest.gd @@ -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) + ] diff --git a/server/scripts/rcon_command_handler.gd b/server/scripts/rcon_command_handler.gd index c3e89c3..4ee1a6c 100644 --- a/server/scripts/rcon_command_handler.gd +++ b/server/scripts/rcon_command_handler.gd @@ -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 Get a cvar's value", " cvar_set Set a writable cvar", + " plugin [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 # ---------------------------------------------------------------------------