5b93c514c7
## Headless Server - New entry point: scenes/headless_server.tscn + scripts/headless_server.gd - Loads multiplayer-fps game, strips UI/rendering, auto-hosts ENet server - Port config via --port arg, SERVER_PORT env, or default 34197 - RCON admin console on port 28960 (configurable via --rcon-port) ## Netfox Headless Bootstrap - scripts/netfox_headless_bootstrap.gd — preloads all netfox dependency scripts in headless mode so class_names register before autoloads parse - Registered as first autoload in project.godot - Fixes 'Could not find type' errors for NetfoxLogger, NorayProtocolHandler, NetworkClocks, etc. ## RCON Admin - Ported from old project: scripts/rcon/rcon_server.gd (TCP auth layer) - scripts/rcon/rcon_command_handler.gd (command dispatch) - Password via config/rcon_password.cfg, --rcon-password, or RCON_PASSWORD env ## Other Changes - Added GameEvents stub (lan-bootstrapper dependency) - Updated project.godot features to 4.7 - Added config/server_config.cfg for operator editing - Updated RCON paths to new project structure
257 lines
8.6 KiB
GDScript
257 lines
8.6 KiB
GDScript
## 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
|