/// <summary> /// Initialize a new MatchmakingRequest object /// </summary> /// <param name="client"> /// An already existing MatchmakingClient to use as the client for the request /// </param> /// <param name="request"> /// The ticket data to send with the matchmaking request. /// Data will be copied internally on construction and treated as immutable. /// </param> /// <param name="timeoutMs"> /// The amount of time to wait (in ms) before aborting an incomplete MatchmakingRequest after it has been sent. /// Match requests that time out on the client side will immediately be set to completed and stop listening for a match /// assignment. /// </param> public MatchmakingRequest(MatchmakingClient client, CreateTicketRequest request, uint timeoutMs = 0) { m_Client = client ?? throw new ArgumentNullException(nameof(client), logPre + $"Matchmaking {nameof(client)} must not be null"); if (request == null) { throw new ArgumentNullException(nameof(request), logPre + $"{nameof(request)} must be a non-null, valid {nameof(CreateTicketRequest)} object"); } // Try to immediately create and store the protobuf version of the CreateTicketRequest // This allows us to fail fast, and also copies the data to prevent it from being mutable // This may cause exceptions inside the protobuf code, which is fine since we're in the constructor var createTicketRequest = new Protobuf.CreateTicketRequest(); string key = nameof(QosTicketInfo).ToLower(); if (request.Properties != null && !request.Properties.ContainsKey(key)) { QosTicketInfo results = QosConnector.Instance.Execute(); if (results?.QosResults?.Count > 0) { request.Properties = request.Properties ?? new Dictionary <string, string>(); request.Properties.Add(key, JsonUtility.ToJson(results)); } } // Only set properties if not null // Request properties have to be massaged to be protobuf ByteString compatible if (request.Properties != null) { foreach (var kvp in request.Properties) { var keyToLower = kvp.Key.ToLower(); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (!kvp.Key.Equals(keyToLower)) { Debug.LogWarning(logPre + $"Ticket property with key {kvp.Key} must be all lowercase; changing in-place."); } #endif createTicketRequest.Properties.Add(keyToLower, ByteString.CopyFrom(Encoding.UTF8.GetBytes(kvp.Value))); } } // Only add attributes if they exist if (request?.Attributes?.Count > 0) { createTicketRequest.Attributes.Add(request.Attributes); } m_CreateTicketRequest = createTicketRequest; State = MatchmakingRequestState.NotStarted; m_MatchRequestTimeoutMs = timeoutMs; }
/// <summary> /// <para>Dispose the MatchmakingRequest and release resources. All in-flight web requests will be disposed regardless of state. /// Object methods will no-op after disposal.</para> /// <para>Best practice is to ensure that Cancel() has been called and completed before calling Dispose().</para> /// </summary> public void Dispose() { if (!m_Disposed) { // Dispose any leftover web requests; most of these should be disposed of elsewhere // This is just a catch-all in case we Dispose() in the middle of a request var createInFlight = false; var getInFlight = false; var deleteInFlight = false; if (m_CreateTicketAsyncOperation != null) { createInFlight = MatchmakingClient.IsWebRequestSent(m_CreateTicketAsyncOperation); m_CreateTicketAsyncOperation.completed -= OnCreateTicketAsyncCompleted; m_CreateTicketAsyncOperation.webRequest?.Dispose(); m_CreateTicketAsyncOperation = null; } if (m_GetTicketAsyncOperation != null) { getInFlight = State == MatchmakingRequestState.Polling; m_GetTicketAsyncOperation.completed -= OnGetTicketAsyncCompleted; m_GetTicketAsyncOperation.webRequest?.Dispose(); m_GetTicketAsyncOperation = null; } if (m_DeleteTicketAsyncOperation != null) { deleteInFlight = !m_DeleteTicketAsyncOperation.isDone; m_DeleteTicketAsyncOperation.completed -= OnDeleteTicketAsyncCompleted; m_DeleteTicketAsyncOperation.webRequest?.Dispose(); m_DeleteTicketAsyncOperation = null; } if ((createInFlight || getInFlight) && State != MatchmakingRequestState.Canceled) { Debug.LogWarning(logPre + $"{nameof(MatchmakingRequest)} was terminated without being deleted." + " This may cause ghost tickets in the matchmaker."); } if (deleteInFlight) { Debug.LogWarning(logPre + $"{nameof(MatchmakingRequest)} was terminated while a Delete request was in flight" + "; Delete may not be processed by the matchmaker."); } if (!IsDone) { State = MatchmakingRequestState.Disposed; SetTerminalState(); } m_Disposed = true; } }
/// <summary> /// <para>Cancel searching for a match, and de-register this request with the matchmaking service.</para> /// <para>May only be called after Start() has been called.</para> /// <para> /// If a match assignment is found after Cancel() is called (but before it is processed by the matchmaker), /// the request will still be canceled and the success handler will not trigger. /// </para> /// <para>Must be called from the main thread.</para> /// </summary> public IEnumerator CancelRequest() { // UnityWebRequest calls can only be made from the Main thread ThrowExceptionIfNotOnMainThread(); if (!InState(MatchmakingRequestState.NotStarted, MatchmakingRequestState.Creating, MatchmakingRequestState.Polling)) { Debug.LogError(logPre + $"Trying to call {nameof(CancelRequest)} in an invalid state ({State})"); return(null); } Debug.Log(logPre + "Canceling matchmaking ticket"); switch (State) { // If we're not started, we can just set the object state to cancelled immediately case MatchmakingRequestState.NotStarted: Debug.Log(logPre + $"Matchmaking aborted before {nameof(SendRequest)} was called"); HandleDelete(); break; // If we're in Polling and have a ticket ID, try to do a delete case MatchmakingRequestState.Polling: DeleteMatchRequestTicket(); break; // If we're in Creating, try to cancel the Create request if it hasn't actually been sent yet // If it has, then queue up a cancellation request for once we have a valid ticket id case MatchmakingRequestState.Creating: State = MatchmakingRequestState.Canceling; if (!MatchmakingClient.IsWebRequestSent(m_CreateTicketAsyncOperation)) { m_CreateTicketAsyncOperation?.webRequest?.Abort(); Debug.LogWarning(logPre + "Attempting to abort match request while Create call is still pending"); } else { Debug.LogWarning(logPre + "Match request does not have a TicketId yet; queuing cancel request"); } break; default: throw new InvalidOperationException(logPre + $"Trying to call {nameof(CancelRequest)} in an invalid state ({State})"); } return(WaitUntilCompleted); }
internal MatchmakingController(string endpoint) { m_Client = new MatchmakingClient(endpoint); }
// Callback for "Delete Ticket" UnityWebRequestAsyncOperation completion void OnDeleteTicketAsyncCompleted(AsyncOperation obj) { if (!(obj is UnityWebRequestAsyncOperation deleteTicketOp)) { throw new ArgumentException(logPre + "Wrong AsyncOperation type in callback."); } // Every return statement in here will trigger finally{} cleanup try { // Short-circuit if we're in a terminal state or have no deletion registered if (IsDone || m_DeleteTicketAsyncOperation == null) { return; } if (deleteTicketOp != m_DeleteTicketAsyncOperation) { throw new InvalidOperationException(logPre + $"Wrong operation object received by {nameof(OnDeleteTicketAsyncCompleted)}."); } // If the request here was a success, we have successfully deleted a ticket if (MatchmakingClient.IsWebRequestFailed(deleteTicketOp.webRequest)) { Debug.LogError(logPre + $"Error deleting matchmaking ticket: {deleteTicketOp.webRequest.error}"); return; } // Everything after this is just to detect if there was additional information // provided in the body of the DELETE response // If no body, we're done if (deleteTicketOp.webRequest?.downloadHandler?.data?.Length == 0) { return; } // Try to parse body if (!MatchmakingClient.TryParseDeleteTicketResponse(deleteTicketOp.webRequest, out var result)) { // Delete succeeded but additional information could not be parsed; not a fatal error Debug.LogError(logPre + $"Error parsing DELETE information for ticket"); return; } // Handle the status code returned inside the DELETE body if (result?.Status != null && result.Status.Code != 0) { Debug.LogError(logPre + $"Error deleting matchmaking ticket. Code {result.Status.Code}: {result.Status.Message}"); return; } // Success! Debug.Log(logPre + $"Matchmaking ticket deleted successfully"); } catch (Exception e) { Debug.LogError(logPre + $"Error deleting matchmaking ticket: {e.Message}"); } finally { // Allow the operation to get garbage collected if (deleteTicketOp == m_DeleteTicketAsyncOperation) { m_DeleteTicketAsyncOperation = null; } deleteTicketOp.webRequest?.Dispose(); // Handle failed deletion cases as a successful cancel HandleDelete(); } }
// Callback for "Get Ticket" UnityWebRequestAsyncOperation completion void OnGetTicketAsyncCompleted(AsyncOperation obj) { if (!(obj is UnityWebRequestAsyncOperation getTicketOp)) { throw new ArgumentException(logPre + "Wrong AsyncOperation type in callback."); } Protobuf.GetTicketResponse getResponse; // Every return statement in here will trigger finally{} cleanup try { // Short-circuit if we're in a terminal state or have no Get call registered if (IsDone || m_GetTicketAsyncOperation == null) { return; } if (getTicketOp != m_GetTicketAsyncOperation) { throw new InvalidOperationException(logPre + $"Wrong operation object received by {nameof(OnGetTicketAsyncCompleted)}."); } // No need to log on an expected abort state if (State == MatchmakingRequestState.Canceling || MatchmakingClient.IsWebRequestAborted(getTicketOp.webRequest)) { return; } // This helps to ensure that GET calls aborted during a delete are not processed if (State != MatchmakingRequestState.Polling) { Debug.LogWarning(logPre + "Ignoring Matchmaking Get Ticket response while not in polling state."); return; } if (MatchmakingClient.IsWebRequestFailed(getTicketOp.webRequest)) { HandleError($"Error getting matchmaking ticket: {getTicketOp.webRequest.error}"); return; } // Parse the body of the response - only try if we actually have a body // Successful responses w/o bodies are not considered failures if (getTicketOp.webRequest?.downloadHandler?.data?.Length == 0) { return; } // Try to parse body if (!MatchmakingClient.TryParseGetTicketResponse(getTicketOp.webRequest, out getResponse)) { // Body was present but we couldn't parse it; this is probably a fatal error HandleError($"Error parsing GET response for ticket"); return; } } catch (Exception e) { HandleError($"Error getting information for ticket: {e.Message}"); return; } finally { // Allow the operation to get garbage collected if (getTicketOp == m_GetTicketAsyncOperation) { m_GetTicketAsyncOperation = null; m_LastPollTime = Time.unscaledTime; } getTicketOp.webRequest?.Dispose(); } // Consume the response if (getResponse.Status != null) { HandleError($"Error getting matchmaking ticket. Code {getResponse.Status.Code}: {getResponse.Status.Message}"); return; } // Check to see if this GET call has an assignment // If assignment is null, ticket hasn't completed matchmaking yet if (getResponse.Assignment != null) { var errorExists = !string.IsNullOrEmpty(getResponse.Assignment.Error); var connectionExists = !string.IsNullOrEmpty(getResponse.Assignment.Connection); var propertiesExists = !string.IsNullOrEmpty(getResponse.Assignment.Properties); // Note that assignment can have null fields Assignment = new Assignment(getResponse.Assignment.Connection, getResponse.Assignment.Error, getResponse.Assignment.Properties); // Set to ErrorRequest state if assignment has no real data if (!errorExists && !connectionExists && !propertiesExists) { HandleError("Error getting matchmaking ticket: assignment returned by service could not be processed"); return; } // Set to ErrorAssignment state if parsed assignment object contains an error entry if (errorExists) { State = MatchmakingRequestState.ErrorAssignment; Debug.LogError(logPre + $"Matchmaking completed with Assignment error: {Assignment.Error}"); SetTerminalState(); return; } // No error and valid connection and/or properties - set to AssignmentReceived State = MatchmakingRequestState.AssignmentReceived; Debug.Log(logPre + $"Matchmaking completed successfully; connection information received."); SetTerminalState(); } }
// Callback for "Create Ticket" UnityWebRequestAsyncOperation completion void OnCreateTicketAsyncCompleted(AsyncOperation obj) { if (!(obj is UnityWebRequestAsyncOperation createTicketOp)) { throw new ArgumentException(logPre + "Wrong AsyncOperation type in callback."); } Protobuf.CreateTicketResponse result; // Every return statement in here will trigger finally{} cleanup try { // Short-circuit if we're in a terminal state or have no creation registered if (IsDone || m_CreateTicketAsyncOperation == null) { return; } if (createTicketOp != m_CreateTicketAsyncOperation) { throw new InvalidOperationException(logPre + $"Wrong operation object received by {nameof(OnCreateTicketAsyncCompleted)}."); } if (State != MatchmakingRequestState.Creating && State != MatchmakingRequestState.Canceling) { Debug.LogWarning(logPre + "Ignoring Matchmaking Create Ticket response while not in creating state."); return; } if (MatchmakingClient.IsWebRequestFailed(createTicketOp.webRequest)) { // If we tried to cancel while the Create call was being constructed or in flight, // the Create request will be set to an aborted state (counts as an "IsFailed" state) if (State == MatchmakingRequestState.Canceling && MatchmakingClient.IsWebRequestAborted(createTicketOp.webRequest)) { //if (MatchmakingClient.IsWebRequestSent(createTicketOp)) //Debug.LogWarning(logPre + "Matchmaking call was aborted, but ticket may have been created on the service end."); Debug.Log(logPre + "Matchmaking was aborted during ticket creation"); HandleDelete(); return; } HandleError($"Error creating matchmaking ticket: {createTicketOp.webRequest.error}"); return; } // Parse the body of the response - only try if we actually have a body if (!MatchmakingClient.TryParseCreateTicketResponse(createTicketOp.webRequest, out result)) { // Could not parse the CREATE response; this is a fatal error HandleError($"Error parsing CREATE response for ticket; could not get Ticket ID from service"); } } catch (Exception e) { HandleError($"Error creating matchmaking ticket: {e.Message}"); return; } finally { // Allow the operation to get garbage collected if (createTicketOp == m_CreateTicketAsyncOperation) { m_CreateTicketAsyncOperation = null; } createTicketOp.webRequest?.Dispose(); } // Try to consume the parsed response if (result == null) { HandleError("Error creating matchmaking ticket."); return; } if (result.Status != null) { HandleError($"Error creating matchmaking ticket. Code {result.Status.Code}: {result.Status.Message}"); return; } if (string.IsNullOrEmpty(result.Id)) { HandleError("Error creating matchmaking ticket. Id not set."); return; } // We were able to parse the Ticket Id TicketId = result.Id; // If a cancellation request is queued, send the cancellation instead of polling for assignment if (State == MatchmakingRequestState.Canceling) { DeleteMatchRequestTicket(); return; } // Start polling for the assignment for the assigned Ticket Id Debug.Log(logPre + $"Ticket ID received; polling matchmaking for assignment"); StartGetTicketRequest(TicketId); }