Files
tactical-shooter/scripts/rcon/rcon_command_handler.gd
T
shawn 5b93c514c7 Phase 8: Headless dedicated server + RCON admin + netfox bootstrap
## Headless Server
- New entry point: scenes/headless_server.tscn + scripts/headless_server.gd
- Loads multiplayer-fps game, strips UI/rendering, auto-hosts ENet server
- Port config via --port arg, SERVER_PORT env, or default 34197
- RCON admin console on port 28960 (configurable via --rcon-port)

## Netfox Headless Bootstrap
- scripts/netfox_headless_bootstrap.gd — preloads all netfox dependency
  scripts in headless mode so class_names register before autoloads parse
- Registered as first autoload in project.godot
- Fixes 'Could not find type' errors for NetfoxLogger, NorayProtocolHandler,
  NetworkClocks, etc.

## RCON Admin
- Ported from old project: scripts/rcon/rcon_server.gd (TCP auth layer)
- scripts/rcon/rcon_command_handler.gd (command dispatch)
- Password via config/rcon_password.cfg, --rcon-password, or RCON_PASSWORD env

## Other Changes
- Added GameEvents stub (lan-bootstrapper dependency)
- Updated project.godot features to 4.7
- Added config/server_config.cfg for operator editing
- Updated RCON paths to new project structure
2026-07-02 21:49:49 -04:00

404 lines
14 KiB
GDScript

