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 dea2a47..51a84d4 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -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 _pendingEntityViews = new ConcurrentQueue(); @@ -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); } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index cb8a372..7be1fab 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -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); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index 4fe2ebb..b2b523d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -238,6 +238,10 @@ namespace GameCult.Eve.UnityScene public interface IEveUnityNavigableProvider { Task NavigateAsync(EveUnitySceneNavigationTarget target); + + void CommitNavigation(); + + void RollbackNavigation(); } public sealed class EveUnitySceneNavigationTarget diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index f6312b8..d3f523c 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -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(); + 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(), + "aetheria")); + var bootstrap = hostObject.AddComponent(); + 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? 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; + } } } } diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 3bfdf79..a28e7da 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -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",