class_name ServerMatchLoop extends Node # The dedicated server's match loop (multiplayer-next.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 const ALLOCATED_WAIT := "WAIT" const ALLOCATED_READY := "READY" const ALLOCATED_CANCEL := "CANCEL" const ALLOCATED_START_WITH_BOTS := "START_WITH_BOTS" var min_players := 1 var start_countdown_seconds := 5.0 var max_matches := 0 # 0 = run forever var rotation_mode := "sequential" var allocated_mode := false var allocated_playlist := "" var allocated_roster_size := 0 var allocated_arena_path := "" var allocated_admission_armed := true var matches_completed := 0 var _countdown_started_ms := -1 var _match_active := false var _next_poll_ms := 0 var _shutting_down := false var _allocated_connect_started_ms := -1 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: if allocated_mode: _poll_allocated_match_start(now) else: _poll_match_start(now) func _poll_allocated_match_start(now: int) -> void: if not allocated_admission_armed: return if _allocated_connect_started_ms < 0: _allocated_connect_started_ms = now var connected := MatchNet.roster.size() var has_team_zero := false var has_team_one := false for info: MatchNet.PlayerInfo in MatchNet.roster.values(): has_team_zero = has_team_zero or info.team == 0 has_team_one = has_team_one or info.team == 1 var action := allocated_initial_connect_action(allocated_playlist, now - _allocated_connect_started_ms, connected, allocated_roster_size, has_team_zero, has_team_one) if action == ALLOCATED_READY: _poll_match_start(now) return if action == ALLOCATED_CANCEL: var reason := "ranked_initial_connect_timeout" if allocated_playlist == "ranked" else "casual_initial_connect_ineligible" _cancel_allocated_no_show(reason, connected) return if action == ALLOCATED_START_WITH_BOTS: NetworkedMatch.server_bot_fill_override = true _poll_match_start(now) func arm_allocated_admission() -> void: allocated_admission_armed = true _allocated_connect_started_ms = -1 static func allocated_initial_connect_action(playlist: String, elapsed_ms: int, connected: int, expected: int, has_team_zero: bool, has_team_one: bool) -> String: if elapsed_ms < 0 or connected < 0 or expected < 1: return ALLOCATED_CANCEL if playlist == "ranked": if expected != 6: return ALLOCATED_CANCEL if connected >= expected: return ALLOCATED_READY return ALLOCATED_CANCEL if elapsed_ms >= 30000 else ALLOCATED_WAIT if playlist == "casual": if expected < 2 or expected > 6: return ALLOCATED_CANCEL if connected >= expected: if expected == 6: return ALLOCATED_READY return ALLOCATED_START_WITH_BOTS if has_team_zero and has_team_one else ALLOCATED_CANCEL if elapsed_ms < 60000: return ALLOCATED_WAIT return ALLOCATED_START_WITH_BOTS if connected >= 2 and has_team_zero and has_team_one else ALLOCATED_CANCEL return ALLOCATED_CANCEL func _cancel_allocated_no_show(reason: String, connected: int) -> void: if _shutting_down: return _shutting_down = true ServerLog.info("initial_connect_cancelled", {"reason": reason, "connected": connected, "expected": allocated_roster_size}) MatchNet.broadcast_server_shutdown(reason) await get_tree().create_timer(0.3).timeout NetworkManager.shutdown() get_tree().quit(0) # 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 := allocated_arena_path if allocated_mode and not allocated_arena_path.is_empty() else 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)