extends Node
# RCON Admin Console — Command Handler
# ============================================================================
# Processes RCON commands, returns formatted response strings.
# Game-affecting commands (changelevel, kick, ban, say, players, etc.)
# are dispatched via the `rcon_command` signal so game logic can handle them
# without coupling RCON to game-specific systems.
#
# Commands handled inline (no game dependency):
# 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
#
# Connection pattern for game logic:
# func _ready():
# var rcon = get_node("/root/RconServer/RconCommandHandler")
# if rcon:
# rcon.rcon_command.connect(_on_rcon_command)
#
# func _on_rcon_command(command: String, args: PackedStringArray):
# match command:
# "changelevel":
# $ServerManager.change_level(args[0])
# "kick":
# $PlayerManager.kick_player(args[0])
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal rcon_command(command: String, args: PackedStringArray)
# ---------------------------------------------------------------------------
# Public API — called by rcon_server.gd
# ---------------------------------------------------------------------------
func handle_command(cmd: String, args: PackedStringArray) -> String:
match cmd:
"help":
return _cmd_help(args)
"echo", "ping":
return _cmd_echo(args)
"status":
return _cmd_status(args)
"quit":
return _cmd_quit(args)
"cvarlist":
return _cmd_cvarlist(args)
"cvar_get":
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", "start_match", "end_round":
return _cmd_dispatch(cmd, args)
_:
return "Unknown command: '" + cmd + "'. Type 'help' for available commands.\\r\\n"
# ---------------------------------------------------------------------------
# Built-in commands
# ---------------------------------------------------------------------------
func _cmd_help(_args: PackedStringArray) -> String:
var lines: PackedStringArray = [
"=== RCON Commands ===",
" help Show this help",
" echo [text] Echo text back (ping test)",
" status Show server engine status",
" players List connected players",
" say <message> Broadcast chat to all players",
" msg <player> <message> Send private message",
" changelevel <map_name> Load a new map",
" start_match Start the match (exit warmup)",
" end_round <team> [reason] Force-end the current round",
" kick <player> [reason] Remove a player",
" ban <player> [duration] Ban a player from the server",
" unban <player_or_steamid> Remove a ban",
" exec <config_file> Execute a server config file",
" cvarlist [pattern] List cvars (optional filter)",
" cvar_get <name> Get a cvar's value",
" cvar_set <name> <value> Set a writable cvar",
" plugin <subcommand> [args] Plugin management (list/load/unload/reload/info/rescan)",
" quit Shut down the server gracefully",
]
return "\r\n".join(lines) + "\r\n"
func _cmd_echo(args: PackedStringArray) -> String:
if args.is_empty():
return "Pong\r\n"
return " ".join(args) + "\\r\\n"
func _cmd_status(_args: PackedStringArray) -> String:
var lines: PackedStringArray = []
lines.append("=== Server Status ===")
# Engine version
var vi: Dictionary = Engine.get_version_info()
lines.append(" Engine: Godot " + str(vi.get("major", "?")) + "." + str(vi.get("minor", "?")) + "." + str(vi.get("patch", "?")))
# FPS
lines.append(" FPS: " + str(Engine.get_frames_per_second()))
# Physics tick rate
lines.append(" Tickrate: " + str(Engine.physics_ticks_per_second) + " Hz")
# Uptime
var uptime_ms: int = Time.get_ticks_msec()
var total_sec: int = uptime_ms / 1000
var h: int = total_sec / 3600
var m: int = (total_sec % 3600) / 60
var s: int = total_sec % 60
lines.append(" Uptime: " + str(h) + "h " + str(m) + "m " + str(s) + "s")
# Server time
var dt: Dictionary = Time.get_datetime_dict_from_system()
lines.append(" Time: " + \
str(dt.get("year", 0)) + "-" + str(dt.get("month", 1)).pad_zeros(2) + "-" + str(dt.get("day", 1)).pad_zeros(2) + " " + \
str(dt.get("hour", 0)).pad_zeros(2) + ":" + str(dt.get("minute", 0)).pad_zeros(2) + ":" + str(dt.get("second", 0)).pad_zeros(2))
# Performance metrics
lines.append(" Objects: " + str(Performance.get_monitor(Performance.OBJECT_COUNT)))
var mem_kb: float = Performance.get_monitor(Performance.MEMORY_STATIC)
lines.append(" Memory: " + str(snapped(mem_kb / 1024.0, 0.1)) + " MB")
# Node count (via SceneTree)
lines.append(" Nodes: " + str(get_tree().node_count))
# RCON info
lines.append(" RCON: enabled on port ? (see rcon_server)")
# Try to get server name from engine
var server_name: String = ProjectSettings.get_setting("application/config/name", "Tactical Shooter")
lines.append(" Server: " + server_name)
return "\r\n".join(lines) + "\r\n"
func _cmd_quit(_args: PackedStringArray) -> String:
# Defer shutdown to next frame so the response can be sent
get_tree().call_deferred("quit")
return "Server shutting down...\r\n"
func _cmd_cvarlist(args: PackedStringArray) -> String:
var pattern: String = args[0] if args.size() > 0 else ""
if not has_node("/root/CvarRegistry"):
if pattern.is_empty() or "rcon" in pattern:
return "rcon_enabled: bool\r\nrcon_port: int\r\nrcon_password: string [PW]\r\n(Cvar registry not available — RCON cvars shown)\r\n"
return "Cvar registry not available. No cvars match '" + pattern + "'.\r\n"
var registry = get_node("/root/CvarRegistry")
if not registry or not registry.has_method("list_cvars"):
return "Cvar registry available but incompatible.\r\n"
var cvar_names: Array = registry.list_cvars()
var lines: PackedStringArray = []
lines.append("=== Cvar List" + (" (filter: '" + pattern + "')" if pattern else "") + " ===")
var found: int = 0
for cvar_name in cvar_names:
if not pattern.is_empty() and pattern not in str(cvar_name):
continue
var val = registry.get(cvar_name)
var flags: int = 0
var cvar_type: String = "?"
if registry.has_method("get_flags"):
flags = registry.get_flags(cvar_name)
if registry.has_method("get_type"):
cvar_type = str(registry.get_type(cvar_name))
var readonly: String = " [RO]" if (flags & 1) else ""
var passwd: String = " [PW]" if str(cvar_name).find("password") >= 0 else ""
lines.append(" " + str(cvar_name) + " = " + str(val) + " (" + cvar_type + ")" + readonly + passwd)
found += 1
if found == 0:
return "No cvars match '" + pattern + "'.\r\n"
lines.append(str(found) + " cvars listed.")
return "\r\n".join(lines) + "\r\n"
func _cmd_cvar_get(args: PackedStringArray) -> String:
if args.is_empty():
return "Usage: cvar_get <name>\r\n"
var name: String = args[0]
if has_node("/root/CvarRegistry"):
var registry = get_node("/root/CvarRegistry")
if registry and registry.has_method("get"):
var val = registry.get(name)
if val != null:
return name + " = " + str(val) + "\r\n"
return "Cvar not found: " + name + "\r\n"
return "Cvar registry not available.\r\n"
func _cmd_cvar_set(args: PackedStringArray) -> String:
if args.size() < 2:
return "Usage: cvar_set <name> <value>\r\n"
var name: String = args[0]
var value: String = args[1]
if has_node("/root/CvarRegistry"):
var registry = get_node("/root/CvarRegistry")
if registry and registry.has_method("set"):
# Use Callable to avoid compile-time void return detection on dynamic object
var fn = Callable(registry, "set")
var err = fn.call(name, value)
if err == OK:
return name + " = " + value + "\r\n"
match err:
ERR_INVALID_PARAMETER:
return "Cvar not found or read-only: " + name + "\r\n"
ERR_INVALID_DATA:
return "Invalid value for " + name + ": " + value + "\r\n"
_:
return "Failed to set " + name + " (error " + str(err) + ")\r\n"
return "Cvar registry not available.\r\n"
# ---------------------------------------------------------------------------
# Plugin management — delegates to PluginManager singleton
# ---------------------------------------------------------------------------
func _cmd_plugin(args: PackedStringArray) -> String:
# Try to find PluginManager singleton (autoload)
var pm = get_node("/root/PluginManager")
if not pm:
return "PluginManager not available. Ensure PluginManager is registered as an autoload in project.godot.\r\n"
if not pm.has_method("rcon_command"):
return "PluginManager does not support RCON commands.\r\n"
return pm.rcon_command(args)
# ---------------------------------------------------------------------------
# Delegated commands — emit signal for game logic to handle
# ---------------------------------------------------------------------------
func _cmd_dispatch(cmd: String, args: PackedStringArray) -> String:
rcon_command.emit(cmd, args)
return "Command '" + cmd + "' dispatched to server.\r\n"
# ---------------------------------------------------------------------------
# Plugin management commands — interfaces with PluginManager singleton
# ---------------------------------------------------------------------------
func _cmd_plugins(args: PackedStringArray) -> String:
"""Handle 'plugins' command with subcommands."""
if args.is_empty():
return _cmd_plugins_help()
var subcmd: String = args[0].to_lower()
match subcmd:
"list":
return _cmd_plugins_list()
"load":
if args.size() < 2:
return "Usage: plugins load <name>\r\n"
return _cmd_plugins_load(args[1])
"unload":
if args.size() < 2:
return "Usage: plugins unload <name>\r\n"
return _cmd_plugins_unload(args[1])
"reload":
if args.size() < 2:
return "Usage: plugins reload <name>\r\n"
return _cmd_plugins_reload(args[1])
"info":
if args.size() < 2:
return "Usage: plugins info <name>\r\n"
return _cmd_plugins_info(args[1])
"scan", "rescan":
return _cmd_plugins_rescan()
_:
return "Unknown plugins subcommand: '" + subcmd + "'\r\n" + _cmd_plugins_help()
func _cmd_plugins_help() -> String:
return "\r\n".join([
"=== Plugin Management ===",
" plugins list List all loaded plugins",
" plugins load <name> Load a plugin by name from disk",
" plugins unload <name> Unload a running plugin",
" plugins reload <name> Reload a plugin (hotswap)",
" plugins info <name> Show metadata for a plugin",
" plugins rescan Rescan plugin directory for new manifests",
]) + "\r\n"
func _cmd_plugins_list() -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available (no PluginManager singleton).\r\n"
var plugins = mgr.list_plugins()
if plugins.is_empty():
return "No plugins loaded.\r\n"
var lines: PackedStringArray = []
lines.append("=== Loaded Plugins (" + str(plugins.size()) + ") ===")
lines.append(" %-24s %-10s %s" % ["Name", "Version", "Author"])
lines.append(" " + "-".repeat(50))
for p in plugins:
lines.append(" %-24s %-10s %s" % [p.name, p.version, p.author])
return "\r\n".join(lines) + "\r\n"
func _cmd_plugins_load(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
if not mgr.has_method("discover_plugins"):
return "PluginManager does not support targeted loading yet — use 'plugins rescan' to reload all.\r\n"
if mgr.get_plugin(name):
return "Plugin '" + name + "' is already loaded.\r\n"
var result = mgr.discover_plugins()
if mgr.get_plugin(name):
return "Plugin '" + name + "' loaded successfully.\r\n"
else:
return "Failed to load '" + name + "'. Check that plugin.tres exists in user://plugins/" + name.to_lower() + "/\r\n"
func _cmd_plugins_unload(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
if not mgr.has_method("unload_plugin"):
return "PluginManager does not support unloading.\r\n"
if mgr.unload_plugin(name):
return "Plugin '" + name + "' unloaded.\r\n"
else:
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
func _cmd_plugins_reload(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
if not mgr.has_method("reload_plugin"):
return "PluginManager does not support reloading.\r\n"
if mgr.reload_plugin(name):
return "Plugin '" + name + "' reloaded.\r\n"
else:
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
func _cmd_plugins_info(name: String) -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
var plugin = mgr.get_plugin(name)
if not plugin:
return "Plugin '" + name + "' not found. Use 'plugins list' to see loaded plugins.\r\n"
var lines: PackedStringArray = []
lines.append("=== Plugin: " + plugin.plugin_name + " ===")
lines.append(" Name: " + plugin.plugin_name)
lines.append(" Version: " + plugin.plugin_version)
lines.append(" Author: " + plugin.plugin_author)
lines.append(" Description: " + plugin.plugin_description)
lines.append(" Manifest: " + plugin.plugin_manifest_path)
return "\r\n".join(lines) + "\r\n"
func _cmd_plugins_rescan() -> String:
var mgr = _get_plugin_manager()
if not mgr:
return "Plugin system not available.\r\n"
var result = mgr.discover_plugins()
return "Rescan complete — loaded: " + str(result.get("loaded", 0)) + \
", failed: " + str(result.get("failed", 0)) + \
", skipped: " + str(result.get("skipped", 0)) + "\r\n"
# ---------------------------------------------------------------------------
# Helper to locate PluginManager singleton
# ---------------------------------------------------------------------------
func _get_plugin_manager():
"""Get the PluginManager singleton, or null if not available."""
if Engine.has_singleton("PluginManager"):
return Engine.get_singleton("PluginManager")
if has_node("/root/PluginManager"):
return get_node("/root/PluginManager")
return null