diff --git a/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs b/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs index 79978e2..0ae0bb7 100644 --- a/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs +++ b/ReleaseConsumerProject/Assets/GenericEveUnityLauncher.cs @@ -57,8 +57,12 @@ public sealed class GenericEveUnityLauncher : MonoBehaviour while (true) { var connected = false; + var preparation = _provider.PrepareAsync(); + while (!preparation.IsCompleted) + yield return null; try { + preparation.GetAwaiter().GetResult(); var presentation = _bootstrap.Mount(); LowerSurface(_provider.CurrentDocument); Debug.Log($"Connected generic Eve client: {_provider.Selection?.ProviderId} / {_provider.Selection?.SurfaceId} / {presentation.ActiveEntities} entities"); diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 220184b..e966e86 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -12,6 +12,9 @@ networked client. Give it one Verse rendezvous endpoint and it discovers an advertised `interactive-world`, constructs the generic CultMesh transport, and provides every bootstrap port. Provider, surface, and Verse IDs are optional selection filters; they are not product knowledge required by the client. +Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. +Discovery and session setup never run synchronously from Unity lifecycle or +input getters, so an unavailable provider cannot stall the editor main thread. The package never imports provider product assemblies. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index 725dc18..64a7ef1 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading.Tasks; using GameCult.Eve.Surface; using GameCult.Eve.PluginFields; using GameCult.Eve.UnityScene.Fields; @@ -36,6 +37,7 @@ namespace GameCult.Eve.UnityScene private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; + private Task? _preparation; private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); private readonly ConcurrentQueue _pendingFields = new ConcurrentQueue(); @@ -53,7 +55,7 @@ namespace GameCult.Eve.UnityScene { get { - EnsureTransport(); + RequirePrepared(); return _transport!.CurrentInputCapability; } } @@ -62,7 +64,7 @@ namespace GameCult.Eve.UnityScene { get { - EnsureTransport(); + RequirePrepared(); return _transport!.CurrentAssetBodyTransportKind; } } @@ -88,7 +90,7 @@ namespace GameCult.Eve.UnityScene string requiredSurfaceKind = "interactive-world", string clientRuntimeId = "eve-unity") { - if (_bridge != null) + if (_bridge != null || _preparation != null) throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration."); rendezvousEndpoint = endpoint ?? ""; @@ -105,6 +107,15 @@ namespace GameCult.Eve.UnityScene Bridge.Connect(); } + public Task PrepareAsync() + { + if (_bridge != null) + return Task.CompletedTask; + if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted) + _preparation = PrepareTransportAsync(); + return _preparation; + } + public void Disconnect() { _bridge?.Disconnect(); @@ -125,13 +136,13 @@ namespace GameCult.Eve.UnityScene public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { - EnsureTransport(); + RequirePrepared(); return _transport!.ResolvePrefab(asset); } public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType) { - EnsureTransport(); + RequirePrepared(); return _transport!.ResolveAsset(asset, assetType); } @@ -139,13 +150,13 @@ namespace GameCult.Eve.UnityScene EveUnityPlayableWorldAssetBinding asset, out IReadOnlyDictionary metadata) { - EnsureTransport(); + RequirePrepared(); return _transport!.TryResolveAssetMetadata(asset, out metadata); } public bool TryGetRenderChannelLayer(string channel, out int layer) { - EnsureTransport(); + RequirePrepared(); return _transport!.TryGetRenderChannelLayer(channel, out layer); } @@ -157,6 +168,7 @@ namespace GameCult.Eve.UnityScene while (_pendingFields.TryDequeue(out _)) { } _bridge = null; _transport = null; + _preparation = null; Selection = null; } @@ -184,19 +196,19 @@ namespace GameCult.Eve.UnityScene { get { - EnsureTransport(); + RequirePrepared(); return _bridge!; } } - private void EnsureTransport() + private async Task PrepareTransportAsync() { if (_transport != null) return; if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = new EveUnityCultMeshProviderDiscovery().Discover( + Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync( rendezvousEndpoint, providerFilter, surfaceFilter, @@ -216,6 +228,13 @@ namespace GameCult.Eve.UnityScene _bridge = new EveUnitySceneLiveProviderBridge(_transport); } + private void RequirePrepared() + { + if (_transport == null || _bridge == null) + throw new InvalidOperationException( + "The EveUnity CultMesh provider is not prepared. Await PrepareAsync before mounting the playable world."); + } + private sealed class EntityViewLease { public EntityViewLease(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index b2941d7..9c9efcb 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -41,6 +41,15 @@ namespace GameCult.Eve.UnityScene }; public EveUnityCultMeshProviderSelection Discover( + string rendezvousEndpoint, + string providerId = "", + string surfaceId = "", + string surfaceKind = "interactive-world", + string verseId = "") => + DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId) + .GetAwaiter().GetResult(); + + public async Task DiscoverAsync( string rendezvousEndpoint, string providerId = "", string surfaceId = "", @@ -50,13 +59,13 @@ namespace GameCult.Eve.UnityScene 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" - })); + }); var candidates = response.Verses .Where(verse => string.IsNullOrWhiteSpace(verseId) || @@ -73,7 +82,7 @@ namespace GameCult.Eve.UnityScene { try { - var advertisement = FetchAdvertisements(candidate.Endpoint) + var advertisement = (await FetchAdvertisementsAsync(candidate.Endpoint)) .Where(document => string.IsNullOrWhiteSpace(providerId) || string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) .Select(document => new @@ -107,7 +116,7 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}"); } - private static EveProviderAdvertisementDocument[] FetchAdvertisements(string endpoint) + private static async Task FetchAdvertisementsAsync(string endpoint) { var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes); var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry); @@ -128,11 +137,7 @@ namespace GameCult.Eve.UnityScene RudpMaxFragmentBytes = 1024 } }); - return RunNetwork(() => snapshot.FetchDocumentsAsync()) - .ToArray(); + return (await snapshot.FetchDocumentsAsync()).ToArray(); } - - private static T RunNetwork(Func> operation) => - Task.Run(operation).GetAwaiter().GetResult(); } } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index 1fd2603..ce69ca5 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -1,12 +1,38 @@ +using System; using System.Reflection; using System.Collections.Generic; +using System.Diagnostics; using GameCult.Eve.UnityScene; using NUnit.Framework; +using UnityEngine; namespace GameCult.Eve.UnityScene.Tests { public sealed class EveUnityCultMeshAssetLookupTests { + [Test] + public void UnpreparedProviderGetterFailsFastWithoutStartingNetworkDiscovery() + { + var gameObject = new GameObject("unprepared-eve-provider"); + try + { + var provider = gameObject.AddComponent(); + provider.Configure("rudp://127.0.0.1:1"); + var stopwatch = Stopwatch.StartNew(); + + var error = Assert.Throws(() => _ = provider.CurrentInputCapability); + + stopwatch.Stop(); + StringAssert.Contains("Await PrepareAsync", error!.Message); + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(100)); + Assert.That(provider.Selection, Is.Null); + } + finally + { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + [Test] public void ResolvesUnityNormalizedBundleNamesWithoutChangingLogicalAssetIdentity() { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 7c390d9..cc7abb5 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.67", + "version": "0.3.68", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {