Files
CosmicClash/Game/scripts/server_boot.gd
T
2026-09-01 21:04:55 +01:00

198 lines
9.1 KiB
GDScript

extends Node
const NetCodec = preload("res://scripts/net_codec.gd")
const ServerControlScript = preload("res://scripts/server_control.gd")
const AgonesSDKScript = preload("res://scripts/agones_sdk.gd")
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
# via NetworkManager, logs structured lines, and watches for physics-tick
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
# a tick overrunning 16.7ms backs up the accumulator and the next frame
# runs multiple ticks, spiking CPU further — worth logging, not just
# silently absorbing).
#
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
#
# Dedicated *exports* reach this scene without the CLI argument above, via the
# `run/main_scene.dedicated_server` feature override in project.godot — the same
# project-setting mechanism the training export uses for training.tscn. That
# override is deliberately uncommented in project.godot: Godot's ConfigFile
# writer does not round-trip comments, and a `#` block directly above a setting
# can be spliced into the setting's own line on rewrite, silently commenting it
# out and sending dedicated builds to the interactive main menu instead of here.
# `tests/cases/test_project_settings.gd` fails loudly if that ever happens.
#
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
# scene. This is just the process shell: listen, log, idle cheaply.
var _last_physics_frame := 0
var config: ServerConfig = null
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
var _control: ServerControl = null
var _agones = null
var _drain_requested := false
func _ready() -> void:
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
# Task 6.3. This process owns the whole command line, so it parses STRICTLY:
# an unknown flag or an out-of-range value stops the server with a message
# rather than starting one that silently ignores half of what it was told.
config = ServerConfig.parse(OS.get_cmdline_user_args())
if config.help_requested:
print(ServerConfig.help_text())
get_tree().quit(0)
return
if not config.is_valid():
# Straight to stderr-ish plain print rather than through _log: the log
# level itself may be one of the things that failed to parse, and an
# operator running this by hand needs to see every problem at once, not
# the first one.
printerr("cosmic-clash-server: refusing to start")
for problem in config.errors:
printerr(" %s" % problem)
printerr("try --help")
get_tree().quit(1)
return
ServerLog.configure(String(config.get_value("log-level")))
var port := int(config.get_value("port"))
var max_clients := int(config.get_value("max-clients"))
var allocated_mode := bool(config.get_value("allocated-mode"))
var assigned_transport := String(config.get_value("transport"))
# Hosted SDR is not wired into the Godot transport layer yet. Refuse the
# allocated launch rather than silently opening an ENet endpoint that does
# not match the signed assignment's transport contract.
if allocated_mode and assigned_transport != NetworkManager.TRANSPORT_ENET:
printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport)
get_tree().quit(1)
return
if allocated_mode:
var roster_file := String(config.get_value("join-authorisations-file"))
var key_file := String(config.get_value("join-authorisations-key-file"))
var roster_json := FileAccess.get_file_as_string(roster_file)
var signing_key := FileAccess.get_file_as_bytes(key_file)
var roster_tokens = JSON.parse_string(roster_json)
if not roster_tokens is Array or roster_tokens.is_empty() or signing_key.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
"match_id": String(config.get_value("match-id")),
"server_id": String(config.get_value("server-id")),
"protocol": str(NetCodec.PROTOCOL_VERSION),
"protocol_version": NetCodec.PROTOCOL_VERSION,
}, signing_key) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
get_tree().quit(1)
return
_control = ServerControlScript.new()
# An allocated process owns exactly the roster issued for this match.
# Never let the general-purpose direct-server default (one player) start
# an allocated match with only a partial assignment admitted.
config.values["min-players"] = required_min_players(true, roster_tokens.size(), int(config.get_value("min-players")))
_control.name = "ServerControl"
_control.drain_requested.connect(_on_drain_requested)
get_tree().root.add_child.call_deferred(_control)
var control_err := _control.start(int(config.get_value("readiness-port")), OS.get_environment(String(config.get_value("drain-token-env"))))
if control_err != OK:
printerr("cosmic-clash-server: refusing to start with invalid readiness control port")
get_tree().quit(1)
return
_agones = AgonesSDKScript.new()
_agones.name = "AgonesSDK"
get_tree().root.add_child.call_deferred(_agones)
if _agones.configure_from_environment():
_agones.start_health()
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
MatchNet.player_joined.connect(_on_player_joined)
MatchNet.player_left.connect(_on_player_left)
var err := NetworkManager.host(port, max_clients)
if err != OK:
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
if _control != null:
_control.set_process_ready(true)
_install_match_loop()
ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")),
"max_matches": 1 if allocated_mode else int(config.get_value("max-matches")),
"arena_rotation": String(config.get_value("arena-rotation")),
"allocated_mode": allocated_mode,
"match_id": String(config.get_value("match-id")) if allocated_mode else "",
"server_id": String(config.get_value("server-id")) if allocated_mode else "",
"region": String(config.get_value("region")) if allocated_mode else "",
"transport": assigned_transport if allocated_mode else NetworkManager.TRANSPORT_ENET,
})
_last_physics_frame = Engine.get_physics_frames()
# Task 6.5. Parented to the ROOT rather than to this node: the loop calls
# change_scene_to_file, which frees the current scene — and this boot scene IS
# the current scene, so a loop parented here would be freed by the first match
# it started. Same constraint the smoke-test hooks document.
func _install_match_loop() -> void:
var loop := ServerMatchLoop.new()
loop.name = "ServerMatchLoop"
loop.min_players = int(config.get_value("min-players"))
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
loop.max_matches = 1 if bool(config.get_value("allocated-mode")) else int(config.get_value("max-matches"))
loop.rotation_mode = String(config.get_value("arena-rotation"))
loop.allocated_mode = bool(config.get_value("allocated-mode"))
loop.allocated_playlist = String(config.get_value("playlist"))
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
loop.allocated_arena_path = String(config.get_value("arena-path"))
get_tree().root.add_child.call_deferred(loop)
func _process(_delta: float) -> void:
NetworkManager.poll()
if _drain_requested:
var scene := get_tree().current_scene
if not (is_instance_valid(scene) and scene.is_in_group("game")) and MatchNet.roster.is_empty():
get_tree().quit(0)
var current := Engine.get_physics_frames()
var steps := current - _last_physics_frame
_last_physics_frame = current
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
# alternate between 0 and 2 ticks even on an idle, healthy server —
# that's expected quantisation, not backlog. A real overrun is the
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
if steps > 2 and _watchdog_armed:
ServerLog.warn("physics_overrun", {"steps": steps})
_watchdog_armed = true
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_client_connected(peer_id: int) -> void:
ServerLog.debug("peer_connected", {"peer_id": peer_id})
func _on_client_disconnected(peer_id: int) -> void:
ServerLog.debug("peer_disconnected", {"peer_id": peer_id})
func _on_player_joined(peer_id: int, player_name: String) -> void:
ServerLog.info("player_joined", {"peer_id": peer_id, "name": player_name, "roster": MatchNet.roster.size()})
func _on_player_left(peer_id: int) -> void:
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
func _on_drain_requested() -> void:
_drain_requested = true
MatchNet.admissions_open = false
MatchNet.broadcast_server_shutdown("server_draining")
ServerLog.info("server_draining", {"reason": "control_request"})
static func required_min_players(allocated: bool, roster_size: int, configured: int) -> int:
if allocated and roster_size > 0:
return roster_size
return configured