using System; using System.Linq; using System.Threading.Tasks; namespace GameCult.Networking { /// /// Forwards shard writes to an authoritative owner. /// public interface ICultNetShardWriteForwarder { /// /// Forwards a raw document put to the shard owner. /// Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message); /// /// Forwards a raw document delete to the shard owner. /// Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message); } /// /// Options controlling shard write forwarding from a database server bridge. /// public sealed class CultNetDatabaseServerOptions { /// /// Gets or sets whether non-primary writes may be forwarded to shard owners. /// public bool ForwardNonPrimaryWrites { get; set; } /// /// Gets or sets the forwarder used when non-primary write forwarding is enabled. /// public ICultNetShardWriteForwarder? WriteForwarder { get; set; } } /// /// Options for the schema-v0 client-based shard write forwarder. /// public sealed class CultNetSchemaWriteForwarderOptions { /// /// Gets or sets client security options used to connect to primary endpoints. /// public ClientSecurityOptions? Security { get; set; } /// /// Gets or sets how long to wait for a connection before failing the forwarded write. /// public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); /// /// Gets or sets a callback used to customize each ephemeral forwarding client. /// public Action? ConfigureClient { get; set; } /// /// Gets or sets the schema-v0 client factory. Defaults to an endpoint-aware adapter: /// rudp:// endpoints use the C# RUDP schema client and cultnet:// endpoints use LiteNetLib. /// public Func? CreateClient { get; set; } } /// /// Forwards shard writes over the CultNet schema-v0 client transport. /// public sealed class CultNetSchemaWriteForwarder : ICultNetShardWriteForwarder { private readonly CultNetSchemaWriteForwarderOptions _options; /// /// Creates a schema-v0 write forwarder. /// public CultNetSchemaWriteForwarder(CultNetSchemaWriteForwarderOptions? options = null) { _options = options ?? new CultNetSchemaWriteForwarderOptions(); } /// public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; await SendAsync(endpoint, message).ConfigureAwait(false); } /// public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; await SendAsync(endpoint, message).ConfigureAwait(false); } /// /// Parses a CultNet endpoint URI into host and port. /// public static (string Host, int Port) ParseEndpoint(string endpoint) { if (string.IsNullOrWhiteSpace(endpoint)) { throw new ArgumentException("Endpoint must be non-empty.", nameof(endpoint)); } if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || !IsSupportedSchemaEndpointScheme(uri.Scheme) || string.IsNullOrWhiteSpace(uri.Host)) { throw new FormatException( $"CultNet endpoint '{endpoint}' must use cultnet+tcp://host:port, cultnet://host:port, or rudp://host:port."); } return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); } private async Task SendAsync(string endpoint, T message) where T : ICultNetSchemaMessage { var (host, port) = ParseEndpoint(endpoint); using var client = CreateClient(endpoint); client.Connect(host, port); await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); client.SendCultNet(message); } private ICultNetSchemaClient CreateClient(string endpoint) { return _options.CreateClient?.Invoke() ?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient); } private static bool IsSupportedSchemaEndpointScheme(string scheme) { return string.Equals(scheme, "cultnet+tcp", StringComparison.OrdinalIgnoreCase) || string.Equals(scheme, "cultnet", StringComparison.OrdinalIgnoreCase) || string.Equals(scheme, "rudp", StringComparison.OrdinalIgnoreCase); } private async Task WaitForConnectionAsync(ICultNetSchemaClient client, string endpoint) { var deadline = DateTimeOffset.UtcNow + _options.ConnectTimeout; while (!client.Connected) { if (DateTimeOffset.UtcNow >= deadline) { throw new TimeoutException($"Timed out connecting to shard primary endpoint {endpoint}."); } await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false); } } private static string ResolvePrimaryEndpoint(CultNetShardDescriptor shard) { if (shard == null) throw new ArgumentNullException(nameof(shard)); return shard.PrimaryEndpoints.FirstOrDefault() ?? throw new InvalidOperationException( $"Shard '{shard.ShardId}' does not advertise a primary endpoint."); } } }