diff --git a/SSMP/Game/Server/ModServerManager.cs b/SSMP/Game/Server/ModServerManager.cs index cc1c244..63c9c42 100644 --- a/SSMP/Game/Server/ModServerManager.cs +++ b/SSMP/Game/Server/ModServerManager.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using SSMP.Game.Command.Server; using SSMP.Game.Settings; using SSMP.Networking.Packet; @@ -58,9 +59,12 @@ public override void Initialize() { AddonManager.LoadAddons(); // Register handlers for UI events - _uiManager.RequestServerStartHostEvent += (_, port, _, transportType, _) => + _uiManager.RequestServerStartHostEvent += (_, port, _, transportType, _) => OnRequestServerStartHost(port, _modSettings.FullSynchronisation, transportType); _uiManager.RequestServerStopHostEvent += Stop; + PlayerConnectEvent += _ => UpdateMatchmakingRemotePlayerCount(); + PlayerDisconnectEvent += _ => UpdateMatchmakingRemotePlayerCount(); + ServerShutdownEvent += () => _uiManager.ConnectInterface.MmsClient.SetConnectedPlayers(0); // Register application quit handler // ModHooks.ApplicationQuitHook += Stop; @@ -97,6 +101,7 @@ private void OnRequestServerStartHost(int port, bool fullSynchronisation, Transp }; Start(port, fullSynchronisation, transportServer); + UpdateMatchmakingRemotePlayerCount(); } /// @@ -119,4 +124,15 @@ protected override void DeregisterCommands() { CommandManager.DeregisterCommand(_settingsCommand); } + + /// + /// Pushes the current remote-player count to MMS heartbeat state. + /// + private void UpdateMatchmakingRemotePlayerCount() { + var hostAuthKey = _modSettings.AuthKey; + var remotePlayerCount = hostAuthKey == null + ? 0 + : Players.Count(player => player.AuthKey != hostAuthKey); + _uiManager.ConnectInterface.MmsClient.SetConnectedPlayers(remotePlayerCount); + } } diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs new file mode 100644 index 0000000..45b2410 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -0,0 +1,508 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Join; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Transport; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Host; + +/// Host lobby lifecycle: creation, heartbeat, UDP discovery, and teardown. +internal sealed class MmsHostSessionService : IDisposable { + /// The base HTTP URL of the MMS server (e.g. https://mms.example.com). + private readonly string _baseUrl; + + /// NAT discovery hostname; null if disabled. + private readonly string? _discoveryHost; + + /// Synchronization lock for thread-safe access to session state (tokens, lobby IDs). + private readonly object _sessionLock = new(); + + /// Prevents concurrent lobby creation. + private int _creationLock; + + /// Whether this service instance has been disposed. + private volatile bool _disposed; + + /// WebSocket handler that receives real-time MMS server events. + private readonly MmsWebSocketHandler _webSocket; + + /// MMS session bearer token; null if no active lobby. + private volatile string? _hostToken; + + /// + /// The MMS lobby ID of the currently active session, or null when no + /// lobby is active. + /// + private string? _currentLobbyId; + + /// MMS lobby keep-alive timer. + private Timer? _heartbeatTimer; + + /// + /// Cancellation source to suppress in-flight heartbeat continuations after the lobby is closed. + /// + private CancellationTokenSource? _heartbeatCts; + + /// The number of players currently connected to this host's session. + private int _connectedPlayers; + + /// Count of consecutive heartbeat send failures observed by the timer callback. + private int _heartbeatFailureCount; + + /// + /// Cancellation source that controls the background UDP discovery refresh task. + /// null when no refresh is running. + /// + private CancellationTokenSource? _hostDiscoveryRefreshCts; + + /// + /// Initializes a new . + /// + /// Base HTTP URL of the MMS server. + /// + /// Hostname of the MMS UDP discovery endpoint, or null to disable + /// NAT hole-punch discovery. + /// + /// WebSocket handler for real-time MMS events. + public MmsHostSessionService( + string baseUrl, + string? discoveryHost, + MmsWebSocketHandler webSocket + ) { + _baseUrl = baseUrl; + _discoveryHost = discoveryHost; + _webSocket = webSocket; + } + + /// + /// Raised when MMS requests a host-mapping refresh. + /// Provides the join ID, host discovery token, and a server correlation timestamp. + /// Forwarded directly from . + /// + public event Action? RefreshHostMappingRequested { + add => _webSocket.RefreshHostMappingRequested += value; + remove => _webSocket.RefreshHostMappingRequested -= value; + } + + /// + /// Raised when MMS confirms that a host mapping has been received and recorded. + /// Forwarded directly from . + /// + public event Action? HostMappingReceived { + add => _webSocket.HostMappingReceived += value; + remove => _webSocket.HostMappingReceived -= value; + } + + /// + /// Raised when MMS instructs this host to begin NAT hole-punching toward a client. + /// Provides the join ID, client IP, client port, host port, and a startTimeMs correlation timestamp. + /// Forwarded directly from . + /// + public event Action? StartPunchRequested { + add => _webSocket.StartPunchRequested += value; + remove => _webSocket.StartPunchRequested -= value; + } + + /// Updates player count; triggers immediate heartbeat if changed. + public void SetConnectedPlayers(int count) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Connected player count cannot be negative."); + + var previous = Interlocked.Exchange(ref _connectedPlayers, count); + if (previous == count) return; + + if (_hostToken != null) SendHeartbeat(state: null); + } + + /// Creates lobby on MMS and activates session. + public async + Task<((string? lobbyCode, string? lobbyName, string? hostDiscoveryToken) result, MatchmakingError error)> + CreateLobbyAsync( + int hostPort, + bool isPublic, + string gameVersion, + PublicLobbyType lobbyType + ) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (Interlocked.CompareExchange(ref _creationLock, 1, 0) != 0) + return ((null, null, null), MatchmakingError.NetworkFailure); + + try { + lock (_sessionLock) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + if (_hostToken != null) return ((null, null, null), MatchmakingError.NetworkFailure); + } + + using var lease = MmsJsonParser.FormatCreateLobbyJson( + hostPort, isPublic, gameVersion, lobbyType, MmsUtilities.GetLocalIpAddress() + ); + + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + new string(lease.Span) + ); + + if (!response.Success || response.Body == null) + return ((null, null, null), response.Error); + + return TryActivateLobby( + response.Body, + "CreateLobby", + out var lobbyName, + out var lobbyCode, + out var hostDiscoveryToken + ) + ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None) + : ((null, null, null), MatchmakingError.NetworkFailure); + } finally { + Interlocked.Exchange(ref _creationLock, 0); + } + } + + /// + /// Registers an existing Steam lobby with MMS, creating a corresponding MMS lobby entry. + /// + /// Steam lobby identifier to associate. + /// Whether the lobby should appear in public MMS listings. + /// Game version string for matchmaking compatibility. + /// + /// Returns the MMS lobby code on success; otherwise returns null along with a MatchmakingError describing the failure. + /// + public async Task<(string? lobbyCode, MatchmakingError error)> RegisterSteamLobbyAsync( + string steamLobbyId, + bool isPublic, + string gameVersion + ) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (Interlocked.CompareExchange(ref _creationLock, 1, 0) != 0) + return (null, MatchmakingError.NetworkFailure); + + try { + lock (_sessionLock) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + if (_hostToken != null) return (null, MatchmakingError.NetworkFailure); + } + + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + BuildSteamLobbyJson(steamLobbyId, isPublic, gameVersion) + ); + if (!response.Success || response.Body == null) + return (null, response.Error); + + return !TryActivateLobby(response.Body, "RegisterSteamLobby", out _, out var lobbyCode, out _) + ? (null, MatchmakingError.NetworkFailure) + : (lobbyCode, MatchmakingError.None); + } finally { + Interlocked.Exchange(ref _creationLock, 0); + } + } + + /// Stops session: heartbeat, discovery, and socket. Deletes lobby on MMS. + public void CloseLobby() { + (string token, string? lobbyId)? snapshot; + lock (_sessionLock) { + if (_hostToken == null) return; + snapshot = SnapshotAndClearSessionUnsafe(); + StopHeartbeat(); + } + + StopHostDiscoveryRefresh(); + _webSocket.Stop(); + + var (tokenSnapshot, lobbyIdSnapshot) = snapshot.Value; + _ = SafeDeleteLobbyAsync(tokenSnapshot, lobbyIdSnapshot); + } + + /// + /// Starts the WebSocket connection that receives pending-client and punch events + /// from MMS. Requires an active lobby; logs an error and returns if no host token is available. + /// + public void StartWebSocketConnection() { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (_hostToken == null) { + Logger.Error("MmsHostSessionService: cannot start WebSocket without a host token."); + return; + } + + _webSocket.Start(_hostToken); + } + + /// Starts periodic background UDP discovery for external IP learning. + /// Session token sent inside each UDP packet. + /// + /// Callback that writes raw bytes through the caller's UDP socket to the given endpoint. + /// + public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (_discoveryHost == null) return; + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); + + var oldCts = Interlocked.Exchange(ref _hostDiscoveryRefreshCts, cts); + if (oldCts != null) { + try { + oldCts.Cancel(); + } catch (ObjectDisposedException) { + /*ignored*/ + } + + oldCts.Dispose(); + } + + MmsUtilities.RunBackground( + RunHostDiscoveryRefreshAsync(hostDiscoveryToken, sendRawAction, cts), + nameof(MmsHostSessionService), + "host UDP discovery" + ); + } + + /// + /// Cancels the active UDP discovery refresh task, if any. + /// Safe to call when no refresh is running. + /// + public void StopHostDiscoveryRefresh() { + var cts = Interlocked.Exchange(ref _hostDiscoveryRefreshCts, null); + if (cts == null) + return; + try { + cts.Cancel(); + } catch (ObjectDisposedException) { + /*ignored*/ + } + + cts.Dispose(); + } + + /// + /// Builds the JSON request body for a Steam lobby registration. + /// + /// Steam lobby ID sent as ConnectionData. + /// Public-visibility flag. + /// Game version string for compatibility filtering. + /// A JSON string ready to POST to the MMS lobby endpoint. + private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, string gameVersion) => + $"{{\"{MmsFields.ConnectionDataRequest}\":\"{MmsUtilities.EscapeJsonString(steamLobbyId)}\"," + + $"\"{MmsFields.IsPublicRequest}\":{MmsUtilities.BoolToJson(isPublic)}," + + $"\"{MmsFields.GameVersionRequest}\":\"{MmsUtilities.EscapeJsonString(gameVersion)}\"," + + $"\"{MmsFields.LobbyTypeRequest}\":\"steam\"}}"; + + + /// + /// Captures the current session token and lobby ID, then clears both fields. + /// Called during to ensure the delete request uses + /// the correct values even if state is mutated concurrently. + /// IMPORTANT: Must be called while holding _sessionLock, and with _hostToken non-null. + /// + /// + /// A tuple of (hostToken, lobbyId) holding the values that were active + /// at the moment of the snapshot. + /// + private (string token, string? lobbyId) SnapshotAndClearSessionUnsafe() { + System.Diagnostics.Debug.Assert(Monitor.IsEntered(_sessionLock)); + var snapshot = (_hostToken!, _currentLobbyId); + _hostToken = null; + _currentLobbyId = null; + return snapshot; + } + + /// Validates and records lobby activation. Deletes if disposed mid-flight. + /// Raw JSON response body from MMS. + /// Human-readable operation name used in log messages. + /// Receives the lobby display name, or null on failure. + /// Receives the short lobby join code, or null on failure. + /// Receives the UDP discovery token, or null on failure. + /// true if parsing and activation succeeded; false otherwise. + private bool TryActivateLobby( + string response, + string operation, + out string? lobbyName, + out string? lobbyCode, + out string? hostDiscoveryToken + ) { + if (!MmsResponseParser.TryParseLobbyActivation( + response, + out var lobbyId, + out var hostToken, + out lobbyName, + out lobbyCode, + out hostDiscoveryToken + )) { + Logger.Error($"MmsHostSessionService: Invalid {operation} response (length={response.Length})."); + return false; + } + + lock (_sessionLock) { + if (_disposed) { + _ = SafeDeleteLobbyAsync(hostToken!, lobbyId); + return false; + } + + _hostToken = hostToken; + _currentLobbyId = lobbyId; + _heartbeatFailureCount = 0; + StartHeartbeat(); + } + + Logger.Info($"MmsHostSessionService: {operation} succeeded for lobby {lobbyCode}."); + return true; + } + + /// + /// Stops any existing heartbeat timer and starts a new one that fires + /// every . + /// IMPORTANT: Caller must hold _sessionLock. + /// + private void StartHeartbeat() { + StopHeartbeat(); + _heartbeatCts = new CancellationTokenSource(); + _heartbeatTimer = new Timer( + SendHeartbeat, null, MmsProtocol.HeartbeatIntervalMs, MmsProtocol.HeartbeatIntervalMs + ); + } + + /// + /// Disposes the heartbeat timer. Safe to call when no timer is active. + /// IMPORTANT: Caller must hold _sessionLock. + /// + private void StopHeartbeat() { + _heartbeatTimer?.Dispose(); + _heartbeatTimer = null; + if (_heartbeatCts == null) + return; + try { + _heartbeatCts.Cancel(); + } catch (ObjectDisposedException) { + /*ignored*/ + } + + _heartbeatCts.Dispose(); + _heartbeatCts = null; + } + + /// + /// Timer callback that POSTs the current connected-player count to the MMS + /// heartbeat endpoint. Fire-and-forget with a continuation that tracks and logs consecutive failures. + /// Failures are not retried but are logged and tracked via a consecutive failure counter. + /// + /// Unused timer state; always null. + private void SendHeartbeat(object? state) { + string? token; + CancellationToken cancellationToken; + lock (_sessionLock) { + token = _hostToken; + if (token == null || _heartbeatCts == null) + return; + cancellationToken = _heartbeatCts.Token; + } + + var players = Interlocked.CompareExchange(ref _connectedPlayers, 0, 0); + var heartbeatTask = MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.LobbyHeartbeat(token)}", + BuildHeartbeatJson(players) + ); + heartbeatTask.ContinueWith( + task => { + if (cancellationToken.IsCancellationRequested) return; + + if (task.IsFaulted) { + var failures = Interlocked.Increment(ref _heartbeatFailureCount); + Logger.Debug($"MmsHostSessionService: heartbeat send faulted ({failures} consecutive failures)."); + return; + } + + if (task.Result.Success) { + Interlocked.Exchange(ref _heartbeatFailureCount, 0); + return; + } + + var rejectedFailures = Interlocked.Increment(ref _heartbeatFailureCount); + Logger.Debug( + $"MmsHostSessionService: heartbeat rejected or failed ({rejectedFailures} consecutive failures)." + ); + }, + TaskScheduler.Default + ); + } + + /// + /// Builds the JSON body for a heartbeat POST. + /// + /// Current connected-player count to report to MMS. + /// A JSON string ready to POST to the heartbeat endpoint. + private static string BuildHeartbeatJson(int connectedPlayers) => + $"{{\"ConnectedPlayers\":{connectedPlayers}}}"; + + /// + /// Backing task for . Runs + /// . + /// + /// Token forwarded to . + /// UDP send callback forwarded to . + /// The active cancellation token source. + private async Task RunHostDiscoveryRefreshAsync( + string hostDiscoveryToken, + Action sendRawAction, + CancellationTokenSource cts + ) { + try { + // Defensive check; normal flow is already guarded in StartHostDiscoveryRefresh + if (_discoveryHost == null) return; + + await UdpDiscoveryService.SendUntilCancelledAsync( + _discoveryHost, + hostDiscoveryToken, + sendRawAction, + cts.Token + ); + } finally { + // If StopHostDiscoveryRefresh or a new Start request was called concurrently, + // they will have already swapped out _hostDiscoveryRefreshCts and disposed this cts. + // CompareExchange checks if we still own it; if so, we clear the field and dispose it ourselves. + var currentCts = Interlocked.CompareExchange(ref _hostDiscoveryRefreshCts, null, cts); + if (ReferenceEquals(currentCts, cts)) { + cts.Dispose(); + } + } + } + + /// + /// Sends a DELETE to the MMS lobby endpoint. Logs success or warns on failure. + /// Intended to be called fire-and-forget after has + /// already cleared the local session state. + /// + /// Bearer token identifying the lobby to delete. + /// Lobby ID used only for logging. + private async Task SafeDeleteLobbyAsync(string hostToken, string? lobbyId) { + var response = await MmsHttpClient.DeleteAsync($"{_baseUrl}{MmsRoutes.LobbyDelete(hostToken)}"); + if (response.Success) { + Logger.Info($"MmsHostSessionService: closed lobby {lobbyId}."); + return; + } + + Logger.Warn($"MmsHostSessionService: CloseLobby DELETE failed for lobby {lobbyId}."); + } + + /// + /// Marks the service as disposed, prevents further lobby creation, and closes the active lobby if present. + /// + public void Dispose() { + Interlocked.Exchange(ref _creationLock, 1); + lock (_sessionLock) { + _disposed = true; + } + + CloseLobby(); + } +} diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs new file mode 100644 index 0000000..e92bfde --- /dev/null +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Host; + +/// Host-MMS WebSocket manager. No auto-reconnect; manual required on drop. +internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable { + /// The base WebSocket URL of the MMS service. + private readonly string _wsBaseUrl; + + /// Captures context (usually Unity main thread) for event marshaling. + private readonly SynchronizationContext? _mainThreadContext; + + /// Synchronizes swaps of the active socket/CTS pair across overlapping start-stop cycles. + private readonly object _stateGate = new(); + + /// The underlying WebSocket client. + private ClientWebSocket? _socket; + + /// Cancellation source for the background listening loop. + private CancellationTokenSource? _cts; + + /// Generation counter to invalidate stale background runs. + private int _runVersion; + + /// Awaited by to ensure clean exit. + private Task _runTask = Task.CompletedTask; + + /// + /// Maximum time to wait for a graceful WebSocket close handshake before + /// abandoning and disposing the socket. + /// + private static readonly TimeSpan CloseHandshakeTimeout = TimeSpan.FromSeconds(2); + + /// Raised on NAT refresh request. Marshaled to construction thread. + public event Action? RefreshHostMappingRequested; + + /// + /// Raised when MMS signals both sides to start simultaneous hole-punch. + /// Arguments: joinId, clientIp, clientPort, hostPort, startTimeMs. + /// + /// Handlers are always invoked on the that was + /// active when this handler was constructed (typically the Unity main thread). + /// Calling or from within a handler is safe. + /// Calling and awaiting it inline from within a handler + /// will deadlock; schedule the await on a separate task instead. + /// + /// + /// Ordering: Events are posted to the main-thread queue in arrival order, + /// but Unity processes posted callbacks at its own schedule (typically the next + /// frame). Two events received in rapid succession are guaranteed to execute in + /// order, but may execute across different frames. + /// + /// + public event Action? StartPunchRequested; + + /// Raised on mapping confirmation. Marshaled to construction thread. + public event Action? HostMappingReceived; + + /// Initializes handler. Capture current for marshaling. + /// Base WebSocket URL of the MMS service (e.g. wss://mms.example.com). + public MmsWebSocketHandler(string wsBaseUrl) { + _wsBaseUrl = wsBaseUrl; + _mainThreadContext = SynchronizationContext.Current; + } + + /// Opens connection and starts listener. Stops existing connection first. + /// Bearer token used to authenticate the WebSocket URL. + public void Start(string hostToken) { + var runVersion = InvalidateActiveRun(); + _runTask = MmsUtilities.RunBackground( + RunAsync(hostToken, runVersion), + nameof(MmsWebSocketHandler), + "host WebSocket listener" + ); + } + + /// + /// Cancels the listening loop and disposes the WebSocket connection. + /// Safe to call when no connection is active. + /// + public void Stop() { + InvalidateActiveRun(); + } + + /// + /// + /// + /// This overload calls but does not await the + /// background task. If the background loop is still running when this returns, any + /// in-flight I/O may complete after the caller has moved on. + /// Prefer in async contexts to guarantee the background + /// task has fully exited before disposal completes. + /// + /// + public void Dispose() => Stop(); + + /// Stops and awaits background task. Prefer over in async contexts. + public async ValueTask DisposeAsync() { + Stop(); + await _runTask.ConfigureAwait(false); + } + + /// + /// Entry point for the background task. Connects the socket, runs the receive + /// loop, then drains any remaining queued events before tearing down. + /// Each run creates its own isolated event queue so that overlapping start-stop + /// cycles cannot steal or drop events across runs. + /// + /// Bearer token used to build the WebSocket URL. + /// Generation number captured when this run was started. + private async Task RunAsync(string hostToken, int runVersion) { + var cts = new CancellationTokenSource(); + var socket = new ClientWebSocket(); + var eq = new EventQueue(); + + if (!TryRegisterRun(runVersion, socket, cts)) { + cts.Dispose(); + socket.Dispose(); + eq.Dispose(); + return; + } + + try { + await ConnectAsync(socket, hostToken, cts.Token); + var drainTask = DrainEventQueueAsync(eq); + await ReceiveLoopAsync(socket, cts.Token, eq); + // Signal the dispatcher to stop after all queued callbacks are posted. + eq.Enqueue(null); + await drainTask; + } catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Error($"MmsWebSocketHandler: error - {ex.Message}"); + } finally { + eq.Dispose(); + await TearDownSocket(runVersion, socket, cts); + } + } + + /// + /// Connects to the host WebSocket endpoint. + /// + /// The WebSocket instance owned by the current run. + /// Token appended to the WebSocket URL path. + /// Cancellation token for the connection attempt. + private async Task ConnectAsync(ClientWebSocket socket, string hostToken, CancellationToken cancellationToken) { + var uri = new Uri($"{_wsBaseUrl}{MmsRoutes.HostWebSocket(hostToken)}"); + await socket.ConnectAsync(uri, cancellationToken); + Logger.Info("MmsWebSocketHandler: connected"); + } + + /// + /// Reads messages from the supplied until the connection closes or + /// cancellation is requested. Each text frame is forwarded to + /// . Events are enqueued rather than raised directly. + /// + /// The WebSocket instance owned by the current run. + /// Cancellation token that ends the receive loop. + /// Run-local event queue for marshaling event invocations. + private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken, EventQueue eq) { + while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { + WebSocketMessageType messageType; + string? message; + try { + (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, cancellationToken); + } catch (InvalidOperationException ex) { + Logger.Error($"MmsWebSocketHandler: disconnecting - {ex.Message}"); + break; + } + + if (messageType == WebSocketMessageType.Close) break; + if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; + + HandleMessage(message, eq); + } + } + + /// + /// Waits for items in the run-local and dispatches them + /// sequentially until a sentinel is dequeued, indicating + /// shutdown. Runs unconditionally to completion so that no already-queued events + /// are dropped on shutdown. + /// + /// Each action is posted through when one was + /// captured at construction, ensuring all public events are raised on the Unity + /// main thread. When no context is available the action is invoked directly. + /// + /// + /// Ordering vs. same-frame execution: + /// is fire-and-forget -- this loop does not await completion of the posted + /// callback before dequeuing the next item. Consequently: + /// + /// Events are enqueued on the main-thread context in strict arrival + /// order. + /// Unity drains its posted-callback queue at its own cadence (typically once + /// per frame), so two rapidly arriving events are ordered but may execute across + /// different frames. + /// Using SynchronizationContext.Send instead would enforce same-frame + /// execution but risks deadlocking if a handler calls + /// inline, so Post is the correct choice here. + /// See the Unity + /// Asynchronous Programming docs for background on UnitySynchronizationContext. + /// + /// + /// + /// Run-local event queue to drain. + private async Task DrainEventQueueAsync(EventQueue eq) { + while (true) { + await eq.WaitAsync(); + + if (!eq.TryDequeue(out var action) || action == null) + break; + + if (_mainThreadContext != null) + _mainThreadContext.Post(static s => ((Action)s!)(), action); + else + action(); + } + } + + /// + /// Attempts a graceful WebSocket close handshake, clears shared references + /// if this run still owns them, then disposes the socket and cancellation source. + /// Called from the finally block of . + /// + /// Generation number for the run being torn down. + /// The socket owned by that run. + /// The cancellation source owned by that run. + private async Task TearDownSocket(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) { + lock (_stateGate) { + if (_runVersion == runVersion) { + if (ReferenceEquals(_socket, socket)) + _socket = null; + + if (ReferenceEquals(_cts, cts)) + _cts = null; + } + } + + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) { + using var closeCts = new CancellationTokenSource(CloseHandshakeTimeout); + try { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "stopping", closeCts.Token); + } catch { + // best-effort only - swallow all exceptions + } + } + + cts.Dispose(); + socket.Dispose(); + Logger.Info("MmsWebSocketHandler: disconnected"); + } + + /// + /// Cancels any active run and returns the next valid version number. + /// The cancel is performed inside the lock to prevent a race where + /// disposes the CTS on the background thread + /// between the lock release and the Cancel() call on the calling thread. + /// + /// The generation number that should be used by the next background run. + private int InvalidateActiveRun() { + int nextVersion; + + lock (_stateGate) { + _cts?.Cancel(); + _cts = null; + _socket = null; + nextVersion = unchecked(++_runVersion); + } + + return nextVersion; + } + + /// + /// Registers the run-local socket and cancellation source if the run is still current. + /// + /// Generation number captured when the run was started. + /// Socket allocated for the run. + /// Cancellation source allocated for the run. + /// if the run is still current and has become active. + private bool TryRegisterRun(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) { + lock (_stateGate) { + if (_runVersion != runVersion) + return false; + + _socket = socket; + _cts = cts; + return true; + } + } + + /// + /// Extracts the action field from and + /// routes it to the appropriate handler method. + /// Unrecognised actions are silently ignored. + /// + /// Decoded UTF-8 text frame received from MMS. + /// Run-local event queue for marshalling event invocations. + private void HandleMessage(string message, EventQueue eq) { + var span = message.AsSpan(); + var action = MmsJsonParser.ExtractValue(span, MmsFields.Action); + + switch (action) { + case MmsActions.RefreshHostMapping: HandleRefreshHostMapping(span, eq); break; + case MmsActions.StartPunch: HandleStartPunch(span, eq); break; + case MmsActions.HostMappingReceived: HandleHostMappingReceived(eq); break; + case MmsActions.JoinFailed: HandleJoinFailed(message); break; + default: + Logger.Debug($"MmsWebSocketHandler: unknown action '{new string(action)}' mapped to message dropping"); + break; + } + } + + /// + /// Handles a refresh_host_mapping message by extracting the join ID, + /// discovery token, and server timestamp, then enqueuing a raise of + /// . Silently ignored if any required + /// field is missing or unparseable. + /// + /// Span over the raw message text. + /// Run-local event queue for marshalling event invocations. + private void HandleRefreshHostMapping(ReadOnlySpan span, EventQueue eq) { + var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId); + var token = MmsJsonParser.ExtractValue(span, MmsFields.HostDiscoveryToken); + var timeStr = MmsJsonParser.ExtractValue(span, MmsFields.ServerTimeMs); + + if (joinId == null || token == null || !long.TryParse(timeStr, out var time)) + return; + + Logger.Info($"MmsWebSocketHandler: {MmsActions.RefreshHostMapping} for join {joinId}"); + eq.Enqueue(() => RefreshHostMappingRequested?.Invoke(joinId, token, time)); + } + + /// + /// Handles a start_punch message by extracting the join ID, client + /// endpoint, host port, and start timestamp, then enqueuing a raise of + /// . Silently ignored if any required field + /// is missing or unparseable. + /// + /// Span over the raw message text. + /// Run-local event queue for marshalling event invocations. + private void HandleStartPunch(ReadOnlySpan span, EventQueue eq) { + var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId); + var clientIp = MmsJsonParser.ExtractValue(span, MmsFields.ClientIp); + var clientPortStr = MmsJsonParser.ExtractValue(span, MmsFields.ClientPort); + var hostPortStr = MmsJsonParser.ExtractValue(span, MmsFields.HostPort); + var startTimeStr = MmsJsonParser.ExtractValue(span, MmsFields.StartTimeMs); + + if (joinId == null || + clientIp == null || + !int.TryParse(clientPortStr, out var clientPort) || + !int.TryParse(hostPortStr, out var hostPort) || + !long.TryParse(startTimeStr, out var startTimeMs)) + return; + + Logger.Info($"MmsWebSocketHandler: {MmsActions.StartPunch} for join {joinId} -> {clientIp}:{clientPort}"); + eq.Enqueue(() => StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, startTimeMs)); + } + + /// + /// Handles a host_mapping_received message by logging and enqueuing + /// a raise of . + /// + /// Run-local event queue for marshalling event invocations. + private void HandleHostMappingReceived(EventQueue eq) { + Logger.Info($"MmsWebSocketHandler: {MmsActions.HostMappingReceived}"); + eq.Enqueue(() => HostMappingReceived?.Invoke()); + } + + /// + /// Handles a join_failed message by logging the full message body + /// as a warning. No event is raised because the host has no corrective action + /// beyond surfacing the diagnostic. + /// + /// Full raw message text, logged verbatim for diagnostics. + private static void HandleJoinFailed(string message) { + Logger.Warn($"MmsWebSocketHandler: {MmsActions.JoinFailed} - {message}"); + } + + /// + /// Run-local event queue that pairs a with a + /// to provide an async-wait, single-consumer dispatch + /// channel. Each creates its own instance so that + /// overlapping start-stop cycles cannot steal or drop events across runs. + /// + private sealed class EventQueue : IDisposable { + private readonly ConcurrentQueue _queue = new(); + private readonly SemaphoreSlim _semaphore = new(0); + + /// + /// Enqueues an action (or sentinel) and releases the + /// semaphore so the drain loop wakes. + /// + public void Enqueue(Action? action) { + _queue.Enqueue(action); + _semaphore.Release(); + } + + /// Waits until an item is available. + public Task WaitAsync() => _semaphore.WaitAsync(); + + /// Attempts to dequeue the next item. + public bool TryDequeue(out Action? action) => _queue.TryDequeue(out action); + + /// + public void Dispose() => _semaphore.Dispose(); + } +} diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs new file mode 100644 index 0000000..976ccf8 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs @@ -0,0 +1,253 @@ +using System; +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Join; + +/// Client-side matchmaking coordinator. Drives UDP mapping and awaits hole-punch signals. +internal sealed class MmsJoinCoordinator { + /// Base HTTP URL of the MMS server (e.g. https://mms.example.com). + private readonly string _baseUrl; + + /// + /// Hostname used for UDP NAT hole-punch discovery, or null if discovery + /// is unavailable. When null, begin_client_mapping messages are + /// silently skipped. + /// + private readonly string? _discoveryHost; + + /// + /// Initialises a new . + /// + /// Base HTTP URL of the MMS server. + /// + /// Hostname of the MMS UDP discovery endpoint, or null to skip + /// NAT hole-punch discovery. + /// + public MmsJoinCoordinator(string baseUrl, string? discoveryHost) { + _baseUrl = baseUrl; + _discoveryHost = discoveryHost; + } + + /// + /// Mutable holder for the active UDP discovery , + /// allowing handler methods to update it without ref parameters. + /// + private sealed class DiscoverySession : IDisposable { + /// + /// The CTS governing the currently running discovery task, or null + /// if no discovery is active. + /// + public CancellationTokenSource? Cts; + + /// Cancels without disposing it. + public void Cancel() { + Cts?.Cancel(); + } + + /// Cancels and disposes if it is set. + public void Dispose() { + Cts?.Cancel(); + Cts?.Dispose(); + Cts = null; + } + } + + /// Connects to join WebSocket and drives server-directed UDP mapping flow. + public async Task CoordinateAsync( + string joinId, + Action sendRawAction, + Action onJoinFailed, + CancellationToken cancellationToken + ) { + if (_discoveryHost == null) + Logger.Warn("MmsJoinCoordinator: discovery host unknown; UDP mapping will be skipped"); + + using var socket = new ClientWebSocket(); + using var sessionCts = + new CancellationTokenSource(TimeSpan.FromMilliseconds(MmsProtocol.MatchmakingWebSocketTimeoutMs)); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(sessionCts.Token, cancellationToken); + var discovery = new DiscoverySession(); + + try { + await ConnectAsync(socket, joinId, timeoutCts.Token); + return await RunMessageLoopAsync(socket, timeoutCts, sendRawAction, discovery, onJoinFailed); + } catch (OperationCanceledException) { + onJoinFailed("Timeout."); + } catch (WebSocketException ex) { + onJoinFailed(ex.Message); + Logger.Error($"MmsJoinCoordinator: matchmaking WebSocket error: {ex.Message}"); + } catch (Exception ex) { + onJoinFailed(ex.Message); + Logger.Error($"MmsJoinCoordinator: CoordinateAsync failed: {ex.Message}"); + } finally { + discovery.Dispose(); + } + + return null; + } + + /// Connects to the MMS join WebSocket URL. + private async Task ConnectAsync(ClientWebSocket socket, string joinId, CancellationToken ct) { + var wsUrl = + $"{MmsUtilities.ToWebSocketUrl(_baseUrl)}{MmsRoutes.JoinWebSocket(joinId)}" + + $"?{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}"; + + await socket.ConnectAsync(new Uri(wsUrl), ct); + } + + /// Reads WebSocket frames until terminal signal or timeout. + private async Task RunMessageLoopAsync( + ClientWebSocket socket, + CancellationTokenSource timeoutCts, + Action sendRaw, + DiscoverySession discovery, + Action onJoinFailed + ) { + while (socket.State == WebSocketState.Open && !timeoutCts.Token.IsCancellationRequested) { + WebSocketMessageType messageType; + string? message; + try { + (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, timeoutCts.Token); + } catch (InvalidOperationException ex) { + onJoinFailed($"Matchmaking error: {ex.Message}"); + break; + } + + if (messageType == WebSocketMessageType.Close) { + onJoinFailed("Connection closed prematurely by server."); + break; + } + if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; + + var outcome = await HandleMessage(message, timeoutCts, sendRaw, discovery, onJoinFailed); + if (outcome.hasResult) return outcome.result; + } + + return null; + } + + /// Routes message actions to handlers. + private async Task<(bool hasResult, MatchmakingJoinStartResult? result)> HandleMessage( + string message, + CancellationTokenSource timeoutCts, + Action sendRaw, + DiscoverySession discovery, + Action onJoinFailed + ) { + var action = MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.Action); + + switch (action) { + case MmsActions.BeginClientMapping: + RestartDiscovery(message, sendRaw, discovery); + break; + + case MmsActions.StartPunch: + var joinStart = await HandleStartPunchAsync(message, timeoutCts, discovery, onJoinFailed); + return (true, joinStart); + + case MmsActions.ClientMappingReceived: + discovery.Cancel(); + break; + + case MmsActions.JoinFailed: + HandleJoinFailed(message, onJoinFailed); + return (true, null); + + default: + Logger.Debug($"MmsJoinCoordinator: Unknown action '{new string(action)}' mapped to message dropping"); + break; + } + + return (false, null); + } + + /// Restarts UDP discovery with new token. + private void RestartDiscovery( + string message, + Action sendRaw, + DiscoverySession discovery + ) { + var token = MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.ClientDiscoveryToken); + discovery.Cancel(); + discovery.Cts = StartDiscovery(token, sendRaw); + } + + /// Stops discovery, parses payload, and delays until start time. + private static async Task HandleStartPunchAsync( + string message, + CancellationTokenSource timeoutCts, + DiscoverySession discovery, + Action onJoinFailed + ) { + discovery.Cancel(); + + var joinStart = MmsResponseParser.ParseStartPunch(message.AsSpan()); + if (joinStart == null) { + Logger.Warn($"MmsJoinCoordinator: Failed to parse start punch payload: {message}"); + onJoinFailed("Invalid start_punch payload received from server."); + return null; + } + + await DelayUntilAsync(joinStart.StartTimeMs, timeoutCts.Token); + return joinStart; + } + + /// + /// Starts a new UDP discovery task for . + /// Returns null without starting anything if + /// is null or empty, or if is null. + /// + /// UDP discovery token from the begin_client_mapping message. + /// UDP send callback forwarded to . + /// + /// A new governing the started discovery + /// task, or null if discovery was not started. + /// + private CancellationTokenSource? StartDiscovery(string? token, Action sendRaw) { + if (string.IsNullOrEmpty(token)) { + Logger.Warn("MmsJoinCoordinator: begin_client_mapping missing token"); + return null; + } + + if (_discoveryHost == null) + return null; + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); + MmsUtilities.RunBackground( + UdpDiscoveryService.SendUntilCancelledAsync(_discoveryHost, token, sendRaw, cts.Token), + nameof(MmsJoinCoordinator), + "client UDP discovery" + ); + return cts; + } + + /// + /// Waits until the specified Unix timestamp (in milliseconds) before returning. + /// Returns immediately if the target time is already in the past. If the target time + /// is far in the future, the delay will simply block until fires. + /// + /// Target time expressed as milliseconds since the Unix epoch (UTC). + /// Cancellation token that can abort the wait early. + private static async Task DelayUntilAsync(long targetUnixMs, CancellationToken ct) { + var delayMs = targetUnixMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (delayMs > 0) await Task.Delay(TimeSpan.FromMilliseconds(delayMs), ct); + } + + /// + /// Extracts the server-supplied failure reason from a join_failed message, + /// forwards it to the caller, and records the payload for diagnostics. + /// + /// Raw JSON WebSocket message from MMS. + /// Callback that updates higher-level matchmaking state. + private static void HandleJoinFailed(string message, Action onJoinFailed) { + onJoinFailed(MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.Reason) ?? "join_failed"); + Logger.Warn($"MmsJoinCoordinator: {MmsActions.JoinFailed} - {message}"); + } +} diff --git a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs new file mode 100644 index 0000000..a862e68 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Join; + +/// Sends UDP discovery pulses to learn external IP/port for NAT hole-punching. +internal static class UdpDiscoveryService { + /// Expected discovery token length in bytes. + private const int ExpectedTokenByteLength = 32; + + /// Resolves endpoint and sends token pulses until cancellation. + public static async Task SendUntilCancelledAsync( + string discoveryHost, + string token, + Action sendRaw, + CancellationToken cancellationToken + ) { + var endpoint = await ResolveEndpointAsync(discoveryHost); + if (endpoint is null) return; + + var tokenBytes = EncodeToken(token); + if (tokenBytes.Length != ExpectedTokenByteLength) { + Logger.Error( + $"UdpDiscoveryService: discovery token encoded to {tokenBytes.Length} bytes; expected {ExpectedTokenByteLength}. Aborting discovery." + ); + return; + } + + await RunDiscoveryLoopAsync(sendRaw, tokenBytes, endpoint, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves to an on + /// . Returns null and logs an + /// error if DNS resolution yields no addresses. + /// + private static async Task ResolveEndpointAsync(string host) { + try { + var addresses = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false); + + if (addresses is { Length: > 0 }) { + var address = addresses[0]; + foreach (var a in addresses) { + if (a.AddressFamily != AddressFamily.InterNetwork) + continue; + address = a; + break; + } + return new IPEndPoint(address, MmsProtocol.DiscoveryPort); + } + + Logger.Error($"UdpDiscoveryService: could not resolve host '{host}'"); + return null; + } catch (Exception ex) when (ex is SocketException or OperationCanceledException) { + Logger.Warn($"UdpDiscoveryService: DNS resolution failed for '{host}': {ex.Message}"); + return null; + } + } + + /// Encodes to a UTF-8 byte array. + private static byte[] EncodeToken(string token) => + Encoding.UTF8.GetBytes(token); + + /// + /// Loops, sending to + /// every until + /// fires or a send error occurs. + /// + private static async Task RunDiscoveryLoopAsync( + Action sendRaw, + byte[] tokenBytes, + IPEndPoint endpoint, + CancellationToken cancellationToken + ) { + while (!cancellationToken.IsCancellationRequested) { + TrySend(sendRaw, tokenBytes, endpoint); + + if (!await TryDelayAsync(cancellationToken).ConfigureAwait(false)) return; + } + } + + /// + /// Attempts a single send. Returns false (and logs a warning) on failure. + /// + private static void TrySend( + Action sendRaw, + byte[] tokenBytes, + IPEndPoint endpoint + ) { + try { + sendRaw(tokenBytes, endpoint); + } catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Warn($"UdpDiscoveryService: send error, aborting – {ex}"); + } + } + + /// + /// Waits for one discovery interval. Returns false when the + /// cancellation token fires (normal shutdown), true otherwise. + /// + private static async Task TryDelayAsync(CancellationToken cancellationToken) { + try { + await Task.Delay(MmsProtocol.DiscoveryIntervalMs, cancellationToken) + .ConfigureAwait(false); + return true; + } catch (OperationCanceledException) { + return false; + } + } +} diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 9b11641..15ece9b 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -1,751 +1,156 @@ using System; -using System.Buffers; using System.Collections.Generic; -using System.Net.Http; -using System.Net.WebSockets; -using System.Text; +using System.Net; using System.Threading; using System.Threading.Tasks; using SSMP.Logging; -using System.Net.Sockets; -using System.Net; +using SSMP.Networking.Matchmaking.Host; +using SSMP.Networking.Matchmaking.Join; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Query; +using SSMP.Networking.Matchmaking.Utilities; namespace SSMP.Networking.Matchmaking; -/// -/// High-performance client for the MatchMaking Service (MMS) API. -/// Handles lobby creation, lookup, heartbeat, and NAT hole-punching coordination. -/// -internal class MmsClient { - /// - /// Base URL of the MMS server (e.g., "http://localhost:5000") - /// - private readonly string _baseUrl; - - /// - /// MMS UDP discovery host derived from the configured base URL. - /// - private readonly string? _discoveryHost; - - /// - /// Authentication token for host operations (heartbeat, close, pending clients). - /// Set when a lobby is created, cleared when closed. - /// - private string? _hostToken; - - /// - /// The currently active lobby ID, if this client is hosting a lobby. - /// - private string? CurrentLobbyId { get; set; } - - /// - /// Timer that sends periodic heartbeats to keep the lobby alive on the MMS. - /// Fires every 30 seconds while a lobby is active. - /// - private Timer? _heartbeatTimer; - - /// - /// Interval between heartbeat requests (30 seconds). - /// Keeps the lobby registered and prevents timeout on the MMS. - /// - private const int HeartbeatIntervalMs = 30000; - - /// - /// HTTP request timeout in milliseconds (5 seconds). - /// Prevents hanging on unresponsive server. - /// - private const int HttpTimeoutMs = 5000; - - /// - /// WebSocket connection for receiving push notifications from MMS. - /// - private ClientWebSocket? _hostWebSocket; - - /// - /// Cancellation token source for WebSocket connection. - /// - private CancellationTokenSource? _webSocketCts; - - /// - /// Reusable empty JSON object bytes for heartbeat requests. - /// Eliminates allocations since heartbeats send no data. - /// - private static readonly byte[] EmptyJsonBytes = "{}"u8.ToArray(); - - /// - /// Shared character array pool for zero-allocation JSON string building. - /// Reuses buffers across all JSON formatting operations. - /// - private static readonly ArrayPool CharPool = ArrayPool.Shared; - - /// - /// Shared HttpClient instance for connection pooling and reuse across all MmsClient instances. - /// This provides 3-5x performance improvement over creating new connections per request. - /// Configured for optimal performance with disabled cookies, proxies, and redirects. - /// - private static readonly HttpClient HttpClient = CreateHttpClient(); - - /// The port used for UDP discovery broadcasts. - private const int DiscoveryPort = 5001; +/// Public entry point for MMS coordination. Delegates to specialized services. +internal sealed class MmsClient { + private readonly MmsHostSessionService _hostSession; + private readonly MmsLobbyQueryService _queries; + private readonly MmsJoinCoordinator _joinCoordinator; - /// An empty JSON object body used for POST requests that require no payload. - private const string EmptyJsonBody = "{}"; + /// Last error from most recent operation. + public MatchmakingError LastMatchmakingError { get; private set; } = MatchmakingError.None; - /// - /// Creates and configures the shared HttpClient with optimal performance settings. - /// - /// Configured HttpClient instance for MMS API calls - private static HttpClient CreateHttpClient() { - // Configure handler for maximum performance - var handler = new HttpClientHandler { - // Skip proxy detection for faster connections - UseProxy = false, - // MMS doesn't use cookies - UseCookies = false, - // MMS doesn't redirect - AllowAutoRedirect = false - }; - - // Configure ServicePointManager for connection pooling (works in Unity Mono) - ServicePointManager.DefaultConnectionLimit = 10; - // Disable Nagle for lower latency - ServicePointManager.UseNagleAlgorithm = false; - // Skip 100-Continue handshake - ServicePointManager.Expect100Continue = false; - - return new HttpClient(handler) { - Timeout = TimeSpan.FromMilliseconds(HttpTimeoutMs) - }; + /// + public event Action? RefreshHostMappingRequested { + add => _hostSession.RefreshHostMappingRequested += value; + remove => _hostSession.RefreshHostMappingRequested -= value; } - /// - /// Static constructor to hook process exit and dispose the shared HttpClient. - /// Ensures that OS-level resources are released when the host process shuts down. - /// - static MmsClient() { - AppDomain.CurrentDomain.ProcessExit += (_, _) => { HttpClient.Dispose(); }; + /// + public event Action? HostMappingReceived { + add => _hostSession.HostMappingReceived += value; + remove => _hostSession.HostMappingReceived -= value; } - /// - /// Initializes a new instance of the MmsClient. - /// - /// Base URL of the MMS server (default: "http://localhost:5000") - public MmsClient(string baseUrl = "http://localhost:5000") { - _baseUrl = baseUrl.TrimEnd('/'); + /// + public event Action? StartPunchRequested { + add => _hostSession.StartPunchRequested += value; + remove => _hostSession.StartPunchRequested -= value; + } - if (Uri.TryCreate(_baseUrl, UriKind.Absolute, out var baseUri)) { - _discoveryHost = baseUri.Host; + public MmsClient( + string baseUrl, + MmsHostSessionService? hostSession = null, + MmsLobbyQueryService? queries = null, + MmsJoinCoordinator? joinCoordinator = null + ) { + var normalizedBaseUrl = baseUrl.TrimEnd('/'); + if (!Uri.TryCreate(normalizedBaseUrl, UriKind.Absolute, out var uri)) { + throw new ArgumentException($"Invalid base URL: {baseUrl}. NAT discovery will fail.", nameof(baseUrl)); } + + var discoveryHost = uri.Host; + + _hostSession = hostSession ?? + new MmsHostSessionService( + normalizedBaseUrl, + discoveryHost, + new MmsWebSocketHandler(MmsUtilities.ToWebSocketUrl(normalizedBaseUrl)) + ); + _queries = queries ?? new MmsLobbyQueryService(normalizedBaseUrl); + _joinCoordinator = joinCoordinator ?? new MmsJoinCoordinator(normalizedBaseUrl, discoveryHost); } + /// Updates connected players; triggers heartbeat if count changes. + public void SetConnectedPlayers(int count) => _hostSession.SetConnectedPlayers(count); - /// - /// Creates a new lobby asynchronously with configuration options. - /// Non-blocking - runs STUN discovery and HTTP request on background thread. - /// - /// Local port the host is listening on. - /// Whether to list in public browser. - /// Game version for compatibility. - /// Type of lobby. - /// Task containing the lobby code, lobby name, and host discovery token if successful. + /// Creates lobby and starts host services. + /// Lobby code, lobby name, and host discovery token; all null on failure. public async Task<(string? lobbyCode, string? lobbyName, string? hostDiscoveryToken)> CreateLobbyAsync( int hostPort, bool isPublic = true, string gameVersion = "unknown", PublicLobbyType lobbyType = PublicLobbyType.Matchmaking ) { - try { - var buffer = CharPool.Rent(512); - try { - var localIp = GetLocalIpAddress(); - var length = FormatCreateLobbyJsonPortOnly( - buffer, hostPort, isPublic, gameVersion, lobbyType, localIp - ); - Logger.Info($"MmsClient: Creating lobby on port {hostPort}, Local IP: {localIp}"); - - var json = new string(buffer, 0, length); - var response = await PostJsonAsync($"{_baseUrl}/lobby", json); - if (response == null) return (null, null, null); - - var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData"); - var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken"); - var lobbyName = ExtractJsonValueSpan(response.AsSpan(), "lobbyName"); - var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode"); - var hostDiscoveryToken = ExtractJsonValueSpan(response.AsSpan(), "hostDiscoveryToken"); - - if (lobbyId == null || hostToken == null || lobbyName == null || lobbyCode == null) { - Logger.Error($"MmsClient: Invalid response from CreateLobby: {response}"); - return (null, null, null); - } - - _hostToken = hostToken; - CurrentLobbyId = lobbyId; - StartHeartbeat(); - - Logger.Info($"MmsClient: Created lobby {lobbyCode}, token {hostDiscoveryToken}"); - return (lobbyCode, lobbyName, hostDiscoveryToken); - } finally { - CharPool.Return(buffer); - } - } catch (Exception ex) { - Logger.Error($"MmsClient: Failed to create lobby: {ex.Message}"); - return (null, null, null); - } + ClearErrors(); + var result = await _hostSession.CreateLobbyAsync(hostPort, isPublic, gameVersion, lobbyType); + LastMatchmakingError = result.error; + return result.result; } - /// - /// Registers a Steam lobby with MMS for discovery. - /// Called after creating a Steam lobby via SteamMatchmaking.CreateLobby(). - /// - /// The Steam lobby ID (CSteamID as string) - /// Whether to list in public browser - /// Game version for compatibility - /// Task containing the MMS lobby ID if successful, null on failure + /// Registers existing Steam lobby for discovery. + /// MMS lobby code, or null on failure. public async Task RegisterSteamLobbyAsync( string steamLobbyId, bool isPublic = true, string gameVersion = "unknown" ) { - try { - var json = - $"{{\"ConnectionData\":\"{steamLobbyId}\",\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"steam\"}}"; - - var response = await PostJsonAsync($"{_baseUrl}/lobby", json); - if (response == null) return null; - - var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData"); - var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken"); - var lobbyName = ExtractJsonValueSpan(response.AsSpan(), "lobbyName"); - var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode"); - - if (lobbyId == null || hostToken == null || lobbyName == null || lobbyCode == null) { - Logger.Error($"MmsClient: Invalid response from RegisterSteamLobby: {response}"); - return null; - } - - _hostToken = hostToken; - CurrentLobbyId = lobbyId; - - StartHeartbeat(); - Logger.Info($"MmsClient: Registered Steam lobby {steamLobbyId} as MMS lobby {lobbyCode}"); - return lobbyCode; - } catch (TaskCanceledException) { - Logger.Warn("MmsClient: Steam lobby registration was canceled"); - return null; - } catch (Exception ex) { - Logger.Warn($"MmsClient: Failed to register Steam lobby: {ex.Message}"); - return null; - } - } - - /// - /// Gets the list of public lobbies asynchronously. - /// Non-blocking - runs HTTP request on background thread. - /// - /// Optional: filter by Steam or Matchmaking. - /// Task containing list of public lobby info, or null on failure. - public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) { - try { - var url = $"{_baseUrl}/lobbies"; - if (lobbyType != null) { - url += $"?type={lobbyType.ToString().ToLower()}"; - } - - var response = await GetJsonAsync(url); - if (response == null) return null; - - var result = new List(); - var span = response.AsSpan(); - var idx = 0; - - while (idx < span.Length) { - var connStart = span[idx..].IndexOf("\"connectionData\":"); - if (connStart == -1) break; - - connStart += idx; - var connectionData = ExtractJsonValueSpan(span[connStart..], "connectionData"); - var name = ExtractJsonValueSpan(span[connStart..], "name"); - var typeString = ExtractJsonValueSpan(span[connStart..], "lobbyType"); - var code = ExtractJsonValueSpan(span[connStart..], "lobbyCode"); - - PublicLobbyType? type = null; - if (typeString != null) { - Enum.TryParse(typeString, true, out PublicLobbyType parsedType); - type = parsedType; - } - - if (connectionData != null && name != null) { - result.Add( - new PublicLobbyInfo( - connectionData, name, type ?? PublicLobbyType.Matchmaking, code ?? "" - ) - ); - } - - idx = connStart + 1; - } - - return result; - } catch (Exception ex) { - Logger.Error($"MmsClient: Failed to get public lobbies: {ex.Message}"); - return null; - } + ClearErrors(); + var result = await _hostSession.RegisterSteamLobbyAsync(steamLobbyId, isPublic, gameVersion); + LastMatchmakingError = result.error; + return result.lobbyCode; } + /// Closes active lobby and deregisters from MMS. + public void CloseLobby() => _hostSession.CloseLobby(); - /// - /// Closes the currently hosted lobby and unregisters it from the MMS. - /// Stops heartbeat and WebSocket connection. - /// - public void CloseLobby() { - if (_hostToken == null) return; - - // Stop all connections before closing - StopHeartbeat(); - StopWebSocket(); - - try { - // Send DELETE request to remove lobby from MMS (run on background thread) - Task.Run(async () => await DeleteRequestAsync($"{_baseUrl}/lobby/{_hostToken}")).Wait(HttpTimeoutMs); - Logger.Info($"MmsClient: Closed lobby {CurrentLobbyId}"); - } catch (Exception ex) { - Logger.Warn($"MmsClient: Failed to close lobby: {ex.Message}"); - } - - // Clear state - _hostToken = null; - CurrentLobbyId = null; + /// Retrieves join details from MMS. + public async Task JoinLobbyAsync(string lobbyId, int clientPort) { + ClearErrors(); + var result = await _queries.JoinLobbyAsync(lobbyId, clientPort); + LastMatchmakingError = result.error; + return result.result; } - /// - /// Joins a lobby, performs NAT hole-punching, and returns host connection details. - /// - /// The ID of the lobby to join - /// The local port the client is listening on - /// Host connection details (connectionData, lobbyType, and optionally lanConnectionData) or null on - /// failure - public async Task<(string connectionData, PublicLobbyType lobbyType, string? lanConnectionData, string? - clientDiscoveryToken)?> JoinLobbyAsync( - string lobbyId, - int clientPort + /// Drives UDP discovery and WebSocket hole-punch signal wait. + public async Task CoordinateMatchmakingJoinAsync( + string joinId, + Action sendRawAction, + CancellationToken cancellationToken = default ) { - try { - var jsonRequest = $"{{\"ClientIp\":null,\"ClientPort\":{clientPort}}}"; - var response = await PostJsonAsync($"{_baseUrl}/lobby/{lobbyId}/join", jsonRequest); - - if (response == null) return null; - - var buffer = CharPool.Rent(response.Length); - try { - response.CopyTo(0, buffer, 0, response.Length); - var span = buffer.AsSpan(0, response.Length); - - var connectionData = ExtractJsonValueSpan(span, "connectionData"); - var lobbyTypeString = ExtractJsonValueSpan(span, "lobbyType"); - var lanConnectionData = ExtractJsonValueSpan(span, "lanConnectionData"); - var clientDiscoveryToken = ExtractJsonValueSpan(span, "clientDiscoveryToken"); - - if (connectionData == null || lobbyTypeString == null) { - Logger.Error($"MmsClient: Invalid response from JoinLobby: {response}"); - return null; - } - - if (!Enum.TryParse(lobbyTypeString, true, out PublicLobbyType lobbyType)) { - Logger.Error($"MmsClient: Invalid lobby type from JoinLobby: {lobbyTypeString}"); - return null; - } - - Logger.Info( - $"MmsClient: Joined lobby {lobbyId}, type: {lobbyType}, connection: {connectionData}, lan: {lanConnectionData}" - ); - return (connectionData, lobbyType, lanConnectionData, clientDiscoveryToken); - } finally { - CharPool.Return(buffer); - } - } catch (Exception ex) { - Logger.Error($"MmsClient: Failed to join lobby: {ex.Message}"); - return null; - } - } - - /// - /// Event raised when a pending client needs NAT hole-punching. - /// Subscribers should send packets to the specified endpoint to punch through NAT. - /// - public static event Action? PunchClientRequested; - - /// - /// Starts WebSocket connection to MMS for receiving push notifications. - /// Should be called after creating a lobby to enable instant client notifications. - /// - public void StartPendingClientPolling() { - if (_hostToken == null) { - Logger.Error("MmsClient: Cannot start WebSocket without host token"); - return; - } - - // Run WebSocket connection on background thread - Task.Run(ConnectWebSocketAsync); - } - - /// - /// Connects to MMS WebSocket and listens for pending client notifications. - /// - private async Task ConnectWebSocketAsync() { - StopWebSocket(); // Ensure no duplicate connections - - _webSocketCts = new CancellationTokenSource(); - _hostWebSocket = new ClientWebSocket(); - - try { - // Convert http:// to ws:// - var wsUrl = _baseUrl.Replace("http://", "ws://").Replace("https://", "wss://"); - var uri = new Uri($"{wsUrl}/ws/{_hostToken}"); - - await _hostWebSocket.ConnectAsync(uri, _webSocketCts.Token); - Logger.Info($"MmsClient: WebSocket connected to MMS"); - - // Listen for messages - var buffer = new byte[1024]; - while (_hostWebSocket.State == WebSocketState.Open && !_webSocketCts.Token.IsCancellationRequested) { - var result = await _hostWebSocket.ReceiveAsync(buffer, _webSocketCts.Token); - if (result.MessageType == WebSocketMessageType.Close) break; - - if (result is { MessageType: WebSocketMessageType.Text, Count: > 0 }) { - var message = Encoding.UTF8.GetString(buffer, 0, result.Count); - HandleWebSocketMessage(message); - } - } - } catch (Exception ex) when (ex is not OperationCanceledException) { - Logger.Error($"MmsClient: WebSocket error: {ex.Message}"); - } finally { - _hostWebSocket?.Dispose(); - _hostWebSocket = null; - Logger.Info("MmsClient: WebSocket disconnected"); - } - } - - /// - /// Handles incoming WebSocket message containing pending client info. - /// - private void HandleWebSocketMessage(string message) { - // Parse JSON: {"clientIp":"x.x.x.x","clientPort":12345} - var ip = ExtractJsonValueSpan(message.AsSpan(), "clientIp"); - var portStr = ExtractJsonValueSpan(message.AsSpan(), "clientPort"); - - if (ip != null && int.TryParse(portStr, out var port)) { - Logger.Info($"MmsClient: WebSocket received pending client {ip}:{port}"); - PunchClientRequested?.Invoke(ip, port); - } - } - - /// - /// Stops WebSocket connection. - /// - private void StopWebSocket() { - _webSocketCts?.Cancel(); - _webSocketCts?.Dispose(); - _webSocketCts = null; - _hostWebSocket?.Dispose(); - _hostWebSocket = null; - } - - /// - /// Starts the heartbeat timer to keep the lobby alive on the MMS. - /// Lobbies without heartbeats expire after a timeout period. - /// - private void StartHeartbeat() { - StopHeartbeat(); // Ensure no duplicate timers - _heartbeatTimer = new Timer(SendHeartbeat, null, HeartbeatIntervalMs, HeartbeatIntervalMs); - } - - /// - /// Stops the heartbeat timer. - /// Called when lobby is closed. - /// - private void StopHeartbeat() { - _heartbeatTimer?.Dispose(); - _heartbeatTimer = null; - } - - /// - /// Timer callback that sends a heartbeat to the MMS. - /// Uses empty JSON body and reusable byte array to minimize allocations. - /// - /// Unused timer state parameter - private void SendHeartbeat(object? state) { - if (_hostToken == null) return; - - try { - // Send empty JSON body - just need to hit the endpoint (run on background thread) - Task.Run(async () => await PostJsonBytesAsync($"{_baseUrl}/lobby/heartbeat/{_hostToken}", EmptyJsonBytes)) - .Wait(HttpTimeoutMs); - } catch (Exception ex) { - Logger.Warn($"MmsClient: Heartbeat failed: {ex.Message}"); - } - } - - #region HTTP Helpers (Async with HttpClient) - - /// - /// Performs an HTTP GET request and returns the response body as a string. - /// Uses ResponseHeadersRead for efficient streaming. - /// - /// The URL to GET - /// Response body as string, or null if request failed - private static async Task GetJsonAsync(string url) { - try { - // ResponseHeadersRead allows streaming without buffering entire response - var response = await HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - if (!response.IsSuccessStatusCode) return null; - - return await response.Content.ReadAsStringAsync(); - } catch (HttpRequestException) { - // Network error or invalid response - return null; - } catch (TaskCanceledException) { - // Timeout exceeded - return null; - } - } - - /// - /// Performs an HTTP POST request with JSON content. - /// - /// The URL to POST to - /// JSON string to send as request body - /// Response body as string - private static async Task PostJsonAsync(string url, string json) { - // StringContent handles UTF-8 encoding and sets Content-Type header - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var response = await HttpClient.PostAsync(url, content); - return await response.Content.ReadAsStringAsync(); + ClearErrors(); + return await _joinCoordinator.CoordinateAsync(joinId, sendRawAction, SetJoinFailed, cancellationToken); } - /// - /// Performs an HTTP POST request with pre-encoded JSON bytes. - /// More efficient than string-based version for reusable content like heartbeats. - /// - /// The URL to POST to - /// JSON bytes to send as request body - /// Response body as string - private static async Task PostJsonBytesAsync(string url, byte[] jsonBytes) { - using var content = new ByteArrayContent(jsonBytes); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - using var response = await HttpClient.PostAsync(url, content); - return await response.Content.ReadAsStringAsync(); + /// Fetches public lobbies from MMS. + public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) { + ClearErrors(); + var result = await _queries.GetPublicLobbiesAsync(lobbyType); + LastMatchmakingError = result.error; + return result.lobbies; } - /// - /// Performs an HTTP DELETE request. - /// Used to close lobbies on the MMS. - /// - /// The URL to DELETE - private static async Task DeleteRequestAsync(string url) { - await HttpClient.DeleteAsync(url); + /// Checks server reaching and version compatibility. + public async Task ProbeMatchmakingCompatibilityAsync() { + ClearErrors(); + var (isCompatible, error) = await _queries.ProbeMatchmakingCompatibilityAsync(); + LastMatchmakingError = error; + return isCompatible; } - #endregion - - #region Zero-Allocation JSON Helpers + /// Starts host push event listener. Call after creating lobby. + public void StartWebSocketConnection() => _hostSession.StartWebSocketConnection(); - /// - /// Formats JSON for CreateLobby request with port only. - /// MMS will use the HTTP connection's source IP as the host address. - /// - private static int FormatCreateLobbyJsonPortOnly( - Span buffer, - int port, - bool isPublic, - string gameVersion, - PublicLobbyType lobbyType, - string? hostLanIp - ) { - var lanIpPart = hostLanIp != null ? $",\"HostLanIp\":\"{hostLanIp}:{port}\"" : ""; - var json = - $"{{\"HostPort\":{port},\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"{lobbyType.ToString().ToLower()}\"{lanIpPart}}}"; - json.AsSpan().CopyTo(buffer); - return json.Length; - } - - /// - /// Extracts a JSON value by key from a JSON string using zero allocations. - /// Supports both string values (quoted) and numeric values (unquoted). - /// - /// JSON string to search - /// Key to find (without quotes) - /// The value as a string, or null if not found - /// - /// This is a simple parser suitable for MMS responses. It assumes well-formed JSON. - /// Searches for "key": pattern and extracts the following value. - /// - private static string? ExtractJsonValueSpan(ReadOnlySpan json, string key) { - // Build search pattern: "key": - Span searchKey = stackalloc char[key.Length + 3]; - searchKey[0] = '"'; - key.AsSpan().CopyTo(searchKey[1..]); - searchKey[key.Length + 1] = '"'; - searchKey[key.Length + 2] = ':'; + /// Triggers background UDP discovery refresh for given token. + public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) => + _hostSession.StartHostDiscoveryRefresh(hostDiscoveryToken, sendRawAction); - // Find the key in JSON - var idx = json.IndexOf(searchKey, StringComparison.Ordinal); - if (idx == -1) return null; - - var valueStart = idx + searchKey.Length; - - // Skip any whitespace after the colon - while (valueStart < json.Length && char.IsWhiteSpace(json[valueStart])) - valueStart++; - - if (valueStart >= json.Length) return null; - - // Determine if value is quoted (string) or unquoted (number) - if (json[valueStart] == '"') { - // String value - find closing quote - var valueEnd = json[(valueStart + 1)..].IndexOf('"'); - return valueEnd == -1 ? null : json.Slice(valueStart + 1, valueEnd).ToString(); - } else { - // Numeric value - read until non-digit character - var valueEnd = valueStart; - while (valueEnd < json.Length && - (char.IsDigit(json[valueEnd]) || json[valueEnd] == '.' || json[valueEnd] == '-')) - valueEnd++; - return json.Slice(valueStart, valueEnd - valueStart).ToString(); - } - } - - #endregion - - /// - /// Gets the local IP address of the machine. - /// Uses a UDP socket to determine the routing to the internet to pick the correct interface. - /// Will not actually establish a connection, so used IP and port (8.8.8.8:65530) are irrelevant. - /// - private static string? GetLocalIpAddress() { - try { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); - socket.Connect("8.8.8.8", 65530); - return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString(); - } catch { - return null; - } - } + /// Stops active host discovery refresh loop. + public void StopHostDiscoveryRefresh() => _hostSession.StopHostDiscoveryRefresh(); /// - /// Performs UDP port discovery by sending packets and polling the TCP verification endpoint. - /// Returns the discovered external port, or null if discovery times out. + /// Signals a join failure with a specific reason. /// - /// Discovery token issued by MMS for this host or client session. - /// Callback that sends the raw token bytes through the caller's pre-bound UDP socket. - /// The discovered external port, or null if discovery fails or times out. - public async Task PerformDiscoveryAsync( - string token, - Action sendRawAction - ) { - if (_discoveryHost is null) { - Logger.Error("MmsClient: MMS URL must use a direct IP address for UDP discovery"); - return null; - } - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - - var tokenBytes = Encoding.UTF8.GetBytes(token); - var encodedToken = Uri.EscapeDataString(token); - - // Resolve the host address for the MMS discovery service - var hostAddresses = await Dns.GetHostAddressesAsync(_discoveryHost); - if (hostAddresses == null || hostAddresses.Length == 0) { - Logger.Error($"MmsClient: Could not resolve discovery host ({_discoveryHost}) to IP address"); - return null; - } - - var ipEndpoint = new IPEndPoint(hostAddresses[0], DiscoveryPort); - - // Send UDP packets in background until discovery succeeds or times out - var udpTask = SendDiscoveryPacketsAsync(tokenBytes, ipEndpoint, sendRawAction, cts.Token); - - try { - while (!cts.Token.IsCancellationRequested) { - var response = await PostJsonAsync( - $"{_baseUrl}/lobby/discovery/verify/{encodedToken}", - EmptyJsonBody - ); - - if (response is not null) { - var portStr = ExtractJsonValueSpan(response.AsSpan(), "externalPort"); - - if (int.TryParse(portStr, out var port)) { - cts.Cancel(); - return port; - } - } - - await Task.Delay(1000, cts.Token); - } - } catch (OperationCanceledException) { - // Either timed out (cts expired) or canceled after successful discovery above - } finally { - // Ensure the UDP loop has exited before returning - await udpTask; - } - - return null; + private void SetJoinFailed(string reason) { + Logger.Warn($"MmsClient: matchmaking join failed – {reason}"); + LastMatchmakingError = MatchmakingError.JoinFailed; } /// - /// Sends discovery packets asynchronously in a loop until canceled. + /// Clears the internal and HTTP error states. /// - /// The bytes of the discovery token to send. - /// The target IP endpoint (MMS discovery port). - /// Action to send raw UDP data. - /// Token to cancel the sending loop. - /// A task representing the asynchronous operation. - private static async Task SendDiscoveryPacketsAsync( - byte[] tokenBytes, - IPEndPoint endpoint, - Action sendRawAction, - CancellationToken cancellationToken - ) { - while (!cancellationToken.IsCancellationRequested) { - try { - sendRawAction(tokenBytes, endpoint); - } catch (SocketException ex) { - Logger.Debug($"Transient socket error during UDP discovery send - Exception: {ex}"); - } catch (Exception ex) { - Logger.Warn($"Unexpected error during UDP discovery send; aborting loop - Exception: {ex}"); - return; - } - - try { - await Task.Delay(500, cancellationToken); - } catch (OperationCanceledException) { - break; - } - } + private void ClearErrors() { + LastMatchmakingError = MatchmakingError.None; } } - -/// -/// Public lobby information for the lobby browser. -/// -public record PublicLobbyInfo( - // IP:Port for Matchmaking, Steam lobby ID for Steam - string ConnectionData, - string Name, - PublicLobbyType LobbyType, - string LobbyCode -); - -/// -/// Enum for public lobby types. -/// -public enum PublicLobbyType { - /// - /// Standalone matchmaking through MMS. - /// - Matchmaking, - /// - /// Steam matchmaking through MMS. - /// - Steam -} diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs new file mode 100644 index 0000000..031ed34 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs @@ -0,0 +1,166 @@ +using System; +using System.Buffers; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Parsing; + +/// Reads and writes the small JSON payloads used by MMS. +internal static class MmsJsonParser { + /// + /// A scoped rental of a pooled char buffer. Disposing returns the buffer to the shared + /// pool, making double-return safe and ensuring the caller cannot forget to release. + /// + internal sealed class CharLease : IDisposable { + private static readonly ArrayPool Pool = ArrayPool.Shared; + private char[]? _buffer; + + /// The number of valid characters in . + private int Length { get; } + + /// The serialized JSON content, valid only while this lease is undisposed. + public ReadOnlySpan Span => _buffer != null + ? _buffer.AsSpan(0, Length) + : ReadOnlySpan.Empty; + + internal CharLease(char[] buffer, int length) { + _buffer = buffer; + Length = length; + } + + /// Returns the rented buffer to the pool. Safe to call more than once. + public void Dispose() { + var buf = _buffer; + if (buf == null) { + return; + } + + _buffer = null; + Pool.Return(buf); + } + } + + /// + /// Parses a JSON string and returns the first property with the requested key. + /// Returns null when the payload is invalid or the key is missing. + /// + private static string? ExtractValue(string json, string key) { + try { + var token = JToken.Parse(json); + var property = FindPropertyRecursive(token, key); + return property == null ? null : ConvertTokenToString(property.Value); + } catch (JsonReaderException) { + return null; + } + } + + /// + /// Span-based wrapper for callers that already have a message buffer. + /// Converts once, then reuses the string overload. + /// + public static string? ExtractValue(ReadOnlySpan json, string key) => ExtractValue(json.ToString(), key); + + /// + /// Serializes the create-lobby payload into a scoped . + /// Dispose the returned lease (e.g. with using) to return the buffer to the pool. + /// + public static CharLease FormatCreateLobbyJson( + int port, + bool isPublic, + string gameVersion, + PublicLobbyType lobbyType, + string? hostLanIp + ) { + var payload = new JObject { + [MmsFields.HostPortRequest] = port, + [MmsFields.IsPublicRequest] = isPublic, + [MmsFields.GameVersionRequest] = gameVersion, + [MmsFields.LobbyTypeRequest] = SerializeLobbyType(lobbyType) + }; + + if (hostLanIp != null) { + payload[MmsFields.HostLanIpRequest] = $"{hostLanIp}:{port}"; + } + + // Matchmaking lobbies carry a protocol version so MMS can reject stale clients. + if (lobbyType == PublicLobbyType.Matchmaking) { + payload[MmsFields.MatchmakingVersionRequest] = MmsProtocol.CurrentVersion; + } + + var json = payload.ToString(Formatting.None); + var buffer = ArrayPool.Shared.Rent(json.Length); + json.AsSpan().CopyTo(buffer); + return new CharLease(buffer, json.Length); + } + + /// Walks nested objects and arrays until it finds a matching property name. + private static JProperty? FindPropertyRecursive(JToken token, string key) { + switch (token.Type) { + case JTokenType.Object: { + foreach (var property in token.Children()) { + if (string.Equals(property.Name, key, StringComparison.Ordinal)) { + return property; + } + + var nestedMatch = FindPropertyRecursive(property.Value, key); + if (nestedMatch != null) { + return nestedMatch; + } + } + + return null; + } + + case JTokenType.Array: + return token.Children() + .Select(child => FindPropertyRecursive(child, key)) + .OfType() + .FirstOrDefault(); + + // Scalar and leaf token types contain no child properties to search. + case JTokenType.None: + case JTokenType.Constructor: + case JTokenType.Property: + case JTokenType.Comment: + case JTokenType.Integer: + case JTokenType.Float: + case JTokenType.String: + case JTokenType.Boolean: + case JTokenType.Null: + case JTokenType.Undefined: + case JTokenType.Date: + case JTokenType.Raw: + case JTokenType.Bytes: + case JTokenType.Guid: + case JTokenType.Uri: + case JTokenType.TimeSpan: + default: + return null; + } + } + + /// + /// Maps the local enum to the lowercase lobby type string MMS expects. + /// Throws for unrecognized values so that + /// new enum members are caught at development time rather than silently misrouted. + /// + private static string SerializeLobbyType(PublicLobbyType lobbyType) => lobbyType switch { + PublicLobbyType.Matchmaking => "matchmaking", + PublicLobbyType.Steam => "steam", + _ => throw new ArgumentOutOfRangeException(nameof(lobbyType), lobbyType, + $"No MMS name defined for lobby type '{lobbyType}'.") + }; + + /// Converts a JSON token into the string shape expected by existing callers. + private static string? ConvertTokenToString(JToken token) => token.Type switch { + JTokenType.Null => null, + JTokenType.String => token.Value(), + JTokenType.Integer => token.Value().ToString(CultureInfo.InvariantCulture), + JTokenType.Float => token.Value().ToString(CultureInfo.InvariantCulture), + JTokenType.Boolean => token.Value() ? "true" : "false", + _ => token.ToString(Formatting.None) + }; +} diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs new file mode 100644 index 0000000..dfff463 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Parsing; + +/// Builds Matchmaking models from raw MMS response payloads. +internal static class MmsResponseParser { + /// + /// Reads the create-lobby response fields needed to start a host session. + /// Returns false when any required field is missing. + /// + /// + /// is intentionally excluded from the required-field + /// check: MMS omits it for non-matchmaking lobbies, so null is a valid server response and + /// callers must guard against it rather than treating it as a parse failure. + /// + public static bool TryParseLobbyActivation( + string response, + out string? lobbyId, + out string? hostToken, + out string? lobbyName, + out string? lobbyCode, + out string? hostDiscoveryToken + ) { + var root = ParseJsonObject(response); + lobbyId = root?.Value(MmsFields.ConnectionData); + hostToken = root?.Value(MmsFields.HostToken); + lobbyName = root?.Value(MmsFields.LobbyName); + lobbyCode = root?.Value(MmsFields.LobbyCode); + hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken); + + return lobbyId != null && hostToken != null && lobbyName != null && lobbyCode != null; + } + + /// + /// Parses the join response into the local result model. + /// Returns null when MMS omits the required connection fields. + /// + public static JoinLobbyResult? ParseJoinLobbyResult(string response) { + var root = ParseJsonObject(response); + if (root == null) { + return null; + } + + var connectionData = root.Value(MmsFields.ConnectionData); + var lobbyTypeString = root.Value(MmsFields.LobbyType); + + if (connectionData == null || lobbyTypeString == null) { + return null; + } + + return new JoinLobbyResult { + ConnectionData = connectionData, + LobbyType = ParseLobbyType(lobbyTypeString), + LanConnectionData = root.Value(MmsFields.LanConnectionData), + JoinId = root.Value(MmsFields.JoinId) + }; + } + + /// + /// Parses the public lobby listing returned by MMS. + /// Returns an empty list when the payload is malformed. + /// + public static List ParsePublicLobbies(string response) { + try { + var lobbies = ParseLobbiesAsArray(response); + return ExtractValidLobbies(lobbies); + } catch (JsonReaderException) { + Logger.Debug("MmsResponseParser: Failed to parse public lobbies JSON."); + return []; + } + } + + /// + /// Parses the start-punch message sent before synchronized hole punching. + /// Returns null when the host endpoint or timestamp is missing. + /// + private static MatchmakingJoinStartResult? ParseStartPunch(string json) { + var root = ParseJsonObject(json); + if (root == null) { + return null; + } + + var hostIp = root.Value(MmsFields.HostIp); + var hostPort = root.Value(MmsFields.HostPort); + var startTime = root.Value(MmsFields.StartTimeMs); + + if (hostIp == null || hostPort == null || startTime == null) { + return null; + } + + return new MatchmakingJoinStartResult { + HostIp = hostIp, + HostPort = hostPort.Value, + StartTimeMs = startTime.Value + }; + } + + /// Span-based wrapper for callers that already work with message spans. + public static MatchmakingJoinStartResult? ParseStartPunch(ReadOnlySpan span) => + ParseStartPunch(span.ToString()); + + /// Normalizes lobby-list payloads so callers can always iterate a JSON array. + private static JArray ParseLobbiesAsArray(string response) { + return JToken.Parse(response) switch { + JArray array => array, + JObject obj => [obj], + var other => LogAndReturnEmpty(other) + }; + + static JArray LogAndReturnEmpty(JToken other) { + Logger.Debug($"MmsResponseParser: Unexpected lobby payload token type '{other.Type}', expected array or object."); + return []; + } + } + + /// Filters malformed lobby entries and converts the valid ones to models. + private static List ExtractValidLobbies(JArray lobbies) { + var result = new List(lobbies.Count); + + foreach (var token in lobbies) { + if (token is not JObject lobbyObject) { + Logger.Debug("MmsResponseParser: Skipped non-object lobby entry."); + continue; + } + + var lobby = TryParseLobbyEntry(lobbyObject); + if (lobby != null) { + result.Add(lobby); + } else { + Logger.Debug("MmsResponseParser: Skipped unparseable lobby entry."); + } + } + + return result; + } + + /// Parses one lobby entry from the public lobby list. + private static PublicLobbyInfo? TryParseLobbyEntry(JObject lobby) { + var connectionData = lobby.Value(MmsFields.ConnectionData); + var name = lobby.Value(MmsFields.Name); + var lobbyCode = lobby.Value(MmsFields.LobbyCode); + + // All three fields are required: a missing lobby code would break client-side + // code entry and is not a valid state a well-formed MMS response can produce. + if (connectionData == null || name == null || lobbyCode == null) { + return null; + } + + var lobbyTypeString = lobby.Value(MmsFields.LobbyType); + var lobbyType = lobbyTypeString != null + ? ParseLobbyType(lobbyTypeString) + : PublicLobbyType.Matchmaking; + + return new PublicLobbyInfo(connectionData, name, lobbyType, lobbyCode); + } + + /// Parses a lobby type string and defaults unknown values to Matchmaking. + private static PublicLobbyType ParseLobbyType(string lobbyTypeString) { + if (Enum.TryParse(lobbyTypeString, ignoreCase: true, out PublicLobbyType lobbyType)) { + return lobbyType; + } + + Logger.Debug($"MmsResponseParser: Unknown lobby type '{lobbyTypeString}', defaulting to Matchmaking."); + return PublicLobbyType.Matchmaking; + } + + /// Parses a JSON object root and returns null when the payload is invalid. + private static JObject? ParseJsonObject(string json) { + try { + return JObject.Parse(json); + } catch (JsonReaderException) { + return null; + } + } +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs b/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs new file mode 100644 index 0000000..119debf --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs @@ -0,0 +1,27 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// WebSocket action field values exchanged between the MMS server +/// and both host and client connections. +/// +internal static class MmsActions +{ + /// Sent by the server to a joining client to initiate its NAT mapping process. + public const string BeginClientMapping = "begin_client_mapping"; + + /// Sent by the server to a host to request it to start hole punching towards a client. + public const string StartPunch = "start_punch"; + + /// Sent by the client to the server once its NAT mapping has been successfully determined. + public const string ClientMappingReceived = "client_mapping_received"; + + /// Sent by the server to a client when a join request has failed. + public const string JoinFailed = "join_failed"; + + /// Sent by the server to a host to request a refresh of its NAT mapping. + public const string RefreshHostMapping = "refresh_host_mapping"; + + /// Sent by the host to the server once its NAT mapping has been successfully refreshed or determined. + public const string HostMappingReceived = "host_mapping_received"; + +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs new file mode 100644 index 0000000..eac7fff --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs @@ -0,0 +1,97 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// JSON property names used in MMS request and response bodies. +/// +internal static class MmsFields +{ + /// The action being performed in a WebSocket message. + public const string Action = "action"; + + /// The error code returned in a failed response. + public const string ErrorCode = "errorCode"; + + /// A machine-readable failure reason returned in an error or control message. + public const string Reason = "reason"; + + /// The version of the protocol or application. + public const string Version = "version"; + + /// A general-purpose name field (e.g., player name). + public const string Name = "name"; + + /// The current server time in milliseconds. + public const string ServerTimeMs = "serverTimeMs"; + + /// The start time of an event in milliseconds. + public const string StartTimeMs = "startTimeMs"; + + /// Opaque connection data used for NAT traversal. + public const string ConnectionData = "connectionData"; + + /// A unique token identifying a host session. + public const string HostToken = "hostToken"; + + /// The display name of the lobby. + public const string LobbyName = "lobbyName"; + + /// A short code used to join a specific lobby. + public const string LobbyCode = "lobbyCode"; + + /// A token used by the host for discovery purposes. + public const string HostDiscoveryToken = "hostDiscoveryToken"; + + /// The type of lobby (e.g., Public, Private). + public const string LobbyType = "lobbyType"; + + /// Connection data specifically for LAN connections. + public const string LanConnectionData = "lanConnectionData"; + + /// A token used by the client for discovery purposes. + public const string ClientDiscoveryToken = "clientDiscoveryToken"; + + /// A unique identifier for a join attempt. + public const string JoinId = "joinId"; + + /// The public IP address of the host. + public const string HostIp = "hostIp"; + + /// The public port of the host. + public const string HostPort = "hostPort"; + + /// The public IP address of the client. + public const string ClientIp = "clientIp"; + + /// The public port of the client. + public const string ClientPort = "clientPort"; + + // Request-specific fields (often used in HTTP POST bodies) + + /// The port the host is listening on (Request field). + public const string HostPortRequest = "HostPort"; + + /// Whether the lobby is public (Request field). + public const string IsPublicRequest = "IsPublic"; + + /// The version of the game (Request field). + public const string GameVersionRequest = "GameVersion"; + + /// The type of lobby (Request field). + public const string LobbyTypeRequest = "LobbyType"; + + /// The LAN IP address of the host (Request field). + public const string HostLanIpRequest = "HostLanIp"; + + /// Opaque connection data (Request field). + public const string ConnectionDataRequest = "ConnectionData"; + + /// The IP address of the client (Request field). + public const string ClientIpRequest = "ClientIp"; + + /// The port of the client (Request field). + public const string ClientPortRequest = "ClientPort"; + + /// The version of the matchmaking system (Request field). + public const string MatchmakingVersionRequest = "MatchmakingVersion"; + +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs new file mode 100644 index 0000000..5d066d2 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs @@ -0,0 +1,92 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// MatchMaking Service (MMS) protocol constants. +internal static class MmsProtocol { + /// The current version of the matchmaking protocol. + public const int CurrentVersion = 1; + + /// Error code returned by MMS when the client version is too old. + public const string UpdateRequiredErrorCode = "update_required"; + + /// Interval for sending heartbeats to the server. + public const int HeartbeatIntervalMs = 30_000; + + /// Timeout for HTTP requests to the MMS API. + public const int HttpTimeoutMs = 5_000; + + /// The UDP port used for NAT discovery. + public const int DiscoveryPort = 6001; + + /// Timeout for the client-side matchmaking WebSocket handshake. + public const int MatchmakingWebSocketTimeoutMs = 20_000; + + /// Duration for which UDP discovery packets are sent. + public const int DiscoveryDurationSeconds = 15; + + /// Interval between individual UDP discovery packets. + public const int DiscoveryIntervalMs = 500; +} + +/// Matchmaking error classification. +internal enum MatchmakingError { + /// No error. + None, + + /// The client matchmaking version is outdated. + UpdateRequired, + + /// The join operation failed (e.g. timeout or invalid ID). + JoinFailed, + + /// A generic network error occurred. + NetworkFailure +} + +/// Supported MMS lobby subtypes. +public enum PublicLobbyType { + /// Standalone matchmaking through MMS. + Matchmaking, + + /// Steam matchmaking through MMS. + Steam +} + +/// Lobby join request metadata. +internal sealed class JoinLobbyResult { + /// The connection string for the lobby (e.g. "IP:Port" or Steam ID). + public required string ConnectionData { get; init; } + + /// The type of the lobby. + public required PublicLobbyType LobbyType { get; init; } + + /// Optional LAN connection string for local play. + public string? LanConnectionData { get; init; } + + /// Unique ID for the join session. + public string? JoinId { get; init; } +} + +/// NAT hole-punch peer data and synchronized start time. +internal sealed class MatchmakingJoinStartResult { + /// The resolved public IP of the host. + public required string HostIp { get; init; } + + /// The resolved public port of the host. + public required int HostPort { get; init; } + + /// The Unix timestamp (ms) when both sides should start punching. + public required long StartTimeMs { get; init; } +} + +/// Registry entry for the lobby browser. +/// The connection string for the lobby (e.g. "IP:Port" or Steam ID). +/// The display name of the lobby. +/// The type of the lobby (e.g. Matchmaking or Steam). +/// A short alphanumeric code used to join the lobby. +public record PublicLobbyInfo( + string ConnectionData, + string Name, + PublicLobbyType LobbyType, + string LobbyCode +); + diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs b/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs new file mode 100644 index 0000000..e5270e8 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs @@ -0,0 +1,14 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// Query-string parameter keys appended to MMS request URLs. +/// +internal static class MmsQueryKeys +{ + /// The type of lobby (e.g. Matchmaking or Steam). + public const string Type = "type"; + + /// The version of the matchmaking protocol being used by the client. + public const string MatchmakingVersion = "matchmakingVersion"; + +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs b/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs new file mode 100644 index 0000000..3fcece1 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs @@ -0,0 +1,45 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// MMS REST and WebSocket route segments. Use the static helper methods to +/// build parameterised paths; use the constants directly for fixed routes. +/// +internal static class MmsRoutes { + /// MMS health-check and version endpoint. + public const string Root = "/health"; + + /// Base path for all lobby operations. + public const string Lobby = "/lobby"; + + /// Public lobby listing endpoint. + public const string Lobbies = "/lobbies"; + + /// Base path for all WebSocket connections. + private const string WebSocketBase = "/ws"; + + /// Builds the URL path for a client to join a specific lobby. + /// The unique identifier or short code of the lobby. + /// The formatted join route. + public static string LobbyJoin(string lobbyId) => $"{Lobby}/{lobbyId}/join"; + + /// Builds the URL path for a host to send a heartbeat for its lobby. + /// The token identifying the host session. + /// The formatted heartbeat route. + public static string LobbyHeartbeat(string hostToken) => $"{Lobby}/heartbeat/{hostToken}"; + + /// Builds the URL path for a host to delete its lobby. + /// The token identifying the host session. + /// The formatted delete route. + public static string LobbyDelete(string hostToken) => $"{Lobby}/{hostToken}"; + + /// Builds the WebSocket path for a client to connect for matchmaking coordination. + /// The unique join attempt identifier. + /// The formatted WebSocket join route. + public static string JoinWebSocket(string joinId) => $"{WebSocketBase}/join/{joinId}"; + + /// Builds the WebSocket path for a host to connect for matchmaking coordination. + /// The token identifying the host session. + /// The formatted WebSocket host route. + public static string HostWebSocket(string hostToken) => $"{WebSocketBase}/{hostToken}"; +} + diff --git a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs new file mode 100644 index 0000000..3b5ea04 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Transport; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Query; + +/// Non-host queries: joining, browsing public lobbies, and health/version probes. +internal sealed class MmsLobbyQueryService { + /// Base HTTP URL of the MMS server (e.g. https://mms.example.com). + private readonly string _baseUrl; + + /// Initializes a new . + public MmsLobbyQueryService(string baseUrl) { + _baseUrl = baseUrl; + } + + /// Sends join request with local UDP port for NAT mapping. + public async Task<(JoinLobbyResult? result, MatchmakingError error)> + JoinLobbyAsync(string lobbyId, int clientPort) { + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.LobbyJoin(lobbyId)}", + BuildJoinRequestJson(clientPort) + ); + if (!response.Success || response.Body == null) + return (null, response.Error); + + var result = ParseAndLogJoinResult(lobbyId, response.Body); + return result == null + ? (null, MatchmakingError.NetworkFailure) + : (result, MatchmakingError.None); + } + + /// Retrieves public lobbies, filtered by type and protocol version. + public async Task<(List? lobbies, MatchmakingError error)> GetPublicLobbiesAsync( + PublicLobbyType? lobbyType = null + ) { + var url = BuildPublicLobbiesUrl(lobbyType); + var response = await MmsHttpClient.GetAsync(url); + if (!response.Success || response.Body == null) { + return (null, response.Error); + } + + var startIdx = MmsUtilities.SkipWhitespace(response.Body.AsSpan(), 0); + if (startIdx >= response.Body.Length || response.Body[startIdx] != '[') { + Logger.Warn($"MmsLobbyQueryService: GetPublicLobbiesAsync received malformed non-array response (length={response.Body.Length})."); + return (null, MatchmakingError.NetworkFailure); + } + + return (MmsResponseParser.ParsePublicLobbies(response.Body), MatchmakingError.None); + } + + /// Probes server health and protocol compatibility. + public async Task<(bool? isCompatible, MatchmakingError error)> ProbeMatchmakingCompatibilityAsync() { + var response = await MmsHttpClient.GetAsync($"{_baseUrl}{MmsRoutes.Root}"); + if (!response.Success || response.Body == null) + return (null, response.Error); + + if (!TryParseServerVersion(response.Body, out var serverVersion)) + return (null, MatchmakingError.NetworkFailure); + + return CheckVersionCompatibility(serverVersion); + } + + /// + /// Builds the JSON request body for a lobby join, advertising the client's + /// local port and protocol version. ClientIp is sent as null; + /// MMS infers the external IP from the incoming socket address. + /// + /// Local UDP port the client is listening on. + /// A JSON string ready to POST to the join endpoint. + private static string BuildJoinRequestJson(int clientPort) => + $"{{\"{MmsFields.ClientIpRequest}\":null," + + $"\"{MmsFields.ClientPortRequest}\":{clientPort}," + + $"\"{MmsFields.MatchmakingVersionRequest}\":{MmsProtocol.CurrentVersion}}}"; + + /// + /// Parses the join response and logs the outcome. Returns the result on + /// success or logs an error and returns null on parse failure. + /// + /// Lobby ID, used only for log messages. + /// Raw JSON response body from the join endpoint. + /// A populated , or null if parsing failed. + private static JoinLobbyResult? ParseAndLogJoinResult(string lobbyId, string response) { + var joinResult = MmsResponseParser.ParseJoinLobbyResult(response); + if (joinResult == null) { + var hasJoinId = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.JoinId) != null; + var hasConnectionData = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.ConnectionData) != null; + Logger.Error( + $"MmsLobbyQueryService: invalid JoinLobby response (length={response.Length}, hasJoinId={hasJoinId}, hasConnectionData={hasConnectionData})." + ); + return null; + } + + Logger.Info($"MmsLobbyQueryService: joined lobby {lobbyId}, type={joinResult.LobbyType}."); + return joinResult; + } + + /// + /// Extracts and parses the version field from an MMS health response. + /// Logs a warning and returns false if the field is absent or non-numeric. + /// + /// Raw JSON health response body. + /// Receives the parsed version number on success; 0 on failure. + /// true if a valid integer version was found; false otherwise. + private static bool TryParseServerVersion(string response, out int serverVersion) { + var versionString = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.Version); + if (int.TryParse(versionString, out serverVersion)) return true; + + Logger.Warn("MmsLobbyQueryService: MMS health response did not include a valid protocol version."); + return false; + } + + /// + /// Compares against + /// and returns the appropriate + /// compatibility result. Logs a warning on mismatch. + /// + /// Protocol version reported by the MMS health endpoint. + /// + /// (true, None) if versions match; + /// (false, UpdateRequired) if they differ. + /// + private static (bool? isCompatible, MatchmakingError error) CheckVersionCompatibility(int serverVersion) { + if (serverVersion == MmsProtocol.CurrentVersion) + return (true, MatchmakingError.None); + + Logger.Warn( + $"MmsLobbyQueryService: MMS protocol mismatch " + + $"(client={MmsProtocol.CurrentVersion}, server={serverVersion})." + ); + return (false, MatchmakingError.UpdateRequired); + } + + /// + /// Builds the public lobbies query URL, appending a type filter when + /// is specified and a matchmakingVersion + /// parameter when the type is . + /// + /// + /// Optional lobby type filter. null returns the unfiltered lobbies URL. + /// + /// The fully constructed URL string ready for an HTTP GET request. + private string BuildPublicLobbiesUrl(PublicLobbyType? lobbyType) { + var url = $"{_baseUrl}{MmsRoutes.Lobbies}"; + if (lobbyType == null) return url; + + var typeString = lobbyType == PublicLobbyType.Matchmaking ? "matchmaking" : "steam"; + url += $"?{MmsQueryKeys.Type}={typeString}"; + if (lobbyType == PublicLobbyType.Matchmaking) + url += $"&{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}"; + + return url; + } +} diff --git a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs new file mode 100644 index 0000000..9122f2b --- /dev/null +++ b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs @@ -0,0 +1,117 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Transport; + +/// HTTP transport for MMS; shared for pool reuse. +internal static class MmsHttpClient { + /// Shared HTTP client instance for connection pooling. + private static readonly HttpClient Http = CreateHttpClient(); + + static MmsHttpClient() { + // Note: ProcessExit only fires on graceful shutdown. + // Hard crashes will bypass this, meaning the OS will clean up the socket. + AppDomain.CurrentDomain.ProcessExit += (_, _) => Http.Dispose(); + } + + /// + /// Performs a GET request to the specified URL. + /// + public static async Task GetAsync(string url) { + try { + using var response = await Http.GetAsync(url); + var body = await response.Content.ReadAsStringAsync(); + return new MmsHttpResponse( + response.IsSuccessStatusCode, + body, + InspectErrorBody(response.StatusCode, body) + ); + } catch (Exception ex) when (IsTransient(ex)) { + return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure); + } + } + + /// + /// Performs a POST request with a JSON body to the specified URL. + /// + public static async Task PostJsonAsync(string url, string json) { + try { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await Http.PostAsync(url, content); + var body = await response.Content.ReadAsStringAsync(); + return new MmsHttpResponse( + response.IsSuccessStatusCode, + body, + InspectErrorBody(response.StatusCode, body) + ); + } catch (Exception ex) when (IsTransient(ex)) { + return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure); + } + } + + /// Returns HTTP metadata and classified matchmaking errors without throwing. + public static async Task DeleteAsync(string url) { + try { + using var response = await Http.DeleteAsync(url); + var body = await response.Content.ReadAsStringAsync(); + return new MmsHttpResponse( + response.IsSuccessStatusCode, + body, + InspectErrorBody(response.StatusCode, body) + ); + } catch (Exception ex) when (IsTransient(ex)) { + return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure); + } + } + + /// + /// Checks the response body for MMS-specific error codes. + /// + /// The HTTP status code. + /// The response body. + private static MatchmakingError InspectErrorBody(HttpStatusCode status, string? body) { + if ((int) status < 400 || body == null) return MatchmakingError.None; + + var errorCode = MmsJsonParser.ExtractValue(body.AsSpan(), MmsFields.ErrorCode); + return errorCode == MmsProtocol.UpdateRequiredErrorCode + ? MatchmakingError.UpdateRequired + : MatchmakingError.NetworkFailure; + } + + /// + /// Determines if an exception represents a transient network issue. + /// + /// The exception to check. + /// true if transient; otherwise, false. + private static bool IsTransient(Exception ex) => + ex is HttpRequestException or TaskCanceledException; + + /// + /// Configures and returns an optimized instance. + /// + /// A new instance. + private static HttpClient CreateHttpClient() { + var handler = new HttpClientHandler { + UseProxy = false, + UseCookies = false, + AllowAutoRedirect = false + }; + + var client = new HttpClient(handler) { + Timeout = TimeSpan.FromMilliseconds(MmsProtocol.HttpTimeoutMs) + }; + client.DefaultRequestHeaders.ExpectContinue = false; + return client; + } +} + +internal readonly record struct MmsHttpResponse( + bool Success, + string? Body, + MatchmakingError Error +); diff --git a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs new file mode 100644 index 0000000..c0978e5 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs @@ -0,0 +1,161 @@ +using System; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; + +namespace SSMP.Networking.Matchmaking.Utilities; + +/// Stateless helpers for MMS protocol handling. +internal static class MmsUtilities { + /// Converts http(s) to ws(s). Throws if not absolute. + public static string ToWebSocketUrl(string httpUrl) { + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri)) + throw new ArgumentException("Matchmaking URL must be an absolute URI.", nameof(httpUrl)); + + var scheme = uri.Scheme switch { + "http" => "ws", + "https" => "wss", + _ => throw new ArgumentException("Matchmaking URL must use http or https.", nameof(httpUrl)) + }; + + var builder = new UriBuilder(uri) { Scheme = scheme }; + return builder.Uri.AbsoluteUri.TrimEnd('/'); + } + + /// Returns JSON literal for boolean. + public static string BoolToJson(bool value) => value ? "true" : "false"; + + /// Logs unexpected failures in fire-and-forget task. + /// The task to monitor. + /// Component name included in failure logs. + /// Human-readable operation label for diagnostics. + public static Task RunBackground(Task task, string owner, string operationName) => + ObserveAsync(task, owner, operationName); + + /// Assembles text frames from WebSocket. Returns null payload for non-text/close. + public static async Task<(WebSocketMessageType messageType, string? message)> ReceiveTextMessageAsync( + ClientWebSocket socket, + CancellationToken cancellationToken, + int maxMessageBytes = 16 * 1024 + ) { + const int chunkSize = 1024; + var buffer = ArrayPool.Shared.Rent(chunkSize); + var writer = new ArrayBufferWriter(); + + try { + while (true) { + var frame = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (frame.MessageType == WebSocketMessageType.Close) + return (frame.MessageType, null); + + AppendFrame(writer, buffer, frame.Count, maxMessageBytes); + + if (!frame.EndOfMessage) + continue; + + return frame.MessageType != WebSocketMessageType.Text + ? (frame.MessageType, null) + : (frame.MessageType, + writer.WrittenCount == 0 ? string.Empty : Encoding.UTF8.GetString(writer.WrittenSpan)); + } + } finally { + ArrayPool.Shared.Return(buffer); + } + } + + /// Deterministically identifies outbound IPv4 via dummy UDP connect. + public static string? GetLocalIpAddress() { + try { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); + socket.Connect("8.8.8.8", 65530); + return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString(); + } catch (Exception ex) { + Logger.Debug($"MmsUtilities: GetLocalIpAddress failed: {ex.Message}"); + return null; + } + } + + /// + /// Awaits a background task and suppresses expected cancellation while logging unexpected failures. + /// + /// The task being observed. + /// Component name included in failure logs. + /// Human-readable operation label for diagnostics. + private static async Task ObserveAsync(Task task, string owner, string operationName) { + try { + await task.ConfigureAwait(false); + } catch (OperationCanceledException) { + /*ignored*/ + } catch (Exception ex) { + Logger.Warn($"{owner}: {operationName} failed: {ex.Message}"); + } + } + + /// + /// Appends a single received frame into the accumulating message buffer while enforcing the message size cap. + /// + /// The destination buffer holding the message assembled so far. + /// Scratch receive buffer containing the latest frame bytes. + /// Number of valid bytes currently in . + /// Maximum total message size allowed. + private static void AppendFrame(ArrayBufferWriter writer, byte[] buffer, int count, int maxMessageBytes) { + if (count <= 0) + return; + + var nextLength = writer.WrittenCount + count; + if (nextLength > maxMessageBytes) + throw new InvalidOperationException("Matchmaking WebSocket message exceeded the maximum size."); + + buffer.AsSpan(0, count).CopyTo(writer.GetSpan(count)); + writer.Advance(count); + } + + + /// + /// Advances an index past any whitespace characters in a JSON span. + /// + /// The JSON character span being parsed. + /// The position to start skipping from. + /// The index of the first non-whitespace character, or .Length if the rest of the span is whitespace. + public static int SkipWhitespace(ReadOnlySpan json, int index) { + while (index < json.Length && char.IsWhiteSpace(json[index])) + index++; + + return index; + } + + /// + /// Escapes a string for safe embedding in JSON, encoding special characters + /// and non-printable control characters as their JSON escape sequences. + /// + /// The raw string to escape. + /// A JSON-safe escaped string, without surrounding quotes. + public static string EscapeJsonString(string value) { + var builder = new StringBuilder(value.Length); + foreach (var ch in value) { + switch (ch) { + case '"': builder.Append("\\\""); break; + case '\\': builder.Append(@"\\"); break; + case '/': builder.Append("\\/"); break; + case '\b': builder.Append("\\b"); break; + case '\f': builder.Append("\\f"); break; + case '\n': builder.Append("\\n"); break; + case '\r': builder.Append("\\r"); break; + case '\t': builder.Append("\\t"); break; + default: + if (char.IsControl(ch)) + builder.AppendFormat("\\u{0:X4}", (int) ch); + else + builder.Append(ch); + break; + } + } + + return builder.ToString(); + } +} diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs index a6891c4..34365f6 100644 --- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs +++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs @@ -1,6 +1,7 @@ using System; using System.Net; using System.Net.Sockets; +using System.Threading.Tasks; using System.Threading; using SSMP.Logging; using SSMP.Networking.Client; @@ -56,6 +57,11 @@ internal class HolePunchEncryptedTransport : IEncryptedTransport { /// private const int PunchPacketDelayMs = 50; + /// + /// Small synchronous burst sent before DTLS starts so port-restricted NATs see the peer endpoint immediately. + /// + private const int InitialPunchPacketCount = 5; + /// /// The IP address used for self-connecting (host connecting to own server). /// Localhost connections bypass hole-punching as no NAT traversal is needed. @@ -235,9 +241,10 @@ public void Disconnect() { /// Hole-punching sequence: /// 1. Reuse pre-bound socket from STUN discovery (or create new one) /// 2. Configure socket to ignore ICMP Port Unreachable errors - /// 3. Send 100 "PUNCH" packets over 5 seconds to open NAT mapping + /// 3. Send a short priming burst to open the NAT mapping /// 4. Connect socket to peer endpoint - /// 5. Return socket for DTLS handshake + /// 5. Continue punching in the background while DTLS handshakes + /// 6. Return socket for DTLS handshake /// private static Socket PerformHolePunch(string address, int port) { // Attempt to reuse the socket passed from ConnectInterface @@ -266,14 +273,11 @@ private static Socket PerformHolePunch(string address, int port) { // Parse target endpoint var endpoint = new IPEndPoint(IPAddress.Parse(address), port); - Logger.Debug($"HolePunch: Sending {PunchPacketCount} punch packets to {endpoint}"); + Logger.Debug($"HolePunch: Sending initial punch burst ({InitialPunchPacketCount} packets) to {endpoint}"); - // Send punch packets to create/maintain NAT mapping - // Each packet refreshes the NAT timer and increases chance of success - for (var i = 0; i < PunchPacketCount; i++) { + // Prime the NAT mapping immediately before DTLS begins. + for (var i = 0; i < InitialPunchPacketCount; i++) { socket.SendTo(PunchPacket, endpoint); - - // Wait between packets to spread them over time Thread.Sleep(PunchPacketDelayMs); } @@ -281,6 +285,7 @@ private static Socket PerformHolePunch(string address, int port) { // This is important for DTLS which expects point-to-point communication socket.Connect(endpoint); + StartBackgroundPunchBurst(socket, endpoint); Logger.Info($"HolePunch: NAT traversal complete, socket connected to {endpoint}"); return socket; } catch (Exception ex) { @@ -290,6 +295,31 @@ private static Socket PerformHolePunch(string address, int port) { } } + /// + /// Continues sending punch packets while DTLS handshakes so stricter NATs keep the mapping alive. + /// + private static void StartBackgroundPunchBurst(Socket socket, IPEndPoint endpoint) { + _ = Task.Run(() => { + try { + Logger.Debug( + $"HolePunch: Continuing background punch burst ({PunchPacketCount - InitialPunchPacketCount} packets) to {endpoint}" + ); + for (var i = InitialPunchPacketCount; i < PunchPacketCount; i++) { + socket.Send(PunchPacket); + Thread.Sleep(PunchPacketDelayMs); + } + + Logger.Debug($"HolePunch: Background punch burst complete to {endpoint}"); + } catch (ObjectDisposedException) { + // Socket closed during disconnect or failed handshake. + } catch (SocketException ex) { + Logger.Debug($"HolePunch: Background punch burst stopped for {endpoint}: {ex.Message}"); + } catch (Exception ex) { + Logger.Warn($"HolePunch: Background punch burst failed for {endpoint}: {ex.Message}"); + } + }); + } + /// /// Handles data received from the DTLS client. /// Forwards decrypted data to subscribers of . diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs index 62dd9d7..ea128fc 100644 --- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs +++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs @@ -2,7 +2,6 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; -using System.Threading; using System.Threading.Tasks; using SSMP.Logging; using SSMP.Networking.Matchmaking; @@ -67,27 +66,33 @@ public HolePunchEncryptedTransportServer(MmsClient? mmsClient = null) { /// public void Start(int port) { Logger.Info($"HolePunch Server: Starting on port {port}"); - - // Subscribe to punch coordination - MmsClient.PunchClientRequested += OnPunchClientRequested; + + if (_mmsClient != null) { + _mmsClient.RefreshHostMappingRequested += OnHostMappingRefreshRequested; + _mmsClient.HostMappingReceived += OnHostMappingReceived; + _mmsClient.StartPunchRequested += OnStartPunchRequested; + } var socket = PreBoundSocket; PreBoundSocket = null; _dtlsServer.Start(port, socket); + + _mmsClient?.StartWebSocketConnection(); } /// public void Stop() { Logger.Info("HolePunch Server: Stopping"); - - // Close MMS lobby if we have an MMS client - _mmsClient?.CloseLobby(); - _mmsClient = null; - - // Unsubscribe from punch coordination - MmsClient.PunchClientRequested -= OnPunchClientRequested; - + + if (_mmsClient != null) { + _mmsClient.RefreshHostMappingRequested -= OnHostMappingRefreshRequested; + _mmsClient.HostMappingReceived -= OnHostMappingReceived; + _mmsClient.StartPunchRequested -= OnStartPunchRequested; + _mmsClient.CloseLobby(); + _mmsClient = null; + } + _dtlsServer.Stop(); _clients.Clear(); } @@ -95,13 +100,14 @@ public void Stop() { /// /// Called when MMS notifies us of a client needing punch-back. /// - private void OnPunchClientRequested(string clientIp, int clientPort) { + private void OnStartPunchRequested(string joinId, string clientIp, int clientPort, int hostPort, long startTimeMs) { + _mmsClient?.StopHostDiscoveryRefresh(); if (!IPAddress.TryParse(clientIp, out var ip)) { Logger.Warn($"HolePunch Server: Invalid client IP: {clientIp}"); return; } - - PunchToClient(new IPEndPoint(ip, clientPort)); + + _ = PunchToClientAsync(new IPEndPoint(ip, clientPort), startTimeMs); } /// @@ -113,25 +119,6 @@ public void DisconnectClient(IEncryptedTransportClient client) { _clients.TryRemove(hpClient.EndPoint, out _); } - /// - /// Initiates hole punch to a client that wants to connect. - /// Uses the DTLS server's socket so the punch comes from the correct port. - /// - /// The client's public endpoint. - private void PunchToClient(IPEndPoint clientEndpoint) { - // Run on background thread to avoid blocking the calling thread for 5 seconds - Task.Run(() => { - Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}"); - - for (var i = 0; i < PunchPacketCount; i++) { - _dtlsServer.SendRaw(PunchPacket, clientEndpoint); - Thread.Sleep(PunchPacketDelayMs); - } - - Logger.Info($"HolePunch Server: Punch packets sent to {clientEndpoint}"); - }); - } - /// /// Callback method for when data is received from a server client. /// @@ -144,4 +131,44 @@ private void OnClientDataReceived(DtlsServerClient dtlsClient, byte[] data, int client.RaiseDataReceived(data, length); } + + /// + /// Called when MMS asks the host to refresh its matchmaking mapping on the live UDP server socket. + /// + private void OnHostMappingRefreshRequested(string joinId, string hostDiscoveryToken, long serverTimeMs) { + Logger.Info($"HolePunch Server: Refreshing host mapping for join {joinId}"); + _mmsClient?.StartHostDiscoveryRefresh(hostDiscoveryToken, (data, endpoint) => _dtlsServer.SendRaw(data, endpoint)); + } + + /// + /// Called when MMS confirms it has learned the host's current external mapping. + /// + private void OnHostMappingReceived() { + Logger.Info("HolePunch Server: Host mapping learned by MMS, stopping refresh"); + _mmsClient?.StopHostDiscoveryRefresh(); + } + + /// + /// Sends UDP packets to , + /// spaced ms apart, starting at . + /// Exceptions are caught and logged rather than propagated, since this runs fire-and-forget. + /// + private async Task PunchToClientAsync(IPEndPoint clientEndpoint, long startTimeMs) + { + try + { + Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}"); + var delay = startTimeMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (delay > 0) await Task.Delay(TimeSpan.FromMilliseconds(delay)); + + for (var i = 0; i < PunchPacketCount; i++) + { + _dtlsServer.SendRaw(PunchPacket, clientEndpoint); + await Task.Delay(PunchPacketDelayMs); + } + + Logger.Info($"HolePunch Server: Punch complete to {clientEndpoint}"); + } + catch (Exception ex) { Logger.Error($"HolePunch Server: Punch to {clientEndpoint} failed – {ex.Message}"); } + } } diff --git a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs index e9ed6d7..634be6c 100644 --- a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs +++ b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs @@ -241,7 +241,7 @@ private void ReceivePackets() { public void Disconnect() { if (!_isConnected) return; - // Signal shutdown first + // Signal shutdown first so the receive loop exits at the top of its next iteration. _isConnected = false; _receiveTokenSource?.Cancel(); @@ -257,8 +257,6 @@ public void Disconnect() { SteamNetworking.CloseP2PSessionWithUser(_remoteSteamId); } - _remoteSteamId = NilSteamId; - if (_receiveThread != null) { if (!_receiveThread.Join(5000)) { Logger.Warn("Steam P2P: Receive thread did not terminate within 5 seconds"); @@ -267,6 +265,9 @@ public void Disconnect() { _receiveThread = null; } + // Safe to zero connection state only after the thread has exited. + _remoteSteamId = NilSteamId; + _receiveTokenSource?.Dispose(); _receiveTokenSource = null; } diff --git a/SSMP/Ui/Component/LobbyBrowserPanel.cs b/SSMP/Ui/Component/LobbyBrowserPanel.cs index 8addc21..8e95ede 100644 --- a/SSMP/Ui/Component/LobbyBrowserPanel.cs +++ b/SSMP/Ui/Component/LobbyBrowserPanel.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using SSMP.Networking.Matchmaking; +using SSMP.Networking.Matchmaking.Protocol; using UnityEngine; using UnityEngine.UI; using Object = UnityEngine.Object; diff --git a/SSMP/Ui/Component/LobbyConfigPanel.cs b/SSMP/Ui/Component/LobbyConfigPanel.cs index bc00f05..41246f3 100644 --- a/SSMP/Ui/Component/LobbyConfigPanel.cs +++ b/SSMP/Ui/Component/LobbyConfigPanel.cs @@ -1,5 +1,5 @@ using System; -using SSMP.Networking.Matchmaking; +using SSMP.Networking.Matchmaking.Protocol; using UnityEngine; using UnityEngine.UI; using Object = UnityEngine.Object; @@ -293,51 +293,6 @@ private static GameObject CreateText( return go; } - private static GameObject CreateInputField(Vector2 pos, float width, float height, string placeholder) { - var go = new GameObject("InputField"); - var rect = go.AddComponent(); - rect.anchorMin = rect.anchorMax = new Vector2(0.5f, 1f); - rect.pivot = new Vector2(0.5f, 0.5f); - rect.anchoredPosition = new Vector2(pos.x, pos.y - height / 2f); - rect.sizeDelta = new Vector2(width, height); - - var bg = go.AddComponent(); - bg.color = new Color(0.15f, 0.15f, 0.18f, 1f); - - var textGo = new GameObject("Text"); - var textRect = textGo.AddComponent(); - textRect.anchorMin = Vector2.zero; - textRect.anchorMax = Vector2.one; - textRect.offsetMin = new Vector2(10f, 5f); - textRect.offsetMax = new Vector2(-10f, -5f); - var text = textGo.AddComponent(); - text.font = Resources.FontManager.UIFontRegular; - text.fontSize = 14; - text.color = Color.white; - text.alignment = TextAnchor.MiddleLeft; - textGo.transform.SetParent(go.transform, false); - - var placeholderGo = new GameObject("Placeholder"); - var phRect = placeholderGo.AddComponent(); - phRect.anchorMin = Vector2.zero; - phRect.anchorMax = Vector2.one; - phRect.offsetMin = new Vector2(10f, 5f); - phRect.offsetMax = new Vector2(-10f, -5f); - var phText = placeholderGo.AddComponent(); - phText.font = Resources.FontManager.UIFontRegular; - phText.fontSize = 14; - phText.color = new Color(0.5f, 0.5f, 0.5f, 1f); - phText.alignment = TextAnchor.MiddleLeft; - phText.text = placeholder; - placeholderGo.transform.SetParent(go.transform, false); - - var input = go.AddComponent(); - input.textComponent = text; - input.placeholder = phText; - - return go; - } - private static GameObject CreateButton( string text, Vector2 pos, diff --git a/SSMP/Ui/Component/TextComponent.cs b/SSMP/Ui/Component/TextComponent.cs index fd3b2f3..24935f5 100644 --- a/SSMP/Ui/Component/TextComponent.cs +++ b/SSMP/Ui/Component/TextComponent.cs @@ -17,8 +17,9 @@ public TextComponent( string text, int fontSize, FontStyle fontStyle = FontStyle.Normal, - TextAnchor alignment = TextAnchor.MiddleCenter - ) : this(componentGroup, position, size, new Vector2(0.5f, 0.5f), text, fontSize, fontStyle, alignment) { + TextAnchor alignment = TextAnchor.MiddleCenter, + bool wrap = false + ) : this(componentGroup, position, size, new Vector2(0.5f, 0.5f), text, fontSize, fontStyle, alignment, wrap) { } public TextComponent( @@ -29,9 +30,10 @@ public TextComponent( string text, int fontSize, FontStyle fontStyle = FontStyle.Normal, - TextAnchor alignment = TextAnchor.MiddleCenter + TextAnchor alignment = TextAnchor.MiddleCenter, + bool wrap = false ) : base(componentGroup, position, size) { - _textObject = CreateTextObject(text, fontSize, fontStyle, alignment, pivot); + _textObject = CreateTextObject(text, fontSize, fontStyle, alignment, pivot, wrap); AddSizeFitter(); AddOutline(); } @@ -85,7 +87,14 @@ public float GetPreferredWidth() { /// Create the Unity Text object with all the parameters. /// /// The Unity object. - private Text CreateTextObject(string text, int fontSize, FontStyle fontStyle, TextAnchor alignment, Vector2 pivot) { + private Text CreateTextObject( + string text, + int fontSize, + FontStyle fontStyle, + TextAnchor alignment, + Vector2 pivot, + bool wrap + ) { var textObj = GameObject.AddComponent(); textObj.supportRichText = true; @@ -94,7 +103,7 @@ private Text CreateTextObject(string text, int fontSize, FontStyle fontStyle, Te textObj.fontSize = fontSize; textObj.fontStyle = fontStyle; textObj.alignment = alignment; - textObj.horizontalOverflow = HorizontalWrapMode.Overflow; + textObj.horizontalOverflow = wrap ? HorizontalWrapMode.Wrap : HorizontalWrapMode.Overflow; textObj.verticalOverflow = VerticalWrapMode.Overflow; textObj.rectTransform.pivot = pivot; textObj.raycastTarget = false; // do not block input diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs index e9e450b..eaf9f73 100644 --- a/SSMP/Ui/ConnectInterface.cs +++ b/SSMP/Ui/ConnectInterface.cs @@ -6,6 +6,7 @@ using SSMP.Game.Settings; using SSMP.Networking.Client; using SSMP.Networking.Matchmaking; +using SSMP.Networking.Matchmaking.Protocol; using SSMP.Ui.Component; using Steamworks; using SSMP.Networking.Transport.Common; @@ -158,6 +159,11 @@ internal class ConnectInterface { /// private const float FeedbackTextOffset = 310f; + /// + /// Height of the bottom feedback area so longer matchmaking messages can wrap cleanly. + /// + private const float FeedbackTextHeight = 72f; + #endregion #region UI Text Constants @@ -329,6 +335,22 @@ internal class ConnectInterface { /// private const string ErrorUnknown = "Failed to connect:\nUnknown reason"; + /// + /// Large blocking message shown when the client must update before using matchmaking. + /// + private const string MatchmakingUpdateRequiredText = + "Please update to the latest version in order to use matchmaking!"; + + /// + /// Temporary message shown while the client verifies matchmaking compatibility. + /// + private const string MatchmakingCheckingText = "Checking matchmaking compatibility..."; + + /// + /// Blocking message shown when MMS cannot be reached, so matchmaking stays unavailable. + /// + private const string MatchmakingUnavailableText = "Unable to contact matchmaking server right now."; + #endregion #region Fields @@ -473,6 +495,31 @@ internal class ConnectInterface { /// private Coroutine? _feedbackHideCoroutine; + /// + /// Whether the current bottom feedback message is being driven by matchmaking status UI. + /// + private bool _isMatchmakingFeedbackActive; + + /// + /// Whether MMS has reported that this client is too old for matchmaking. + /// + private bool _isMatchmakingVersionBlocked; + + /// + /// Whether the UI is currently verifying matchmaking compatibility with MMS. + /// + private bool _isCheckingMatchmakingVersion; + + /// + /// Whether MMS has been reached successfully and the matchmaking version check passed. + /// + private bool _isMatchmakingReady; + + /// + /// Tracks the currently selected tab so async UI refreshes preserve the active view. + /// + private Tab _activeTab = Tab.Matchmaking; + /// /// Public accessor for the MMS client. /// Used by server manager to pass to HolePunch transport for lobby cleanup. @@ -650,6 +697,7 @@ public ConnectInterface(ModSettings modSettings, ComponentGroup connectGroup) { FinalizeLayout(); + BeginMatchmakingVersionCheck(); SwitchTab(Tab.Matchmaking); } @@ -1028,11 +1076,12 @@ private ITextComponent CreateFeedbackText(float contentY) { var feedback = new TextComponent( _backgroundGroup, new Vector2(InitialX, contentY), - new Vector2(ContentWidth, LabelHeight), + new Vector2(ContentWidth, FeedbackTextHeight), new Vector2(0.5f, 1f), "", UiManager.SubTextFontSize, - alignment: TextAnchor.UpperCenter + alignment: TextAnchor.UpperCenter, + wrap: true ); feedback.SetActive(false); @@ -1082,6 +1131,8 @@ _directConnectButton is Component.Component connectComp && /// /// The tab to activate. private void SwitchTab(Tab tab) { + _activeTab = tab; + // Hide lobby browsers and config panels if visible _lobbyBrowserPanel.Hide(); _steamLobbyBrowserPanel?.Hide(); @@ -1093,9 +1144,15 @@ private void SwitchTab(Tab tab) { _directIpTab.SetTabActive(tab == Tab.DirectIp); // Show only the active tab's content - _matchmakingGroup.SetActive(tab == Tab.Matchmaking); + _matchmakingGroup.SetActive( + tab == Tab.Matchmaking && + _isMatchmakingReady && + !_isMatchmakingVersionBlocked && + !_isCheckingMatchmakingVersion + ); _steamGroup?.SetActive(tab == Tab.Steam); _directIpGroup.SetActive(tab == Tab.DirectIp); + RefreshMatchmakingStatusFeedback(); } /// @@ -1105,6 +1162,10 @@ private void SwitchTab(Tab tab) { public void SetMenuActive(bool active) { _backgroundPanel.SetActive(active); _glowingNotch.SetActive(active); + + if (active && !_isMatchmakingVersionBlocked && !_isCheckingMatchmakingVersion) { + BeginMatchmakingVersionCheck(); + } } #endregion @@ -1116,6 +1177,10 @@ public void SetMenuActive(bool active) { /// Looks up lobby via MMS and connects to the host. /// private void OnLobbyConnectButtonPressed() { + if (IsMatchmakingBlocked()) { + return; + } + if (!ValidateUsername(out var username)) { return; } @@ -1144,27 +1209,76 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { var task = MmsClient.JoinLobbyAsync(lobbyId, clientPort); yield return new WaitUntil(() => task.IsCompleted); + if (!task.IsCompletedSuccessfully) { + CleanupHolePunchSocket(holePunchSocket); + Logger.Error( + $"ConnectInterface: JoinLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}" + ); + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + var lobbyInfo = task.Result; if (lobbyInfo == null) { CleanupHolePunchSocket(holePunchSocket); + + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + ActivateMatchmakingVersionBlock(); + yield break; + } + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); yield break; } - var (connectionData, lobbyType, lanConnectionData, clientDiscoveryToken) = lobbyInfo.Value; - - yield return RunDiscoveryCoroutine( - holePunchSocket, - clientDiscoveryToken, - "MmsClient: UDP discovery timed out, falling back to local port" - ); + var connectionData = lobbyInfo.ConnectionData; + var lobbyType = lobbyInfo.LobbyType; + var lanConnectionData = lobbyInfo.LanConnectionData; + var joinId = lobbyInfo.JoinId; // Handle connection based on lobby type if (lobbyType == PublicLobbyType.Steam) { CleanupHolePunchSocket(holePunchSocket); ConnectToSteamLobby(connectionData, username); } else { - ConnectToMatchmakingLobby(connectionData, lanConnectionData, username, holePunchSocket); + if (string.IsNullOrEmpty(joinId)) { + CleanupHolePunchSocket(holePunchSocket); + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + + var joinTask = MmsClient.CoordinateMatchmakingJoinAsync( + joinId, + (data, endpoint) => { holePunchSocket.SendTo(data, endpoint); } + ); + + yield return new WaitUntil(() => joinTask.IsCompleted); + + if (!joinTask.IsCompletedSuccessfully) { + CleanupHolePunchSocket(holePunchSocket); + Logger.Error( + $"ConnectInterface: CoordinateMatchmakingJoinAsync failed: {joinTask.Exception?.GetBaseException().Message ?? "cancelled"}" + ); + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + + var joinStart = joinTask.Result; + if (joinStart == null) { + CleanupHolePunchSocket(holePunchSocket); + + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + ActivateMatchmakingVersionBlock(); + yield break; + } + + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + + ConnectToMatchmakingLobby( + $"{joinStart.HostIp}:{joinStart.HostPort}", lanConnectionData, username, holePunchSocket + ); } } @@ -1173,6 +1287,10 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { /// Shows the lobby configuration panel. /// private void OnHostLobbyButtonPressed() { + if (IsMatchmakingBlocked()) { + return; + } + if (!ValidateUsername(out _)) { return; } @@ -1295,21 +1413,27 @@ string username yield return new WaitUntil(() => task.IsCompleted); - var (lobbyId, lobbyName, hostDiscoveryToken) = task.Result; - if (lobbyId == null || lobbyName == null) { - ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?"); + if (!task.IsCompletedSuccessfully) { CleanupHolePunchSocket(holePunchSocket); + Logger.Error( + $"ConnectInterface: CreateLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}" + ); + ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?"); yield break; } - yield return RunDiscoveryCoroutine( - holePunchSocket, - hostDiscoveryToken, - "MmsClient: UDP discovery timed out, falling back to mapping-less hosting" - ); + var (lobbyId, lobbyName, _) = task.Result; + if (lobbyId == null || lobbyName == null) { + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + CleanupHolePunchSocket(holePunchSocket); + ActivateMatchmakingVersionBlock(); + yield break; + } - // Start polling for pending clients to punch back - MmsClient.StartPendingClientPolling(); + ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?"); + CleanupHolePunchSocket(holePunchSocket); + yield break; + } // For private lobbies, show invite code in ChatBox so it's easily shareable if (visibility == LobbyVisibility.Private) { @@ -1335,6 +1459,10 @@ string username /// Fetches and displays public lobbies from the MMS. /// private void OnBrowseMatchmakingLobbiesPressed() { + if (IsMatchmakingBlocked()) { + return; + } + // Hide matchmaking content and show lobby browser _matchmakingGroup.SetActive(false); _lobbyBrowserPanel.Show(); @@ -1354,6 +1482,12 @@ private IEnumerator FetchLobbiesCoroutine() { var lobbies = task.Result; if (lobbies == null) { + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + _lobbyBrowserPanel.Hide(); + ActivateMatchmakingVersionBlock(); + yield break; + } + ShowFeedback(Color.red, "Failed to fetch lobbies. Is MMS running?"); yield break; } @@ -1691,6 +1825,7 @@ private void SaveConnectionSettings(string address, int port, string username) { /// The color of the feedback text. /// The message to display. private void ShowFeedback(Color color, string message) { + _isMatchmakingFeedbackActive = false; _feedbackHideCoroutine = ConnectInterfaceHelpers.SetFeedbackText( _feedbackText, color, @@ -1699,6 +1834,105 @@ private void ShowFeedback(Color color, string message) { ); } + /// + /// Activates the persistent matchmaking version block UI. + /// + private void ActivateMatchmakingVersionBlock() { + _isCheckingMatchmakingVersion = false; + _isMatchmakingReady = false; + _isMatchmakingVersionBlocked = true; + _lobbyBrowserPanel.Hide(); + _lobbyConfigPanel.Hide(); + SwitchTab(Tab.Matchmaking); + } + + /// + /// Starts an immediate matchmaking compatibility probe so the UI can block before showing actions. + /// + private void BeginMatchmakingVersionCheck() { + if (_isCheckingMatchmakingVersion || _isMatchmakingVersionBlocked) { + return; + } + + _isMatchmakingReady = false; + _isCheckingMatchmakingVersion = true; + SwitchTab(_activeTab); + MonoBehaviourUtil.Instance.StartCoroutine(CheckMatchmakingVersionCoroutine()); + } + + /// + /// Contacts MMS first and only enables matchmaking when the server is both reachable and compatible. + /// + private IEnumerator CheckMatchmakingVersionCoroutine() { + var task = MmsClient.ProbeMatchmakingCompatibilityAsync(); + yield return new WaitUntil(() => task.IsCompleted); + + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + ActivateMatchmakingVersionBlock(); + yield break; + } + + _isCheckingMatchmakingVersion = false; + _isMatchmakingReady = task.Result == true; + SwitchTab(_activeTab); + } + + /// + /// Keeps matchmaking status messages aligned with the standard bottom feedback area. + /// + private void RefreshMatchmakingStatusFeedback() { + if (_activeTab != Tab.Matchmaking) { + if (_isMatchmakingFeedbackActive) { + _feedbackText.SetActive(false); + _isMatchmakingFeedbackActive = false; + } + + return; + } + + if (_isCheckingMatchmakingVersion) { + SetMatchmakingStatusFeedback(MatchmakingCheckingText, Color.yellow); + return; + } + + if (_isMatchmakingVersionBlocked) { + SetMatchmakingStatusFeedback(MatchmakingUpdateRequiredText, Color.red); + return; + } + + if (!_isMatchmakingReady) { + SetMatchmakingStatusFeedback(MatchmakingUnavailableText, Color.red); + return; + } + + if (_isMatchmakingFeedbackActive) { + _feedbackText.SetActive(false); + _isMatchmakingFeedbackActive = false; + } + } + + /// + /// Returns whether matchmaking is blocked for this client version. + /// + private bool IsMatchmakingBlocked() { + return _isMatchmakingVersionBlocked; + } + + /// + /// Displays matchmaking-owned status feedback without clobbering feedback from other tabs. + /// + private void SetMatchmakingStatusFeedback(string message, Color color) { + if (_feedbackHideCoroutine != null) { + MonoBehaviourUtil.Instance.StopCoroutine(_feedbackHideCoroutine); + _feedbackHideCoroutine = null; + } + + _isMatchmakingFeedbackActive = true; + _feedbackText.SetText(message); + _feedbackText.SetColor(color); + _feedbackText.SetActive(true); + } + /// /// Resets the connection buttons to their default state after a connection attempt. /// @@ -1749,31 +1983,6 @@ private static int GetSocketPort(Socket socket) { return ((IPEndPoint) socket.LocalEndPoint!).Port; } - /// - /// Performs MMS-backed UDP discovery for a pre-bound hole-punch socket. - /// - private IEnumerator RunDiscoveryCoroutine(Socket holePunchSocket, string? discoveryToken, string timeoutMessage) { - if (string.IsNullOrEmpty(discoveryToken)) { - yield break; - } - - ShowFeedback(Color.yellow, "Mapping external port..."); - - var discoveryTask = MmsClient.PerformDiscoveryAsync( - discoveryToken, - (data, endpoint) => { holePunchSocket.SendTo(data, endpoint); } - ); - - yield return new WaitUntil(() => discoveryTask.IsCompleted); - - if (discoveryTask.Result == null) { - Logger.Warn(timeoutMessage); - yield break; - } - - Logger.Info($"MmsClient: Discovered external port {discoveryTask.Result}"); - } - /// /// Handles connection to a matchmaking lobby with LAN/public fallback. ///