mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
181 lines
7.2 KiB
GDScript
181 lines
7.2 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 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 _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)
|
|
|
|
|
|
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 connected >= expected:
|
|
return ALLOCATED_READY
|
|
if playlist == "ranked":
|
|
return ALLOCATED_CANCEL if elapsed_ms >= 30000 else ALLOCATED_WAIT
|
|
if playlist == "casual":
|
|
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)
|