Follow provider-owned Verse navigation

This commit is contained in:
Meta Vens 2026-08-17 22:43:10 +02:00
commit c6b7ecebc5
8 changed files with 200 additions and 11 deletions

View file

@ -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))

View file

@ -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)
{

View file

@ -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,

View file

@ -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()

View file

@ -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<EveUnitySceneCommandReceipt> ReceiptAvailable;
}
public interface IEveUnityNavigableProvider
{
Task NavigateAsync(EveUnitySceneNavigationTarget target);
}
public sealed class EveUnitySceneNavigationTarget
{
public EveUnitySceneNavigationTarget(
string verseId,
string providerId,
string surfaceId,
string surfaceKind,
IReadOnlyList<string>? rendezvousEndpoints = null)
{
VerseId = verseId ?? "";
ProviderId = providerId ?? "";
SurfaceId = surfaceId ?? "";
SurfaceKind = surfaceKind ?? "";
RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty<string>();
}
public string VerseId { get; }
public string ProviderId { get; }
public string SurfaceId { get; }
public string SurfaceKind { get; }
public IReadOnlyList<string> 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 =>

View file

@ -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);
}

View file

@ -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<FakePlayableWorldProviderComponent>();
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<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
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<EveSurfaceCommandRequest> Submitted { get; } = new List<EveSurfaceCommandRequest>();
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;
}
}
}
}

View file

@ -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",