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:
Josh Creek
2026-09-04 18:12:25 +01:00
parent 7d50612abb
commit ae4a6f937f
3 changed files with 83 additions and 1 deletions
+26
View File
@@ -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
View File
File diff suppressed because one or more lines are too long