Make Eve navigation one rollbackable presentation transaction

This commit is contained in:
Meta Vens 2026-08-19 00:22:23 +02:00
commit 7ba4041320
5 changed files with 112 additions and 16 deletions

View file

@ -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<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
@ -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))

View file

@ -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()

View file

@ -241,6 +241,8 @@ namespace GameCult.Eve.UnityScene
void CommitNavigation();
void FinalizeNavigation();
void RollbackNavigation();
}

View file

@ -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<Transform>()
.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++;

View file

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