using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
using R3;
namespace GameCult.Mesh
{
/// Configures one application-lifetime CultMesh client.
public sealed class CultMeshClientOptions
{
/// Gets or sets the rendezvous endpoints used to bootstrap stable identities.
public IReadOnlyList RendezvousEndpoints { get; set; } = Array.Empty();
/// Gets or sets Verse discovery transport and persistence options.
public CultMeshVerseDiscoveryClientOptions Discovery { get; set; } = new();
/// Gets or sets session path and diagnostic policy.
public CultMeshSessionManagerOptions Sessions { get; set; } = new();
/// Gets or sets schema connectors. Length-prefixed TCP is used by default.
public IReadOnlyList? Connectors { get; set; }
///
/// Gets or sets streaming content connectors. TCP content delivery is used by default;
/// applications may replace it or explicitly add legacy RUDP.
///
public IReadOnlyList? ContentConnectors { get; set; }
///
/// Gets or sets realtime state connectors. No connector is installed implicitly;
/// promoted runtimes should register QUIC and may explicitly add a legacy fallback.
///
public IReadOnlyList RealtimeConnectors { get; set; } =
Array.Empty();
/// Gets or sets the deadline before an unanswered subscription intent is replayed.
public TimeSpan SubscriptionResponseTimeout { get; set; } = TimeSpan.FromSeconds(2);
/// Gets or sets the deadline for one correlated typed operation response.
public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
}
///
/// Carries one provider-authored operation response. Transport delivery does not by itself
/// establish that the provider accepted or committed the requested gameplay change.
///
public sealed class CultMeshOperationResult
{
internal CultMeshOperationResult(CultNetOperationResponseMessage envelope, TResponse value)
{
MessageId = envelope.MessageId;
ServiceId = envelope.ServiceId;
Operation = envelope.Operation;
Status = envelope.Status;
PayloadSchema = envelope.PayloadSchema;
SourceRuntimeId = envelope.SourceRuntimeId;
Diagnostics = envelope.Diagnostics ?? Array.Empty();
Value = value;
}
/// Gets the durable caller-supplied idempotency key.
public string MessageId { get; }
/// Gets the provider service that answered.
public string ServiceId { get; }
/// Gets the operation that answered.
public string Operation { get; }
/// Gets the provider-authored operation status.
public string Status { get; }
/// Gets the portable response payload schema.
public string PayloadSchema { get; }
/// Gets the responding runtime identity, when supplied.
public string? SourceRuntimeId { get; }
/// Gets provider or transport diagnostics carried by the response.
public IReadOnlyList Diagnostics { get; }
/// Gets the decoded typed response payload.
public TResponse Value { get; }
}
/// Correlated framework-level failure returned before a typed domain reply exists.
public sealed class CultMeshRemoteOperationException : Exception
{
internal CultMeshRemoteOperationException(
CultNetOperationResponseMessage envelope,
CultNetOperationFailure failure)
: base(failure?.Message ?? "CultMesh operation failed.")
{
Status = envelope?.Status ?? "error";
Code = failure?.Code ?? "operation-error";
Diagnostics = envelope?.Diagnostics ?? Array.Empty();
}
/// Gets the provider-authored failure status.
public string Status { get; }
/// Gets the stable machine-readable failure code.
public string Code { get; }
/// Gets provider diagnostics carried by the response.
public IReadOnlyList Diagnostics { get; }
}
/// Owns one shared live document subscription until disposed.
public sealed class CultMeshDocumentLease : IDisposable where TDocument : class
{
private Action? _release;
internal CultMeshDocumentLease(CultMeshDocumentHandle handle, Action release)
{
Handle = handle ?? throw new ArgumentNullException(nameof(handle));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
/// Gets the live typed document handle owned by this lease.
public CultMeshDocumentHandle Handle { get; }
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
/// Owns one shared live collection subscription until disposed.
public sealed class CultMeshCollectionLease : IDisposable where TDocument : class
{
private Action? _release;
internal CultMeshCollectionLease(CultMeshCollectionHandle handle, Action release)
{
Handle = handle ?? throw new ArgumentNullException(nameof(handle));
_release = release ?? throw new ArgumentNullException(nameof(release));
}
/// Gets the live typed collection handle owned by this lease.
public CultMeshCollectionHandle Handle { get; }
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
///
/// Owns discovery and reusable sessions for one application lifetime.
/// Applications address stable identities; CultMesh owns physical routes and reconnection.
///
public sealed class CultMeshClient : IDisposable
{
private readonly CultMeshDiscoveryService _discovery;
private readonly CultMeshSessionManager _sessions;
private readonly TimeSpan _subscriptionResponseTimeout;
private readonly TimeSpan _operationResponseTimeout;
private readonly ConcurrentDictionary _documents = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _collections = new(StringComparer.Ordinal);
private bool _disposed;
public CultMeshClient(CultMeshClientOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
if (options.SubscriptionResponseTimeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(options.SubscriptionResponseTimeout));
if (options.OperationResponseTimeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(options.OperationResponseTimeout));
var endpoints = options.RendezvousEndpoints
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (endpoints.Length == 0)
throw new ArgumentException("At least one CultMesh rendezvous endpoint is required.", nameof(options));
var discoveryClient = new CultMeshVerseDiscoveryClient(options.Discovery);
_discovery = new CultMeshDiscoveryService(
endpoints.Select(endpoint => new RendezvousLookupSource(endpoint, discoveryClient, options.Discovery)),
new CultMeshDiscoveryServiceOptions
{
Clock = options.Discovery.Clock,
Diagnostics = options.Discovery.Diagnostics,
Store = options.Discovery.DiscoveryStore
});
_sessions = new CultMeshSessionManager(
_discovery,
options.Connectors ?? new ICultMeshTransportConnector[]
{
new CultMeshTcpSchemaTransportConnector(clock: options.Sessions.Clock)
},
options.ContentConnectors ?? new ICultMeshContentTransportConnector[]
{
new CultMeshTcpContentTransportConnector()
},
options.RealtimeConnectors,
options.Sessions);
_subscriptionResponseTimeout = options.SubscriptionResponseTimeout;
_operationResponseTimeout = options.OperationResponseTimeout;
}
/// Connects to one stable Verse/provider target using one application protocol.
public Task ConnectAsync(
CultMeshSessionTarget target,
CultMeshProtocolId protocol,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return _sessions.ConnectAsync(target, protocol, cancellationToken);
}
/// Connects the reusable realtime state plane for one stable Verse/provider target.
public Task ConnectRealtimeAsync(
CultMeshSessionTarget target,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return _sessions.ConnectRealtimeAsync(target, cancellationToken);
}
/// Creates a verified-transfer provider over this client's reusable content session owner.
public CultMeshSessionContentProvider ContentProvider(
string providerId,
CultMeshSessionTarget target)
{
ThrowIfDisposed();
return new CultMeshSessionContentProvider(
providerId,
_sessions,
target);
}
/// Creates a direct body provider over this client's reusable negotiated session owner.
public CultMeshSessionBodyProvider BodyProvider(
string providerId,
CultMeshSessionTarget target,
CultMeshSessionBodyProviderOptions? options = null)
{
ThrowIfDisposed();
return new CultMeshSessionBodyProvider(
providerId,
_sessions,
target,
options);
}
/// Leases one shared live typed document by stable Verse/provider identity and record key.
public async Task> LeaseDocumentAsync(
CultMeshSessionTarget target,
string recordKey,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Record key is required.", nameof(recordKey));
var key = target.SessionKey + "\u001f" + typeof(TDocument).AssemblyQualifiedName + "\u001f" + recordKey.Trim();
while (true)
{
var resource = _documents.GetOrAdd(key, _ => new SharedResource(
async owner => await OpenDocumentAsync(target, recordKey.Trim(), owner).ConfigureAwait(false)));
if (!resource.TryAcquire())
{
RemoveExact(_documents, key, resource);
continue;
}
try
{
var binding = (RemoteDocumentBinding)await AwaitForCallerAsync(resource.Value, cancellationToken)
.ConfigureAwait(false);
return new CultMeshDocumentLease(
binding.Handle,
() => Release(_documents, key, resource));
}
catch
{
Release(_documents, key, resource);
throw;
}
}
}
/// Leases a shared live collection by stable Verse/provider identity.
public Task> LeaseCollectionAsync(
CultMeshSessionTarget target,
CancellationToken cancellationToken)
where TDocument : class =>
LeaseCollectionAsync(target, includeInitialSnapshot: true, cancellationToken);
/// Leases a live collection and optionally skips replaying its initial snapshot.
public async Task> LeaseCollectionAsync(
CultMeshSessionTarget target,
bool includeInitialSnapshot = true,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
var identity = target.SessionKey;
var key = identity + "\u001f" + typeof(TDocument).AssemblyQualifiedName + "\u001f" + includeInitialSnapshot;
while (true)
{
var resource = _collections.GetOrAdd(key, _ => new SharedResource(
async owner => await OpenCollectionAsync(target, includeInitialSnapshot, owner).ConfigureAwait(false)));
if (!resource.TryAcquire())
{
RemoveExact(_collections, key, resource);
continue;
}
try
{
var binding = (RemoteCollectionBinding)await AwaitForCallerAsync(resource.Value, cancellationToken)
.ConfigureAwait(false);
return new CultMeshCollectionLease(
binding.Handle,
() => Release(_collections, key, resource));
}
catch
{
Release(_collections, key, resource);
throw;
}
}
}
///
/// Submits one typed document to a provider by stable identity. Submission is uncommitted input;
/// only a provider-authored receipt or resulting state can establish acceptance.
///
public async Task SubmitDocumentAsync(
CultMeshSessionTarget target,
string recordKey,
TDocument document,
string sourceRuntimeId,
string sourceRole,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Record key is required.", nameof(recordKey));
if (document == null) throw new ArgumentNullException(nameof(document));
if (string.IsNullOrWhiteSpace(sourceRuntimeId)) throw new ArgumentException("Source runtime identity is required.", nameof(sourceRuntimeId));
if (string.IsNullOrWhiteSpace(sourceRole)) throw new ArgumentException("Source role is required.", nameof(sourceRole));
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(typeof(TDocument));
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(TDocument) }, cacheRegistry);
var message = networkRegistry.CreateRawDocumentPutMessage(
"cultmesh-client-submit-" + Guid.NewGuid().ToString("N"),
new CultRecordHandle(new CultRecordKey(recordKey.Trim())),
document,
new CultNetDocumentMessageOptions
{
SourceRuntimeId = sourceRuntimeId.Trim(),
SourceRole = sourceRole.Trim()
});
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
session.OpenSchemaClient().SendCultNet(message);
}
///
/// Invokes one typed provider operation through the reusable identity-owned session.
/// The caller supplies a durable idempotency key; the returned status and payload remain
/// provider-authored evidence rather than client-side authority over canonical state.
///
public async Task> InvokeAsync(
CultMeshSessionTarget target,
string serviceId,
string operation,
string requestSchema,
string responseSchema,
TRequest request,
string sourceRuntimeId,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(serviceId)) throw new ArgumentException("Service identity is required.", nameof(serviceId));
if (string.IsNullOrWhiteSpace(operation)) throw new ArgumentException("Operation identity is required.", nameof(operation));
if (string.IsNullOrWhiteSpace(requestSchema)) throw new ArgumentException("Request schema is required.", nameof(requestSchema));
if (string.IsNullOrWhiteSpace(responseSchema)) throw new ArgumentException("Response schema is required.", nameof(responseSchema));
if (request is null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(sourceRuntimeId)) throw new ArgumentException("Source runtime identity is required.", nameof(sourceRuntimeId));
if (string.IsNullOrWhiteSpace(idempotencyKey)) throw new ArgumentException("A durable idempotency key is required.", nameof(idempotencyKey));
var expectedService = serviceId.Trim();
var expectedOperation = operation.Trim();
var expectedResponseSchema = responseSchema.Trim();
var messageId = idempotencyKey.Trim();
var message = new CultNetOperationRequestMessage
{
MessageId = messageId,
ServiceId = expectedService,
Operation = expectedOperation,
PayloadSchema = requestSchema.Trim(),
PayloadEncoding = "messagepack-base64",
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(request, CultNetSchemaMessageSerialization.Options)),
SourceRuntimeId = sourceRuntimeId.Trim()
};
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_operationResponseTimeout);
CultNetOperationResponseMessage envelope;
try
{
while (true)
{
var session = await ConnectOnlineAsync(target, timeout.Token).ConfigureAwait(false);
var completion = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var pathChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var generation = session.PhysicalGeneration;
using var responseSubscription = session.OnCultNet(response =>
{
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
completion.TrySetResult(response);
});
using var stateSubscription = session.WatchState().Subscribe(state =>
{
if (state.Status != CultMeshSessionStatus.Online || session.PhysicalGeneration != generation)
pathChanged.TrySetResult(true);
});
using var cancellation = timeout.Token.Register(() => completion.TrySetCanceled(timeout.Token));
if (session.State.Status != CultMeshSessionStatus.Online ||
session.PhysicalGeneration != generation)
continue;
session.SendCultNet(message);
var finished = await Task.WhenAny(completion.Task, pathChanged.Task).ConfigureAwait(false);
if (finished == completion.Task)
{
envelope = await completion.Task.ConfigureAwait(false);
break;
}
// The operation has a durable idempotency key. A route loss before a
// correlated response replays the same request after replacement finality.
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"CultMesh operation '{expectedService}/{expectedOperation}' did not answer idempotency key '{messageId}' " +
$"within {_operationResponseTimeout}.");
}
cancellationToken.ThrowIfCancellationRequested();
if (!string.Equals(envelope.ServiceId, expectedService, StringComparison.Ordinal) ||
!string.Equals(envelope.Operation, expectedOperation, StringComparison.Ordinal))
throw new InvalidOperationException("CultMesh operation response did not match the requested service and operation.");
if (string.Equals(envelope.PayloadSchema, CultNetOperationServer.FailureSchemaId, StringComparison.Ordinal))
{
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
throw new InvalidOperationException(
$"CultMesh operation failure returned unsupported payload encoding '{envelope.PayloadEncoding}'.");
CultNetOperationFailure failure;
try
{
failure = MessagePackSerializer.Deserialize(
Convert.FromBase64String(envelope.Payload),
CultNetSchemaMessageSerialization.Options);
}
catch (Exception error) when (error is FormatException || error is MessagePackSerializationException)
{
throw new InvalidOperationException("CultMesh operation failure payload was malformed.", error);
}
throw new CultMeshRemoteOperationException(envelope, failure);
}
if (!string.Equals(envelope.PayloadSchema, expectedResponseSchema, StringComparison.Ordinal))
throw new InvalidOperationException(
$"CultMesh operation returned payload schema '{envelope.PayloadSchema}', expected '{expectedResponseSchema}'.");
if (!string.Equals(envelope.PayloadEncoding, "messagepack-base64", StringComparison.Ordinal))
throw new InvalidOperationException(
$"CultMesh operation returned unsupported payload encoding '{envelope.PayloadEncoding}'.");
if (string.IsNullOrWhiteSpace(envelope.Status))
throw new InvalidOperationException("CultMesh operation response omitted its provider-authored status.");
TResponse value;
try
{
value = MessagePackSerializer.Deserialize(
Convert.FromBase64String(envelope.Payload),
CultNetSchemaMessageSerialization.Options);
}
catch (FormatException error)
{
throw new InvalidOperationException("CultMesh operation response payload was not valid base64.", error);
}
if (value is null) throw new InvalidOperationException("CultMesh operation response decoded to null.");
return new CultMeshOperationResult(envelope, value);
}
private async Task ConnectOnlineAsync(
CultMeshSessionTarget target,
CancellationToken cancellationToken)
{
while (true)
{
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
.ConfigureAwait(false);
if (session.State.Status == CultMeshSessionStatus.Online)
return session;
var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var subscription = session.WatchState().Subscribe(state =>
{
if (state.Status == CultMeshSessionStatus.Online || state.Status == CultMeshSessionStatus.Offline)
changed.TrySetResult(true);
});
using var cancellation = cancellationToken.Register(() => changed.TrySetCanceled(cancellationToken));
if (session.State.Status != CultMeshSessionStatus.Reconnecting)
continue;
await changed.Task.ConfigureAwait(false);
if (session.State.Status == CultMeshSessionStatus.Online)
return session;
}
}
/// Gets the number of currently leased shared document subscriptions.
public int ActiveDocumentResourceCount => _documents.Count;
/// Gets the number of currently leased shared collection subscriptions.
public int ActiveCollectionResourceCount => _collections.Count;
/// Reads one typed record once through the reusable document session.
public async Task ReadAsync(
CultMeshSessionTarget target,
string recordKey,
CancellationToken cancellationToken = default)
where TDocument : class
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Record key is required.", nameof(recordKey));
var documents = await ReadManyAsync(target, new[] { recordKey }, cancellationToken)
.ConfigureAwait(false);
return documents.FirstOrDefault()
?? throw new InvalidOperationException(
$"CultMesh target '{target}' did not publish {typeof(TDocument).FullName} record '{recordKey}'.");
}
/// Reads one typed record once with an explicit response deadline.
public async Task ReadAsync(
CultMeshSessionTarget target,
string recordKey,
TimeSpan responseTimeout,
CancellationToken cancellationToken = default)
where TDocument : class
{
var documents = await ReadManyCoreAsync(
target,
new[] { recordKey },
responseTimeout,
cancellationToken)
.ConfigureAwait(false);
return documents.FirstOrDefault()
?? throw new InvalidOperationException(
$"CultMesh target '{target}' did not publish {typeof(TDocument).FullName} record '{recordKey}'.");
}
/// Reads typed records once in one request through the reusable document session.
public async Task> ReadManyAsync(
CultMeshSessionTarget target,
IReadOnlyList recordKeys,
CancellationToken cancellationToken = default)
where TDocument : class
=> await ReadManyCoreAsync(
target,
recordKeys,
TimeSpan.FromSeconds(10),
cancellationToken)
.ConfigureAwait(false);
private async Task> ReadManyCoreAsync(
CultMeshSessionTarget target,
IReadOnlyList recordKeys,
TimeSpan responseTimeout,
CancellationToken cancellationToken)
where TDocument : class
{
ThrowIfDisposed();
if (target == null) throw new ArgumentNullException(nameof(target));
if (recordKeys == null || recordKeys.Count == 0 || recordKeys.Any(string.IsNullOrWhiteSpace))
throw new ArgumentException("At least one non-empty record key is required.", nameof(recordKeys));
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(typeof(TDocument));
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(new[] { typeof(TDocument) }, cacheRegistry);
using var snapshot = await CultMeshSnapshotSession.ConnectAsync(
_sessions,
target,
CultMeshProtocols.Content,
new CultMeshSnapshotRequestOptions
{
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseTimeout = responseTimeout,
MessageIdPrefix = "cultmesh-client-read"
},
networkRegistry,
cancellationToken)
.ConfigureAwait(false);
return await snapshot.FetchDocumentsAsync(
recordKeys: recordKeys,
schemaIds: new[] { cacheRegistry.GetRequired().SchemaId })
.ConfigureAwait(false);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var resource in _documents.Values.Concat(_collections.Values).Distinct()) resource.Dispose();
_documents.Clear();
_collections.Clear();
_sessions.Dispose();
_discovery.Dispose();
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshClient));
}
private async Task