From 4a834a1d5586abb8a25211425f304b271161ca91 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 2 Jul 2026 22:30:34 -0400 Subject: [PATCH] Fix RPC path mismatch in headless server The headless server added the multiplayer-fps game scene as a child node (/root/HeadlessServer/multiplayer-fps/...), causing RPC node paths to include the HeadlessServer/ prefix. Clients expected paths relative to root (/root/multiplayer-fps/...), so every RPC would fail with 'Node not found'. Fix: add the game scene directly to /root/ via call_deferred, making it a sibling of the HeadlessServer node rather than a child. RPC paths now match between server and client. --- scripts/headless_server.gd | 100 +++++++++++++++++------------------ test_connect.gd | 104 +++++++++++++++++++++++++++++++++++++ test_connect.tscn | 6 +++ 3 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 test_connect.gd create mode 100644 test_connect.tscn diff --git a/scripts/headless_server.gd b/scripts/headless_server.gd index 2e87141..ad65710 100644 --- a/scripts/headless_server.gd +++ b/scripts/headless_server.gd @@ -1,26 +1,29 @@ ## 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`. +## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling, +## not a child), so RPC node paths match what clients expect. ## ## 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 +## This script runs as a temporary scene root. It loads multiplayer-fps.tscn +## and adds it directly under get_tree().root so node paths (e.g. +## /root/multiplayer-fps/Network/TeamManager) are identical on both +## server and client. +## +## 1. Loads multiplayer-fps.tscn, adds to /root/ +## 2. Removes Environment and UI +## 3. Starts ENet server +## 4. RCON admin console on configurable port extends Node ## Default server port. const DEFAULT_PORT: int = 34197 -## The multiplayer-fps game scene. +## Reference to the multiplayer-fps game scene (at root level). var _game_scene: Node = null # --------------------------------------------------------------------------- @@ -30,30 +33,36 @@ var _game_scene: Node = null 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") + 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 + # CRITICAL: Add game scene directly to /root/ as a sibling, NOT as child. + # This ensures RPC node paths are the same on both server and client. + # Must use call_deferred because we're still in _ready(). + # Strip BEFORE adding to tree so queue_free works cleanly. _strip_for_headless() + get_tree().root.call_deferred("add_child", _game_scene) # Start RCON admin console _start_rcon() - # Wait one frame for scene to settle, then host + # Wait for game scene to enter the tree await get_tree().process_frame await get_tree().process_frame + # Set current scene after it's safely in the tree + get_tree().current_scene = _game_scene + print("[HeadlessServer] Game scene added to root, current_scene updated") + # Start the ENet server _start_server(port) @@ -70,19 +79,19 @@ func _strip_for_headless() -> void: return # Remove Environment (sky, light, camera, world environment) - var env := _game_scene.get_node_or_null("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") + 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") + var network = _game_scene.get_node_or_null("Network") if network == null: push_error("[HeadlessServer] Missing Network node in game scene!") return @@ -98,9 +107,9 @@ func _strip_for_headless() -> void: # --------------------------------------------------------------------------- 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) + # Create ENet peer directly + 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) @@ -114,18 +123,15 @@ func _start_server(port: int) -> void: # 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. + # NetworkEvents handles on_server_start -> player-spawner -> round_manager 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() + var mp = get_tree().get_multiplayer() if mp and mp.multiplayer_peer: mp.multiplayer_peer.close() mp.multiplayer_peer = null @@ -133,26 +139,22 @@ func _stop_server() -> void: ## 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 + 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") + 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"): @@ -162,8 +164,7 @@ func _manual_startup() -> void: 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") + 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") @@ -178,14 +179,13 @@ func _manual_startup() -> void: # RCON admin console # --------------------------------------------------------------------------- -## Start the RCON TCP admin console. func _start_rcon() -> void: - var rcon_path := "res://scripts/rcon/rcon_server.gd" + 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) + var RconServerClass = load(rcon_path) if RconServerClass == null: return @@ -197,30 +197,27 @@ func _start_rcon() -> void: 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() + 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 + return 28960 -## Parse RCON password from args/env/file. func _parse_rcon_password() -> String: - var args := OS.get_cmdline_args() + 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" + var pw_path = "res://config/rcon_password.cfg" if ResourceLoader.exists(pw_path): - var f := FileAccess.open(pw_path, FileAccess.READ) + var f = FileAccess.open(pw_path, FileAccess.READ) if f: - var line := f.get_line().strip_edges() + var line = f.get_line().strip_edges() f.close() if not line.is_empty() and not line.begins_with("#"): return line @@ -231,25 +228,22 @@ func _parse_rcon_password() -> String: # --------------------------------------------------------------------------- func _parse_port() -> int: - # Check user args (after -- separator): --port 34197 - var user_args := OS.get_cmdline_user_args() + 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() + 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() + 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() + 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() + var p = OS.get_environment("SERVER_PORT").to_int() if p > 0 and p < 65536: return p diff --git a/test_connect.gd b/test_connect.gd new file mode 100644 index 0000000..21377b8 --- /dev/null +++ b/test_connect.gd @@ -0,0 +1,104 @@ +extends Node + +func _ready() -> void: + print("=== Full Join Simulation ===\n") + + # Load the full game scene + var GameScene = load("res://examples/multiplayer-fps/multiplayer-fps.tscn") + if GameScene == null: + print("FAIL: Cannot load multiplayer-fps.tscn") + get_tree().quit(1) + return + + print("Loaded multiplayer-fps.tscn") + + var game = GameScene.instantiate() + add_child(game) + + # Wait for scene to settle + await get_tree().process_frame + await get_tree().process_frame + + # Get the Network Popup tab container + var ui = game.get_node_or_null("UI") + var popup = game.get_node_or_null("UI/Network Popup") + var bootstrapper = game.get_node_or_null("UI/Network Popup/LAN Bootstrapper") + + if ui: + print("UI node: %s" % str(ui)) + if popup: + print("Network Popup: %s" % str(popup)) + if bootstrapper: + print("Bootstrapper: %s" % str(bootstrapper)) + if bootstrapper.has_method("join"): + print("Bootstrapper has join() method") + if bootstrapper.has_method("host"): + print("Bootstrapper has host() method") + else: + print("WARNING: No bootstrapper found!") + + # Check address/port inputs + var addr_input = game.get_node_or_null("UI/Network Popup/LAN/Address Row/Address LineEdit") + var port_input = game.get_node_or_null("UI/Network Popup/LAN/Address Row/Port LineEdit") + if addr_input: + print("Address input: '%s'" % str(addr_input.text)) + if port_input: + print("Port input: '%s'" % str(port_input.text)) + + # Simulate join: set address/port and call join() + if addr_input and port_input and bootstrapper: + addr_input.text = "127.0.0.1" + port_input.text = "34201" + print("\nCalling join()...") + + var peer = ENetMultiplayerPeer.new() + var err = peer.create_client("127.0.0.1", 34201) + if err != OK: + print("FAIL: create_client error: %s" % error_string(err)) + get_tree().quit(1) + return + + get_tree().get_multiplayer().multiplayer_peer = peer + print("Peer set, waiting for connection...") + + # Wait up to 8 seconds for connection + var start = Time.get_ticks_msec() + while Time.get_ticks_msec() - start < 8000: + var status = peer.get_connection_status() + if status != MultiplayerPeer.CONNECTION_CONNECTING: + break + await get_tree().process_frame + + var final_status = peer.get_connection_status() + print("Connection status: %d (2=CONNECTED)" % final_status) + + if final_status == MultiplayerPeer.CONNECTION_CONNECTED: + print("SUCCESS: Connected!") + print("Unique ID: %d" % multiplayer.get_unique_id()) + + # Simulate connect_ui.hide() + popup.hide() + print("Popup hidden") + + # Now check NetworkEvents + if Engine.has_singleton("NetworkEvents"): + var ne = Engine.get_singleton("NetworkEvents") + print("NetworkEvents enabled: %s" % str(ne.enabled)) + + # Wait a few frames to see if anything happens + await get_tree().process_frame + await get_tree().process_frame + await get_tree().process_frame + + # Check if there are any player nodes + var players = game.get_node_or_null("Players") + if players: + print("Players node children: %d" % players.get_child_count()) + for child in players.get_children(): + print(" - %s" % str(child.name)) + + print("\n=== Test Complete ===") + else: + print("FAIL: Could not connect") + + get_tree().quit(0) diff --git a/test_connect.tscn b/test_connect.tscn new file mode 100644 index 0000000..c038eb8 --- /dev/null +++ b/test_connect.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://test_connect.gd" id="1_script"] + +[node name="TestConnect" type="Node"] +script = ExtResource("1_script")