Make Eve navigation and Hangar lowering transactional
This commit is contained in:
parent
e8e116ecc4
commit
de91053dd4
6 changed files with 224 additions and 44 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Reference in a new issue