using System; using System.Collections.Generic; using System.Threading.Tasks; using MessagePack; namespace GameCult.Networking { /// Portable failure payload for operation routing and envelope validation. [MessagePackObject] public sealed class CultNetOperationFailure { /// Gets or sets the stable machine-readable failure code. [Key("code")] public string Code { get; set; } = "operation-error"; /// Gets or sets the human-readable failure description. [Key("message")] public string Message { get; set; } = "CultNet operation failed."; } /// /// Typed application input delivered through one CultNet operation envelope. /// The message id is the caller's idempotency key; the application transaction /// remains responsible for applying it exactly once. /// public sealed class CultNetOperationContext { internal CultNetOperationContext(CultNetOperationRequestMessage envelope, TRequest value) { Envelope = envelope ?? throw new ArgumentNullException(nameof(envelope)); Value = value; } /// Gets the decoded application request. public TRequest Value { get; } /// Gets the durable caller-supplied idempotency key. public string IdempotencyKey => Envelope.MessageId; /// Gets the calling runtime identity, when supplied. public string? SourceRuntimeId => Envelope.SourceRuntimeId; /// Gets the optional target runtime identity. public string? TargetRuntimeId => Envelope.TargetRuntimeId; /// Gets the original transport-neutral envelope for diagnostics. public CultNetOperationRequestMessage Envelope { get; } } /// Provider-authored typed result for one CultNet operation. public sealed class CultNetOperationReply { /// Creates one provider-authored typed reply. public CultNetOperationReply( string status, TResponse value, IEnumerable? diagnostics = null) { Status = RequireNonEmpty(status, nameof(status)); Value = value; Diagnostics = diagnostics == null ? Array.Empty() : new List(diagnostics).ToArray(); } /// Gets the provider-authored operation status. public string Status { get; } /// Gets the typed response payload. public TResponse Value { get; } /// Gets provider-authored diagnostics. public IReadOnlyList Diagnostics { get; } /// Creates an accepted typed reply. public static CultNetOperationReply Accepted(TResponse value) => new("accepted", value); /// Creates a rejected typed reply whose payload remains schema-valid. public static CultNetOperationReply Rejected( TResponse value, params string[] diagnostics) => new("rejected", value, diagnostics); private static string RequireNonEmpty(string value, string paramName) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", paramName) : value.Trim(); } /// /// Transport-neutral typed dispatcher for CultNet application operations. /// It owns envelope validation and serialization only; registered handlers own /// domain validation, idempotency, state mutation, and durable receipts. /// public sealed class CultNetOperationServer : IDisposable { /// Schema used by correlated framework-level operation failures. public const string FailureSchemaId = "gamecult.cultnet.operation_failure.v1"; private const string PayloadEncoding = "messagepack-base64"; private readonly ICultNetSchemaServer _server; private readonly string? _sourceRuntimeId; private readonly Dictionary _bindings = new(); private readonly Func _handler; private bool _disposed; /// Attaches one typed operation dispatcher to a schema server. public CultNetOperationServer(ICultNetSchemaServer server, string? sourceRuntimeId = null) { _server = server ?? throw new ArgumentNullException(nameof(server)); _sourceRuntimeId = string.IsNullOrWhiteSpace(sourceRuntimeId) ? null : sourceRuntimeId!.Trim(); _handler = HandleAsync; _server.OnCultNet(_handler); } /// /// Registers one typed operation. Duplicate service/operation routes are rejected. /// public CultNetOperationServer Register( string serviceId, string operation, string requestSchema, string responseSchema, Func, Task>> handler) { ThrowIfDisposed(); var route = new Route(serviceId, operation); if (_bindings.ContainsKey(route)) throw new InvalidOperationException($"CultNet operation '{route}' is already registered."); _bindings.Add(route, new Binding( route, requestSchema, responseSchema, handler ?? throw new ArgumentNullException(nameof(handler)), _sourceRuntimeId)); return this; } /// /// Registers one typed operation whose successful handler result is always accepted. /// public CultNetOperationServer Register( string serviceId, string operation, string requestSchema, string responseSchema, Func, Task> handler) { if (handler == null) throw new ArgumentNullException(nameof(handler)); return Register( serviceId, operation, requestSchema, responseSchema, async context => CultNetOperationReply.Accepted(await handler(context).ConfigureAwait(false))); } /// public void Dispose() { if (_disposed) return; _disposed = true; _server.RemoveCultNetMessageListener(_handler); _bindings.Clear(); } private async Task HandleAsync(CultNetOperationRequestMessage request, ICultNetSchemaServerPeer peer) { if (_disposed) return; if (request == null) throw new ArgumentNullException(nameof(request)); if (peer == null) throw new ArgumentNullException(nameof(peer)); if (_sourceRuntimeId != null && !string.Equals(request.TargetRuntimeId, _sourceRuntimeId, StringComparison.Ordinal)) { peer.SendCultNet(Failure( request, "denied", "target-runtime-mismatch", $"Operation targeted runtime '{request.TargetRuntimeId ?? "(missing)"}', not '{_sourceRuntimeId}'.")); return; } Route route; try { route = new Route(request.ServiceId, request.Operation); } catch (ArgumentException error) { peer.SendCultNet(Failure(request, "invalid", "invalid-route", error.Message)); return; } if (!_bindings.TryGetValue(route, out var binding)) { peer.SendCultNet(Failure( request, "unsupported", "operation-not-registered", $"CultNet operation '{route}' is not registered.")); return; } try { peer.SendCultNet(await binding.InvokeAsync(request).ConfigureAwait(false)); } catch (RequestException error) { peer.SendCultNet(Failure(request, "invalid", error.Code, error.Message)); } catch (Exception error) { peer.SendCultNet(Failure( request, "error", "handler-failed", $"CultNet operation '{route}' failed: {error.Message}")); } } private CultNetOperationResponseMessage Failure( CultNetOperationRequestMessage request, string status, string code, string message) { return new CultNetOperationResponseMessage { MessageId = request.MessageId ?? "", ServiceId = request.ServiceId ?? "", Operation = request.Operation ?? "", Status = status, PayloadSchema = FailureSchemaId, PayloadEncoding = PayloadEncoding, Payload = Convert.ToBase64String(MessagePackSerializer.Serialize( new CultNetOperationFailure { Code = code, Message = message }, CultNetSchemaMessageSerialization.Options)), Diagnostics = new[] { message }, SourceRuntimeId = _sourceRuntimeId }; } private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(CultNetOperationServer)); } private interface IBinding { Task InvokeAsync(CultNetOperationRequestMessage request); } private sealed class Binding : IBinding { private readonly Route _route; private readonly string _requestSchema; private readonly string _responseSchema; private readonly Func, Task>> _handler; private readonly string? _sourceRuntimeId; public Binding( Route route, string requestSchema, string responseSchema, Func, Task>> handler, string? sourceRuntimeId) { _route = route; _requestSchema = RequireNonEmpty(requestSchema, nameof(requestSchema)); _responseSchema = RequireNonEmpty(responseSchema, nameof(responseSchema)); _handler = handler; _sourceRuntimeId = sourceRuntimeId; } public async Task InvokeAsync(CultNetOperationRequestMessage request) { if (string.IsNullOrWhiteSpace(request.MessageId)) throw new RequestException("missing-idempotency-key", "CultNet operation omitted its idempotency key."); if (!string.Equals(request.PayloadSchema, _requestSchema, StringComparison.Ordinal)) throw new RequestException( "request-schema-mismatch", $"CultNet operation '{_route}' expected payload schema '{_requestSchema}', got '{request.PayloadSchema}'."); if (!string.Equals(request.PayloadEncoding, PayloadEncoding, StringComparison.Ordinal)) throw new RequestException( "request-encoding-mismatch", $"CultNet operation '{_route}' expected payload encoding '{PayloadEncoding}', got '{request.PayloadEncoding}'."); TRequest value; try { value = MessagePackSerializer.Deserialize( Convert.FromBase64String(request.Payload), CultNetSchemaMessageSerialization.Options); } catch (FormatException error) { throw new RequestException("request-payload-invalid", "CultNet operation payload was not valid base64.", error); } catch (MessagePackSerializationException error) { throw new RequestException("request-payload-invalid", "CultNet operation payload was not valid MessagePack.", error); } if (value == null) throw new RequestException("request-payload-null", "CultNet operation payload decoded to null."); var reply = await _handler(new CultNetOperationContext(request, value)).ConfigureAwait(false) ?? throw new InvalidOperationException("CultNet operation handler returned no reply."); if (reply.Value == null) throw new InvalidOperationException("CultNet operation handler returned a null payload."); return new CultNetOperationResponseMessage { MessageId = request.MessageId, ServiceId = _route.ServiceId, Operation = _route.Operation, Status = reply.Status, PayloadSchema = _responseSchema, PayloadEncoding = PayloadEncoding, Payload = Convert.ToBase64String(MessagePackSerializer.Serialize( reply.Value, CultNetSchemaMessageSerialization.Options)), Diagnostics = new List(reply.Diagnostics).ToArray(), SourceRuntimeId = _sourceRuntimeId }; } } private sealed class RequestException : Exception { public RequestException(string code, string message, Exception? inner = null) : base(message, inner) { Code = code; } public string Code { get; } } private readonly struct Route : IEquatable { public Route(string serviceId, string operation) { ServiceId = RequireNonEmpty(serviceId, nameof(serviceId)); Operation = RequireNonEmpty(operation, nameof(operation)); } public string ServiceId { get; } public string Operation { get; } public bool Equals(Route other) => string.Equals(ServiceId, other.ServiceId, StringComparison.Ordinal) && string.Equals(Operation, other.Operation, StringComparison.Ordinal); public override bool Equals(object? value) => value is Route route && Equals(route); public override int GetHashCode() => (StringComparer.Ordinal.GetHashCode(ServiceId) * 397) ^ StringComparer.Ordinal.GetHashCode(Operation); public override string ToString() => ServiceId + "/" + Operation; } private static string RequireNonEmpty(string value, string paramName) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", paramName) : value.Trim(); } }