fix(multiplayer): actually connect once matchmaking assigns a match

A player who completed the entire queue -> proposal -> allocate ->
assign pipeline would reach ASSIGNED, see "Your match server is
ready", and then simply sit there forever. connect_to_assignment()
existed in control_plane_client.gd, fully validated (checks the
assignment is available and fresh, splits and validates the
endpoint, carries the join authorisation via MatchNet's hello payload
rather than the URL) with its own assignment_connection_started/
assignment_connection_failed signals, and multiplayer-next.md's own
§8.41 row already described it as wired -- but grepping the whole
client found zero callers. Nothing anywhere in matchmaking.gd or
control_plane_client.gd itself ever invoked it.

ControlPlaneClient._connect_when_assigned() now calls it automatically
the moment state.phase reaches ASSIGNED. Wired into the single call
site every queue-shaped HTTP response already shares (heartbeat,
recover, and resync-triggered recover alike, since the WebSocket
match-lifecycle path always funnels into a REST resync first), so
both the ordinary poll path and the WebSocket-push path are covered
without a second call site to keep in sync. Two orderings are handled:
if the assignment fetch triggered earlier by ASSIGNMENT_READY has
already completed, it connects immediately; if not, it defers via
_pending_connect_match_id and resolves once the assignment becomes
available. _connect_attempted_match_id guards against a duplicate or
replayed ASSIGNED event reattempting the connection.

Verified against the real Godot 4.7.1 binary now that headless
testing has resumed: two new test_control_plane_client.gd tests cover
both orderings and the duplicate-attempt guard directly (216/216
total, 0 failed, no crash, no engine-level error, stable across
repeated runs); full make verify-multiplayer-local and the complete
make verify-enet-integration suite (all five cases, including the
3-process match) both pass clean; zero new crash reports throughout.

multiplayer-next.md's §8.41 row is corrected to describe what was
actually true (connect_to_assignment existed but was never called)
rather than repeating the prior, inaccurate 'already wired' claim.
This commit is contained in:
Josh Creek
2026-09-04 18:00:42 +01:00
parent 8810bf7d8f
commit 4c61b1e28d
3 changed files with 121 additions and 1 deletions
@@ -231,6 +231,85 @@ func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void:
client.free()
# connect_to_assignment() already existed, fully validated, with its own
# assignment_connection_started/assignment_connection_failed signals -- but
# nothing anywhere in the client ever called it. A player reaching the
# ASSIGNED phase (server confirms the complete roster) with a fetched, fresh
# assignment would simply sit on "Your match server is ready" forever,
# because the transport was never actually started. This is the wiring fix,
# not just new test coverage for existing behavior.
func test_client_starts_the_transport_once_the_ticket_reaches_assigned() -> 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-ready", "casual"), "queue setup succeeds")
# The assignment fetch (triggered independently, earlier, by
# ASSIGNMENT_READY) has already completed by the time ASSIGNED arrives --
# the common case.
client._operation = "assignment"
var assignment_payload := {"match_id": "match_connect_1234567890", "server_id": "server_connect_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65500", "join_authorisation": "opaque-join-token"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
assert_true(client.assignment.available, "assignment fetch applies")
var connect_started := [false]
var connect_failed := [false]
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-ready", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_connect_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())
# connect_to_assignment() itself calls state.mark_connecting() as part of a
# successful attempt, so by the time control returns here phase has
# already advanced past ASSIGNED to CONNECTING -- that advancement is
# itself the proof the connect was actually attempted.
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "reaching ASSIGNED with a ready assignment actually started the transport, rather than sitting idle")
assert_true(connect_started[0] or connect_failed[0], "connect_to_assignment's own signal fired")
assert_true(client._pending_connect_match_id.is_empty(), "an attempted connect is not left pending")
# A duplicate/replayed ASSIGNED event for the same match (e.g. an
# at-least-once outbox redelivery) must not fire a second connection
# attempt. Called directly against the guarded function rather than
# through another full _on_request_completed round-trip: phase has
# already moved on to CONNECTING, so both of _connect_when_assigned's own
# guards (phase != ASSIGNED, and the _connect_attempted_match_id match)
# now independently refuse a second attempt for this match.
connect_started[0] = false
connect_failed[0] = false
client._connect_when_assigned("match_connect_1234567890")
assert_true(not connect_started[0] and not connect_failed[0], "a duplicate connect attempt for an already-attempted match is not reattempted")
NetworkManager.shutdown()
client.free()
func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
assert_true(client.state.begin_queue("ticket-connect-deferred", "casual"), "queue setup succeeds")
var connect_started := [false]
var connect_failed := [false]
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
# ASSIGNED arrives before the assignment fetch (triggered earlier by
# ASSIGNMENT_READY) has actually completed -- the ordering the deferred
# path exists for. client.assignment is still the default, unavailable one.
client._operation = "queue_recover"
var ticket_payload := {"ticket_id": "ticket-connect-deferred", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_deferred_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.ASSIGNED, "ticket state machine still reaches ASSIGNED")
assert_eq(client._pending_connect_match_id, "match_deferred_1234567890", "the connect attempt is deferred until the assignment is actually available")
assert_true(not connect_started[0] and not connect_failed[0], "no connection attempt is made before the assignment is ready -- nothing to connect to yet")
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")