Files

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