239 lines
8.5 KiB
GDScript
239 lines
8.5 KiB
GDScript
## HeadlessServer — Dedicated server entry point for headless mode.
|
|
##
|
|
## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling,
|
|
## not a child), so RPC node paths match what clients expect.
|
|
##
|
|
## Architecture:
|
|
## 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.
|
|
##
|
|
## Critical: NetworkEvents.on_server_start is DISCONNECTED from
|
|
## NetworkTime.start(). The netfox tick loop only begins AFTER the first
|
|
## real client connects (on_peer_join). This ensures the rollback system
|
|
## always has at least one player subject before ticking starts, preventing
|
|
## PropertyPool/PerObjectHistory typed array corruption (Godot 4.7).
|
|
|
|
extends Node
|
|
|
|
## Default server port.
|
|
const DEFAULT_PORT: int = 34197
|
|
|
|
## Reference to the multiplayer-fps game scene (at root level).
|
|
var _game_scene: Node = null
|
|
|
|
## Whether a client has connected (deferred tick start).
|
|
var _first_client_connected: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
print("[HeadlessServer] Starting dedicated server...")
|
|
|
|
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()
|
|
|
|
# Disconnect NetworkEvents.on_server_start from NetworkTime.start.
|
|
# The netfox tick loop must NOT start until the first client connects,
|
|
# so the rollback system has at least one player subject.
|
|
if NetworkEvents and NetworkEvents.on_server_start.is_connected(NetworkTime.start):
|
|
NetworkEvents.on_server_start.disconnect(NetworkTime.start)
|
|
print("[HeadlessServer] Disconnected NetworkTime.start from on_server_start")
|
|
|
|
# Keep full scene tree — no stripping. Environment/UI nodes are harmless
|
|
# in headless mode. Keeping the tree identical to the client ensures
|
|
# NetworkIdentityServer produces matching identity references.
|
|
_verify_scene()
|
|
get_tree().root.call_deferred("add_child", _game_scene)
|
|
|
|
# Start RCON admin console
|
|
_start_rcon()
|
|
|
|
# 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")
|
|
|
|
# Set dedicated server flag BEFORE starting ENet, so _handle_host()
|
|
# runs in the correct mode.
|
|
var spawner = _game_scene.get_node_or_null("Network/Player Spawner")
|
|
if spawner and spawner.has_method("set_dedicated_server"):
|
|
spawner.set_dedicated_server()
|
|
print("[HeadlessServer] PlayerSpawner set to dedicated server mode")
|
|
|
|
# Connect first-client handler BEFORE starting the server, so we
|
|
# don't miss the on_peer_join event for the very first client.
|
|
NetworkEvents.on_peer_join.connect(_on_first_peer_join)
|
|
|
|
# Start the ENet server
|
|
_start_server(port)
|
|
|
|
func _exit_tree() -> void:
|
|
_stop_server()
|
|
|
|
func _verify_scene() -> void:
|
|
if _game_scene == null:
|
|
return
|
|
|
|
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 tree verified, keeping full tree for identity sync")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# First peer handling — deferred tick and match start
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_first_peer_join(_peer_id: int) -> void:
|
|
if _first_client_connected:
|
|
return
|
|
_first_client_connected = true
|
|
|
|
print("[HeadlessServer] First client connected — letting avatar spawn...")
|
|
|
|
# Wait TWO frames so PlayerSpawner._handle_new_peer() creates the avatar
|
|
# BEFORE NetworkTime starts. The tick loop needs at least one subject.
|
|
await get_tree().process_frame
|
|
await get_tree().process_frame
|
|
|
|
print("[HeadlessServer] Starting netfox tick loop with player subjects...")
|
|
|
|
# Start netfox tick loop now that we have at least one player
|
|
if NetworkTime and NetworkTime.has_method("start"):
|
|
var err = await NetworkTime.start()
|
|
if err != OK:
|
|
push_error("[HeadlessServer] NetworkTime.start() returned error: %d" % err)
|
|
|
|
# Start the match via the round manager
|
|
var network = _game_scene.get_node_or_null("Network") if _game_scene else null
|
|
if network:
|
|
var round_mgr = network.get_node_or_null("RoundManager")
|
|
if round_mgr and round_mgr.has_method("start_match"):
|
|
round_mgr.start_match()
|
|
print("[HeadlessServer] RoundManager.start_match() called on first join")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Server startup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _start_server(port: int) -> void:
|
|
# 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)
|
|
return
|
|
|
|
get_tree().get_multiplayer().multiplayer_peer = peer
|
|
get_tree().get_multiplayer().server_relay = true
|
|
|
|
print("[HeadlessServer] ENet server started on port %d" % port)
|
|
|
|
# Let NetworkEvents fire on_server_start (without starting NetworkTime)
|
|
await get_tree().process_frame
|
|
print("[HeadlessServer] NetworkEvents on_server_start should have fired (NetworkTime not started yet)")
|
|
# No host avatar — no RPC errors, no client-side duplicate
|
|
|
|
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")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RCON 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)
|
|
|
|
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
|
|
|
|
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")
|
|
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:
|
|
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
|
|
|
|
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
|
|
|
|
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
|