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
+41
View File
@@ -38,6 +38,16 @@ var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
var _pending_proposal_id := ""
var _pending_assignment_match_id := ""
var _pending_resync_resource_id := ""
# The final wiring step of the matchmaking pipeline: once state.phase reaches
# ASSIGNED, the client must actually start the game transport. connect_to_assignment()
# already existed with correct validation/signal behavior, but nothing ever
# called it -- a player would sit on "Your match server is ready" forever.
# These two fields defer the connect attempt until the assignment fetch
# (triggered independently, earlier, by ASSIGNMENT_READY) has actually
# completed, and prevent a duplicate/replayed ASSIGNED update from firing a
# second connection attempt for the same match.
var _pending_connect_match_id := ""
var _connect_attempted_match_id := ""
func _ready() -> void:
@@ -85,9 +95,39 @@ func _process(_delta: float) -> void:
var match_id := _pending_assignment_match_id
_pending_assignment_match_id = ""
fetch_assignment(match_id)
if not _pending_connect_match_id.is_empty() and _assignment_ready_for(_pending_connect_match_id):
var match_id := _pending_connect_match_id
_pending_connect_match_id = ""
_connect_attempted_match_id = match_id
connect_to_assignment()
_poll_authoritative_recovery(_delta)
# The assignment fetch (triggered independently by ASSIGNMENT_READY, which
# always precedes ASSIGNED) and the ASSIGNED transition that should start the
# transport can arrive in either order. This is the shared readiness check
# both _connect_when_assigned and the deferred _process retry above use.
func _assignment_ready_for(match_id: String) -> bool:
return assignment != null and assignment.available and assignment.match_id == match_id and _assignment_is_fresh(assignment)
# Starts (or defers, if the assignment fetch triggered by the earlier
# ASSIGNMENT_READY event hasn't completed yet) the game transport once the
# ticket-state machine reaches ASSIGNED. connect_to_assignment() itself
# already existed with full validation and failure signalling; nothing ever
# called it, so a player reaching "Your match server is ready" never actually
# connected. _connect_attempted_match_id guards against a duplicate/replayed
# ASSIGNED update firing a second connection attempt for the same match.
func _connect_when_assigned(match_id: String) -> void:
if state.phase != MatchmakingState.ASSIGNED or not is_valid_resource_id(match_id) or match_id == _connect_attempted_match_id:
return
if _assignment_ready_for(match_id):
_connect_attempted_match_id = match_id
connect_to_assignment()
else:
_pending_connect_match_id = match_id
func configure(url: String, token: String) -> bool:
var normalized := url.strip_edges().trim_suffix("/")
var normalized_token := token.strip_edges()
@@ -508,6 +548,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"):
_queue_proposal_if_ready(payload)
_queue_assignment_if_ready(payload)
_connect_when_assigned(String(payload.get("match_id", "")))
elif operation.begins_with("proposal_"):
state.apply_proposal_update(normalize_proposal(payload))
elif operation == "ranked_profile":