class_name ServerMatchLoop extends Node # The dedicated server's match loop (multiplayer-todo.md task 6.5). # # THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs # a full match headless", but nothing in the product ever started a match: # lobby.gd has no start path, and every match in this project's history was # begun by a test harness calling change_scene_to_file directly. The dedicated # server booted, listened, and could never play anything. 6.5 was written as # "arena rotation between matches", which presumes a first match that nothing # produced — so the whole loop lives here, not just the rotation. # # Lifecycle: # # wait for --min-players (roster, not raw peers: a peer that has # connected but not completed the hello # handshake is not a player yet) # -> --start-countdown seconds (so a second player joining 200ms later # is in THIS match, not the next one) # -> networked_match.tscn on the arena --arena-rotation picked # -> the match runs itself and returns to the lobby at RESULTS # -> repeat, or exit(0) once --max-matches have completed # # Parented to the scene tree ROOT, never to current_scene: change_scene_to_file # frees whatever scene is live, and an orchestrator that gets freed by the # transition it just requested cannot orchestrate the next one. This is the # same constraint tests/networked_match_test_hooks.gd documents, arrived at the # same way — it is a property of Godot's scene switching, not of testing. # # The countdown is deliberately NOT a Timer: §6.1's tick-derived-clock rule # applies to anything whose timing a client can observe, and the wait before a # match is exactly that. signal match_starting(arena_path: String, match_index: int) const POLL_INTERVAL_MS := 250 var min_players := 1 var start_countdown_seconds := 5.0 var max_matches := 0 # 0 = run forever var rotation_mode := "sequential" var matches_completed := 0 var _countdown_started_ms := -1 var _match_active := false var _next_poll_ms := 0 var _shutting_down := false func _process(_delta: float) -> void: if _shutting_down or not multiplayer.is_server(): return var now := Time.get_ticks_msec() if now < _next_poll_ms: return _next_poll_ms = now + POLL_INTERVAL_MS if _match_active: _poll_match_end() else: _poll_match_start(now) # A match is over when the match scene is gone. NetworkedMatch returns both # peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby # when everyone has left (§6.4), so "the scene we started is no longer the # current scene" covers the clean end and the abandoned one identically — # without this node having to duplicate either rule or reach into match state. func _poll_match_end() -> void: var scene := get_tree().current_scene if is_instance_valid(scene) and scene.is_in_group("game"): return _match_active = false matches_completed += 1 ServerLog.info("match_completed", { "completed": matches_completed, "of": max_matches if max_matches > 0 else "unlimited", }) if max_matches > 0 and matches_completed >= max_matches: # §6's drain-and-exit: the point of --max-matches is that a supervisor # can restart the process on a new build between matches instead of # killing players mid-game. Exiting anywhere else would defeat it. _shutting_down = true ServerLog.info("server_draining", {"reason": "max_matches_reached", "matches": matches_completed}) get_tree().quit(0) return # Straight back to waiting. The countdown restarts from scratch rather than # carrying over, so players who left during the last match are not counted # toward starting the next one. _countdown_started_ms = -1 func _poll_match_start(now: int) -> void: var players := MatchNet.roster.size() if players < min_players: if _countdown_started_ms >= 0: ServerLog.info("match_start_cancelled", {"players": players, "needed": min_players}) _countdown_started_ms = -1 return if _countdown_started_ms < 0: _countdown_started_ms = now ServerLog.info("match_start_countdown", { "players": players, "seconds": start_countdown_seconds, }) return if now - _countdown_started_ms < int(start_countdown_seconds * 1000.0): return _start_match() func _start_match() -> void: var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode) # The match scene picks its own arena at random by default. Handing it one # explicitly is what makes rotation a rotation rather than a coincidence. NetworkedMatch.server_arena_override = arena_path _match_active = true _countdown_started_ms = -1 ServerLog.info("match_starting", { "index": matches_completed + 1, "arena": arena_path, "players": MatchNet.roster.size(), "rotation": rotation_mode, }) match_starting.emit(arena_path, matches_completed) get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)