diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 750709d..01fc8a4 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -176,3 +176,11 @@ authority. Native volume programs may advertise a `cameraToWorld` matrix port when their procedural passes require an explicit camera transform instead of relying on render-pipeline globals. + +Command finality is presentation-causal. When a terminal receipt names a +`sourceVersion` newer than the mounted surface, EveUnity keeps the command and +its exact receipt lease alive, makes the obsolete surface read-only, and emits +the terminal receipt only after that surface generation and its qualified asset +catalog have committed together. Asset preparation failure therefore cannot +leave an accepted Verse switch paired with actionable controls from the prior +Verse. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index a76f720..f7f01a9 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -36,6 +36,9 @@ namespace GameCult.Eve.UnityScene new Dictionary(StringComparer.Ordinal); private readonly Dictionary> _receiptSubscriptions = new Dictionary>(StringComparer.Ordinal); + private readonly object _presentationFinalityGate = new object(); + private readonly Dictionary _deferredTerminalReceipts = + new Dictionary(StringComparer.Ordinal); private readonly HashSet _continuousCommandIds = new HashSet(StringComparer.Ordinal); private readonly ConcurrentQueue _liveDocuments = new ConcurrentQueue(); private readonly object _realtimeFrameGate = new object(); @@ -68,6 +71,7 @@ namespace GameCult.Eve.UnityScene private PendingAssetCatalogUpdate? _pendingAssetCatalog; private bool _assetCatalogUpdateRunning; private long _surfaceAssetGeneration; + private long _presentationBarrierVersion = -1; private long _lastQueuedEntityViewEpoch = -1; private long _lastQueuedEntityViewSequence = -1; private long _lastPresentedEntityViewEpoch = -1; @@ -351,6 +355,7 @@ namespace GameCult.Eve.UnityScene { if (request == null) throw new ArgumentNullException(nameof(request)); EnsurePrepared(); + EnsurePresentationWritable(); var interaction = RequireWorldInteraction(); if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef)) throw new InvalidOperationException("The provider advertisement does not publish a command record reference."); @@ -451,6 +456,11 @@ namespace GameCult.Eve.UnityScene DisposeReceiptSubscription(subscription); _receiptSubscriptions.Clear(); } + lock (_presentationFinalityGate) + { + _deferredTerminalReceipts.Clear(); + _presentationBarrierVersion = -1; + } _lifetime.Dispose(); } @@ -915,6 +925,7 @@ namespace GameCult.Eve.UnityScene // candidate. Keep the previous surface mounted until the candidate assets // have resolved and preloaded; never lower a remote Verse surface against // the previous Verse's catalog merely because the surface record arrived first. + BeginPresentationTransition(surface.Version); _ = RefreshAssetsForSurfaceAsync(surface, source, generation, _lifetime.Token); } @@ -965,6 +976,7 @@ namespace GameCult.Eve.UnityScene foreach (var command in prepared.ContinuousCommands) _continuousCommandIds.Add(command); SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument); + CompletePresentationTransition(CurrentSurfaceDocument.Version); } internal static EveSurfaceDocument ComposeSurface( @@ -1978,10 +1990,72 @@ namespace GameCult.Eve.UnityScene if (!_pendingCommands.TryGetValue(receipt.CommandId, out var request) || !ReceiptMatches(request, receipt)) return; - if (terminal) _pendingCommands.Remove(receipt.CommandId); } + if (terminal && DeferTerminalReceiptUntilPresentation(receipt)) + return; + FinalizeReceipt(receipt, terminal); + } + + private void EnsurePresentationWritable() + { + lock (_presentationFinalityGate) + { + if (_presentationBarrierVersion > CurrentSurfaceDocument.Version) + throw new InvalidOperationException( + $"The Eve presentation is awaiting provider surface version {_presentationBarrierVersion}; " + + $"mounted version {CurrentSurfaceDocument.Version} is read-only until that generation commits."); + } + } + + private void BeginPresentationTransition(long sourceVersion) + { + if (sourceVersion <= CurrentSurfaceDocument.Version) + return; + lock (_presentationFinalityGate) + _presentationBarrierVersion = Math.Max(_presentationBarrierVersion, sourceVersion); + } + + private bool DeferTerminalReceiptUntilPresentation(EveCommandReceiptDocument receipt) + { + lock (_presentationFinalityGate) + { + if (receipt.SourceVersion > CurrentSurfaceDocument.Version) + _presentationBarrierVersion = Math.Max(_presentationBarrierVersion, receipt.SourceVersion); + if (_presentationBarrierVersion <= CurrentSurfaceDocument.Version) + return false; + _deferredTerminalReceipts[receipt.CommandId] = receipt; + return true; + } + } + + private void CompletePresentationTransition(long committedVersion) + { + EveCommandReceiptDocument[] ready; + lock (_presentationFinalityGate) + { + if (_presentationBarrierVersion > committedVersion) + return; + _presentationBarrierVersion = -1; + ready = _deferredTerminalReceipts.Values + .Where(receipt => receipt.SourceVersion <= committedVersion) + .ToArray(); + foreach (var receipt in ready) + _deferredTerminalReceipts.Remove(receipt.CommandId); + } + foreach (var receipt in ready) + FinalizeReceipt(receipt, terminal: true); + } + + private void FinalizeReceipt(EveCommandReceiptDocument receipt, bool terminal) + { if (terminal) { + lock (_pendingCommands) + { + if (!_pendingCommands.ContainsKey(receipt.CommandId)) + return; + _pendingCommands.Remove(receipt.CommandId); + } _commandOutbox?.Acknowledge(receipt.CommandId); ForgetPendingCommand(receipt.CommandId); } diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index 54abb48..d3bd735 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -187,6 +187,48 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending", "accepted" })); } + [Test] + public void TerminalReceiptWaitsForItsPresentationGenerationAndFreezesTheOldSurface() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-cache", + "cultnet+tcp://127.0.0.1:1", + "test.verse", + "test.runtime", + "test.provider", + "test.surface"); + var request = Request("select-verse-1", "selectVerse"); + var type = typeof(EveUnityCultMeshLiveProviderTransport); + var pending = (IDictionary)type.GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(transport)!; + var publish = type.GetMethod("PublishReceipt", BindingFlags.NonPublic | BindingFlags.Instance)!; + var complete = type.GetMethod( + "CompletePresentationTransition", + BindingFlags.NonPublic | BindingFlags.Instance)!; + type.GetField("_bootstrapped", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(transport, true); + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + + publish.Invoke(transport, new object[] + { + Receipt(request, "accepted", "receipt-accepted", sourceVersion: 7) + }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed, Is.Empty); + var blocked = Assert.Throws(() => + transport.SubmitCommand(Request("stale-launch", "launch"))); + StringAssert.Contains("read-only", blocked!.Message); + + complete.Invoke(transport, new object[] { 7L }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.False); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "accepted" })); + } + [Test] public void ReceiptForAnotherInvocationCannotRetireTheCommand() { @@ -278,7 +320,8 @@ namespace GameCult.Eve.UnityScene.Tests private static EveCommandReceiptDocument Receipt( EveSurfaceCommandRequest request, string state, - string receiptId) => new EveCommandReceiptDocument( + string receiptId, + long sourceVersion = 0) => new EveCommandReceiptDocument( receiptId, request.CommandId, request.Command, @@ -289,7 +332,7 @@ namespace GameCult.Eve.UnityScene.Tests request.SurfaceId, "", DateTimeOffset.UtcNow.ToString("O"), - 1, + sourceVersion, invocationHash: EveCommandInvocationHash.Compute(request)); private static async Task AwaitOrCancel(Task task, CancellationToken cancellationToken) diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index d09c26c..9e98294 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.128", + "version": "0.3.129", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {