feat(multiplayer): notify clients before server shutdown

This commit is contained in:
Josh Creek
2026-09-01 16:42:15 +01:00
parent 8c2dc66c7c
commit b24f9fc448
5 changed files with 43 additions and 0 deletions
+26
View File
@@ -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)
+1
View File
@@ -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"})
+2
View File
@@ -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)
+12
View File
@@ -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")
+2
View File
@@ -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.