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:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+539
View File
@@ -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