Bind receipts to mounted Eve presentations
This commit is contained in:
parent
a11b8f7d45
commit
1b6701456b
4 changed files with 129 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Reference in a new issue