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 LiteNetLibTransportConnection? _transportConnection; private IDisposable? _pollSubscription; private IDisposable? _reconnectSubscription; private bool _disposed; private bool _manualDisconnect; private bool _isReconnecting; private readonly CultNetReconnectController _reconnectController; private readonly ConcurrentDictionary _messageDelegates = new(); private readonly ConcurrentDictionary _cultNetMessageDelegates = new(); private string? _sessionToken; private string _lastHost = "localhost"; private int _lastPort; private readonly ClientSecurityOptions _security; private ILogger _logger = new NullLogger(); /// /// 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 the portable reconnect policy used by this client. /// public CultNetReconnectPolicy ReconnectPolicy => _reconnectController.Policy; /// /// Gets a transport profile describing the client's current LiteNetLib production lane. /// public CultNetTransportProfile TransportProfile => CultNetTransportProfiles.CreateLiteNetLib( "csharp-client", new LiteNetLibTransportProfileOptions { TransportId = "litenetlib-client", Host = _lastHost, Port = _lastPort > 0 ? _lastPort : null, ReconnectPolicy = ReconnectPolicy }); /// /// Gets the last scheduled reconnect attempt number. /// public int ReconnectAttempt => _reconnectController.Attempt; /// /// Gets the absolute scheduler time for the next reconnect attempt. /// public long? NextReconnectAttemptAtMs => _reconnectController.NextAttemptAtMs; /// /// Gets or sets whether raw payload bodies may be logged for diagnostics. /// public bool LogSensitivePayloads { get; set; } /// /// Gets or sets whether CultNet schema-v0 messages may be sent before user session verification. /// public bool AllowUnverifiedCultNetMessages { 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. /// Optional portable reconnect policy for the LiteNetLib client lane. public Client(ClientSecurityOptions security, CultNetReconnectPolicy? reconnectPolicy = null) { _security = security ?? throw new ArgumentNullException(nameof(security)); _reconnectController = new CultNetReconnectController(reconnectPolicy); 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}"); } (_transportConnection ??= new LiteNetLibTransportConnection(_peer)).SendLegacy(m); } else Logger.LogError("Cannot send, client is not verified!"); } else Logger.LogError("Cannot send, client is not connected!"); } /// /// Sends a modern CultNet schema-v0 message when the client is connected and authorized. /// public void SendCultNet(T message) where T : ICultNetSchemaMessage { if (_disposed) { throw new ObjectDisposedException(nameof(Client)); } if (message == null) throw new ArgumentNullException(nameof(message)); if (Connected && _peer != null) { if (AllowUnverifiedCultNetMessages || Verified || message is CultNetLoginMessage or CultNetRegisterMessage or CultNetVerifyMessage) { Logger.LogDebug($"Sending CultNet schema message {message.SchemaVersion}"); (_transportConnection ??= new LiteNetLibTransportConnection(_peer)).SendSchema(message); } else { Logger.LogError("Cannot send CultNet schema message, client is not verified!"); } } else { Logger.LogError("Cannot send CultNet schema message, client is not connected!"); } } /// /// Removes all registered message listeners. /// public void ClearMessageListeners() { _messageDelegates.Clear(); _cultNetMessageDelegates.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}"); }); } /// /// Adds a listener for a specific message type. /// public void On(Action callback) where T : Message { AddMessageListener(callback); } /// /// Adds a listener for a modern CultNet schema-v0 message type. /// public void AddCultNetMessageListener(Action callback) where T : ICultNetSchemaMessage { var type = typeof(T); _cultNetMessageDelegates.AddOrUpdate(type, _ => callback, (t, current) => { var combined = Delegate.Combine(current, callback) as Action; return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}"); }); } /// /// Adds a listener for a modern CultNet schema-v0 message type. /// public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener(callback); } /// /// 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 } } /// /// Removes a previously registered listener for a specific message type. /// public void Off(Action callback) where T : Message { RemoveMessageListener(callback); } /// /// Removes a previously registered modern CultNet schema-v0 listener. /// public void RemoveCultNetMessageListener(Action callback) where T : ICultNetSchemaMessage { if (_cultNetMessageDelegates.TryGetValue(typeof(T), out var currentDelegate)) { var newDelegate = Delegate.Remove(currentDelegate, callback) as Action; _cultNetMessageDelegates[typeof(T)] = newDelegate ?? (_ => { }); } } /// /// Removes a previously registered modern CultNet schema-v0 listener. /// public void OffCultNet(Action callback) where T : ICultNetSchemaMessage { RemoveCultNetMessageListener(callback); } /// /// 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) { _reconnectController.Reset(); } 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 transport = _transportConnection ??= new LiteNetLibTransportConnection(peer); var frame = transport.Receive(bytes); if (string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal)) { var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame); Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}"); var cultNetType = cultNetMessage.GetType(); if (_cultNetMessageDelegates.TryGetValue(cultNetType, out var cultNetDelegate) && cultNetDelegate != null) { cultNetDelegate.DynamicInvoke(cultNetMessage); } else { Logger.LogWarning($"No listener for CultNet schema message {cultNetMessage.SchemaVersion}"); } return; } var message = LiteNetLibTransportConnection.DecodeLegacy(frame); if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(frame.Payload))}"); } 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; _transportConnection = new LiteNetLibTransportConnection(peer); _reconnectController.Reset(); SetReconnectState(ClientReconnectState.Idle); if (Verified) { var nonce = Secret.NewNonce; _transportConnection.SendLegacy(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; _reconnectController.Reset(); SetReconnectState(ClientReconnectState.Idle); DisposeTransport(); } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _manualDisconnect = true; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; _reconnectController.Reset(); SetReconnectState(ClientReconnectState.Idle); DisposeTransport(); } private void ScheduleReconnect() { if (_disposed || _manualDisconnect || string.IsNullOrWhiteSpace(_lastHost) || _isReconnecting) { return; } _isReconnecting = true; var decision = _reconnectController.RecordFailure( CurrentSchedulerTimeMs(), RandomNumberGenerator.GetInt32(ReconnectPolicy.MaxJitterMs + 1)); if (!decision.ShouldRetry) { _isReconnecting = false; Logger.LogWarning("Reconnect attempts exhausted."); SetReconnectState(ClientReconnectState.Idle); return; } var delay = TimeSpan.FromMilliseconds(decision.DelayMs); 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 jitterMilliseconds = RandomNumberGenerator.GetInt32(CultNetReconnectPolicies.CreateDefault().MaxJitterMs + 1); return GetReconnectDelayForAttempt(attempt, jitterMilliseconds, CultNetReconnectPolicies.CreateDefault()); } internal static TimeSpan GetReconnectDelayForAttempt( int attempt, int jitterMs, CultNetReconnectPolicy? policy = null) { var reconnectPolicy = policy ?? CultNetReconnectPolicies.CreateDefault(); return TimeSpan.FromMilliseconds( CultNetReconnectPolicies.ComputeDelayMs(reconnectPolicy, attempt, jitterMs)); } private static long CurrentSchedulerTimeMs() { return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } private void DisposeTransport() { _pollSubscription?.Dispose(); _pollSubscription = null; _transportConnection = 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); } } }