Bind receipts to mounted Eve presentations

This commit is contained in:
Meta Vens 2026-08-19 09:10:37 +02:00
commit 1b6701456b
4 changed files with 129 additions and 4 deletions

View file

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

View file

@ -36,6 +36,9 @@ namespace GameCult.Eve.UnityScene
new Dictionary<string, EveSurfaceCommandRequest>(StringComparer.Ordinal);
private readonly Dictionary<string, Task<ReceiptSubscription>> _receiptSubscriptions =
new Dictionary<string, Task<ReceiptSubscription>>(StringComparer.Ordinal);
private readonly object _presentationFinalityGate = new object();
private readonly Dictionary<string, EveCommandReceiptDocument> _deferredTerminalReceipts =
new Dictionary<string, EveCommandReceiptDocument>(StringComparer.Ordinal);
private readonly HashSet<string> _continuousCommandIds = new HashSet<string>(StringComparer.Ordinal);
private readonly ConcurrentQueue<object> _liveDocuments = new ConcurrentQueue<object>();
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);
}

View file

@ -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<string, EveSurfaceCommandRequest>)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<EveUnitySceneCommandReceipt>();
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<InvalidOperationException>(() =>
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)

View file

@ -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": {