Files

197 lines
7.7 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
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)