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.
This commit is contained in:
+47
-53
@@ -1,26 +1,29 @@
|
|||||||
## HeadlessServer — Dedicated server entry point for headless mode.
|
## HeadlessServer — Dedicated server entry point for headless mode.
|
||||||
##
|
##
|
||||||
## Loads the multiplayer-fps game scene, strips UI/rendering overhead,
|
## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling,
|
||||||
## and auto-starts the ENet server. Designed to run with `--headless`.
|
## not a child), so RPC node paths match what clients expect.
|
||||||
##
|
##
|
||||||
## Usage:
|
## Usage:
|
||||||
## godot --headless --scene res://scenes/headless_server.tscn
|
## godot --headless --scene res://scenes/headless_server.tscn
|
||||||
## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197
|
## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197
|
||||||
##
|
##
|
||||||
## Architecture:
|
## Architecture:
|
||||||
## This script runs as the main scene root in headless mode.
|
## This script runs as a temporary scene root. It loads multiplayer-fps.tscn
|
||||||
## 1. Loads multiplayer-fps.tscn (the full game scene)
|
## and adds it directly under get_tree().root so node paths (e.g.
|
||||||
## 2. Removes Environment (sky/light/camera) and UI (HUD/network popup)
|
## /root/multiplayer-fps/Network/TeamManager) are identical on both
|
||||||
## 3. Starts the ENet server on the configured port
|
## server and client.
|
||||||
## 4. Calls RoundManager.start_match() to begin gameplay
|
##
|
||||||
## 5. Listens for player connections via NetworkEvents
|
## 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
|
extends Node
|
||||||
|
|
||||||
## Default server port.
|
## Default server port.
|
||||||
const DEFAULT_PORT: int = 34197
|
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
|
var _game_scene: Node = null
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -30,30 +33,36 @@ var _game_scene: Node = null
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
print("[HeadlessServer] Starting dedicated server...")
|
print("[HeadlessServer] Starting dedicated server...")
|
||||||
|
|
||||||
# Determine port from command-line args or default
|
|
||||||
var port: int = _parse_port()
|
var port: int = _parse_port()
|
||||||
print("[HeadlessServer] Port: %d" % port)
|
print("[HeadlessServer] Port: %d" % port)
|
||||||
|
|
||||||
# Load the multiplayer-fps game scene
|
# 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:
|
if GameScene == null:
|
||||||
push_error("[HeadlessServer] Failed to load multiplayer-fps.tscn")
|
push_error("[HeadlessServer] Failed to load multiplayer-fps.tscn")
|
||||||
get_tree().quit(1)
|
get_tree().quit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
_game_scene = GameScene.instantiate()
|
_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()
|
_strip_for_headless()
|
||||||
|
get_tree().root.call_deferred("add_child", _game_scene)
|
||||||
|
|
||||||
# Start RCON admin console
|
# Start RCON admin console
|
||||||
_start_rcon()
|
_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
|
||||||
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 the ENet server
|
||||||
_start_server(port)
|
_start_server(port)
|
||||||
|
|
||||||
@@ -70,19 +79,19 @@ func _strip_for_headless() -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Remove Environment (sky, light, camera, world environment)
|
# 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:
|
if env:
|
||||||
env.queue_free()
|
env.queue_free()
|
||||||
print("[HeadlessServer] Removed Environment node")
|
print("[HeadlessServer] Removed Environment node")
|
||||||
|
|
||||||
# Remove UI (network popup, HUD, crosshair, etc.)
|
# 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:
|
if ui:
|
||||||
ui.queue_free()
|
ui.queue_free()
|
||||||
print("[HeadlessServer] Removed UI node")
|
print("[HeadlessServer] Removed UI node")
|
||||||
|
|
||||||
# Verify critical game logic nodes exist
|
# 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:
|
if network == null:
|
||||||
push_error("[HeadlessServer] Missing Network node in game scene!")
|
push_error("[HeadlessServer] Missing Network node in game scene!")
|
||||||
return
|
return
|
||||||
@@ -98,9 +107,9 @@ func _strip_for_headless() -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _start_server(port: int) -> void:
|
func _start_server(port: int) -> void:
|
||||||
# Create ENet peer directly (same as lan-bootstrapper.gd::host())
|
# Create ENet peer directly
|
||||||
var peer := ENetMultiplayerPeer.new()
|
var peer = ENetMultiplayerPeer.new()
|
||||||
var err := peer.create_server(port)
|
var err = peer.create_server(port)
|
||||||
if err != OK:
|
if err != OK:
|
||||||
push_error("[HeadlessServer] Failed to create ENet server on port %d: %s" % [port, error_string(err)])
|
push_error("[HeadlessServer] Failed to create ENet server on port %d: %s" % [port, error_string(err)])
|
||||||
get_tree().quit(1)
|
get_tree().quit(1)
|
||||||
@@ -114,18 +123,15 @@ func _start_server(port: int) -> void:
|
|||||||
# Wait for NetworkEvents to fire on_server_start
|
# Wait for NetworkEvents to fire on_server_start
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
|
|
||||||
# The player-spawner.gd handles on_server_start by spawning the host avatar
|
# NetworkEvents handles on_server_start -> player-spawner -> round_manager
|
||||||
# and calling round_manager.start_match(). If NetworkEvents fires it, we're set.
|
|
||||||
# Otherwise, manually start the match.
|
|
||||||
if NetworkEvents and NetworkEvents.enabled:
|
if NetworkEvents and NetworkEvents.enabled:
|
||||||
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
|
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
|
||||||
else:
|
else:
|
||||||
# Fallback: manually start NetworkTime and signal server start
|
|
||||||
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
|
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
|
||||||
_manual_startup()
|
_manual_startup()
|
||||||
|
|
||||||
func _stop_server() -> void:
|
func _stop_server() -> void:
|
||||||
var mp := get_tree().get_multiplayer()
|
var mp = get_tree().get_multiplayer()
|
||||||
if mp and mp.multiplayer_peer:
|
if mp and mp.multiplayer_peer:
|
||||||
mp.multiplayer_peer.close()
|
mp.multiplayer_peer.close()
|
||||||
mp.multiplayer_peer = null
|
mp.multiplayer_peer = null
|
||||||
@@ -133,26 +139,22 @@ func _stop_server() -> void:
|
|||||||
|
|
||||||
## Fallback startup when NetworkEvents isn't available.
|
## Fallback startup when NetworkEvents isn't available.
|
||||||
func _manual_startup() -> void:
|
func _manual_startup() -> void:
|
||||||
# Start NetworkTime if available
|
|
||||||
if Engine.has_singleton("NetworkTime"):
|
if Engine.has_singleton("NetworkTime"):
|
||||||
var nt = Engine.get_singleton("NetworkTime")
|
var nt = Engine.get_singleton("NetworkTime")
|
||||||
if nt and nt.has_method("start"):
|
if nt and nt.has_method("start"):
|
||||||
nt.start()
|
nt.start()
|
||||||
print("[HeadlessServer] NetworkTime started manually")
|
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:
|
if network == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
var spawner := network.get_node_or_null("Player Spawner")
|
var spawner = network.get_node_or_null("Player Spawner")
|
||||||
if spawner:
|
if spawner:
|
||||||
# Manually call the host handler that spawns the host avatar
|
|
||||||
if spawner.has_method("_handle_host"):
|
if spawner.has_method("_handle_host"):
|
||||||
spawner._handle_host()
|
spawner._handle_host()
|
||||||
print("[HeadlessServer] PlayerSpawner._handle_host() called")
|
print("[HeadlessServer] PlayerSpawner._handle_host() called")
|
||||||
|
|
||||||
# Manually emit peer join for host (peer 1)
|
|
||||||
if Engine.has_singleton("NetworkEvents"):
|
if Engine.has_singleton("NetworkEvents"):
|
||||||
var ne = Engine.get_singleton("NetworkEvents")
|
var ne = Engine.get_singleton("NetworkEvents")
|
||||||
if ne and ne.has_signal("on_server_start"):
|
if ne and ne.has_signal("on_server_start"):
|
||||||
@@ -162,8 +164,7 @@ func _manual_startup() -> void:
|
|||||||
ne.on_peer_join.emit(1)
|
ne.on_peer_join.emit(1)
|
||||||
print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(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:
|
||||||
if round_mgr.has_method("start_match"):
|
if round_mgr.has_method("start_match"):
|
||||||
round_mgr.call_deferred("start_match")
|
round_mgr.call_deferred("start_match")
|
||||||
@@ -178,14 +179,13 @@ func _manual_startup() -> void:
|
|||||||
# RCON admin console
|
# RCON admin console
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Start the RCON TCP admin console.
|
|
||||||
func _start_rcon() -> void:
|
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):
|
if not ResourceLoader.exists(rcon_path):
|
||||||
print("[HeadlessServer] RCON script not found — skipping")
|
print("[HeadlessServer] RCON script not found — skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
var RconServerClass := load(rcon_path)
|
var RconServerClass = load(rcon_path)
|
||||||
if RconServerClass == null:
|
if RconServerClass == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -197,30 +197,27 @@ func _start_rcon() -> void:
|
|||||||
add_child(rcon)
|
add_child(rcon)
|
||||||
print("[HeadlessServer] RCON admin console started on port %d" % rcon.port)
|
print("[HeadlessServer] RCON admin console started on port %d" % rcon.port)
|
||||||
|
|
||||||
## Parse RCON port from args/env.
|
|
||||||
func _parse_rcon_port() -> int:
|
func _parse_rcon_port() -> int:
|
||||||
var args := OS.get_cmdline_args()
|
var args = OS.get_cmdline_args()
|
||||||
for i in range(args.size()):
|
for i in range(args.size()):
|
||||||
if args[i] == "--rcon-port" and i + 1 < args.size():
|
if args[i] == "--rcon-port" and i + 1 < args.size():
|
||||||
return clampi(args[i + 1].to_int(), 1024, 65535)
|
return clampi(args[i + 1].to_int(), 1024, 65535)
|
||||||
if OS.has_environment("RCON_PORT"):
|
if OS.has_environment("RCON_PORT"):
|
||||||
return clampi(OS.get_environment("RCON_PORT").to_int(), 1024, 65535)
|
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:
|
func _parse_rcon_password() -> String:
|
||||||
var args := OS.get_cmdline_args()
|
var args = OS.get_cmdline_args()
|
||||||
for i in range(args.size()):
|
for i in range(args.size()):
|
||||||
if args[i] == "--rcon-password" and i + 1 < args.size():
|
if args[i] == "--rcon-password" and i + 1 < args.size():
|
||||||
return args[i + 1]
|
return args[i + 1]
|
||||||
if OS.has_environment("RCON_PASSWORD"):
|
if OS.has_environment("RCON_PASSWORD"):
|
||||||
return OS.get_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):
|
if ResourceLoader.exists(pw_path):
|
||||||
var f := FileAccess.open(pw_path, FileAccess.READ)
|
var f = FileAccess.open(pw_path, FileAccess.READ)
|
||||||
if f:
|
if f:
|
||||||
var line := f.get_line().strip_edges()
|
var line = f.get_line().strip_edges()
|
||||||
f.close()
|
f.close()
|
||||||
if not line.is_empty() and not line.begins_with("#"):
|
if not line.is_empty() and not line.begins_with("#"):
|
||||||
return line
|
return line
|
||||||
@@ -231,25 +228,22 @@ func _parse_rcon_password() -> String:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _parse_port() -> int:
|
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()):
|
for i in range(user_args.size()):
|
||||||
if user_args[i] == "--port" and i + 1 < 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:
|
if p > 0 and p < 65536:
|
||||||
return p
|
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()):
|
for i in range(args.size()):
|
||||||
if args[i] == "--port" and i + 1 < 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:
|
if p > 0 and p < 65536:
|
||||||
return p
|
return p
|
||||||
|
|
||||||
# Check environment variable
|
|
||||||
if OS.has_environment("SERVER_PORT"):
|
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:
|
if p > 0 and p < 65536:
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|||||||
+104
@@ -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)
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user