mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): notify clients before server shutdown
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user