Make Eve provider navigation transactional
This commit is contained in:
parent
de91053dd4
commit
ccc5a2605c
5 changed files with 220 additions and 14 deletions
|
|
@ -47,6 +47,7 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
private EveUnityCultMeshLiveProviderTransport? _transport;
|
||||
private EveUnitySceneLiveProviderBridge? _bridge;
|
||||
private PreviousProvider? _previousProvider;
|
||||
private Task? _preparation;
|
||||
private CancellationTokenSource? _preparationLifetime;
|
||||
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
|
||||
|
|
@ -163,7 +164,7 @@ namespace GameCult.Eve.UnityScene
|
|||
_navigationAuthorityTrust,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
AdoptPrepared(prepared);
|
||||
StagePrepared(prepared);
|
||||
rendezvousEndpoint = prepared.Selection.RendezvousEndpoint;
|
||||
verseFilter = target.VerseId;
|
||||
providerFilter = target.ProviderId;
|
||||
|
|
@ -173,6 +174,39 @@ namespace GameCult.Eve.UnityScene
|
|||
_preparation = Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void CommitNavigation()
|
||||
{
|
||||
var previous = _previousProvider;
|
||||
if (previous == null) return;
|
||||
_previousProvider = null;
|
||||
previous.Bridge?.Dispose();
|
||||
previous.Transport?.Dispose();
|
||||
}
|
||||
|
||||
public void RollbackNavigation()
|
||||
{
|
||||
var previous = _previousProvider;
|
||||
if (previous == null) return;
|
||||
_previousProvider = null;
|
||||
DetachPrepared(_transport, _bridge);
|
||||
_bridge?.Dispose();
|
||||
_transport?.Dispose();
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
|
||||
Selection = previous.Selection;
|
||||
_transport = previous.Transport;
|
||||
_bridge = previous.Bridge;
|
||||
rendezvousEndpoint = previous.RendezvousEndpoint;
|
||||
verseFilter = previous.VerseFilter;
|
||||
providerFilter = previous.ProviderFilter;
|
||||
surfaceFilter = previous.SurfaceFilter;
|
||||
surfaceKind = previous.SurfaceKind;
|
||||
_authorityTrust = previous.AuthorityTrust;
|
||||
AttachPrepared(_transport, _bridge);
|
||||
_preparation = Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
if (Bridge.IsConnected)
|
||||
|
|
@ -227,6 +261,9 @@ namespace GameCult.Eve.UnityScene
|
|||
}
|
||||
_bridge?.Dispose();
|
||||
_transport?.Dispose();
|
||||
_previousProvider?.Bridge?.Dispose();
|
||||
_previousProvider?.Transport?.Dispose();
|
||||
_previousProvider = null;
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
_bridge = null;
|
||||
|
|
@ -345,21 +382,16 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
private void AdoptPrepared(PreparedProvider prepared)
|
||||
{
|
||||
if (_previousProvider != null)
|
||||
throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first.");
|
||||
var oldBridge = _bridge;
|
||||
var oldTransport = _transport;
|
||||
if (oldBridge != null)
|
||||
{
|
||||
oldBridge.DocumentAvailable -= ForwardDocument;
|
||||
oldBridge.ReceiptAvailable -= ForwardReceipt;
|
||||
}
|
||||
DetachPrepared(oldTransport, oldBridge);
|
||||
|
||||
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.DocumentAvailable += ForwardDocument;
|
||||
_bridge.ReceiptAvailable += ForwardReceipt;
|
||||
AttachPrepared(_transport, _bridge);
|
||||
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
|
|
@ -367,6 +399,69 @@ namespace GameCult.Eve.UnityScene
|
|||
oldTransport?.Dispose();
|
||||
}
|
||||
|
||||
private void StagePrepared(PreparedProvider prepared)
|
||||
{
|
||||
if (_previousProvider != null)
|
||||
{
|
||||
prepared.Bridge.Dispose();
|
||||
prepared.Transport.Dispose();
|
||||
throw new InvalidOperationException("A staged Eve provider navigation must be committed or rolled back first.");
|
||||
}
|
||||
|
||||
_previousProvider = new PreviousProvider(
|
||||
Selection,
|
||||
_transport,
|
||||
_bridge,
|
||||
rendezvousEndpoint,
|
||||
verseFilter,
|
||||
providerFilter,
|
||||
surfaceFilter,
|
||||
surfaceKind,
|
||||
_authorityTrust);
|
||||
DetachPrepared(_transport, _bridge);
|
||||
Selection = prepared.Selection;
|
||||
_transport = prepared.Transport;
|
||||
_bridge = prepared.Bridge;
|
||||
AttachPrepared(_transport, _bridge);
|
||||
while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose();
|
||||
while (_pendingFields.TryDequeue(out _)) { }
|
||||
}
|
||||
|
||||
private void AttachPrepared(
|
||||
EveUnityCultMeshLiveProviderTransport? transport,
|
||||
EveUnitySceneLiveProviderBridge? bridge)
|
||||
{
|
||||
if (transport != null)
|
||||
{
|
||||
transport.EntityViewAvailable += QueueEntityView;
|
||||
transport.FieldsSplatsAvailable += QueueFields;
|
||||
}
|
||||
if (bridge != null)
|
||||
{
|
||||
bridge.DocumentAvailable += ForwardDocument;
|
||||
bridge.ReceiptAvailable += ForwardReceipt;
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachPrepared(
|
||||
EveUnityCultMeshLiveProviderTransport? transport,
|
||||
EveUnitySceneLiveProviderBridge? bridge)
|
||||
{
|
||||
if (transport != null)
|
||||
{
|
||||
transport.EntityViewAvailable -= QueueEntityView;
|
||||
transport.FieldsSplatsAvailable -= QueueFields;
|
||||
}
|
||||
if (bridge == null) return;
|
||||
bridge.DocumentAvailable -= ForwardDocument;
|
||||
bridge.ReceiptAvailable -= ForwardReceipt;
|
||||
}
|
||||
|
||||
private void QueueEntityView(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease) =>
|
||||
_pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
|
||||
|
||||
private void QueueFields(EveFieldsSplatsDocument fields) => _pendingFields.Enqueue(fields);
|
||||
|
||||
private void ForwardDocument(EveUnitySceneProviderSurfaceDocument document) =>
|
||||
DocumentAvailable?.Invoke(document);
|
||||
|
||||
|
|
@ -402,5 +497,16 @@ namespace GameCult.Eve.UnityScene
|
|||
EveUnityCultMeshProviderSelection Selection,
|
||||
EveUnityCultMeshLiveProviderTransport Transport,
|
||||
EveUnitySceneLiveProviderBridge Bridge);
|
||||
|
||||
private sealed record PreviousProvider(
|
||||
EveUnityCultMeshProviderSelection? Selection,
|
||||
EveUnityCultMeshLiveProviderTransport? Transport,
|
||||
EveUnitySceneLiveProviderBridge? Bridge,
|
||||
string RendezvousEndpoint,
|
||||
string VerseFilter,
|
||||
string ProviderFilter,
|
||||
string SurfaceFilter,
|
||||
string SurfaceKind,
|
||||
CultMeshAuthorityTrustPolicy AuthorityTrust);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,16 +81,31 @@ namespace GameCult.Eve.UnityScene
|
|||
yield break;
|
||||
_navigationInProgress = true;
|
||||
var navigation = navigable.NavigateAsync(target);
|
||||
var mountAttempted = false;
|
||||
while (!navigation.IsCompleted)
|
||||
yield return null;
|
||||
try
|
||||
{
|
||||
navigation.GetAwaiter().GetResult();
|
||||
LastNavigationFailure = null;
|
||||
mountAttempted = true;
|
||||
Mount();
|
||||
navigable.CommitNavigation();
|
||||
LastNavigationFailure = null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
navigable.RollbackNavigation();
|
||||
if (mountAttempted)
|
||||
{
|
||||
try
|
||||
{
|
||||
Mount();
|
||||
}
|
||||
catch (Exception restoreError)
|
||||
{
|
||||
Debug.LogError($"Eve provider navigation rollback could not remount the previous surface. {restoreError}");
|
||||
}
|
||||
}
|
||||
var failure = new EveUnitySceneNavigationFailure(target, error.Message, DateTimeOffset.UtcNow);
|
||||
LastNavigationFailure = failure;
|
||||
NavigationFailed?.Invoke(failure);
|
||||
|
|
|
|||
|
|
@ -238,6 +238,10 @@ namespace GameCult.Eve.UnityScene
|
|||
public interface IEveUnityNavigableProvider
|
||||
{
|
||||
Task NavigateAsync(EveUnitySceneNavigationTarget target);
|
||||
|
||||
void CommitNavigation();
|
||||
|
||||
void RollbackNavigation();
|
||||
}
|
||||
|
||||
public sealed class EveUnitySceneNavigationTarget
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
|
|
@ -1192,6 +1193,62 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
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));
|
||||
Assert.That(provider.CommitNavigationCount, Is.EqualTo(1));
|
||||
Assert.That(provider.RollbackNavigationCount, Is.Zero);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(hostObject);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator PlayableWorldClientBootstrapRollsBackAndRemountsAfterNavigationMountFailure()
|
||||
{
|
||||
var hostObject = new GameObject("generic-eve-navigation-rollback-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.FailNextNavigationMount = true;
|
||||
LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted"));
|
||||
|
||||
provider.PublishReceipt(new EveUnitySceneCommandReceipt(
|
||||
"receipt:launch-failure",
|
||||
"aetheria.hangar.launch",
|
||||
"launch",
|
||||
"accepted",
|
||||
"Aetheria",
|
||||
"commander-daemon",
|
||||
navigation: new EveUnitySceneNavigationTarget(
|
||||
"gamecult.aetheria",
|
||||
"broken-provider",
|
||||
"aetheria.pilot",
|
||||
"interactive-world",
|
||||
new[] { "cultnet+tcp://odin.example:3999" })));
|
||||
|
||||
yield return null;
|
||||
yield return null;
|
||||
|
||||
Assert.That(provider.CommitNavigationCount, Is.Zero);
|
||||
Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1));
|
||||
Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null);
|
||||
Assert.That(bootstrap.Host!.Runtime, Is.Not.Null);
|
||||
Assert.That(bootstrap.Host.ActiveWorld, Is.Not.Null);
|
||||
Assert.That(bootstrap.Host.ActiveWorld!.PlayerEntityId, Is.EqualTo("player-vanguard"));
|
||||
Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(2));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -3293,6 +3350,14 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
|
||||
public EveUnitySceneNavigationTarget? LastNavigation { get; private set; }
|
||||
|
||||
public int CommitNavigationCount { get; private set; }
|
||||
|
||||
public int RollbackNavigationCount { get; private set; }
|
||||
|
||||
public bool FailNextNavigationMount { get; set; }
|
||||
|
||||
private bool _failSurfaceRead;
|
||||
|
||||
public string SinkKind => "fake-provider-command-sink";
|
||||
|
||||
public string ManifestRef => CurrentDocument.ManifestRef;
|
||||
|
|
@ -3314,7 +3379,9 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
new EveInputCapabilityDocument();
|
||||
|
||||
EveUnitySceneProviderSurfaceDocument IEveUnitySceneProviderSurfaceDocumentSource.CurrentDocument =>
|
||||
CurrentSurfaceDocument;
|
||||
_failSurfaceRead
|
||||
? throw new InvalidOperationException("Deliberate candidate surface read failure.")
|
||||
: CurrentSurfaceDocument;
|
||||
|
||||
public event Action<EveUnitySceneProviderSurfaceDocument>? DocumentAvailable;
|
||||
|
||||
|
|
@ -3358,8 +3425,22 @@ namespace GameCult.Eve.UnityScene.Tests
|
|||
public Task NavigateAsync(EveUnitySceneNavigationTarget target)
|
||||
{
|
||||
LastNavigation = target;
|
||||
_failSurfaceRead = FailNextNavigationMount;
|
||||
FailNextNavigationMount = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void CommitNavigation()
|
||||
{
|
||||
CommitNavigationCount++;
|
||||
_failSurfaceRead = false;
|
||||
}
|
||||
|
||||
public void RollbackNavigation()
|
||||
{
|
||||
RollbackNavigationCount++;
|
||||
_failSurfaceRead = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "org.gamecult.eve.unity-scene",
|
||||
"displayName": "Eve Unity Scene Lowering",
|
||||
"version": "0.3.112",
|
||||
"version": "0.3.113",
|
||||
"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.cultlib": "1.0.50",
|
||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||
"org.gamecult.eve.surface": "0.3.3",
|
||||
"org.gamecult.eve.unity-uitoolkit": "0.1.6",
|
||||
|
|
|
|||
Reference in a new issue