Files
CosmicClash/Game/scripts/server_match_loop.gd
T
Josh Creek 4fb7ddfecf docs(multiplayer): consolidate tracking into one document
multiplayer-todo.md and multiplayer-next.md tracked overlapping
information in two places. Fold everything into multiplayer-next.md
(architecture decisions, wire format, task breakdown with checkboxes,
gotchas list, testing notes) and delete multiplayer-todo.md. Section
numbers are unchanged, so existing code comments citing them by
section/task number still resolve; update every such reference to
point at the new filename.
2026-09-01 12:32:43 +01:00

123 lines
4.9 KiB
GDScript

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
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)