From 7ba4041320f397c96095b13689d1f60a575b31bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 00:22:23 +0200 Subject: [PATCH] Make Eve navigation one rollbackable presentation transaction --- .../EveUnityCultMeshPlayableWorldProvider.cs | 45 ++++++++++++++---- .../EveUnityPlayableWorldClientBootstrap.cs | 47 ++++++++++++++++--- .../EveUnityPlayableWorldLiveClient.cs | 2 + .../EveUnitySceneSurfaceLowererTests.cs | 30 ++++++++++++ .../org.gamecult.eve.unity-scene/package.json | 4 +- 5 files changed, 112 insertions(+), 16 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 d580681..17ea29d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -48,6 +48,7 @@ namespace GameCult.Eve.UnityScene private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; private PreviousProvider? _previousProvider; + private bool _navigationRouteCommitted; private Task? _preparation; private CancellationTokenSource? _preparationLifetime; private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); @@ -178,24 +179,32 @@ namespace GameCult.Eve.UnityScene { var previous = _previousProvider; if (previous == null) return; - _previousProvider = null; + if (_navigationRouteCommitted) return; if (previous.Bridge != null) previous.Bridge.ReceiptAvailable -= ForwardReceipt; AttachPrepared(_transport, _bridge); - previous.Bridge?.Dispose(); - previous.Transport?.Dispose(); + _navigationRouteCommitted = true; + } + + public void FinalizeNavigation() + { + var previous = _previousProvider; + if (previous == null) return; + _previousProvider = null; + _navigationRouteCommitted = false; + SafeDispose(previous.Bridge); + SafeDispose(previous.Transport); } public void RollbackNavigation() { var previous = _previousProvider; if (previous == null) return; - _previousProvider = null; if (previous.Bridge != null) previous.Bridge.ReceiptAvailable -= ForwardReceipt; DetachPrepared(_transport, _bridge); - _bridge?.Dispose(); - _transport?.Dispose(); + var rejectedBridge = _bridge; + var rejectedTransport = _transport; while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); while (_pendingFields.TryDequeue(out _)) { } @@ -209,12 +218,18 @@ namespace GameCult.Eve.UnityScene surfaceKind = previous.SurfaceKind; _authorityTrust = previous.AuthorityTrust; AttachPrepared(_transport, _bridge); + _previousProvider = null; + _navigationRouteCommitted = false; _preparation = Task.CompletedTask; + SafeDispose(rejectedBridge); + SafeDispose(rejectedTransport); } public void Refresh() { - var activeBridge = _previousProvider?.Bridge ?? Bridge; + var activeBridge = _navigationRouteCommitted + ? Bridge + : _previousProvider?.Bridge ?? Bridge; if (activeBridge.IsConnected) activeBridge.Refresh(); else @@ -226,7 +241,7 @@ namespace GameCult.Eve.UnityScene // Navigation preparation is not a command-routing commit. The mounted // presentation continues to target its old provider until the visual // candidate has been lowered and accepted by the bootstrap. - (_previousProvider?.Bridge ?? Bridge).Submit(request); + (_navigationRouteCommitted ? Bridge : _previousProvider?.Bridge ?? Bridge).Submit(request); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) @@ -273,6 +288,7 @@ namespace GameCult.Eve.UnityScene _previousProvider?.Bridge?.Dispose(); _previousProvider?.Transport?.Dispose(); _previousProvider = null; + _navigationRouteCommitted = false; while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); while (_pendingFields.TryDequeue(out _)) { } _bridge = null; @@ -428,6 +444,7 @@ namespace GameCult.Eve.UnityScene surfaceFilter, surfaceKind, _authorityTrust); + _navigationRouteCommitted = false; DetachPrepared(_transport, _bridge); if (_previousProvider.Bridge != null) _previousProvider.Bridge.ReceiptAvailable += ForwardReceipt; @@ -479,6 +496,18 @@ namespace GameCult.Eve.UnityScene private void ForwardReceipt(EveUnitySceneCommandReceipt receipt) => ReceiptAvailable?.Invoke(receipt); + private static void SafeDispose(IDisposable? disposable) + { + try + { + disposable?.Dispose(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve provider cleanup failed after navigation ownership changed: {error}"); + } + } + private static void TraceStartup(string message) { if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal)) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index 9f39d66..c1037c1 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -36,6 +36,7 @@ namespace GameCult.Eve.UnityScene private IEveUnitySceneCommandReceiptSource? _navigationReceiptSource; private bool _navigationInProgress; + private StagedPresentation? _ownedPresentation; public void ConfigureProvider(MonoBehaviour providerComponent) { @@ -48,7 +49,12 @@ namespace GameCult.Eve.UnityScene BindNavigation(); } - private void OnDestroy() => UnbindNavigation(); + private void OnDestroy() + { + UnbindNavigation(); + _ownedPresentation?.Dispose(); + _ownedPresentation = null; + } private void BindNavigation() { @@ -89,8 +95,9 @@ namespace GameCult.Eve.UnityScene { navigation.GetAwaiter().GetResult(); staged = PrepareStagedPresentation(); - swap = ActivateStagedPresentation(staged); navigable.CommitNavigation(); + swap = ActivateStagedPresentation(staged); + navigable.FinalizeNavigation(); swap.Complete(); swap = null; staged = null; @@ -190,6 +197,7 @@ namespace GameCult.Eve.UnityScene return new PresentationSwap( this, staged, + _ownedPresentation, previousHost, previousRoot, previousPresentation, @@ -253,8 +261,18 @@ namespace GameCult.Eve.UnityScene public void Dispose() { - Host.Disconnect(); - DestroyObject(GameObject); + try + { + Host.Disconnect(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve presentation disconnect failed during cleanup: {error}"); + } + finally + { + DestroyObject(GameObject); + } } } @@ -262,6 +280,7 @@ namespace GameCult.Eve.UnityScene { private readonly EveUnityPlayableWorldClientBootstrap _owner; private readonly StagedPresentation _candidate; + private readonly StagedPresentation? _previousOwnedPresentation; private readonly EveUnityPlayableWorldClientHost? _previousHost; private readonly Transform? _previousRoot; private readonly EveUnityPlayableWorldPresentation? _previousPresentation; @@ -278,6 +297,7 @@ namespace GameCult.Eve.UnityScene public PresentationSwap( EveUnityPlayableWorldClientBootstrap owner, StagedPresentation candidate, + StagedPresentation? previousOwnedPresentation, EveUnityPlayableWorldClientHost? previousHost, Transform? previousRoot, EveUnityPlayableWorldPresentation? previousPresentation, @@ -292,6 +312,7 @@ namespace GameCult.Eve.UnityScene { _owner = owner; _candidate = candidate; + _previousOwnedPresentation = previousOwnedPresentation; _previousHost = previousHost; _previousRoot = previousRoot; _previousPresentation = previousPresentation; @@ -308,9 +329,23 @@ namespace GameCult.Eve.UnityScene public void Complete() { if (_settled) return; - if (_previousHost != null && !ReferenceEquals(_previousHost, _candidate.Host)) - _previousHost.Disconnect(); _settled = true; + _owner._ownedPresentation = _candidate; + if (_previousOwnedPresentation != null && + !ReferenceEquals(_previousOwnedPresentation, _candidate)) + { + _previousOwnedPresentation.Dispose(); + return; + } + if (_previousHost == null || ReferenceEquals(_previousHost, _candidate.Host)) return; + try + { + _previousHost.Disconnect(); + } + catch (Exception error) + { + Debug.LogWarning($"Eve previous presentation disconnect failed after navigation commit: {error}"); + } } public void Rollback() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs index b2b523d..4767284 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldLiveClient.cs @@ -241,6 +241,8 @@ namespace GameCult.Eve.UnityScene void CommitNavigation(); + void FinalizeNavigation(); + void RollbackNavigation(); } 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 0d8f5eb..1b03d7f 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1196,7 +1196,30 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(bootstrap.Host, Is.Not.SameAs(originalHost)); Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1)); Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(1)); Assert.That(provider.RollbackNavigationCount, Is.Zero); + + provider.PublishReceipt(new EveUnitySceneCommandReceipt( + "receipt:launch-again", + "aetheria.hangar.launch", + "launch", + "accepted", + "Aetheria", + "commander-daemon", + navigation: new EveUnitySceneNavigationTarget( + "gamecult.aetheria", + "aetheria.daemon", + "aetheria.pilot", + "interactive-world", + new[] { "cultnet+tcp://odin.example:3076" }))); + + yield return null; + yield return null; + + Assert.That(provider.CommitNavigationCount, Is.EqualTo(2)); + Assert.That(provider.FinalizeNavigationCount, Is.EqualTo(2)); + Assert.That(hostObject.transform.Cast() + .Count(child => child.name == "Eve Unity Candidate Presentation"), Is.EqualTo(1)); } finally { @@ -3362,6 +3385,8 @@ namespace GameCult.Eve.UnityScene.Tests public int CommitNavigationCount { get; private set; } + public int FinalizeNavigationCount { get; private set; } + public int RollbackNavigationCount { get; private set; } public bool FailNextNavigationMount { get; set; } @@ -3446,6 +3471,11 @@ namespace GameCult.Eve.UnityScene.Tests _failSurfaceRead = false; } + public void FinalizeNavigation() + { + FinalizeNavigationCount++; + } + public void RollbackNavigation() { RollbackNavigationCount++; diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 6881a86..b8e664d 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.114", + "version": "0.3.115", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.51", + "org.gamecult.cultlib": "1.0.52", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.3", "org.gamecult.eve.unity-uitoolkit": "0.1.6",