Make Eve navigation and Hangar lowering transactional

This commit is contained in:
Meta Vens 2026-08-18 20:54:14 +02:00
commit de91053dd4
6 changed files with 224 additions and 44 deletions

View file

@ -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<string>())
.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<PreparedProvider> PrepareCandidateAsync(
IReadOnlyList<string> endpoints,
string requiredProviderId,
string requiredSurfaceId,
string requiredSurfaceKind,
string requiredVerseId,
CultMeshAuthorityTrustPolicy trust,
CancellationToken cancellationToken)
{
var failures = new List<string>();
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);
}
}

View file

@ -30,6 +30,10 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldPresentation? LastPresentation { get; private set; }
public EveUnitySceneNavigationFailure? LastNavigationFailure { get; private set; }
public event Action<EveUnitySceneNavigationFailure>? 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);
}

View file

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

View file

@ -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<string, string>(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);

View file

@ -52,6 +52,69 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
}
}
[Test]
public void SelectKeepsStableValuesWhenVerseLabelsCollide()
{
EveSurfaceCommandRequest? emitted = null;
var select = Component(
"verse",
"control.select",
new Dictionary<string, string>
{
["label"] = "VERSE",
["value"] = "verse:a",
["command"] = "eve.client.verse.select"
},
new[]
{
Component("a", "control.option", new Dictionary<string, string> { ["label"] = "Aetheria", ["value"] = "verse:a" }),
Component("b", "control.option", new Dictionary<string, string> { ["label"] = "Aetheria", ["value"] = "verse:b" })
});
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(select), request => emitted = request);
var field = root.Q<DropdownField>();
var window = EditorWindow.CreateInstance<EditorWindow>();
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<string, string> { ["label"] = "LAUNCH", ["disabled"] = "true" },
Array.Empty<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
Array.Empty<EveEmbeddedDocumentSlot>(),
new Dictionary<string, string>
{
["minWidth"] = "280",
["maxWidth"] = "360",
["flexGrow"] = "1"
});
var root = new EveUiToolkitSurfaceLowerer().Lower(Document(button));
var lowered = root.Q<Button>();
Assert.That(lowered.enabledSelf, Is.False);
Assert.That(lowered.style.minWidth.value.value, Is.EqualTo(280f));
Assert.That(lowered.style.maxWidth.value.value, Is.EqualTo(360f));
Assert.That(lowered.style.flexGrow.value, Is.EqualTo(1f));
}
[Test]
public void DefaultOptionsExposeSaiNornAndTeXProjectionAdapters()
{

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.unity-uitoolkit",
"displayName": "Eve UI Toolkit Lowering",
"version": "0.1.5",
"version": "0.1.6",
"unity": "2021.3",
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
"dependencies": {