using System; using System.Collections.Concurrent; using System.Security.Cryptography; using GameCult.Logging; using LiteNetLib; using MessagePack; using R3; namespace GameCult.Networking { /// /// Describes the client's reconnect posture. /// public enum ClientReconnectState { /// /// The client is connected or otherwise not trying to reconnect. /// Idle, /// /// The client is waiting for the next reconnect attempt. /// WaitingToReconnect, /// /// The client is actively attempting a reconnect. /// Reconnecting } /// /// Provides client-side connection, authentication, and message dispatch logic. /// public class Client : IDisposable { /// /// Raised when the client encounters a user-visible error. /// public event Action? OnError; // Hook for important user-facing feedback (e.g. via modal UI element) /// /// Raised when reconnect state changes. /// public event Action? OnReconnectStateChanged; private NetManager? _client; private NetPeer? _peer; private IDisposable? _pollSubscription; private IDisposable? _reconnectSubscription; private bool _disposed; private bool _manualDisconnect; private bool _isReconnecting; private int _reconnectAttemptCount; private readonly ConcurrentDictionary _messageDelegates = new(); private string? _sessionToken; private string _lastHost = "localhost"; private int _lastPort; private readonly ClientSecurityOptions _security; private ILogger _logger = new NullLogger(); private static readonly TimeSpan BaseReconnectDelay = TimeSpan.FromSeconds(1); private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30); private static readonly TimeSpan MaxReconnectJitter = TimeSpan.FromMilliseconds(250); /// /// Gets or sets the logger used by the client. /// public ILogger Logger { get => _logger; set => _logger = value ?? new NullLogger(); } /// /// Gets the last reported round-trip latency in milliseconds. /// public int Ping { get; private set; } /// /// Gets the current reconnect posture exposed to consumers. /// public ClientReconnectState ReconnectState { get; private set; } = ClientReconnectState.Idle; /// /// Gets or sets whether raw payload bodies may be logged for diagnostics. /// public bool LogSensitivePayloads { get; set; } /// /// Gets a value indicating whether the client currently has a verified session. /// public bool Verified => !string.IsNullOrWhiteSpace(_sessionToken); /// /// Gets a value indicating whether the network manager has been created. /// public bool Connected => _client is { IsRunning: true } && _peer != null; /// /// Initializes a new client instance. /// /// Validated client security configuration. This must be provided explicitly by the caller. public Client(ClientSecurityOptions security) { _security = security ?? throw new ArgumentNullException(nameof(security)); OnError += s => Logger.LogError(s); } /// /// Sends a message when the client is connected and authorized to do so. /// /// The message type. /// The message to send. public void Send(T m) where T : Message { if (_disposed) { throw new ObjectDisposedException(nameof(Client)); } if (Connected && _peer != null) { if (Verified || m is LoginMessage || m is RegisterMessage || m is VerifyMessage) { if (LogSensitivePayloads) { Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m)}"); } else { Logger.LogDebug($"Sending message {typeof(T).Name}"); } _peer.Send(m); } else Logger.LogError("Cannot send, client is not verified!"); } else Logger.LogError("Cannot send, client is not connected!"); } /// /// Removes all registered message listeners. /// public void ClearMessageListeners() { _messageDelegates.Clear(); } /// /// Adds a listener for a specific message type. /// /// The message type to subscribe to. /// The callback to invoke when the message is received. public void AddMessageListener(Action callback) where T : Message { var type = typeof(T); // Atomically aggregate into multicast delegate (thread-safe via CAS) _messageDelegates.AddOrUpdate(type, _ => callback, // First callback (t, current) => { // Combine with existing (supports multiple callbacks) var combined = Delegate.Combine(current, callback) as Action; return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}"); }); } /// /// Removes a previously registered listener for a specific message type. /// /// The message type to unsubscribe from. /// The callback to remove. public void RemoveMessageListener(Action callback) where T : Message { if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate)) { var newDelegate = Delegate.Remove(currentDelegate, callback) as Action; _messageDelegates[typeof(T)] = newDelegate ?? (_ => { }); // Null if empty } } /// /// Sends a login request. /// /// The email address or username. /// The plaintext password. public void Login(string auth, string password) { if (auth.Length > 0) { if (password.Length is >= 8 and < 32) { var nonce = Secret.NewNonce; Send(new LoginMessage { Nonce = nonce, Auth = Secret.EncryptString(auth, nonce, _security) ?? Array.Empty(), Password = Secret.EncryptString(password, nonce, _security) ?? Array.Empty() }); } else OnError?.Invoke("Password Invalid: needs to be at least 8 characters"); } else OnError?.Invoke("Email/Username Empty"); } /// /// Sends a registration request. /// /// The email address for the new account. /// The requested username. /// The plaintext password. public void Register(string email, string username, string password) { if (email.Length > 0) { if (username.Length > 0) { if (password.Length is >= 8 and < 32) { var nonce = Secret.NewNonce; Send(new RegisterMessage { Nonce = nonce, Email = Secret.EncryptString(email, nonce, _security) ?? Array.Empty(), Name = Secret.EncryptString(username, nonce, _security) ?? Array.Empty(), Password = Secret.EncryptString(password, nonce, _security) ?? Array.Empty() }); } else OnError?.Invoke("Password Invalid: needs to be at least 8 characters"); } else OnError?.Invoke("Username Empty"); } else OnError?.Invoke("Email Empty"); } /// /// Connects the client to a server and starts polling LiteNetLib events. /// /// The remote host name or IP address. /// The remote server port. public void Connect(string host = "localhost", int port = 3075) { ConnectCore(host, port, resetReconnectBackoff: true); } private void ConnectCore(string host, int port, bool resetReconnectBackoff) { if (_disposed) { throw new ObjectDisposedException(nameof(Client)); } _manualDisconnect = false; _isReconnecting = false; if (resetReconnectBackoff) { _reconnectAttemptCount = 0; } SetReconnectState(ClientReconnectState.Idle); _reconnectSubscription?.Dispose(); _reconnectSubscription = null; _lastHost = host; _lastPort = port; DisposeTransport(); var listener = new EventBasedNetListener(); _client = new NetManager(listener) { NatPunchEnabled = true }; _client.Start(3074); _peer = _client.Connect(host, port, _security.ConnectionKey); _pollSubscription = Observable.EveryUpdate().Subscribe(_ => _client?.PollEvents()); listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}"); listener.NetworkReceiveEvent += (peer, reader, channel, method) => { try { var bytes = reader.GetRemainingBytes(); var message = MessageSerialization.Deserialize(bytes); if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(bytes))}"); } else { Logger.LogDebug($"Received message {message.GetType().Name}"); } var type = message.GetType(); switch (message) { case LoginSuccessMessage loginSuccess: _sessionToken = Secret.DecryptString(loginSuccess.Session, loginSuccess.Nonce, _security); break; case ErrorMessage error: OnError?.Invoke(error.Error); break; } if (_messageDelegates.TryGetValue(type, out var del) && del != null) del.DynamicInvoke(message); else Logger.LogWarning($"No listener for {type.Name}"); } catch (Exception ex) { Logger.LogError($"Message processing failed: {ex.Message}"); } }; listener.PeerConnectedEvent += peer => { Logger.LogInfo($"Peer {peer.Address}:{peer.Port} connected."); _peer = peer; _reconnectAttemptCount = 0; SetReconnectState(ClientReconnectState.Idle); if (Verified) { var nonce = Secret.NewNonce; peer.Send(new VerifyMessage { Nonce = nonce, Session = Secret.EncryptString(_sessionToken, nonce, _security) ?? Array.Empty() }); } }; listener.PeerDisconnectedEvent += (peer, info) => { Logger.LogInfo($"Peer {peer.Address}:{peer.Port} disconnected: {info.Reason}."); DisposeTransport(); ScheduleReconnect(); }; listener.NetworkLatencyUpdateEvent += (peer, latency) => Ping = latency; //Logger($"Ping received: {latency} ms"); } /// /// Disconnects the client and suppresses automatic reconnect. /// public void Disconnect() { _manualDisconnect = true; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; SetReconnectState(ClientReconnectState.Idle); DisposeTransport(); } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _manualDisconnect = true; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; SetReconnectState(ClientReconnectState.Idle); DisposeTransport(); } private void ScheduleReconnect() { if (_disposed || _manualDisconnect || string.IsNullOrWhiteSpace(_lastHost) || _isReconnecting) { return; } _isReconnecting = true; _reconnectAttemptCount++; var delay = GetReconnectDelayForAttempt(_reconnectAttemptCount); SetReconnectState(ClientReconnectState.WaitingToReconnect); _reconnectSubscription = Observable.Timer(delay).Subscribe(_ => { _isReconnecting = false; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; SetReconnectState(ClientReconnectState.Reconnecting); try { ConnectCore(_lastHost, _lastPort, resetReconnectBackoff: false); } catch (Exception ex) { Logger.LogError($"Reconnect failed: {ex.Message}"); ScheduleReconnect(); } }); } internal static TimeSpan GetReconnectDelayForAttempt(int attempt) { var normalizedAttempt = Math.Max(1, attempt); var exponentialSeconds = Math.Min( MaxReconnectDelay.TotalSeconds, BaseReconnectDelay.TotalSeconds * Math.Pow(2, normalizedAttempt - 1)); var jitterMilliseconds = RandomNumberGenerator.GetInt32((int)MaxReconnectJitter.TotalMilliseconds + 1); return TimeSpan.FromSeconds(exponentialSeconds) + TimeSpan.FromMilliseconds(jitterMilliseconds); } private void DisposeTransport() { _pollSubscription?.Dispose(); _pollSubscription = null; if (_peer != null) { try { _peer.Disconnect(); } catch { // Best-effort cleanup. } } if (_client != null) { try { _client.Stop(); } finally { _client = null; _peer = null; } } } private void SetReconnectState(ClientReconnectState state) { if (ReconnectState == state) { return; } ReconnectState = state; OnReconnectStateChanged?.Invoke(state); } } }