Stage Eve navigation presentation before commit

This commit is contained in:
Meta Vens 2026-08-18 23:50:14 +02:00
commit 49f49203c8
4 changed files with 263 additions and 23 deletions

View file

@ -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;

View file

@ -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<IEveUnitySceneProviderSurfaceDocumentSource>(
providerSurfaceDocuments,
resolvedProvider,
nameof(providerSurfaceDocuments));
var resolvedCommandSink = ResolveBehaviour<IEveUnitySceneCommandSink>(
commandSink,
resolvedProvider,
nameof(commandSink));
var resolvedAssetDocuments = ResolveOptionalBehaviour<IEveUnityPlayableWorldAssetManifestDocumentSource>(
assetManifestDocuments,
resolvedProvider);
var resolvedReceiptSource = ResolveOptionalBehaviour<IEveUnitySceneCommandReceiptSource>(
receiptSource,
resolvedProvider);
var resolvedFallbackAssetProvider = ResolveOptionalBehaviour<IEveUnityGameObjectAssetProvider>(
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<EveUnityPlayableWorldClientHost>();
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<EveUnityPlayableWorldInputDriver>();
var previousInputHost = input?.Host;
var previousInputCamera = input?.CameraTransform;
var rig = GetComponent<EveUnityPlayableWorldCameraRig>();
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();

View file

@ -1171,6 +1171,7 @@ namespace GameCult.Eve.UnityScene.Tests
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
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<EveUnityPlayableWorldClientBootstrap>();
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
{

View file

@ -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",