mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): surface matchmaking connect failures to the player
Closes §8.43's 'failed-reconnect UX' gap, found immediately after wiring connect_to_assignment() itself: even with that fix in place, a connection failure had nowhere to go. connect_to_assignment()'s synchronous failures (assignment missing/expired, invalid endpoint, NetworkManager.join() erroring immediately) only ever emitted assignment_connection_failed -- a signal nothing in the client listened to. state.phase would stay stuck at ASSIGNED, the UI would keep showing "Your match server is ready" forever, with no way back to a fresh search. Worse, the likelier real-world failure mode had no handler at all: NetworkManager.join() returns OK immediately once the attempt starts, but the actual ENet handshake can still fail asynchronously afterward (unreachable server, refused connection, ENet's own ~5s connect timeout). This is exactly the gap main_menu.gd's own _on_connection_failed exists to cover for the direct-join flow (see its header comment) -- nothing covered the equivalent for a matchmaking-driven connect. ControlPlaneClient now connects both assignment_connection_failed and NetworkManager.connection_failed (guarded to state.phase == CONNECTING, so it never misattributes an unrelated direct-join failure to a matchmaking search) to state.fail(...), so either failure mode now surfaces as a failed search the player can actually retry from. Verified: three new test_control_plane_client.gd tests cover the synchronous failure path, the async NetworkManager.connection_failed path (via a real end-to-end ASSIGNED -> CONNECTING flow), and that an unrelated connection_failed outside CONNECTING is correctly ignored. 220/220 tests pass, stable across 3 repeated runs, no crash, no engine-level error; full make verify-multiplayer-local and the complete make verify-enet-integration suite (all five cases) both clean; zero new crash reports throughout. Also fixes a markdown table-integrity mistake introduced while documenting this in the same edit pass: an earlier Edit call accidentally duplicated a sentence and dropped the row's closing 'remains' clause in §8.43 -- caught and corrected before commit via the usual pipe-count check.
This commit is contained in:
@@ -62,6 +62,32 @@ func _ready() -> void:
|
||||
_request.request_completed.connect(_on_request_completed)
|
||||
state.resync_required.connect(_on_resync_required)
|
||||
_websocket = WebSocketPeer.new()
|
||||
assignment_connection_failed.connect(_on_assignment_connection_failed)
|
||||
NetworkManager.connection_failed.connect(_on_network_connection_failed)
|
||||
|
||||
|
||||
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
||||
# synchronous failures (assignment missing/expired, invalid endpoint,
|
||||
# NetworkManager.join() erroring immediately) previously only emitted
|
||||
# assignment_connection_failed -- a signal nothing in the client actually
|
||||
# listened to. state.phase would stay stuck at ASSIGNED, the UI would keep
|
||||
# showing "Your match server is ready" forever, and there was no way back to
|
||||
# a fresh search.
|
||||
func _on_assignment_connection_failed(detail: String) -> void:
|
||||
state.fail(detail)
|
||||
|
||||
|
||||
# The likelier real-world failure than the synchronous one above:
|
||||
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
||||
# actual ENet handshake fails asynchronously later -- unreachable server,
|
||||
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
||||
# main_menu.gd's own _on_connection_failed exists to cover for the direct-join
|
||||
# flow (see its header comment); nothing covered it for a matchmaking-driven
|
||||
# connect. Guarded to CONNECTING so this never reacts to an unrelated
|
||||
# connection_failed, such as one belonging to main_menu.gd's own direct join.
|
||||
func _on_network_connection_failed() -> void:
|
||||
if state.phase == MatchmakingState.CONNECTING:
|
||||
state.fail("Unable to connect to the match server")
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
|
||||
@@ -310,6 +310,62 @@ func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> vo
|
||||
client.free()
|
||||
|
||||
|
||||
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
||||
# synchronous failures previously only emitted assignment_connection_failed,
|
||||
# a signal nothing in the client listened to -- state.phase stayed stuck at
|
||||
# ASSIGNED, the UI kept showing "Your match server is ready" forever, and
|
||||
# there was no way back to a fresh search.
|
||||
func test_synchronous_assignment_connection_failure_surfaces_as_a_failed_search() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket-connect-unavailable", "casual"), "queue setup succeeds")
|
||||
# client.assignment is still the default, unavailable one.
|
||||
var err := client.connect_to_assignment()
|
||||
assert_eq(err, ERR_UNAUTHORIZED, "connect fails closed when the assignment isn't ready")
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "the failure is surfaced as a failed search rather than leaving the UI stuck at ASSIGNED")
|
||||
assert_true(client.state.message.to_lower().contains("unavailable") or client.state.message.to_lower().contains("expired"), "the failure detail is retained: %s" % client.state.message)
|
||||
client.free()
|
||||
|
||||
|
||||
# The likelier real-world failure than the synchronous one above:
|
||||
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
||||
# actual ENet handshake fails asynchronously later -- unreachable server,
|
||||
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
||||
# main_menu.gd's own _on_connection_failed exists to cover for the
|
||||
# direct-join flow; nothing covered it for a matchmaking-driven connect.
|
||||
func test_async_network_connection_failure_after_assignment_ready_surfaces_as_a_failed_search() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
client.player_id = "player_1234567890"
|
||||
assert_true(client.state.begin_queue("ticket-connect-asyncfail", "casual"), "queue setup succeeds")
|
||||
client._operation = "assignment"
|
||||
var assignment_payload := {"match_id": "match_asyncfail_1234567890", "server_id": "server_asyncfail_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65501", "join_authorisation": "opaque-join-token"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
|
||||
client._operation = "queue_recover"
|
||||
var ticket_payload := {"ticket_id": "ticket-connect-asyncfail", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_asyncfail_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
||||
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "the transport attempt started")
|
||||
|
||||
NetworkManager.connection_failed.emit()
|
||||
assert_eq(client.state.phase, MatchmakingState.FAILED, "the async handshake failure is surfaced rather than leaving CONNECTING stuck forever")
|
||||
|
||||
NetworkManager.shutdown()
|
||||
client.free()
|
||||
|
||||
|
||||
func test_network_connection_failure_is_ignored_outside_a_matchmaking_driven_connect() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.state.begin_queue("ticket-unrelated-failure", "casual"), "queue setup succeeds")
|
||||
# state.phase is QUEUED, not CONNECTING -- this connection_failed belongs
|
||||
# to something else (e.g. main_menu.gd's own direct-join flow) and must
|
||||
# not be misattributed to matchmaking.
|
||||
NetworkManager.connection_failed.emit()
|
||||
assert_eq(client.state.phase, MatchmakingState.QUEUED, "an unrelated connection_failed does not fail an active queue search")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
|
||||
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
|
||||
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user