From de91053dd4b37b9f0cce1493db4e3e56ef869856 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 20:54:14 +0200 Subject: [PATCH] Make Eve navigation and Hangar lowering transactional --- .../EveUnityCultMeshPlayableWorldProvider.cs | 147 +++++++++++++----- .../EveUnityPlayableWorldClientBootstrap.cs | 17 ++ .../org.gamecult.eve.unity-scene/package.json | 4 +- .../Runtime/EveUiToolkitSurfaceLowerer.cs | 35 ++++- .../Editor/EveUiToolkitSurfaceLowererTests.cs | 63 ++++++++ .../package.json | 2 +- 6 files changed, 224 insertions(+), 44 deletions(-) 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 056d652..dea2a47 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -141,19 +141,36 @@ namespace GameCult.Eve.UnityScene 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; - _authorityTrust = _navigationAuthorityTrust; - } + var endpoints = (target.RendezvousEndpoints ?? Array.Empty()) + .Where(endpoint => !string.IsNullOrWhiteSpace(endpoint)) + .Select(endpoint => endpoint.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (endpoints.Length == 0 && !string.IsNullOrWhiteSpace(rendezvousEndpoint)) + endpoints = new[] { rendezvousEndpoint }; + if (endpoints.Length == 0) + throw new InvalidOperationException("Eve provider navigation requires at least one Odin rendezvous endpoint."); + + var requiredSurfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) + ? "interactive-world" + : target.SurfaceKind; + var prepared = await PrepareCandidateAsync( + endpoints, + target.ProviderId, + target.SurfaceId, + requiredSurfaceKind, + target.VerseId, + _navigationAuthorityTrust, + CancellationToken.None).ConfigureAwait(false); + + AdoptPrepared(prepared); + rendezvousEndpoint = prepared.Selection.RendezvousEndpoint; verseFilter = target.VerseId; providerFilter = target.ProviderId; surfaceFilter = target.SurfaceId; - surfaceKind = string.IsNullOrWhiteSpace(target.SurfaceKind) ? "interactive-world" : target.SurfaceKind; - await PrepareAsync(); + surfaceKind = requiredSurfaceKind; + _authorityTrust = _navigationAuthorityTrust; + _preparation = Task.CompletedTask; } public void Refresh() @@ -257,44 +274,97 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(rendezvousEndpoint)) throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint."); - Selection = await new EveUnityCultMeshProviderDiscovery(_authorityTrust).DiscoverAsync( - rendezvousEndpoint, + var prepared = await PrepareCandidateAsync( + new[] { rendezvousEndpoint }, providerFilter, surfaceFilter, surfaceKind, verseFilter, - cancellationToken); - TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); - elapsed.Restart(); - var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) - ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") - : cacheDirectory; - var transport = new EveUnityCultMeshLiveProviderTransport( - resolvedCachePath, - Selection.RendezvousEndpoint, - Selection.VerseId, - Selection.AuthorityRuntimeId, - Selection.ProviderId, - Selection.SurfaceId, - runtimeId, - authorityTrust: _authorityTrust); - try + _authorityTrust, + cancellationToken).ConfigureAwait(false); + TraceStartup($"discovery and transport preparation {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + AdoptPrepared(prepared); + } + + private async Task PrepareCandidateAsync( + IReadOnlyList endpoints, + string requiredProviderId, + string requiredSurfaceId, + string requiredSurfaceKind, + string requiredVerseId, + CultMeshAuthorityTrustPolicy trust, + CancellationToken cancellationToken) + { + var failures = new List(); + foreach (var endpoint in endpoints) { - await transport.PrepareAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + try + { + var selection = await new EveUnityCultMeshProviderDiscovery(trust).DiscoverAsync( + endpoint, + requiredProviderId, + requiredSurfaceId, + requiredSurfaceKind, + requiredVerseId, + cancellationToken).ConfigureAwait(false); + var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory) + ? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}") + : cacheDirectory; + var transport = new EveUnityCultMeshLiveProviderTransport( + resolvedCachePath, + selection.RendezvousEndpoint, + selection.VerseId, + selection.AuthorityRuntimeId, + selection.ProviderId, + selection.SurfaceId, + runtimeId, + authorityTrust: trust); + try + { + await transport.PrepareAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new PreparedProvider(selection, transport, new EveUnitySceneLiveProviderBridge(transport)); + } + catch + { + transport.Dispose(); + throw; + } + } + catch (Exception error) when (error is not OperationCanceledException) + { + failures.Add($"{endpoint}: {error.Message}"); + } } - catch + + throw new InvalidOperationException( + $"No configured Odin endpoint prepared Verse '{requiredVerseId}' surface '{requiredSurfaceId}'. " + + string.Join(" | ", failures)); + } + + private void AdoptPrepared(PreparedProvider prepared) + { + var oldBridge = _bridge; + var oldTransport = _transport; + if (oldBridge != null) { - transport.Dispose(); - throw; + oldBridge.DocumentAvailable -= ForwardDocument; + oldBridge.ReceiptAvailable -= ForwardReceipt; } - cancellationToken.ThrowIfCancellationRequested(); - _transport = transport; + + Selection = prepared.Selection; + _transport = prepared.Transport; + _bridge = prepared.Bridge; _transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); _transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields); - _bridge = new EveUnitySceneLiveProviderBridge(_transport); _bridge.DocumentAvailable += ForwardDocument; _bridge.ReceiptAvailable += ForwardReceipt; - TraceStartup($"transport-construction {elapsed.Elapsed.TotalMilliseconds:0.###}ms"); + + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); + while (_pendingFields.TryDequeue(out _)) { } + oldBridge?.Dispose(); + oldTransport?.Dispose(); } private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) => @@ -327,5 +397,10 @@ namespace GameCult.Eve.UnityScene public EveEntitySoaViewDocument Document { get; } public ICultMeshBodyReadLease Lease { get; } } + + private sealed record PreparedProvider( + EveUnityCultMeshProviderSelection Selection, + EveUnityCultMeshLiveProviderTransport Transport, + EveUnitySceneLiveProviderBridge Bridge); } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index ffe1791..cb8a372 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -30,6 +30,10 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; } + public EveUnitySceneNavigationFailure? LastNavigationFailure { get; private set; } + + public event Action? NavigationFailed; + private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource; private bool _navigationInProgress; @@ -82,8 +86,16 @@ namespace GameCult.Eve.UnityScene try { navigation.GetAwaiter().GetResult(); + LastNavigationFailure = null; Mount(); } + catch (Exception error) + { + var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow); + LastNavigationFailure = failure; + NavigationFailed?.Invoke(failure); + Debug.LogError($"Eve provider navigation failed; the current surface remains mounted. {error}"); + } finally { _navigationInProgress = false; @@ -243,4 +255,9 @@ namespace GameCult.Eve.UnityScene return null; } } + + public sealed record EveUnitySceneNavigationFailure( + EveUnitySceneNavigationTarget Target, + string Diagnostic, + DateTimeOffset FailedAtUtc); } diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index d59d7bf..3bfdf79 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,14 +1,14 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.111", + "version": "0.3.112", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { "org.gamecult.cultlib": "1.0.49", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.3", - "org.gamecult.eve.unity-uitoolkit": "0.1.5", + "org.gamecult.eve.unity-uitoolkit": "0.1.6", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index 8b82b3d..2506d96 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -46,6 +46,7 @@ namespace GameCult.Eve.UnityUIToolkit element.AddToClassList($"eve-kind-{SafeClass(component.Kind)}"); element.userData = component; ApplyGeneratedLayout(element, component); + element.SetEnabled(IsEnabled(component)); if (IsHidden(component) || IsExternalProjectionRoot(component.Kind)) return element; @@ -131,6 +132,8 @@ namespace GameCult.Eve.UnityUIToolkit element.style.flexWrap = Wrap.Wrap; return element; } + case "scroll": + return new ScrollView(ScrollViewMode.Vertical); case "pane": case "modal": case "card": @@ -538,18 +541,26 @@ namespace GameCult.Eve.UnityUIToolkit }) .Where(option => !string.IsNullOrWhiteSpace(option.Value)) .ToArray(); - var labels = options.Select(option => option.Label).ToList(); + var duplicateLabels = options + .GroupBy(option => option.Label, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + var labels = options + .Select(option => duplicateLabels.Contains(option.Label) + ? $"{option.Label} [{option.Value}]" + : option.Label) + .ToList(); var selectedValue = component.GetProp("value"); var selectedIndex = Array.FindIndex(options, option => string.Equals(option.Value, selectedValue, StringComparison.Ordinal)); var field = new DropdownField(component.GetProp("label"), labels, Math.Max(0, selectedIndex)); - field.SetEnabled(!ParseBool(component.GetProp("disabled"))); field.RegisterValueChangedCallback(change => { - var option = options.FirstOrDefault(candidate => - string.Equals(candidate.Label, change.newValue, StringComparison.Ordinal)); - if (option == null) + var index = field.index; + if (index < 0 || index >= options.Length) return; + var option = options[index]; var payload = new Dictionary(component.Props, StringComparer.Ordinal) { ["value"] = option.Value @@ -659,6 +670,12 @@ namespace GameCult.Eve.UnityUIToolkit element.style.alignItems = align; if (TryGet(layout, "justifyContent", out var justifyContent) && TryParseJustify(justifyContent, out var justify)) element.style.justifyContent = justify; + if (TryGet(layout, "flexGrow", out var flexGrow)) + element.style.flexGrow = ParseFloat(flexGrow); + if (TryGet(layout, "flexShrink", out var flexShrink)) + element.style.flexShrink = ParseFloat(flexShrink); + if (TryGet(layout, "flexBasis", out var flexBasis)) + element.style.flexBasis = ParseLength(flexBasis); if (TryGet(layout, "width", out var width)) element.style.width = ParseLength(width); if (TryGet(layout, "minWidth", out var minWidth)) @@ -735,6 +752,14 @@ namespace GameCult.Eve.UnityUIToolkit return false; } + private static bool IsEnabled(EveSurfaceComponent component) + { + if (ParseBool(component.GetProp("disabled"))) + return false; + var enabled = component.GetProp("enabled"); + return string.IsNullOrWhiteSpace(enabled) || ParseBool(enabled); + } + private static StyleLength ParseLength(string value) { value = FirstToken(value); diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs index 85d664b..6eba639 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs @@ -52,6 +52,69 @@ namespace GameCult.Eve.UnityUIToolkit.Tests } } + [Test] + public void SelectKeepsStableValuesWhenVerseLabelsCollide() + { + EveSurfaceCommandRequest? emitted = null; + var select = Component( + "verse", + "control.select", + new Dictionary + { + ["label"] = "VERSE", + ["value"] = "verse:a", + ["command"] = "eve.client.verse.select" + }, + new[] + { + Component("a", "control.option", new Dictionary { ["label"] = "Aetheria", ["value"] = "verse:a" }), + Component("b", "control.option", new Dictionary { ["label"] = "Aetheria", ["value"] = "verse:b" }) + }); + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request); + var field = root.Q(); + var window = EditorWindow.CreateInstance(); + + try + { + window.rootVisualElement.Add(root); + window.Show(); + Assert.That(field.choices, Is.EqualTo(new[] { "Aetheria [verse:a]", "Aetheria [verse:b]" })); + field.index = 1; + Assert.That(emitted, Is.Not.Null); + Assert.That(emitted!.PayloadFields["value"], Is.EqualTo("verse:b")); + } + finally + { + window.Close(); + } + } + + [Test] + public void SemanticEnabledStateAndMinMaxFlexLayoutAreLowered() + { + var button = new EveSurfaceComponent( + "launch", + "control.button", + new Dictionary { ["label"] = "LAUNCH", ["disabled"] = "true" }, + Array.Empty(), + Array.Empty(), + Array.Empty(), + new Dictionary + { + ["minWidth"] = "280", + ["maxWidth"] = "360", + ["flexGrow"] = "1" + }); + + var root = new EveUiToolkitSurfaceLowerer().Lower(Document(button)); + var lowered = root.Q