mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat(multiplayer): handle planned shutdowns on clients
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1426,3 +1426,5 @@ Allocated Godot runtime now applies the same initial-connect policy: ranked allo
|
||||
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.
|
||||
|
||||
Clients now consume planned shutdowns: the reason is retained for presentation, an in-match client returns to the lobby after the notice, and the generic disconnect callback is fenced so it cannot overwrite that planned transition. Lobby clients surface the reason directly. The complete Godot harness remains green; real two-process drain delivery is still an external runtime gate.
|
||||
|
||||
Reference in New Issue
Block a user