diff --git a/config/server_config.cfg b/config/server_config.cfg new file mode 100644 index 0000000..f4f6c49 --- /dev/null +++ b/config/server_config.cfg @@ -0,0 +1,47 @@ +; Tactical Shooter — Default Server Configuration +; Copy to user://server_config.cfg or config/server_config.cfg and edit. +[server] +server_name="Tactical Shooter Server" +description="" +bind_ip="0.0.0.0" +port=34197 +max_players=16 +password="" +tick_rate=64 + +[game] +round_time_seconds=600 +warmup_time_seconds=60 +friendly_fire=false +ff_damage_multiplier=0.5 +gravity=-24.0 +respawn_time_seconds=5.0 +spectate_enabled=true + +[movement] +walk_speed=5.0 +sprint_speed=7.0 +crouch_speed=2.0 +jump_velocity=6.0 +acceleration=20.0 +air_acceleration=4.0 +friction=8.0 + +[match] +win_limit=3 +time_limit_seconds=0 +map_rotation_mode="sequence" +maps="test_range" + +[rcon] +enabled=false +password="" +port=28960 + +[logging] +log_level="info" +log_file="" + +[teams] +team_count=2 +players_per_team=8 diff --git a/project.godot b/project.godot index ba9e7c3..16debd4 100644 --- a/project.godot +++ b/project.godot @@ -23,7 +23,9 @@ config/icon="res://icon.png" [autoload] +NetfoxBootstrap="*res://scripts/netfox_headless_bootstrap.gd" Async="*res://examples/shared/scripts/async.gd" +GameEvents="*res://scripts/game_events_stub.gd" Noray="*res://addons/netfox.noray/noray.gd" PacketHandshake="*res://addons/netfox.noray/packet-handshake.gd" WindowTiler="*res://addons/netfox.extras/window-tiler.gd" diff --git a/scenes/headless_server.tscn b/scenes/headless_server.tscn new file mode 100644 index 0000000..9220892 --- /dev/null +++ b/scenes/headless_server.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://headless_server"] + +[ext_resource type="Script" path="res://scripts/headless_server.gd" id="1"] + +[node name="HeadlessServer" type="Node"] +script = ExtResource("1") diff --git a/scripts/game_events_stub.gd b/scripts/game_events_stub.gd new file mode 100644 index 0000000..dce2514 --- /dev/null +++ b/scripts/game_events_stub.gd @@ -0,0 +1,7 @@ +## GameEvents — Stub for headless/server compatibility. +## The forest-brawl GameEvents autoload was removed from project.godot, +## but some shared scripts (lan-bootstrapper.gd) reference it in class-body +## initializers. This stub provides the required singleton without +## pulling in the full forest-brawl game. +extends Node +signal on_event(event_name: String, data: Dictionary) diff --git a/scripts/headless_server.gd b/scripts/headless_server.gd new file mode 100644 index 0000000..2e87141 --- /dev/null +++ b/scripts/headless_server.gd @@ -0,0 +1,256 @@ +## HeadlessServer — Dedicated server entry point for headless mode. +## +## Loads the multiplayer-fps game scene, strips UI/rendering overhead, +## and auto-starts the ENet server. Designed to run with `--headless`. +## +## Usage: +## godot --headless --scene res://scenes/headless_server.tscn +## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197 +## +## Architecture: +## This script runs as the main scene root in headless mode. +## 1. Loads multiplayer-fps.tscn (the full game scene) +## 2. Removes Environment (sky/light/camera) and UI (HUD/network popup) +## 3. Starts the ENet server on the configured port +## 4. Calls RoundManager.start_match() to begin gameplay +## 5. Listens for player connections via NetworkEvents + +extends Node + +## Default server port. +const DEFAULT_PORT: int = 34197 + +## The multiplayer-fps game scene. +var _game_scene: Node = null + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + print("[HeadlessServer] Starting dedicated server...") + + # Determine port from command-line args or default + var port: int = _parse_port() + print("[HeadlessServer] Port: %d" % port) + + # Load the multiplayer-fps game scene + var GameScene := load("res://examples/multiplayer-fps/multiplayer-fps.tscn") + if GameScene == null: + push_error("[HeadlessServer] Failed to load multiplayer-fps.tscn") + get_tree().quit(1) + return + + _game_scene = GameScene.instantiate() + add_child(_game_scene) + + # Strip UI and rendering overhead + _strip_for_headless() + + # Start RCON admin console + _start_rcon() + + # Wait one frame for scene to settle, then host + await get_tree().process_frame + await get_tree().process_frame + + # Start the ENet server + _start_server(port) + +func _exit_tree() -> void: + _stop_server() + +# --------------------------------------------------------------------------- +# Strip scene for headless mode +# --------------------------------------------------------------------------- + +## Remove nodes that are useless in headless mode: rendering, UI, audio. +func _strip_for_headless() -> void: + if _game_scene == null: + return + + # Remove Environment (sky, light, camera, world environment) + var env := _game_scene.get_node_or_null("Environment") + if env: + env.queue_free() + print("[HeadlessServer] Removed Environment node") + + # Remove UI (network popup, HUD, crosshair, etc.) + var ui := _game_scene.get_node_or_null("UI") + if ui: + ui.queue_free() + print("[HeadlessServer] Removed UI node") + + # Verify critical game logic nodes exist + var network := _game_scene.get_node_or_null("Network") + if network == null: + push_error("[HeadlessServer] Missing Network node in game scene!") + return + + for required in ["Player Spawner", "TeamManager", "RoundManager", "EconomyManager", "Bomb"]: + if network.get_node_or_null(required) == null: + push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required) + + print("[HeadlessServer] Scene stripped for headless mode") + +# --------------------------------------------------------------------------- +# Server startup +# --------------------------------------------------------------------------- + +func _start_server(port: int) -> void: + # Create ENet peer directly (same as lan-bootstrapper.gd::host()) + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(port) + if err != OK: + push_error("[HeadlessServer] Failed to create ENet server on port %d: %s" % [port, error_string(err)]) + get_tree().quit(1) + return + + get_tree().get_multiplayer().multiplayer_peer = peer + get_tree().get_multiplayer().server_relay = true + + print("[HeadlessServer] ENet server started on port %d" % port) + + # Wait for NetworkEvents to fire on_server_start + await get_tree().process_frame + + # The player-spawner.gd handles on_server_start by spawning the host avatar + # and calling round_manager.start_match(). If NetworkEvents fires it, we're set. + # Otherwise, manually start the match. + if NetworkEvents and NetworkEvents.enabled: + print("[HeadlessServer] NetworkEvents active — waiting for on_server_start") + else: + # Fallback: manually start NetworkTime and signal server start + print("[HeadlessServer] NetworkEvents not available — manually bootstrapping") + _manual_startup() + +func _stop_server() -> void: + var mp := get_tree().get_multiplayer() + if mp and mp.multiplayer_peer: + mp.multiplayer_peer.close() + mp.multiplayer_peer = null + print("[HeadlessServer] Server stopped") + +## Fallback startup when NetworkEvents isn't available. +func _manual_startup() -> void: + # Start NetworkTime if available + if Engine.has_singleton("NetworkTime"): + var nt = Engine.get_singleton("NetworkTime") + if nt and nt.has_method("start"): + nt.start() + print("[HeadlessServer] NetworkTime started manually") + + # Find and start the match via RoundManager + var network := _game_scene.get_node_or_null("Network") if _game_scene else null + if network == null: + return + + var spawner := network.get_node_or_null("Player Spawner") + if spawner: + # Manually call the host handler that spawns the host avatar + if spawner.has_method("_handle_host"): + spawner._handle_host() + print("[HeadlessServer] PlayerSpawner._handle_host() called") + + # Manually emit peer join for host (peer 1) + if Engine.has_singleton("NetworkEvents"): + var ne = Engine.get_singleton("NetworkEvents") + if ne and ne.has_signal("on_server_start"): + ne.on_server_start.emit() + print("[HeadlessServer] Emitted NetworkEvents.on_server_start") + if ne and ne.has_signal("on_peer_join"): + ne.on_peer_join.emit(1) + print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(1)") + + # Start the match via RoundManager + var round_mgr := network.get_node_or_null("RoundManager") + if round_mgr: + if round_mgr.has_method("start_match"): + round_mgr.call_deferred("start_match") + print("[HeadlessServer] RoundManager.start_match() called") + elif round_mgr.has_method("start_round"): + round_mgr.call_deferred("start_round") + print("[HeadlessServer] RoundManager.start_round() called") + + print("[HeadlessServer] Manual startup complete") + +# --------------------------------------------------------------------------- +# RCON admin console +# --------------------------------------------------------------------------- + +## Start the RCON TCP admin console. +func _start_rcon() -> void: + var rcon_path := "res://scripts/rcon/rcon_server.gd" + if not ResourceLoader.exists(rcon_path): + print("[HeadlessServer] RCON script not found — skipping") + return + + var RconServerClass := load(rcon_path) + if RconServerClass == null: + return + + var rcon = RconServerClass.new() + rcon.name = "RconServer" + rcon.enabled = true + rcon.port = _parse_rcon_port() + rcon.password = _parse_rcon_password() + add_child(rcon) + print("[HeadlessServer] RCON admin console started on port %d" % rcon.port) + +## Parse RCON port from args/env. +func _parse_rcon_port() -> int: + var args := OS.get_cmdline_args() + for i in range(args.size()): + if args[i] == "--rcon-port" and i + 1 < args.size(): + return clampi(args[i + 1].to_int(), 1024, 65535) + if OS.has_environment("RCON_PORT"): + return clampi(OS.get_environment("RCON_PORT").to_int(), 1024, 65535) + return 28960 # default RCON port + +## Parse RCON password from args/env/file. +func _parse_rcon_password() -> String: + var args := OS.get_cmdline_args() + for i in range(args.size()): + if args[i] == "--rcon-password" and i + 1 < args.size(): + return args[i + 1] + if OS.has_environment("RCON_PASSWORD"): + return OS.get_environment("RCON_PASSWORD") + # Try loading from file + var pw_path := "res://config/rcon_password.cfg" + if ResourceLoader.exists(pw_path): + var f := FileAccess.open(pw_path, FileAccess.READ) + if f: + var line := f.get_line().strip_edges() + f.close() + if not line.is_empty() and not line.begins_with("#"): + return line + return "changeme" + +# --------------------------------------------------------------------------- +# Port parsing +# --------------------------------------------------------------------------- + +func _parse_port() -> int: + # Check user args (after -- separator): --port 34197 + var user_args := OS.get_cmdline_user_args() + for i in range(user_args.size()): + if user_args[i] == "--port" and i + 1 < user_args.size(): + var p := user_args[i + 1].to_int() + if p > 0 and p < 65536: + return p + + # Check all args as fallback + var args := OS.get_cmdline_args() + for i in range(args.size()): + if args[i] == "--port" and i + 1 < args.size(): + var p := args[i + 1].to_int() + if p > 0 and p < 65536: + return p + + # Check environment variable + if OS.has_environment("SERVER_PORT"): + var p := OS.get_environment("SERVER_PORT").to_int() + if p > 0 and p < 65536: + return p + + return DEFAULT_PORT diff --git a/scripts/netfox_headless_bootstrap.gd b/scripts/netfox_headless_bootstrap.gd new file mode 100644 index 0000000..0812ac5 --- /dev/null +++ b/scripts/netfox_headless_bootstrap.gd @@ -0,0 +1,89 @@ +## NetfoxHeadlessBootstrap — Preload netfox dependencies for headless/server mode. +## +## In headless mode (`--headless`), Godot doesn't run EditorPlugin script +## scanning, so `class_name` registrations from addon scripts don't happen +## automatically. This causes parse errors when netfox autoloads reference +## class_names like `NetfoxLogger`, `NorayProtocolHandler`, etc. +## +## This bootstrap MUST be the FIRST autoload in project.godot (before any +## netfox autoload). It preloads all dependency scripts so their class_names +## are registered before netfox's own autoloads are parsed. +## +## Architecture: +## project.godot autoload order: +## 1. NetfoxHeadlessBootstrap (this script) ← preloads all deps +## 2. Async +## 3. Noray +## 4. NetworkTime +## 5. NetworkEvents +## 6. ... (all other netfox autoloads) +## +## This only executes in headless mode. In editor mode, the EditorPlugin +## handles class_name registration normally. + +extends Node + +func _init() -> void: + # Only needed in headless mode — editor mode handles class_names via EditorPlugin + if DisplayServer.get_name() != "headless": + return + + print("[NetfoxHeadlessBootstrap] Headless mode detected — preloading netfox dependencies...") + + # Core netfox internals used by all netfox modules + _load_safe("res://addons/netfox.internals/logger.gd") # NetfoxLogger + + # Time system — used by NetworkTime autoload + _load_safe("res://addons/netfox/time/network-clocks.gd") # NetworkClocks + _load_safe("res://addons/netfox/time/network-tickrate-handshake.gd") # NetworkTickrateHandshake + _load_safe("res://addons/netfox/network-time-synchronizer.gd") # NetworkTimeSynchronizer + + # Noray system — used by Noray autoload + _load_safe("res://addons/netfox.noray/protocol-handler.gd") # NorayProtocolHandler + + # Rollback internals — used by NetworkRollback + _load_safe("res://addons/netfox.internals/ring-buffer.gd") # _RingBuffer + _load_safe("res://addons/netfox.internals/history-buffer.gd") # _HistoryBuffer + _load_safe("res://addons/netfox.internals/bimap.gd") # _BiMap + _load_safe("res://addons/netfox.internals/set.gd") # _Set + _load_safe("res://addons/netfox/properties/property-cache.gd") # PropertyCache + _load_safe("res://addons/netfox/properties/property-entry.gd") # PropertyEntry + _load_safe("res://addons/netfox/properties/property-config.gd") # _PropertyConfig + _load_safe("res://addons/netfox/properties/property-snapshot.gd") # _PropertySnapshot + _load_safe("res://addons/netfox/rollback/rollback-synchronizer.gd") # RollbackSynchronizer + _load_safe("res://addons/netfox/rewindable-action.gd") # RewindableAction + _load_safe("res://addons/netfox/tick-interpolator.gd") # TickInterpolator + _load_safe("res://addons/netfox.extras/base-net-input.gd") # BaseNetInput + + # Weapon system (for multiplayer-fps) + _load_safe("res://addons/netfox.extras/weapon/network-weapon.gd") # NetworkWeapon + _load_safe("res://addons/netfox.extras/weapon/network-weapon-hitscan-3d.gd") # NetworkWeaponHitscan3D + _load_safe("res://addons/netfox.extras/weapon/network-weapon-proxy.gd") # _NetworkWeaponProxy + + # State synchronizer — used for non-rollback state sync + _load_safe("res://addons/netfox/state-synchronizer.gd") # StateSynchronizer + + # Schema system — used behind the scenes + _load_safe("res://addons/netfox/schemas/network-schema.gd") # _NetworkSchema + _load_safe("res://addons/netfox/schemas/network-schemas.gd") # NetworkSchemas + _load_safe("res://addons/netfox/schemas/network-schema-serializer.gd") # NetworkSchemaSerializer + + # Serializers — needed internally + _load_safe("res://addons/netfox/serializers/base-snapshot-serializer.gd") # _BaseSnapshotSerializer + _load_safe("res://addons/netfox/serializers/tickset-serializer.gd") # _TicksetSerializer + + # Servers — some needed for NetworkRollback internals + _load_safe("res://addons/netfox/servers/data/snapshot.gd") # _Snapshot + _load_safe("res://addons/netfox/servers/data/network-identifier.gd") # _NetworkIdentifier + _load_safe("res://addons/netfox/servers/data/network-identity-reference.gd") # _NetworkIdentityReference + _load_safe("res://addons/netfox/servers/data/object-snapshot.gd") # _ObjectSnapshot + _load_safe("res://addons/netfox/servers/data/per-object-history.gd") # _PerObjectHistory + _load_safe("res://addons/netfox/servers/data/property-pool.gd") # _PropertyPool + + print("[NetfoxHeadlessBootstrap] ✓ All netfox dependencies preloaded") + +## Load a script and catch errors gracefully. +func _load_safe(path: String) -> void: + var script = load(path) + if script == null: + push_warning("[NetfoxHeadlessBootstrap] Failed to load: %s" % path) diff --git a/scripts/rcon/rcon_command_handler.gd b/scripts/rcon/rcon_command_handler.gd new file mode 100644 index 0000000..4ea4b5f --- /dev/null +++ b/scripts/rcon/rcon_command_handler.gd @@ -0,0 +1,403 @@ +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 Broadcast chat to all players", + " msg Send private message", + " changelevel Load a new map", + " start_match Start the match (exit warmup)", + " end_round [reason] Force-end the current round", + " kick [reason] Remove a player", + " ban [duration] Ban a player from the server", + " unban Remove a ban", + " exec Execute a server config file", + " 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" + +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 \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 \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 \r\n" + return _cmd_plugins_load(args[1]) + "unload": + if args.size() < 2: + return "Usage: plugins unload \r\n" + return _cmd_plugins_unload(args[1]) + "reload": + if args.size() < 2: + return "Usage: plugins reload \r\n" + return _cmd_plugins_reload(args[1]) + "info": + if args.size() < 2: + return "Usage: plugins info \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 Load a plugin by name from disk", + " plugins unload Unload a running plugin", + " plugins reload Reload a plugin (hotswap)", + " plugins info 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 diff --git a/scripts/rcon/rcon_server.gd b/scripts/rcon/rcon_server.gd new file mode 100644 index 0000000..030fd65 --- /dev/null +++ b/scripts/rcon/rcon_server.gd @@ -0,0 +1,325 @@ +extends Node + +# RCON Admin Console — TCP Listener & Auth Layer +# ============================================================================ +# TCP-based remote administration for the dedicated game server. +# Source-RCON-inspired protocol, lightweight custom implementation. +# +# Protocol: +# 1. Client connects, first message is the password +# 2. Server responds with "auth_ok\r\n" or "auth_fail\r\n" +# 3. After auth, client sends single-line commands, newline-terminated +# 4. Server responds with multi-line text, terminated by "END\r\n" +# 5. 3 failed auth attempts → disconnect with 5s reconnect penalty +# +# Integration: +# This is the network layer only. Auth and raw I/O live here. +# Incoming commands are forwarded to RconCommandHandler for processing. +# Connect to child node's `rcon_command` signal from game logic +# to handle game-affecting commands (changelevel, kick, ban, etc.). +# +# Usage (standalone): +# var rcon = RconServer.new() +# rcon.enabled = true +# rcon.port = 28960 +# rcon.password = "mysecret" +# add_child(rcon) +# +# Usage (with cvar registry — t_p4_config): +# # Config system reads server.cfg and sets RCON cvars via CvarRegistry +# # singleton. RconServer auto-detects and reads rcon_enabled, rcon_port, +# # and rcon_password from it on _ready(). +# +# Lifecycle (property-driven): +# rcon.enabled = true → calls _start_listening() via setter +# rcon.enabled = false → calls _stop_listening() via setter +# rcon.port = 28961 → re-binds on new port if currently listening + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +var enabled: bool = false: + set = set_enabled +var port: int = 28960: + set = set_port +var password: String = "changeme": + set = set_password + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +const AUTH_PENDING: int = 0 +const AUTHENTICATED: int = 1 +const MAX_AUTH_FAILS: int = 3 +const AUTH_PENALTY_SECONDS: float = 5.0 +const RESP_END: String = "END\r\n" +const MAX_BUFFER_SIZE: int = 4096 +const HEARTBEAT_INTERVAL: float = 30.0 + +# --------------------------------------------------------------------------- +# Internal state +# --------------------------------------------------------------------------- +var _tcp_server: TCPServer = TCPServer.new() +var _connections: Dictionary = {} # StreamPeerTCP -> ConnectionState +var _banned_ips: Dictionary = {} # "ip" -> float (unban timestamp) +var _command_handler: Node = null +var _heartbeat_timer: float = 0.0 + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- +func _ready() -> void: + # Try to load password from file + _load_password_from_file() + + # Children added in scene file take priority + for child in get_children(): + if child is Node and child.has_method("handle_command"): + _command_handler = child + break + + # Auto-load command handler if not already added as child + if not _command_handler: + var handler_path = "res://server/scripts/rcon_command_handler.gd" + if ResourceLoader.exists(handler_path): + var handler = load(handler_path).new() + add_child(handler) + _command_handler = handler + + # Apply overrides from CvarRegistry singleton (t_p4_config integration) + _apply_cvar_overrides() + + if enabled: + _start_listening() + +func _exit_tree() -> void: + _stop_listening() + +# --------------------------------------------------------------------------- +# Process loop — poll network, accept connections, handle I/O +# --------------------------------------------------------------------------- +func _process(delta: float) -> void: + if not enabled or not _tcp_server or not _tcp_server.is_listening(): + return + + _heartbeat_timer += delta + + # --- Accept new connections --- + while _tcp_server.is_connection_available(): + var stream: StreamPeerTCP = _tcp_server.take_connection() + if not stream: + continue + + var ip: String = stream.get_connected_address() + + # Check if IP is under auth penalty + if _is_ip_banned(ip): + _send_raw(stream, "Server busy — try again later.\r\n") + _send_end(stream) + stream.disconnect_from_host() + continue + + _connections[stream] = { + "state": AUTH_PENDING, + "buffer": "", + "fail_count": 0, + "ip": ip + } + + # --- Poll existing connections --- + var to_remove: Array = [] + for stream in _connections: + var conn = _connections[stream] + var status: int = stream.get_status() + + if status == StreamPeerTCP.STATUS_NONE or status == StreamPeerTCP.STATUS_ERROR: + to_remove.append(stream) + continue + + if status != StreamPeerTCP.STATUS_CONNECTED: + continue + + # Read available data + var avail: int = stream.get_available_bytes() + if avail <= 0: + continue + + var result: Array = stream.get_data(avail) + if result[0] != OK: + to_remove.append(stream) + continue + + conn["buffer"] += result[1].get_string_from_utf8() + + # Buffer overflow protection + if conn["buffer"].length() > MAX_BUFFER_SIZE: + _send_raw(stream, "Buffer overflow — disconnected.\r\n") + _send_end(stream) + to_remove.append(stream) + continue + + # Process complete lines + while "\n" in conn["buffer"]: + var idx: int = conn["buffer"].find("\n") + var line: String = conn["buffer"].substr(0, idx).strip_edges() + conn["buffer"] = conn["buffer"].substr(idx + 1) + + if line.is_empty(): + continue + + if conn["state"] == AUTH_PENDING: + _handle_auth(stream, conn, line) + else: + _handle_command(stream, line) + + # Cleanup disconnected clients + for stream in to_remove: + _cleanup_connection(stream) + +# --------------------------------------------------------------------------- +# Auth handling +# --------------------------------------------------------------------------- +func _handle_auth(stream: StreamPeerTCP, conn: Dictionary, line: String) -> void: + if line == password: + conn["state"] = AUTHENTICATED + _send_raw(stream, "auth_ok\r\n") + _send_end(stream) + else: + conn["fail_count"] += 1 + var remaining: int = MAX_AUTH_FAILS - conn["fail_count"] + _send_raw(stream, "auth_fail\r\n") + if remaining > 0: + _send_raw(stream, str(remaining) + " attempt(s) remaining.\r\n") + _send_end(stream) + + if conn["fail_count"] >= MAX_AUTH_FAILS: + # Ban IP temporarily + var ip: String = conn["ip"] + _banned_ips[ip] = Time.get_unix_time_from_system() + AUTH_PENALTY_SECONDS + stream.disconnect_from_host() + print("[RCON] Auth failed 3 times from " + ip + " — penalty for " + str(AUTH_PENALTY_SECONDS) + "s") + +# --------------------------------------------------------------------------- +# Command dispatch +# --------------------------------------------------------------------------- +func _handle_command(stream: StreamPeerTCP, line: String) -> void: + var parts: PackedStringArray = line.split(" ", false) + if parts.is_empty(): + return + + var cmd: String = parts[0].to_lower() + var args: PackedStringArray = parts.slice(1) + + # Route to command handler + if _command_handler and _command_handler.has_method("handle_command"): + var response: String = _command_handler.handle_command(cmd, args) + _send_raw(stream, response) + _send_end(stream) + else: + _send_raw(stream, "Command handler not available.\r\n") + _send_end(stream) + +# --------------------------------------------------------------------------- +# Response helpers +# --------------------------------------------------------------------------- +func _send_raw(stream: StreamPeerTCP, text: String) -> void: + var data: PackedByteArray = text.to_utf8_buffer() + stream.put_data(data) + +func _send_end(stream: StreamPeerTCP) -> void: + _send_raw(stream, RESP_END) + +# --------------------------------------------------------------------------- +# IP ban / penalty +# --------------------------------------------------------------------------- +func _is_ip_banned(ip: String) -> bool: + if not _banned_ips.has(ip): + return false + if Time.get_unix_time_from_system() >= _banned_ips[ip]: + _banned_ips.erase(ip) + return false + return true + +func _cleanup_connection(stream: StreamPeerTCP) -> void: + if _connections.has(stream): + _connections.erase(stream) + if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED: + stream.disconnect_from_host() + +# --------------------------------------------------------------------------- +# Server lifecycle — called from setters, _ready(), and external code +# --------------------------------------------------------------------------- +func _start_listening() -> void: + if _tcp_server and _tcp_server.is_listening(): + return + + if not _tcp_server: + _tcp_server = TCPServer.new() + + var err: int = _tcp_server.listen(port) + if err != OK: + push_error("[RCON] Failed to listen on port " + str(port) + " (error " + str(err) + ")") + enabled = false + return + + print("[RCON] Listening on port " + str(port)) + +func _stop_listening() -> void: + if _tcp_server and _tcp_server.is_listening(): + _tcp_server.stop() + + # Disconnect all clients gracefully + for stream in _connections: + if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED: + _send_raw(stream, "Server shutting down.\r\n") + _send_end(stream) + stream.disconnect_from_host() + _connections.clear() + + print("[RCON] Stopped") + +# --------------------------------------------------------------------------- +# Config file loading +# --------------------------------------------------------------------------- +func _load_password_from_file() -> void: + var pw_path: String = "res://config/rcon_password.cfg" + if not ResourceLoader.exists(pw_path): + return + + var file: FileAccess = FileAccess.open(pw_path, FileAccess.READ) + if not file: + return + + var line: String = file.get_line().strip_edges() + if not line.is_empty() and not line.begins_with("#"): + password = line + print("[RCON] Password loaded from " + pw_path) + file.close() + +# --------------------------------------------------------------------------- +# Config overrides from cvar registry (if available) +# --------------------------------------------------------------------------- +func _apply_cvar_overrides() -> void: + # CvarRegistry not available in this project version + pass + +# --------------------------------------------------------------------------- +# Setters — react to property changes +# --------------------------------------------------------------------------- +func set_enabled(val: bool) -> void: + enabled = val + if Engine.is_editor_hint(): + return + if enabled: + _start_listening() + else: + _stop_listening() + +func set_port(val: int) -> void: + port = clampi(val, 1024, 65535) + if enabled and _tcp_server and _tcp_server.is_listening(): + _stop_listening() + _start_listening() + +func set_password(val: String) -> void: + password = val