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);
}
}
}