Route EveUnity provider state through CultMesh sessions

This commit is contained in:
Meta Vens 2026-07-12 20:45:25 +01:00
commit 0dee524662
5 changed files with 195 additions and 31 deletions

View file

@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Mesh;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityCultMeshConnectionContext : IDisposable
{
private readonly CultMeshDiscoveryService _discovery;
internal EveUnityCultMeshConnectionContext(
CultMeshEndpointId endpointId,
CultMeshDiscoveryService discovery,
CultMeshSessionManager sessions)
{
EndpointId = endpointId ?? throw new ArgumentNullException(nameof(endpointId));
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions));
}
public CultMeshEndpointId EndpointId { get; }
public CultMeshSessionManager Sessions { get; }
public Task<CultMeshSession> OpenDocumentsAsync(CancellationToken cancellationToken = default) =>
Sessions.ConnectAsync(EndpointId, CultMeshProtocols.Documents, cancellationToken);
public void Dispose()
{
Sessions.Dispose();
_discovery.Dispose();
}
internal static EveUnityCultMeshConnectionContext Create(
string endpointId,
IEnumerable<CultMeshVerseDescriptor> routes)
{
var identity = CultMeshEndpointId.Parse(endpointId);
var discovery = new CultMeshDiscoveryService(new[] { new FixedRouteSource(routes) });
return new EveUnityCultMeshConnectionContext(
identity,
discovery,
new CultMeshSessionManager(discovery, new ICultMeshTransportConnector[]
{
new CultMeshSchemaTransportConnector()
}));
}
private sealed class FixedRouteSource : ICultMeshLookupSource
{
private readonly CultMeshVerseDescriptor[] _routes;
public FixedRouteSource(IEnumerable<CultMeshVerseDescriptor> routes)
{
_routes = routes?.ToArray() ?? throw new ArgumentNullException(nameof(routes));
}
public string SourceId => "eve-unity-rendezvous";
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var now = DateTimeOffset.UtcNow;
IReadOnlyList<CultMeshDiscoveryObservation> observations = _routes
.Where(route => string.Equals(route.VerseId, query.EndpointId, StringComparison.Ordinal))
.Select(route => new CultMeshDiscoveryObservation(
route,
SourceId,
now,
now.AddMinutes(5),
CultMeshDiscoveryTrust.Unsigned,
"CultMesh rendezvous catalog"))
.ToArray();
return Task.FromResult(observations);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e2ece888321b9854fb4424a65448f703

View file

@ -39,6 +39,8 @@ namespace GameCult.Eve.UnityScene
private readonly string _providerId;
private readonly string _surfaceId;
private readonly string _runtimeId;
private readonly EveUnityCultMeshConnectionContext? _connection;
private CultMeshSession? _documentSession;
private readonly HashSet<string> _pendingCommandIds = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> _publishedReceiptIds = new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(StringComparer.Ordinal);
@ -59,7 +61,8 @@ namespace GameCult.Eve.UnityScene
string endpoint,
string providerId,
string surfaceId,
string runtimeId = "eve-unity")
string runtimeId = "eve-unity",
EveUnityCultMeshConnectionContext? connection = null)
{
_replicaPath = string.IsNullOrWhiteSpace(replicaPath)
? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath))
@ -74,6 +77,7 @@ namespace GameCult.Eve.UnityScene
? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId))
: surfaceId.Trim();
_runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim();
_connection = connection;
CurrentSurfaceDocument = EmptySurfaceDocument();
CurrentAssetManifestDocument = EmptyAssetManifest();
}
@ -171,6 +175,7 @@ namespace GameCult.Eve.UnityScene
_node?.Dispose();
_snapshot?.Dispose();
_subscriptions?.Dispose();
_connection?.Dispose();
_node = null;
_snapshot = null;
_subscriptions = null;
@ -235,6 +240,26 @@ namespace GameCult.Eve.UnityScene
})
.GetAwaiter()
.GetResult();
if (_connection != null)
{
_documentSession = _connection.OpenDocumentsAsync().GetAwaiter().GetResult();
_snapshot = CultMeshSnapshotSession.ConnectAsync(
_connection.Sessions,
_connection.EndpointId,
new CultMeshSnapshotRequestOptions
{
ShardId = RemoteShardId,
ShardEpoch = 1,
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseTimeout = TimeSpan.FromSeconds(10),
MessageIdPrefix = "eve-unity",
RudpRuntimeId = _runtimeId,
RudpMaxFragmentBytes = 1024
},
_networkRegistry)
.GetAwaiter().GetResult();
return;
}
_snapshot = CultMesh.SnapshotSession(
_endpoint,
new CultMeshSnapshotRequestOptions
@ -303,16 +328,20 @@ namespace GameCult.Eve.UnityScene
private void EnsureLiveSubscriptions()
{
if (_subscriptions != null) return;
var endpoint = new Uri(_endpoint);
var client = CultNetSchemaClients.CreateForEndpoint(_endpoint);
client.Connect(endpoint.Host, endpoint.Port);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!client.Connected && DateTime.UtcNow < deadline)
System.Threading.Thread.Sleep(1);
if (!client.Connected)
var client = _documentSession?.OpenSchemaClient()
?? CultNetSchemaClients.CreateForEndpoint(_endpoint);
if (_documentSession == null)
{
client.Dispose();
throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'.");
var endpoint = new Uri(_endpoint);
client.Connect(endpoint.Host, endpoint.Port);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!client.Connected && DateTime.UtcNow < deadline)
System.Threading.Thread.Sleep(1);
if (!client.Connected)
{
client.Dispose();
throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'.");
}
}
_subscriptions = new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!);

