From b24f9fc44801e4e9801161119c9429fcda459f82 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:42:15 +0100 Subject: [PATCH] feat(multiplayer): notify clients before server shutdown --- Game/scripts/match_net.gd | 26 ++++++++++++++++++++++++++ Game/scripts/server_boot.gd | 1 + Game/scripts/server_match_loop.gd | 2 ++ Game/tests/cases/test_match_net.gd | 12 ++++++++++++ multiplayer-next.md | 2 ++ 5 files changed, 43 insertions(+) diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index e9a6cbfc..8d32dc22 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -17,6 +17,7 @@ signal player_left(peer_id: int) signal player_state_changed(peer_id: int, team: int, ready: bool) signal rejected(reason: String) # client-side only: the server refused our hello signal welcomed() # client-side only: our hello was accepted +signal server_shutdown(reason: String) # client-side notification before planned close const TEAM_COUNT := 2 const RECONNECT_GRACE_SECONDS := 60.0 @@ -215,6 +216,26 @@ func _broadcast_player_left(peer_id: int) -> void: _player_left.rpc_id(other_peer_id, peer_id) +func broadcast_server_shutdown(reason: String) -> void: + if not multiplayer.is_server(): + return + var safe_reason := _sanitize_shutdown_reason(reason) + for peer_id in multiplayer.get_peers(): + _server_shutdown.rpc_id(peer_id, safe_reason) + + +static func _sanitize_shutdown_reason(raw: String) -> String: + var clean := "" + for c in raw: + var code := c.unicode_at(0) + if code >= 0x20 and code != 0x7F: + clean += c + clean = clean.strip_edges() + if clean.length() > 96: + clean = clean.substr(0, 96) + return clean if not clean.is_empty() else "server_shutdown" + + # Balances a new joiner onto whichever team currently has fewer players # (ties go to team 0). Server only. func _pick_balanced_team() -> int: @@ -488,6 +509,11 @@ func _rejected(reason: String) -> void: rejected.emit(reason) +@rpc("authority", "call_remote", "reliable") +func _server_shutdown(reason: String) -> void: + server_shutdown.emit(_sanitize_shutdown_reason(reason)) + + @rpc("authority", "call_remote", "reliable") func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void: roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready) diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index acb26c67..9d67499f 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -186,6 +186,7 @@ func _on_player_left(peer_id: int) -> void: func _on_drain_requested() -> void: _drain_requested = true MatchNet.admissions_open = false + MatchNet.broadcast_server_shutdown("server_draining") ServerLog.info("server_draining", {"reason": "control_request"}) diff --git a/Game/scripts/server_match_loop.gd b/Game/scripts/server_match_loop.gd index 1586b51b..f43881fb 100644 --- a/Game/scripts/server_match_loop.gd +++ b/Game/scripts/server_match_loop.gd @@ -112,6 +112,8 @@ func _cancel_allocated_no_show(reason: String, connected: int) -> void: 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) diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index 3017ff29..ae3c90c4 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -33,12 +33,24 @@ func test_empty_or_whitespace_only_falls_back_to_default() -> void: assert_eq(MatchNet._sanitize_player_name(""), "Player", "empty string falls back") assert_eq(MatchNet._sanitize_player_name(" "), "Player", "whitespace-only falls back") assert_eq(MatchNet._sanitize_player_name("\n\t\r"), "Player", "control-characters-only falls back") + assert_eq(MatchNet._sanitize_shutdown_reason("\n maintenance \t"), "maintenance", "shutdown reason strips controls") + assert_eq(MatchNet._sanitize_shutdown_reason(""), "server_shutdown", "empty shutdown reason gets a safe fallback") func test_leading_trailing_whitespace_trimmed() -> void: assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed") +func test_server_shutdown_message_is_bounded_and_emitted() -> void: + var instance = MatchNet.new() + var received := [""] + var callback := func(reason: String) -> void: received[0] = reason + instance.server_shutdown.connect(callback) + 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") + + func test_reservation_reclaim_requires_stable_identity() -> void: assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name") assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot") diff --git a/multiplayer-next.md b/multiplayer-next.md index 34286440..f315a8c8 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1424,3 +1424,5 @@ The state-event implementation is now complete through the registration boundary Allocated Godot runtime now applies the same initial-connect policy: ranked allocations cancel and exit after 30 seconds if the signed roster is incomplete; casual allocations wait 60 seconds, cancel when fewer than two humans or one team is absent, and otherwise start with a deterministic six-slot assignment-derived lineup containing explicit bots. The bot branch is opt-in and consumed once, so direct servers and ranked matches cannot inherit it. Godot parse plus the 155-test harness and manifest checks pass; durable no-show penalties/state reconciliation remain owned by the control-plane sweep. An adversarial transaction review found that cancellation released only no-show participant rows, which would leave innocent players marked active in the cancelled match and trip the active-match uniqueness fence on their next match. `ApplyInitialConnectPlan` now releases the complete participant roster on cancellation, while retaining cooldown penalties only for no-shows; the full Go suite, race checks, and vet pass. + +The documented `server_shutdown` reliable control message is now implemented in `MatchNet`, with bounded reason sanitisation and an authority-only receiver signal. Controlled drain broadcasts `server_draining`; allocated initial-connect cancellation broadcasts its policy reason and waits a transport-flush beat before closing. The 156-test Godot harness covers emission and bounds; full multi-process drain delivery remains a live integration gate.