diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 4ee1b88..06a3175 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -1389,7 +1389,15 @@ namespace GameCult.Eve.UnityScene receipt.SurfaceId, receipt.Message, DateTimeOffset.TryParse(receipt.IssuedAtUtc, out var issuedAt) ? issuedAt : null, - receipt.SourceVersion)); + receipt.SourceVersion, + receipt.Navigation == null + ? null + : new EveUnitySceneNavigationTarget( + receipt.Navigation.VerseId, + receipt.Navigation.ProviderId, + receipt.Navigation.SurfaceId, + receipt.Navigation.SurfaceKind, + receipt.Navigation.RendezvousEndpoints))); if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) || string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) || string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) 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 f93be26..3064459 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.Linq; using System.Threading.Tasks; using Stopwatch = System.Diagnostics.Stopwatch; using GameCult.Eve.Surface; @@ -27,7 +28,8 @@ namespace GameCult.Eve.UnityScene IEveUnityCameraRenderPolicySource, IEveUnityNativeAssetProvider, IEveUnityNativeAssetMetadataProvider, - IEveUnityInputCapabilitySource + IEveUnityInputCapabilitySource, + IEveUnityNavigableProvider { [SerializeField] private string rendezvousEndpoint = ""; [SerializeField] private string providerFilter = ""; @@ -116,6 +118,26 @@ namespace GameCult.Eve.UnityScene _bridge?.Disconnect(); } + public async Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (string.IsNullOrWhiteSpace(target.VerseId)) + throw new ArgumentException("Eve provider navigation requires a stable Verse identity.", nameof(target)); + if (string.IsNullOrWhiteSpace(target.SurfaceId)) + throw new ArgumentException("Eve provider navigation requires a surface identity.", nameof(target)); + + ReleaseTransport(); + var navigationEndpoint = target.RendezvousEndpoints + .FirstOrDefault(endpoint => !string.IsNullOrWhiteSpace(endpoint)); + if (!string.IsNullOrWhiteSpace(navigationEndpoint)) + rendezvousEndpoint = navigationEndpoint; + verseFilter = target.VerseId; + providerFilter = target.ProviderId; + surfaceFilter = target.SurfaceId; + surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind; + await PrepareAsync(); + } + public void Refresh() { if (Bridge.IsConnected) @@ -156,6 +178,11 @@ namespace GameCult.Eve.UnityScene } private void OnDestroy() + { + ReleaseTransport(); + } + + private void ReleaseTransport() { if (_bridge != null) { 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 91b9aef..62e6837 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -121,7 +121,7 @@ namespace GameCult.Eve.UnityScene return new EveUnityCultMeshProviderSelection( rendezvousEndpoint, - advertisement.Document.ProviderId, + candidate.VerseId, candidate.VerseId, advertisement.Document.ProviderId, advertisement.Surface.SurfaceId, diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index fcd0924..ffe1791 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using UnityEngine; #nullable enable @@ -29,13 +30,64 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource; + private bool _navigationInProgress; + public void ConfigureProvider(MonoBehaviour providerComponent) { + UnbindNavigation(); provider = providerComponent != null ? providerComponent : throw new ArgumentNullException(nameof(providerComponent)); providerSurfaceDocuments = provider; commandSink = provider; assetManifestDocuments = provider; receiptSource = provider; + BindNavigation(); + } + + private void OnDestroy() => UnbindNavigation(); + + private void BindNavigation() + { + if (provider is not IEveUnityNavigableProvider || provider is not IEveUnitySceneCommandReceiptSource source) + return; + _navigationReceiptSource = source; + source.ReceiptAvailable += OnReceiptAvailable; + } + + private void UnbindNavigation() + { + if (_navigationReceiptSource != null) + _navigationReceiptSource.ReceiptAvailable -= OnReceiptAvailable; + _navigationReceiptSource = null; + } + + private void OnReceiptAvailable(EveUnitySceneCommandReceipt receipt) + { + if (_navigationInProgress || receipt.Navigation == null) + return; + if (!string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) && + !string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase)) + return; + StartCoroutine(NavigateAsync(receipt.Navigation)); + } + + private IEnumerator NavigateAsync(EveUnitySceneNavigationTarget target) + { + if (provider is not IEveUnityNavigableProvider navigable) + yield break; + _navigationInProgress = true; + var navigation = navigable.NavigateAsync(target); + while (!navigation.IsCompleted) + yield return null; + try + { + navigation.GetAwaiter().GetResult(); + Mount(); + } + finally + { + _navigationInProgress = false; + } } public EveUnityPlayableWorldPresentation Mount() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 41ca20f..4fe2ebb 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using GameCult.Eve.Surface; #nullable enable @@ -234,6 +235,34 @@ namespace GameCult.Eve.UnityScene event Action ReceiptAvailable; } + public interface IEveUnityNavigableProvider + { + Task NavigateAsync(EveUnitySceneNavigationTarget target); + } + + public sealed class EveUnitySceneNavigationTarget + { + public EveUnitySceneNavigationTarget( + string verseId, + string providerId, + string surfaceId, + string surfaceKind, + IReadOnlyList? rendezvousEndpoints = null) + { + VerseId = verseId ?? ""; + ProviderId = providerId ?? ""; + SurfaceId = surfaceId ?? ""; + SurfaceKind = surfaceKind ?? ""; + RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty(); + } + + public string VerseId { get; } + public string ProviderId { get; } + public string SurfaceId { get; } + public string SurfaceKind { get; } + public IReadOnlyList RendezvousEndpoints { get; } + } + public sealed class EveUnitySceneCommandReceipt { public EveUnitySceneCommandReceipt( @@ -248,7 +277,8 @@ namespace GameCult.Eve.UnityScene string surfaceId = "", string message = "", DateTimeOffset? issuedAtUtc = null, - long sourceVersion = 0) + long sourceVersion = 0, + EveUnitySceneNavigationTarget? navigation = null) { ReceiptId = receiptId ?? ""; Command = command ?? ""; @@ -262,6 +292,7 @@ namespace GameCult.Eve.UnityScene Message = message ?? ""; IssuedAtUtc = issuedAtUtc; SourceVersion = sourceVersion; + Navigation = navigation; } public string Schema { get; } @@ -288,6 +319,8 @@ namespace GameCult.Eve.UnityScene public long SourceVersion { get; } + public EveUnitySceneNavigationTarget? Navigation { get; } + public bool IsProviderOwned => !string.IsNullOrWhiteSpace(OwnerRepo) && !string.Equals(OwnerRepo, "EveUnity", StringComparison.Ordinal); public bool ShouldRefreshProviderSurface => 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 7cf72b6..f45bc97 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -19,14 +19,14 @@ namespace GameCult.Eve.UnityScene.Tests { var selection = new EveUnityCultMeshProviderSelection( "cultnet+tcp://odin.example:3075", - "aetheria.daemon", + "aetheria.public", "aetheria.public", "aetheria.daemon", "aetheria.pilot", "interactive-world"); Assert.That(selection.RendezvousEndpoint, Is.EqualTo("cultnet+tcp://odin.example:3075")); - Assert.That(selection.EndpointId, Is.EqualTo("aetheria.daemon")); + Assert.That(selection.EndpointId, Is.EqualTo("aetheria.public")); Assert.That(selection.VerseId, Is.EqualTo("aetheria.public")); Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null); } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index acae3fe..ffbda32 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Collections; +using System.Threading.Tasks; using GameCult.Caching; using GameCult.Eve.PluginFields; using GameCult.Eve.Surface; @@ -12,6 +14,7 @@ using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.UIElements; +using UnityEngine.TestTools; #nullable enable @@ -912,11 +915,19 @@ namespace GameCult.Eve.UnityScene.Tests "aetheria.daemon.move_intent", "accepted", "Aetheria", - "provider-owned-daemon")); + "provider-owned-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria", + "aetheria.pilot", + "interactive-world"))); Assert.That(runtime.LastReceipt, Is.Not.Null); Assert.That(runtime.LastReceipt!.IsProviderOwned, Is.True); Assert.That(runtime.LastReceipt.ShouldRefreshProviderSurface, Is.True); + Assert.That(runtime.LastReceipt.Navigation, Is.Not.Null); + Assert.That(runtime.LastReceipt.Navigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(runtime.LastReceipt.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot")); } [Test] @@ -1134,6 +1145,55 @@ namespace GameCult.Eve.UnityScene.Tests } } + [UnityTest] + public IEnumerator PlayableWorldClientBootstrapFollowsProviderOwnedReceiptNavigation() + { + var hostObject = new GameObject("generic-eve-navigation-client"); + try + { + var provider = hostObject.AddComponent(); + provider.Set( + new EveUnitySceneProviderSurfaceDocument( + PlayableArpgDocument(), + Advertisement("aetheria.daemon.game"), + "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", + 1), + new EveUnityPlayableWorldAssetManifestDocument( + "cultmesh://aetheria/assets/manifest", + Array.Empty(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + bootstrap.ConfigureProvider(provider); + bootstrap.Mount(); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.LastNavigation, Is.Not.Null); + Assert.That(provider.LastNavigation!.VerseId, Is.EqualTo("gamecult.aetheria")); + Assert.That(provider.LastNavigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.example:3076" })); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(2)); + } + finally + { + UnityEngine.Object.DestroyImmediate(hostObject); + } + } + [Test] public void PlayableWorldClientHostSubmitsProviderOwnedMoveVector() { @@ -3219,12 +3279,15 @@ namespace GameCult.Eve.UnityScene.Tests IEveUnitySceneCommandSink, IEveUnitySceneCommandReceiptSource, IEveUnityProviderRefreshSource, - IEveUnityInputCapabilitySource + IEveUnityInputCapabilitySource, + IEveUnityNavigableProvider { public List Submitted { get; } = new List(); public int RefreshCount { get; private set; } + public EveUnitySceneNavigationTarget? LastNavigation { get; private set; } + public string SinkKind => "fake-provider-command-sink"; public string ManifestRef => CurrentDocument.ManifestRef; @@ -3286,6 +3349,12 @@ namespace GameCult.Eve.UnityScene.Tests { ReceiptAvailable?.Invoke(receipt); } + + public Task NavigateAsync(EveUnitySceneNavigationTarget target) + { + LastNavigation = target; + return Task.CompletedTask; + } } } } diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 72c3523..1538e10 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,13 +1,13 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.104", + "version": "0.3.105", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.45", + "org.gamecult.cultlib": "1.0.46", "org.gamecult.eve.plugin-fields": "0.2.3", - "org.gamecult.eve.surface": "0.3.0", + "org.gamecult.eve.surface": "0.3.1", "org.gamecult.eve.unity-uitoolkit": "0.1.3", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0",