Files
CosmicClash/Game/scripts/server_boot.gd
T
Josh Creek 06881f05ca feat(server): task 6.1/6.3 — dedicated server export preset and a real CLI surface
6.1: "Linux Dedicated Server" preset (dedicated_server=true,
custom_features="dedicated_server") mirroring the existing training
preset, plus run/main_scene.dedicated_server so the server binary reaches
its own entry point with no flag. Builds: an 85MB Linux x86_64 binary,
gitignored like the training one.

6.3: scripts/server_config.gd declares every server flag once - name,
type, default, section, help - and one parser turns that into parsing,
type checking, range validation, config-file backing and --help. The
flags had grown to ~30 across server_boot.gd and networked_match.gd, each
parsed inline with begins_with, none documented, and an unrecognised flag
was SILENTLY IGNORED: --max-clientss=8 ran a server on the default cap
and said nothing. Unknown flags, missing values, wrong types, duplicates
and out-of-range values are now hard errors, reported all at once.

Precedence is command line > config file > default. server_boot.gd parses
strictly because it owns the whole command line; networked_match.gd reads
the same declaration leniently because it is one consumer of an argv the
smoke harnesses also fill with --role= and --drive-seconds=. Nothing is
lost - every server flag is declared, so the strict pass already caught
any typo before the match scene re-reads its own.

13 unit tests covering the precedence order, the typo rejection that
motivated this, --no-<bool> not double-listing in --help, and --help
documenting every flag asserted against the declaration rather than a
hand-kept list. Verified end to end: --help prints, a typo'd flag refuses
to start, and the plain/replay-log/late-joiner smoke scenarios still pass.
2026-08-21 17:03:08 +01:00

107 lines
4.1 KiB
GDScript

extends Node
# 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
#
# 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.
const LOG_LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
var _boot_ms := 0
var _last_physics_frame := 0
var _log_level := 1 # info
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
func _ready() -> void:
_boot_ms = Time.get_ticks_msec()
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
var port := int(config.get_value("port"))
var max_clients := int(config.get_value("max-clients"))
_log_level = LOG_LEVELS[String(config.get_value("log-level"))]
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:
_log("error", "server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
_log("info", "server_started", {"port": port, "max_clients": max_clients})
_last_physics_frame = Engine.get_physics_frames()
func _process(_delta: float) -> void:
NetworkManager.poll()
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:
_log("warn", "physics_overrun", {"steps": steps})
_watchdog_armed = true
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_client_connected(peer_id: int) -> void:
_log("debug", "peer_connected", {"peer_id": peer_id})
func _on_client_disconnected(peer_id: int) -> void:
_log("debug", "peer_disconnected", {"peer_id": peer_id})
func _on_player_joined(peer_id: int, player_name: String) -> void:
_log("info", "player_joined", {"peer_id": peer_id, "name": player_name})
func _on_player_left(peer_id: int) -> void:
_log("info", "player_left", {"peer_id": peer_id})
func _log(level: String, event: String, fields: Dictionary) -> void:
if LOG_LEVELS.get(level, 1) < _log_level:
return
var parts := PackedStringArray()
for key in fields:
parts.append("%s=%s" % [key, str(fields[key])])
var elapsed_sec := (Time.get_ticks_msec() - _boot_ms) / 1000.0
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])