View file

@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using UnityEngine;
@ -29,6 +30,7 @@ namespace GameCult.Eve.UnityScene
private EveUnityCultMeshLiveProviderTransport? _transport;
private EveUnitySceneLiveProviderBridge? _bridge;
private Task<EveUnityCultMeshProviderSelection>? _discovery;
private readonly ConcurrentQueue<EveEntitySoaViewDocument> _pendingEntityViews = new ConcurrentQueue<EveEntitySoaViewDocument>();
public EveEntitySoaViewDocument? CurrentEntityView { get; private set; }
@ -117,15 +119,20 @@ namespace GameCult.Eve.UnityScene
private void OnDestroy()
{
var transportOwnedConnection = _transport != null;
_bridge?.Dispose();
_transport?.Dispose();
_bridge = null;
_transport = null;
Selection = null;
if (!transportOwnedConnection && _discovery?.Status == TaskStatus.RanToCompletion)
_discovery.Result.Connection.Dispose();
_discovery = null;
}
private void Update()
{
CompleteDiscoveryOnMainThread();
_transport?.PumpLiveEvents();
EveEntitySoaViewDocument? latest = null;
while (_pendingEntityViews.TryDequeue(out var next)) latest = next;
@ -150,12 +157,29 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
Selection = new EveUnityCultMeshProviderDiscovery().Discover(
rendezvousEndpoint,
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
if (_discovery == null)
_discovery = new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
rendezvousEndpoint,
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
CompleteDiscoveryOnMainThread();
if (_transport == null)
throw new InvalidOperationException("EveUnity CultMesh provider discovery is still in progress.");
}
private void CompleteDiscoveryOnMainThread()
{
if (_transport != null || _discovery == null || !_discovery.IsCompleted)
return;
if (_discovery.IsFaulted)
throw _discovery.Exception?.GetBaseException()
?? new InvalidOperationException("EveUnity CultMesh provider discovery failed.");
if (_discovery.IsCanceled)
throw new InvalidOperationException("EveUnity CultMesh provider discovery was cancelled.");
Selection = _discovery.Result;
var resolvedReplicaPath = string.IsNullOrWhiteSpace(replicaPath)
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}.cc")
: replicaPath;
@ -164,7 +188,8 @@ namespace GameCult.Eve.UnityScene
Selection.Endpoint,
Selection.ProviderId,
Selection.SurfaceId,
runtimeId);
runtimeId,
Selection.Connection);
_transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view);
_bridge = new EveUnitySceneLiveProviderBridge(_transport);
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Mesh;
@ -17,13 +18,15 @@ namespace GameCult.Eve.UnityScene
string verseId,
string providerId,
string surfaceId,
string surfaceKind)
string surfaceKind,
EveUnityCultMeshConnectionContext connection)
{
Endpoint = endpoint ?? "";
VerseId = verseId ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
SurfaceKind = surfaceKind ?? "";
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
}
public string Endpoint { get; }
@ -31,6 +34,7 @@ namespace GameCult.Eve.UnityScene
public string ProviderId { get; }
public string SurfaceId { get; }
public string SurfaceKind { get; }
public EveUnityCultMeshConnectionContext Connection { get; }
}
public sealed class EveUnityCultMeshProviderDiscovery
@ -46,17 +50,28 @@ namespace GameCult.Eve.UnityScene
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "")
=> DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId)
.GetAwaiter().GetResult();
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
var response = RunNetwork(() => CultMesh.CreateVerseDiscoveryClient().FetchAsync(
var response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync(
rendezvousEndpoint,
new CultMeshVerseCatalogRequestMessage
{
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
TransportVersion = "cultmesh.v0"
}));
}).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var candidates = response.Verses
.Where(verse => string.IsNullOrWhiteSpace(verseId) ||
@ -71,9 +86,13 @@ namespace GameCult.Eve.UnityScene
var failures = new List<string>();
foreach (var candidate in candidates)
{
EveUnityCultMeshConnectionContext? connection = null;
try
{
var advertisement = FetchAdvertisements(candidate.Endpoint)
connection = EveUnityCultMeshConnectionContext.Create(
candidate.Verse.VerseId,
new[] { candidate.Verse.ToVerseDescriptor() });
var advertisement = (await FetchAdvertisementsAsync(connection, cancellationToken).ConfigureAwait(false))
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
.Select(document => new
@ -94,10 +113,12 @@ namespace GameCult.Eve.UnityScene
candidate.Verse.VerseId,
advertisement.Document.ProviderId,
advertisement.Surface.SurfaceId,
advertisement.Surface.SurfaceKind);
advertisement.Surface.SurfaceKind,
connection);
}
catch (Exception error)
{
connection?.Dispose();
failures.Add($"{candidate.Endpoint}: {error.Message}");
}
}
@ -107,12 +128,15 @@ namespace GameCult.Eve.UnityScene
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}");
}
private static EveProviderAdvertisementDocument[] FetchAdvertisements(string endpoint)
private static async Task<EveProviderAdvertisementDocument[]> FetchAdvertisementsAsync(
EveUnityCultMeshConnectionContext connection,
CancellationToken cancellationToken)
{
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes);
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry);
var snapshot = CultMesh.SnapshotEndpoint(
endpoint,
using var snapshot = await CultMeshSnapshotSession.ConnectAsync(
connection.Sessions,
connection.EndpointId,
new CultMeshSnapshotEndpointOptions
{
Context = CultMesh.Verse("eve.discovery", "eve-unity").Context,
@ -127,12 +151,12 @@ namespace GameCult.Eve.UnityScene
RudpRuntimeId = "eve-unity.discovery",
RudpMaxFragmentBytes = 1024
}
});
return RunNetwork(() => snapshot.FetchDocumentsAsync<EveProviderAdvertisementDocument>())
.ToArray();
}.Request,
networkRegistry,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return (await snapshot.FetchDocumentsAsync<EveProviderAdvertisementDocument>()
.ConfigureAwait(false)).ToArray();
}
private static T RunNetwork<T>(Func<Task<T>> operation) =>
Task.Run(operation).GetAwaiter().GetResult();
}
}