Phase 7: netfox + godot-jolt stack upgrade
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)
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
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
|
||||
Reference in New Issue
Block a user