Make Eve provider navigation transactional

This commit is contained in:
Meta Vens 2026-08-18 23:09:33 +02:00
commit ccc5a2605c
5 changed files with 220 additions and 14 deletions

View file

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

View file

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

View file

@ -238,6 +238,10 @@ namespace GameCult.Eve.UnityScene
public interface IEveUnityNavigableProvider
{
Task NavigateAsync(EveUnitySceneNavigationTarget target);
void CommitNavigation();
void RollbackNavigation();
}
public sealed class EveUnitySceneNavigationTarget

View file

@ -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<FakePlayableWorldProviderComponent>();
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<EveUnityPlayableWorldAssetManifestDocumentEntry>(),
"aetheria"));
var bootstrap = hostObject.AddComponent<EveUnityPlayableWorldClientBootstrap>();
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<EveUnitySceneProviderSurfaceDocument>? 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;
}
}
}
}

View file

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