From 49f49203c8c5c7631c806965916618eaaf238d14 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 23:50:14 +0200 Subject: [PATCH] Stage Eve navigation presentation before commit --- .../EveUnityCultMeshPlayableWorldProvider.cs | 18 +- .../EveUnityPlayableWorldClientBootstrap.cs | 248 +++++++++++++++++- .../EveUnitySceneSurfaceLowererTests.cs | 16 +- .../org.gamecult.eve.unity-scene/package.json | 4 +- 4 files changed, 263 insertions(+), 23 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 51a84d4..e8d74e1 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -179,6 +179,8 @@ namespace GameCult.Eve.UnityScene var previous = _previousProvider; if (previous == null) return; _previousProvider = null; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; previous.Bridge?.Dispose(); previous.Transport?.Dispose(); } @@ -188,6 +190,8 @@ namespace GameCult.Eve.UnityScene var previous = _previousProvider; if (previous == null) return; _previousProvider = null; + if (previous.Bridge != null) + previous.Bridge.ReceiptAvailable -= ForwardReceipt; DetachPrepared(_transport, _bridge); _bridge?.Dispose(); _transport?.Dispose(); @@ -209,15 +213,19 @@ namespace GameCult.Eve.UnityScene public void Refresh() { - if (Bridge.IsConnected) - Bridge.Refresh(); + var activeBridge = _previousProvider?.Bridge ?? Bridge; + if (activeBridge.IsConnected) + activeBridge.Refresh(); else - Bridge.Connect(); + activeBridge.Connect(); } public void Submit(EveSurfaceCommandRequest request) { - Bridge.Submit(request); + // 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); } public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) @@ -419,6 +427,8 @@ namespace GameCult.Eve.UnityScene surfaceKind, _authorityTrust); DetachPrepared(_transport, _bridge); + if (_previousProvider.Bridge != null) + _previousProvider.Bridge.ReceiptAvailable += ForwardReceipt; Selection = prepared.Selection; _transport = prepared.Transport; _bridge = prepared.Bridge; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs index 7be1fab..9f39d66 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientBootstrap.cs @@ -81,31 +81,26 @@ namespace GameCult.Eve.UnityScene yield break; _navigationInProgress = true; var navigation = navigable.NavigateAsync(target); - var mountAttempted = false; + StagedPresentation? staged = null; + PresentationSwap? swap = null; while (!navigation.IsCompleted) yield return null; try { navigation.GetAwaiter().GetResult(); - mountAttempted = true; - Mount(); + staged = PrepareStagedPresentation(); + swap = ActivateStagedPresentation(staged); navigable.CommitNavigation(); + swap.Complete(); + swap = null; + staged = null; LastNavigationFailure = null; } catch (Exception error) { + swap?.Rollback(); + staged?.Dispose(); 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); @@ -117,6 +112,231 @@ namespace GameCult.Eve.UnityScene } } + private StagedPresentation PrepareStagedPresentation() + { + var resolvedProvider = provider; + var surfaceDocuments = ResolveBehaviour( + providerSurfaceDocuments, + resolvedProvider, + nameof(providerSurfaceDocuments)); + var resolvedCommandSink = ResolveBehaviour( + commandSink, + resolvedProvider, + nameof(commandSink)); + var resolvedAssetDocuments = ResolveOptionalBehaviour( + assetManifestDocuments, + resolvedProvider); + var resolvedReceiptSource = ResolveOptionalBehaviour( + receiptSource, + resolvedProvider); + var resolvedFallbackAssetProvider = ResolveOptionalBehaviour( + fallbackAssetProvider, + resolvedProvider); + + var stagingObject = new GameObject("Eve Unity Candidate Presentation"); + stagingObject.SetActive(false); + stagingObject.transform.SetParent(transform, false); + var stagingRootObject = new GameObject("Eve Unity Candidate Scene Root"); + stagingRootObject.transform.SetParent(stagingObject.transform, false); + var stagingHost = stagingObject.AddComponent(); + stagingHost.ConnectOnEnable = false; + try + { + stagingHost.Configure( + stagingRootObject.transform, + surfaceDocuments, + resolvedCommandSink, + resolvedAssetDocuments, + resolvedReceiptSource, + resolvedFallbackAssetProvider); + var presentation = stagingHost.Connect(); + return new StagedPresentation(stagingObject, stagingRootObject.transform, stagingHost, presentation); + } + catch + { + stagingHost.Disconnect(); + DestroyObject(stagingObject); + throw; + } + } + + private PresentationSwap ActivateStagedPresentation(StagedPresentation staged) + { + var previousHost = host; + var previousRoot = sceneRoot; + var previousPresentation = LastPresentation; + var previousRootWasActive = previousRoot == null || previousRoot.gameObject.activeSelf; + var input = GetComponent(); + var previousInputHost = input?.Host; + var previousInputCamera = input?.CameraTransform; + var rig = GetComponent(); + var previousRigHost = rig?.Host; + var previousRigCamera = rig?.CameraTransform; + var previousRigPolicy = rig?.RenderPolicySource; + + try + { + staged.GameObject.SetActive(true); + if (attachInputDriver) + ConfigureInputDriver(staged.Host); + if (attachCameraRig) + ConfigureCameraRig(staged.Host); + host = staged.Host; + sceneRoot = staged.SceneRoot; + LastPresentation = staged.Presentation; + if (previousRoot != null && !ReferenceEquals(previousRoot, transform) && + !ReferenceEquals(previousRoot, staged.SceneRoot)) + previousRoot.gameObject.SetActive(false); + return new PresentationSwap( + this, + staged, + previousHost, + previousRoot, + previousPresentation, + previousRootWasActive, + input, + previousInputHost, + previousInputCamera, + rig, + previousRigHost, + previousRigCamera, + previousRigPolicy); + } + catch + { + if (input != null) + { + input.Host = previousInputHost; + input.CameraTransform = previousInputCamera; + } + if (rig != null) + { + rig.Host = previousRigHost; + rig.CameraTransform = previousRigCamera; + rig.RenderPolicySource = previousRigPolicy; + } + host = previousHost; + sceneRoot = previousRoot; + LastPresentation = previousPresentation; + if (previousRoot != null) + previousRoot.gameObject.SetActive(previousRootWasActive); + throw; + } + } + + private static void DestroyObject(UnityEngine.Object value) + { + if (Application.isPlaying) + Destroy(value); + else + DestroyImmediate(value); + } + + private sealed class StagedPresentation : IDisposable + { + public StagedPresentation( + GameObject gameObject, + Transform sceneRoot, + EveUnityPlayableWorldClientHost host, + EveUnityPlayableWorldPresentation presentation) + { + GameObject = gameObject; + SceneRoot = sceneRoot; + Host = host; + Presentation = presentation; + } + + public GameObject GameObject { get; } + public Transform SceneRoot { get; } + public EveUnityPlayableWorldClientHost Host { get; } + public EveUnityPlayableWorldPresentation Presentation { get; } + + public void Dispose() + { + Host.Disconnect(); + DestroyObject(GameObject); + } + } + + private sealed class PresentationSwap + { + private readonly EveUnityPlayableWorldClientBootstrap _owner; + private readonly StagedPresentation _candidate; + private readonly EveUnityPlayableWorldClientHost? _previousHost; + private readonly Transform? _previousRoot; + private readonly EveUnityPlayableWorldPresentation? _previousPresentation; + private readonly bool _previousRootWasActive; + private readonly EveUnityPlayableWorldInputDriver? _input; + private readonly EveUnityPlayableWorldClientHost? _previousInputHost; + private readonly Transform? _previousInputCamera; + private readonly EveUnityPlayableWorldCameraRig? _rig; + private readonly EveUnityPlayableWorldClientHost? _previousRigHost; + private readonly Transform? _previousRigCamera; + private readonly IEveUnityCameraRenderPolicySource? _previousRigPolicy; + private bool _settled; + + public PresentationSwap( + EveUnityPlayableWorldClientBootstrap owner, + StagedPresentation candidate, + EveUnityPlayableWorldClientHost? previousHost, + Transform? previousRoot, + EveUnityPlayableWorldPresentation? previousPresentation, + bool previousRootWasActive, + EveUnityPlayableWorldInputDriver? input, + EveUnityPlayableWorldClientHost? previousInputHost, + Transform? previousInputCamera, + EveUnityPlayableWorldCameraRig? rig, + EveUnityPlayableWorldClientHost? previousRigHost, + Transform? previousRigCamera, + IEveUnityCameraRenderPolicySource? previousRigPolicy) + { + _owner = owner; + _candidate = candidate; + _previousHost = previousHost; + _previousRoot = previousRoot; + _previousPresentation = previousPresentation; + _previousRootWasActive = previousRootWasActive; + _input = input; + _previousInputHost = previousInputHost; + _previousInputCamera = previousInputCamera; + _rig = rig; + _previousRigHost = previousRigHost; + _previousRigCamera = previousRigCamera; + _previousRigPolicy = previousRigPolicy; + } + + public void Complete() + { + if (_settled) return; + if (_previousHost != null && !ReferenceEquals(_previousHost, _candidate.Host)) + _previousHost.Disconnect(); + _settled = true; + } + + public void Rollback() + { + if (_settled) return; + _settled = true; + _owner.host = _previousHost; + _owner.sceneRoot = _previousRoot; + _owner.LastPresentation = _previousPresentation; + if (_input != null) + { + _input.Host = _previousInputHost; + _input.CameraTransform = _previousInputCamera; + } + if (_rig != null) + { + _rig.Host = _previousRigHost; + _rig.CameraTransform = _previousRigCamera; + _rig.RenderPolicySource = _previousRigPolicy; + } + if (_previousRoot != null) + _previousRoot.gameObject.SetActive(_previousRootWasActive); + _candidate.GameObject.SetActive(false); + } + } + public EveUnityPlayableWorldPresentation Mount() { var resolvedHost = ResolveHost(); 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 d3f523c..0d8f5eb 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1171,6 +1171,7 @@ namespace GameCult.Eve.UnityScene.Tests var bootstrap = hostObject.AddComponent(); bootstrap.ConfigureProvider(provider); bootstrap.Mount(); + var originalHost = bootstrap.Host; provider.PublishReceipt(new EveUnitySceneCommandReceipt( "receipt:launch", @@ -1192,7 +1193,8 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(provider.LastNavigation, Is.Not.Null); 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(bootstrap.Host, Is.Not.SameAs(originalHost)); + Assert.That(bootstrap.Host!.ConnectionEpoch, Is.EqualTo(1)); Assert.That(provider.CommitNavigationCount, Is.EqualTo(1)); Assert.That(provider.RollbackNavigationCount, Is.Zero); } @@ -1203,7 +1205,7 @@ namespace GameCult.Eve.UnityScene.Tests } [UnityTest] - public IEnumerator PlayableWorldClientBootstrapRollsBackAndRemountsAfterNavigationMountFailure() + public IEnumerator PlayableWorldClientBootstrapKeepsMountedPresentationAfterNavigationMountFailure() { var hostObject = new GameObject("generic-eve-navigation-rollback-client"); try @@ -1222,6 +1224,10 @@ namespace GameCult.Eve.UnityScene.Tests var bootstrap = hostObject.AddComponent(); bootstrap.ConfigureProvider(provider); bootstrap.Mount(); + var originalHost = bootstrap.Host; + var originalRuntime = originalHost!.Runtime; + var originalRoot = bootstrap.SceneRoot; + var originalPresentation = bootstrap.LastPresentation; provider.FailNextNavigationMount = true; LogAssert.Expect(LogType.Error, new Regex("Eve provider navigation failed; the current surface remains mounted")); @@ -1245,10 +1251,14 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(provider.CommitNavigationCount, Is.Zero); Assert.That(provider.RollbackNavigationCount, Is.EqualTo(1)); Assert.That(bootstrap.LastNavigationFailure, Is.Not.Null); + Assert.That(bootstrap.Host, Is.SameAs(originalHost)); + Assert.That(bootstrap.Host!.Runtime, Is.SameAs(originalRuntime)); + Assert.That(bootstrap.SceneRoot, Is.SameAs(originalRoot)); + Assert.That(bootstrap.LastPresentation, Is.SameAs(originalPresentation)); 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)); + Assert.That(bootstrap.Host.ConnectionEpoch, Is.EqualTo(1)); } finally { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index a28e7da..6881a86 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.113", + "version": "0.3.114", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.50", + "org.gamecult.cultlib": "1.0.51", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.3.3", "org.gamecult.eve.unity-uitoolkit": "0.1.6",