feat(multiplayer): handle planned shutdowns on clients

This commit is contained in:
Josh Creek
2026-09-01 16:43:47 +01:00
parent b24f9fc448
commit 4e88ea80f3
5 changed files with 25 additions and 1 deletions
+5
View File
@@ -27,6 +27,7 @@ func _ready() -> void:
MatchNet.player_left.connect(_on_roster_changed)
MatchNet.player_state_changed.connect(_on_roster_changed)
MatchNet.rejected.connect(_on_rejected)
MatchNet.server_shutdown.connect(_on_server_shutdown)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
# The server process is never a roster member (§1.1 decision 2) — it
@@ -61,6 +62,10 @@ func _on_rejected(reason: String) -> void:
_status_label.text = "Connection rejected: %s" % reason
func _on_server_shutdown(reason: String) -> void:
_status_label.text = "Server closed: %s" % reason
func _on_disconnected_from_server() -> void:
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+4 -1
View File
@@ -53,6 +53,7 @@ class PlayerInfo:
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
var last_server_shutdown_reason := ""
# Set by the assignment connection path. Direct-IP/community-server joins keep
# this empty for backwards compatibility; allocated matches carry the opaque
# signed authorisation in hello rather than putting it in the endpoint URL.
@@ -80,6 +81,7 @@ func _ready() -> void:
func _on_connected_to_server() -> void:
roster.clear()
last_server_shutdown_reason = ""
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation)
@@ -511,7 +513,8 @@ func _rejected(reason: String) -> void:
@rpc("authority", "call_remote", "reliable")
func _server_shutdown(reason: String) -> void:
server_shutdown.emit(_sanitize_shutdown_reason(reason))
last_server_shutdown_reason = _sanitize_shutdown_reason(reason)
server_shutdown.emit(last_server_shutdown_reason)
@rpc("authority", "call_remote", "reliable")
+13
View File
@@ -321,6 +321,7 @@ var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _match_over := false
var _planned_server_shutdown := false
# Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative
# server, cannot be triggered by an RPC, and defaults to disabled.
var _smoke_force_goal_tick := -1
@@ -391,6 +392,7 @@ func _ready() -> void:
# is not connected" errors per run — it only ever left because a test
# timer happened to fire.
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
MatchNet.server_shutdown.connect(_on_server_shutdown)
_request_match_config_until_received()
@@ -994,12 +996,23 @@ func _broadcast_clock_state() -> void:
func _on_disconnected_from_server() -> void:
if _planned_server_shutdown:
return
# Deferred: this arrives from inside NetworkManager's poll, and gotcha 27
# requires change_scene_to_file never run synchronously from a callback
# mid-traversal.
get_tree().change_scene_to_file.call_deferred(ScenePaths.MAIN_MENU)
func _on_server_shutdown(reason: String) -> void:
if multiplayer.is_server() or _planned_server_shutdown:
return
_planned_server_shutdown = true
print("NetworkedMatch: server shutdown notice: %s" % reason)
NetworkManager.shutdown()
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
func _on_match_bootstrap_received(state: int, at_tick: int, new_score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
score = new_score.duplicate()
score_changed.emit(score.duplicate())
+1
View File
@@ -49,6 +49,7 @@ func test_server_shutdown_message_is_bounded_and_emitted() -> void:
instance._server_shutdown(" planned maintenance " + "x".repeat(200))
instance.server_shutdown.disconnect(callback)
assert_eq(received[0].length(), 96, "shutdown reason is bounded before presentation")
assert_eq(instance.last_server_shutdown_reason.length(), 96, "bounded shutdown reason is retained for UI")
func test_reservation_reclaim_requires_stable_identity() -> void: