using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace GameCult.Networking { /// /// Minimal client port for request/response services that speak CultNet schema-v0 messages. /// public interface ICultNetSchemaClient : IDisposable { /// /// Gets whether the underlying transport reports an established connection. /// bool Connected { get; } /// /// Connects the transport to a remote endpoint. /// void Connect(string host, int port); /// /// Sends one schema-v0 message through the transport. /// void SendCultNet(T message) where T : ICultNetSchemaMessage; /// /// Adds a callback for one schema-v0 response type. /// void OnCultNet(Action callback) where T : ICultNetSchemaMessage; } /// /// Schema client that connects to one exact advertised URI, including its path. /// Host/port-only connection is retained for legacy transports; rendezvous and /// route-aware clients should prefer this contract when available. /// public interface ICultNetUriSchemaClient : ICultNetSchemaClient { /// Connects to the exact advertised schema endpoint. Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default); } /// /// Exposes terminal failure of a schema client's background transport work. /// public interface ICultNetSchemaClientHealth { /// /// Completes if the client's background transport loop terminates unexpectedly. /// Task BackgroundFailure { get; } } /// /// LiteNetLib-backed implementation of the CultNet schema client port. /// public sealed class LiteNetLibCultNetSchemaClient : ICultNetSchemaClient { private readonly Client _client; /// /// Creates a LiteNetLib schema client adapter. /// public LiteNetLibCultNetSchemaClient( ClientSecurityOptions? security = null, Action? configureClient = null) { _client = new Client(security ?? ClientSecurityOptions.Development()) { AllowUnverifiedCultNetMessages = true }; configureClient?.Invoke(_client); } /// public bool Connected => _client.Connected; /// public void Connect(string host, int port) { _client.Connect(host, port); } /// public void SendCultNet(T message) where T : ICultNetSchemaMessage { _client.SendCultNet(message); } /// public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { _client.OnCultNet(callback); } /// public void Dispose() { _client.Dispose(); } } /// /// RUDP-backed implementation of the CultNet schema client port. /// public sealed class RudpCultNetSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth { private readonly string _runtimeId; private readonly uint _connectionId; private readonly string _connectPayload; private readonly int _maxFragmentBytes; private readonly long _resendDelayMs; private readonly ConcurrentDictionary> _handlers = new(); private readonly object _handlerLock = new(); private readonly TaskCompletionSource _backgroundFailure = new( TaskCreationOptions.RunContinuationsAsynchronously); private CultNetRudpSocketTransportConnection? _transport; private Thread? _pumpThread; private volatile bool _disposed; /// /// Creates a RUDP schema client adapter. /// public RudpCultNetSchemaClient( string runtimeId = "csharp-rudp-schema-client", uint connectionId = 0x43554c54, string connectPayload = "cultnet-schema-rudp", int maxFragmentBytes = 1024, long resendDelayMs = 25) { if (string.IsNullOrWhiteSpace(runtimeId)) throw new ArgumentException("Runtime id is required.", nameof(runtimeId)); _runtimeId = runtimeId; _connectionId = connectionId; _connectPayload = connectPayload; _maxFragmentBytes = maxFragmentBytes; _resendDelayMs = resendDelayMs; } /// public bool Connected => _transport?.Connected == true; /// public Task BackgroundFailure => _backgroundFailure.Task; /// public void Connect(string host, int port) { if (_disposed) throw new ObjectDisposedException(nameof(RudpCultNetSchemaClient)); if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("Host is required.", nameof(host)); if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port)); var remoteAddress = IPAddress.TryParse(host, out var parsed) ? parsed : Dns.GetHostAddresses(host)[0]; var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); socket.Bind(new IPEndPoint( IPAddress.IsLoopback(remoteAddress) ? IPAddress.Loopback : IPAddress.Any, 0)); socket.ReceiveTimeout = 20; if (string.Equals(Environment.GetEnvironmentVariable("CULTNET_TRACE_RUDP"), "1", StringComparison.Ordinal)) { Console.Error.WriteLine( $"CultNet RUDP client bind local={socket.LocalEndPoint} remote={remoteAddress}:{port} runtime={_runtimeId}"); } _transport = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions { RuntimeId = _runtimeId, Mode = CultNetRudpSocketMode.Client, Socket = socket, RemoteEndPoint = new IPEndPoint(remoteAddress, port), ConnectionId = _connectionId, TransportId = "schema-rudp", MaxFragmentBytes = _maxFragmentBytes, ResendDelayMs = _resendDelayMs }); _transport.Connect(Encoding.UTF8.GetBytes(_connectPayload)); _pumpThread = new Thread(Pump) { IsBackground = true, Name = "CultNet RUDP schema client pump" }; _pumpThread.Start(); } /// public void SendCultNet(T message) where T : ICultNetSchemaMessage { if (message == null) throw new ArgumentNullException(nameof(message)); var transport = _transport ?? throw new InvalidOperationException("RUDP schema client is not connected."); transport.SendSchemaMessage(message); } /// public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { if (callback == null) throw new ArgumentNullException(nameof(callback)); lock (_handlerLock) { var handlers = _handlers.GetOrAdd(typeof(T), _ => new List()); handlers.Add(callback); } } /// public void Dispose() { if (_disposed) { return; } var transport = _transport; if (transport?.Connected == true) { try { transport.Disconnect(Encoding.UTF8.GetBytes("schema-client-disposed")); } catch (SocketException) { } catch (ObjectDisposedException) { } } _disposed = true; transport?.Dispose(); var pumpThread = _pumpThread; if (pumpThread != null && pumpThread != Thread.CurrentThread) { pumpThread.Join(TimeSpan.FromMilliseconds(250)); } } private void Pump() { try { while (!_disposed) { var transport = _transport; if (transport == null) { return; } var consumed = 0; while (consumed < 256 && transport.TryReceiveOnce(out var frame)) { consumed++; if (frame != null && string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal)) Dispatch(CultNetSchemaMessageSerialization.Deserialize(frame.Payload)); } transport.PollResends(); if (consumed == 0) Thread.Sleep(1); } } catch (ObjectDisposedException) when (_disposed) { } catch (SocketException) when (_disposed) { } catch (Exception error) { _backgroundFailure.TrySetResult(error); } } private void Dispatch(ICultNetSchemaMessage message) { List? handlers; lock (_handlerLock) { if (!_handlers.TryGetValue(message.GetType(), out var registered)) { return; } handlers = registered.ToList(); } foreach (var handler in handlers) { handler.DynamicInvoke(message); } } } /// /// Factory helpers for schema client adapters. /// public static class CultNetSchemaClients { /// /// Creates the default C# LiteNetLib schema client adapter. /// public static ICultNetSchemaClient CreateLiteNetLib( ClientSecurityOptions? security = null, Action? configureClient = null) { return new LiteNetLibCultNetSchemaClient(security, configureClient); } /// /// Creates the C# RUDP schema client adapter. /// public static ICultNetSchemaClient CreateRudp( string runtimeId = "csharp-rudp-schema-client", uint connectionId = 0x43554c54, string connectPayload = "cultnet-schema-rudp", int maxFragmentBytes = 1024, long resendDelayMs = 25) { return new RudpCultNetSchemaClient(runtimeId, connectionId, connectPayload, maxFragmentBytes, resendDelayMs); } /// Creates the length-prefixed TCP schema client. public static ICultNetSchemaClient CreateTcpFramed() => new TcpFramedCultNetSchemaClient(); /// /// Creates the default schema client adapter for an advertised endpoint URI. /// public static ICultNetSchemaClient CreateForEndpoint( string endpoint, ClientSecurityOptions? security = null, Action? configureClient = null) { if (string.IsNullOrWhiteSpace(endpoint)) { throw new ArgumentException("Endpoint must be non-empty.", nameof(endpoint)); } if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) { throw new FormatException($"CultNet endpoint '{endpoint}' must be an absolute URI."); } if (string.Equals(uri.Scheme, "rudp", StringComparison.OrdinalIgnoreCase)) { return CreateRudp(); } if (string.Equals(uri.Scheme, "cultnet+tcp", StringComparison.OrdinalIgnoreCase)) { return CreateTcpFramed(); } if (string.Equals(uri.Scheme, "cultnet", StringComparison.OrdinalIgnoreCase)) { return CreateLiteNetLib(security, configureClient); } throw new FormatException( $"CultNet endpoint '{endpoint}' must use cultnet+tcp://host:port, cultnet://host:port, or rudp://host:port."); } } }