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. 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 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")) 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 _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": int(config.get_value("max-matches")), "arena_rotation": String(config.get_value("arena-rotation")), }) _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 = int(config.get_value("max-matches")) loop.rotation_mode = String(config.get_value("arena-rotation")) get_tree().root.add_child.call_deferred(loop) 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: 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()})