e7299b17e9
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
540 lines
19 KiB
GDScript
540 lines
19 KiB
GDScript
extends Node
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# PluginManager — Server plugin lifecycle & hook dispatch
|
|
#
|
|
# Architecture (SourceMod-inspired):
|
|
# Plugins extend PluginBase and provide hook overrides. The PluginManager
|
|
# discovers .tres manifests in the plugin directory, loads the referenced
|
|
# GDScripts, and dispatches hook events to all loaded plugins.
|
|
#
|
|
# Setup — Add as an autoload in project.godot:
|
|
# [autoload]
|
|
# PluginManager="*res://server/scripts/plugin_manager.gd"
|
|
#
|
|
# Game Integration — Connect signals to forward game events:
|
|
# PluginManager.signal_map_start.connect(_on_map_start)
|
|
# --- After calling your game logic, dispatch to plugins:
|
|
# PluginManager.dispatch_map_start("de_dust2")
|
|
#
|
|
# Hook Dispatch Flow:
|
|
# Game loop → (calls plugin_manager.dispatch_*) → PluginManager iterates
|
|
# loaded plugins → calls virtual method on each → collects results
|
|
#
|
|
# Thread Safety:
|
|
# Hook dispatch runs on the main thread. Plugin _ready() and _exit_tree()
|
|
# also run on the main thread. Plugins must not create threads without
|
|
# proper mutex handling.
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
# ─── Signals (for game-logic integration) ────────────────────────────────
|
|
# Game logic connects these to learn when plugins request actions.
|
|
|
|
## Emitted when a plugin wants to broadcast a chat message.
|
|
signal plugin_broadcast_chat(message: String)
|
|
|
|
## Emitted when a plugin requests a player be kicked.
|
|
signal plugin_request_kick(peer_id: int, reason: String)
|
|
|
|
## Emitted when a plugin explicitly asks the server to change map.
|
|
signal plugin_request_changelevel(map_name: String)
|
|
|
|
|
|
# ─── Exports / Cvars ─────────────────────────────────────────────────────
|
|
|
|
## Master enable/disable. When false, no plugins are loaded.
|
|
@export var sv_plugin_enabled: bool = true:
|
|
set = set_plugin_enabled
|
|
|
|
## Directory to scan for plugin .tres manifests.
|
|
## Relative to user:// for runtime discovery (admins can FTP plugins in).
|
|
## Set to "res://plugins" for development / built-in plugins.
|
|
@export var sv_plugin_dir: String = "user://plugins"
|
|
|
|
## Path separator pattern — subdirectories under sv_plugin_dir are scanned.
|
|
## Each subdirectory should contain a plugin.tres file.
|
|
@export var sv_plugin_rescan_on_map: bool = false
|
|
|
|
|
|
# ─── Internal State ──────────────────────────────────────────────────────
|
|
|
|
## Loaded plugins keyed by plugin_name (lowercase).
|
|
var loaded_plugins: Dictionary = {} # "plugin_name_lower" → PluginBase
|
|
|
|
## Registration order (for deterministic dispatch).
|
|
var _load_order: Array[PluginBase] = []
|
|
|
|
## Whether the server is currently running (set by dispatch_server_start).
|
|
var _server_running: bool = false
|
|
|
|
## Reference to the CvarRegistry singleton (cached).
|
|
var _cvar_registry = null
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Initialization & Lifecycle
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
func _ready() -> void:
|
|
"""Called when the PluginManager is added to the scene tree (autoload)."""
|
|
# Cache CvarRegistry reference
|
|
if Engine.has_singleton("CvarRegistry"):
|
|
_cvar_registry = Engine.get_singleton("CvarRegistry")
|
|
|
|
# Read cvar overrides
|
|
_apply_cvar_overrides()
|
|
|
|
# Discover and load plugins
|
|
if sv_plugin_enabled:
|
|
_discover_plugins()
|
|
else:
|
|
print("[PluginManager] Plugin system disabled (sv_plugin_enabled=false)")
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
"""Clean shutdown — dispatch server_stop if not already done."""
|
|
if _server_running:
|
|
dispatch_server_stop()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Cvar Integration
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
func _apply_cvar_overrides() -> void:
|
|
"""Read overrides from the CvarRegistry (if available)."""
|
|
if not _cvar_registry:
|
|
return
|
|
|
|
if _cvar_registry.has_method("get"):
|
|
var val = _cvar_registry.get("sv_plugin_enabled")
|
|
if val != null:
|
|
sv_plugin_enabled = bool(val)
|
|
|
|
val = _cvar_registry.get("sv_plugin_dir")
|
|
if val != null and not str(val).is_empty():
|
|
sv_plugin_dir = str(val)
|
|
|
|
|
|
func set_plugin_enabled(new_val: bool) -> void:
|
|
"""Setter for sv_plugin_enabled — triggers load/unload if state changes."""
|
|
if new_val == sv_plugin_enabled:
|
|
return
|
|
sv_plugin_enabled = new_val
|
|
|
|
if sv_plugin_enabled:
|
|
print("[PluginManager] Enabling plugin system")
|
|
_discover_plugins()
|
|
else:
|
|
print("[PluginManager] Disabling plugin system — unloading all")
|
|
_unload_all()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Plugin Discovery & Loading
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
func discover_plugins() -> Dictionary:
|
|
"""Force a re-scan of the plugin directory. Returns {loaded, failed, skipped} counts."""
|
|
return _discover_plugins()
|
|
|
|
|
|
func _discover_plugins() -> Dictionary:
|
|
"""Scan sv_plugin_dir for plugin.tres manifests and load new plugins.
|
|
|
|
Returns a dict with:
|
|
loaded: int — plugins successfully loaded
|
|
failed: int — manifests found but failed to load
|
|
skipped: int — manifests skipped (already loaded, disabled, or invalid)
|
|
"""
|
|
var result = {"loaded": 0, "failed": 0, "skipped": 0}
|
|
|
|
# Resolve the plugin directory path
|
|
var dir_path = _resolve_plugin_dir()
|
|
if dir_path.is_empty():
|
|
push_warning("[PluginManager] Could not resolve plugin directory: " + sv_plugin_dir)
|
|
return result
|
|
|
|
var dir = DirAccess.open(dir_path)
|
|
if not dir:
|
|
push_warning("[PluginManager] Plugin directory not found: " + dir_path)
|
|
return result
|
|
|
|
# List subdirectories (each subdir = one plugin)
|
|
dir.list_dir_begin()
|
|
var entry = dir.get_next()
|
|
|
|
while entry:
|
|
if entry != "." and entry != ".." and dir.current_is_dir():
|
|
var sub_path = dir_path.path_join(entry)
|
|
var manifest_path = sub_path.path_join("plugin.tres")
|
|
|
|
if FileAccess.file_exists(manifest_path):
|
|
var load_result = _load_plugin(manifest_path)
|
|
if load_result == OK:
|
|
result["loaded"] += 1
|
|
elif load_result == ERR_SKIP:
|
|
result["skipped"] += 1
|
|
else:
|
|
result["failed"] += 1
|
|
else:
|
|
result["skipped"] += 1
|
|
|
|
entry = dir.get_next()
|
|
|
|
dir.list_dir_end()
|
|
|
|
print("[PluginManager] Discovery complete — loaded: " + str(result["loaded"]) + \
|
|
", failed: " + str(result["failed"]) + ", skipped: " + str(result["skipped"]))
|
|
|
|
return result
|
|
|
|
|
|
func _load_plugin(manifest_path: String) -> int:
|
|
"""Load a single plugin from its manifest path.
|
|
|
|
Returns:
|
|
OK — loaded successfully
|
|
ERR_SKIP — skipped (already loaded, disabled, or invalid manifest)
|
|
ERR_FILE_CORRUPT — manifest couldn't be loaded
|
|
ERR_SCRIPT_FAILED — script failed to instantiate
|
|
"""
|
|
# Load the manifest Resource
|
|
var res = ResourceLoader.load(manifest_path, "Resource", ResourceLoader.CACHE_MODE_IGNORE)
|
|
if not res:
|
|
push_error("[PluginManager] Failed to load manifest: " + manifest_path)
|
|
return ERR_FILE_CORRUPT
|
|
|
|
# The manifest is a PluginManifest if we loaded a custom Resource, or a
|
|
# plain Resource with export properties.
|
|
var manifest = res as PluginManifest
|
|
|
|
# Validate
|
|
if not manifest or not manifest.is_valid():
|
|
push_warning("[PluginManager] Skipping invalid manifest: " + manifest_path)
|
|
return ERR_SKIP
|
|
|
|
if not manifest.plugin_enabled:
|
|
print("[PluginManager] Plugin '" + manifest.plugin_name + "' is disabled — skipping")
|
|
return ERR_SKIP
|
|
|
|
var name_key = manifest.plugin_name.to_lower()
|
|
|
|
# Check if already loaded
|
|
if loaded_plugins.has(name_key):
|
|
print("[PluginManager] Plugin '" + manifest.plugin_name + "' already loaded — skipping")
|
|
return ERR_SKIP
|
|
|
|
# Check version compatibility
|
|
if not _check_version_compatibility(manifest):
|
|
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
|
"' version requirement not met — skipping")
|
|
return ERR_SKIP
|
|
|
|
# Load the plugin script
|
|
if manifest.plugin_script_path.is_empty():
|
|
push_error("[PluginManager] Plugin '" + manifest.plugin_name + "' has no script path")
|
|
return ERR_FILE_CORRUPT
|
|
|
|
# Resolve the script path relative to the plugin directory
|
|
var script_path = manifest.plugin_script_path
|
|
if not script_path.begins_with("res://") and not script_path.begins_with("user://"):
|
|
# If relative, resolve relative to manifest location
|
|
var manifest_dir = manifest_path.get_base_dir()
|
|
script_path = manifest_dir.path_join(script_path)
|
|
|
|
if not ResourceLoader.exists(script_path):
|
|
push_error("[PluginManager] Plugin script not found: " + script_path)
|
|
return ERR_FILE_CORRUPT
|
|
|
|
var script_res = load(script_path)
|
|
if not script_res or not (script_res is GDScript):
|
|
push_error("[PluginManager] Plugin script is not a valid GDScript: " + script_path)
|
|
return ERR_SCRIPT_FAILED
|
|
|
|
# Instantiate the plugin
|
|
var plugin_instance = script_res.new()
|
|
if not plugin_instance or not (plugin_instance is PluginBase):
|
|
push_error("[PluginManager] Plugin script must extend PluginBase: " + script_path)
|
|
if plugin_instance:
|
|
plugin_instance.free()
|
|
return ERR_SCRIPT_FAILED
|
|
|
|
# Apply metadata from manifest
|
|
plugin_instance.plugin_name = manifest.plugin_name
|
|
plugin_instance.plugin_version = manifest.plugin_version
|
|
plugin_instance.plugin_author = manifest.plugin_author
|
|
plugin_instance.plugin_description = manifest.plugin_description
|
|
plugin_instance.plugin_manifest_path = manifest_path
|
|
|
|
# Register
|
|
loaded_plugins[name_key] = plugin_instance
|
|
_load_order.append(plugin_instance)
|
|
|
|
# Add to scene tree to enable _ready(), _process(), etc.
|
|
add_child(plugin_instance)
|
|
|
|
print("[PluginManager] Loaded plugin: " + manifest.plugin_name + " v" + manifest.plugin_version)
|
|
return OK
|
|
|
|
|
|
func unload_plugin(name: String) -> bool:
|
|
"""Unload a specific plugin by name. Returns true on success."""
|
|
var name_key = name.to_lower()
|
|
if not loaded_plugins.has(name_key):
|
|
return false
|
|
|
|
var plugin = loaded_plugins[name_key] as PluginBase
|
|
|
|
# Remove from tracking first so dispatch doesn't hit it mid-unload
|
|
loaded_plugins.erase(name_key)
|
|
var idx = _load_order.find(plugin)
|
|
if idx >= 0:
|
|
_load_order.remove_at(idx)
|
|
|
|
# Save state (call on_server_stop if server is running)
|
|
if _server_running:
|
|
plugin.on_server_stop()
|
|
|
|
# Remove from scene tree
|
|
remove_child(plugin)
|
|
plugin.queue_free()
|
|
|
|
print("[PluginManager] Unloaded plugin: " + plugin.plugin_name)
|
|
return true
|
|
|
|
|
|
func reload_plugin(name: String) -> bool:
|
|
"""Reload a plugin by unloading it, then re-loading from its manifest.
|
|
Returns true if both unload and load succeeded.
|
|
"""
|
|
var name_key = name.to_lower()
|
|
if not loaded_plugins.has(name_key):
|
|
return false
|
|
|
|
var plugin = loaded_plugins[name_key] as PluginBase
|
|
var manifest_path = plugin.plugin_manifest_path
|
|
|
|
if not unload_plugin(name):
|
|
return false
|
|
|
|
if manifest_path.is_empty():
|
|
push_warning("[PluginManager] No manifest path to reload from for '" + name + "'")
|
|
return false
|
|
|
|
var result = _load_plugin(manifest_path)
|
|
if result != OK:
|
|
push_error("[PluginManager] Failed to reload plugin '" + name + "'")
|
|
return false
|
|
|
|
# If server is running, call on_server_start on the new instance
|
|
if _server_running:
|
|
var new_plugin = loaded_plugins.get(name_key)
|
|
if new_plugin:
|
|
new_plugin.on_server_start()
|
|
|
|
return true
|
|
|
|
|
|
func _unload_all() -> void:
|
|
"""Unload every loaded plugin."""
|
|
for plugin in _load_order.duplicate():
|
|
unload_plugin(plugin.plugin_name)
|
|
|
|
|
|
func get_plugin(name: String) -> PluginBase:
|
|
"""Get a loaded plugin by name. Returns null if not found."""
|
|
return loaded_plugins.get(name.to_lower())
|
|
|
|
|
|
func get_plugin_count() -> int:
|
|
"""Number of currently loaded plugins."""
|
|
return _load_order.size()
|
|
|
|
|
|
func list_plugins() -> Array[Dictionary]:
|
|
"""Return metadata for all loaded plugins (for admin display)."""
|
|
var result: Array[Dictionary] = []
|
|
for plugin in _load_order:
|
|
result.append({
|
|
"name": plugin.plugin_name,
|
|
"version": plugin.plugin_version,
|
|
"author": plugin.plugin_author,
|
|
"description": plugin.plugin_description,
|
|
"manifest_path": plugin.plugin_manifest_path,
|
|
})
|
|
return result
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Hook Dispatch Methods
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
# Each dispatch_* method iterates all loaded plugins and calls the
|
|
# corresponding virtual hook method. For hooks that return a bool,
|
|
# the method collects return values and decides: if ANY plugin returns
|
|
# true, the action is considered blocked.
|
|
#
|
|
# The game loop should call these at the appropriate points.
|
|
|
|
func dispatch_server_start() -> void:
|
|
"""Notify all plugins that the server has started."""
|
|
_server_running = true
|
|
for plugin in _load_order:
|
|
plugin.on_server_start()
|
|
|
|
|
|
func dispatch_server_stop() -> void:
|
|
"""Notify all plugins that the server is shutting down."""
|
|
_server_running = false
|
|
# Iterate in reverse for clean shutdown
|
|
for i in range(_load_order.size() - 1, -1, -1):
|
|
_load_order[i].on_server_stop()
|
|
|
|
|
|
func dispatch_map_start(map_name: String) -> void:
|
|
"""Notify plugins a new map has loaded."""
|
|
for plugin in _load_order:
|
|
plugin.on_map_start(map_name)
|
|
|
|
|
|
func dispatch_map_end(map_name: String) -> void:
|
|
"""Notify plugins the current map is ending."""
|
|
for plugin in _load_order:
|
|
plugin.on_map_end(map_name)
|
|
|
|
|
|
func dispatch_player_connect(peer_id: int, name: String, steam_id: String) -> bool:
|
|
"""Ask plugins if a player connection should be blocked.
|
|
|
|
Returns true if ANY plugin blocked the connection.
|
|
"""
|
|
var blocked: bool = false
|
|
for plugin in _load_order:
|
|
if plugin.on_player_connect(peer_id, name, steam_id):
|
|
blocked = true
|
|
return blocked
|
|
|
|
|
|
func dispatch_player_disconnect(peer_id: int, reason: String) -> void:
|
|
"""Notify plugins a player has disconnected."""
|
|
for plugin in _load_order:
|
|
plugin.on_player_disconnect(peer_id, reason)
|
|
|
|
|
|
func dispatch_player_say(peer_id: int, message: String) -> bool:
|
|
"""Ask plugins if a chat message should be blocked.
|
|
|
|
Returns true if ANY plugin blocked the message.
|
|
"""
|
|
var blocked: bool = false
|
|
for plugin in _load_order:
|
|
if plugin.on_player_say(peer_id, message):
|
|
blocked = true
|
|
return blocked
|
|
|
|
|
|
func dispatch_player_spawn(peer_id: int) -> void:
|
|
"""Notify plugins a player has spawned."""
|
|
for plugin in _load_order:
|
|
plugin.on_player_spawn(peer_id)
|
|
|
|
|
|
func dispatch_player_killed(victim_id: int, killer_id: int, weapon: String) -> void:
|
|
"""Notify plugins a player has been killed."""
|
|
for plugin in _load_order:
|
|
plugin.on_player_killed(victim_id, killer_id, weapon)
|
|
|
|
|
|
func dispatch_round_start(round_number: int) -> void:
|
|
"""Notify plugins a new round has started."""
|
|
for plugin in _load_order:
|
|
plugin.on_round_start(round_number)
|
|
|
|
|
|
func dispatch_round_end(winner_team: int, reason: String) -> void:
|
|
"""Notify plugins the current round has ended."""
|
|
for plugin in _load_order:
|
|
plugin.on_round_end(winner_team, reason)
|
|
|
|
|
|
func dispatch_tick(delta: float) -> void:
|
|
"""Dispatch per-tick hook. Call from _physics_process.
|
|
|
|
This is potentially expensive with many plugins. Consider using
|
|
a pooling/tick budget approach if you have >20 plugins.
|
|
"""
|
|
for plugin in _load_order:
|
|
plugin.on_tick(delta)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Helpers
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
func _resolve_plugin_dir() -> String:
|
|
"""Resolve the plugin directory path, handling res:// and user:// prefixes."""
|
|
var path = sv_plugin_dir
|
|
|
|
if path.begins_with("res://"):
|
|
# Convert to absolute filesystem path (DirAccess needs absolute paths)
|
|
return ProjectSettings.globalize_path(path)
|
|
elif path.begins_with("user://"):
|
|
return ProjectSettings.globalize_path(path)
|
|
else:
|
|
# Assume relative — prepend user://
|
|
path = "user://" + path.trim_prefix("/")
|
|
return ProjectSettings.globalize_path(path)
|
|
|
|
|
|
func _check_version_compatibility(manifest: PluginManifest) -> bool:
|
|
"""Check if this server version satisfies the plugin's version requirements."""
|
|
# Get server version
|
|
var server_version = ProjectSettings.get_setting("application/config/version", "0.0.0")
|
|
|
|
if not manifest.min_server_version.is_empty():
|
|
if not _version_gte(server_version, manifest.min_server_version):
|
|
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
|
"' requires server >= " + manifest.min_server_version + \
|
|
" (server: " + server_version + ")")
|
|
return false
|
|
|
|
if not manifest.max_server_version.is_empty():
|
|
if not _version_lte(server_version, manifest.max_server_version):
|
|
push_warning("[PluginManager] Plugin '" + manifest.plugin_name + \
|
|
"' requires server <= " + manifest.max_server_version + \
|
|
" (server: " + server_version + ")")
|
|
return false
|
|
|
|
return true
|
|
|
|
|
|
static func _version_gte(version: String, min_version: String) -> bool:
|
|
"""Simple semver comparison: version >= min_version."""
|
|
var v_parts = version.split(".")
|
|
var m_parts = min_version.split(".")
|
|
|
|
for i in range(max(v_parts.size(), m_parts.size())):
|
|
var v = int(v_parts[i]) if i < v_parts.size() else 0
|
|
var m = int(m_parts[i]) if i < m_parts.size() else 0
|
|
if v > m:
|
|
return true
|
|
if v < m:
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _version_lte(version: String, max_version: String) -> bool:
|
|
"""Simple semver comparison: version <= max_version."""
|
|
var v_parts = version.split(".")
|
|
var m_parts = max_version.split(".")
|
|
|
|
for i in range(max(v_parts.size(), m_parts.size())):
|
|
var v = int(v_parts[i]) if i < v_parts.size() else 0
|
|
var m = int(m_parts[i]) if i < m_parts.size() else 0
|
|
if v < m:
|
|
return true
|
|
if v > m:
|
|
return false
|
|
return true
|