From baf9df618eb25e132e0a2e883e68910a66ed25c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 04:03:00 +0200 Subject: [PATCH] Keep pending Eve commands alive until finality --- .../EveUnityCultMeshLiveProviderTransport.cs | 66 ++++++++++++++----- .../EveUnityCultMeshAssetLookupTests.cs | 52 +++++++++++++++ .../org.gamecult.eve.unity-scene/package.json | 2 +- 3 files changed, 102 insertions(+), 18 deletions(-) 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 d6960c5..c887f2b 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -31,7 +31,8 @@ namespace GameCult.Eve.UnityScene private readonly string _surfaceId; private readonly string _runtimeId; private readonly CultMeshAuthorityTrustPolicy _authorityTrust; - private readonly HashSet _pendingCommandIds = new HashSet(StringComparer.Ordinal); + private readonly Dictionary _pendingCommands = + new Dictionary(StringComparer.Ordinal); private readonly Dictionary> _receiptSubscriptions = new Dictionary>(StringComparer.Ordinal); private readonly Dictionary _prefabs = new Dictionary(StringComparer.Ordinal); @@ -371,7 +372,12 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(commandId)) throw new InvalidOperationException("Eve command invocations require an idempotency key."); - lock (_pendingCommandIds) _pendingCommandIds.Add(commandId); + lock (_pendingCommands) + { + if (_pendingCommands.ContainsKey(commandId)) + throw new InvalidOperationException($"Eve command '{commandId}' is already pending."); + _pendingCommands.Add(commandId, request); + } try { _commandOutbox!.Enqueue(request); } catch { @@ -414,9 +420,9 @@ namespace GameCult.Eve.UnityScene private void ForgetPendingCommand(string commandId) { Task? subscription = null; - lock (_pendingCommandIds) + lock (_pendingCommands) { - _pendingCommandIds.Remove(commandId); + _pendingCommands.Remove(commandId); if (_receiptSubscriptions.TryGetValue(commandId, out subscription)) _receiptSubscriptions.Remove(commandId); } @@ -459,9 +465,9 @@ namespace GameCult.Eve.UnityScene _assetBodyMappings = null; _baseSurface = null; _embeddedSurfaces.Clear(); - lock (_pendingCommandIds) + lock (_pendingCommands) { - _pendingCommandIds.Clear(); + _pendingCommands.Clear(); foreach (var subscription in _receiptSubscriptions.Values) DisposeReceiptSubscription(subscription); _receiptSubscriptions.Clear(); @@ -1080,7 +1086,7 @@ namespace GameCult.Eve.UnityScene CancellationToken cancellationToken) { Task subscription; - lock (_pendingCommandIds) + lock (_pendingCommands) { if (_receiptSubscriptions.TryGetValue(commandId, out subscription!)) return subscription; subscription = OpenReceiptSubscriptionAsync(commandId, cancellationToken); @@ -1096,7 +1102,7 @@ namespace GameCult.Eve.UnityScene try { return await subscription.ConfigureAwait(false); } catch { - lock (_pendingCommandIds) + lock (_pendingCommands) { if (_receiptSubscriptions.TryGetValue(commandId, out var current) && ReferenceEquals(current, subscription)) @@ -1134,11 +1140,13 @@ namespace GameCult.Eve.UnityScene private void QueueReceipt(string expectedCommandId, EveCommandReceiptDocument receipt) { - if (receipt == null || - !string.Equals(receipt.CommandId, expectedCommandId, StringComparison.Ordinal) || - !string.Equals(receipt.ProviderId, _providerId, StringComparison.Ordinal) || - !string.Equals(receipt.SurfaceId, _surfaceId, StringComparison.Ordinal)) - return; + if (receipt == null) return; + lock (_pendingCommands) + { + if (!_pendingCommands.TryGetValue(expectedCommandId, out var request) || + !ReceiptMatches(request, receipt)) + return; + } _liveDocuments.Enqueue(receipt); } @@ -1574,13 +1582,19 @@ namespace GameCult.Eve.UnityScene private void PublishReceipt(EveCommandReceiptDocument receipt) { - lock (_pendingCommandIds) + var terminal = IsTerminalReceiptState(receipt.State); + lock (_pendingCommands) { - if (!_pendingCommandIds.Remove(receipt.CommandId)) + if (!_pendingCommands.TryGetValue(receipt.CommandId, out var request) || + !ReceiptMatches(request, receipt)) return; + if (terminal) _pendingCommands.Remove(receipt.CommandId); + } + if (terminal) + { + _commandOutbox?.Acknowledge(receipt.CommandId); + ForgetPendingCommand(receipt.CommandId); } - _commandOutbox?.Acknowledge(receipt.CommandId); - ForgetPendingCommand(receipt.CommandId); CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt( receipt.ReceiptId, receipt.Command, @@ -1605,6 +1619,24 @@ namespace GameCult.Eve.UnityScene receipt.Navigation.AuthorityRuntimeId))); } + private bool ReceiptMatches(EveSurfaceCommandRequest request, EveCommandReceiptDocument receipt) => + string.Equals(receipt.Schema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal) && + string.Equals(receipt.CommandId, request.CommandId, StringComparison.Ordinal) && + string.Equals(receipt.Command, request.Command, StringComparison.Ordinal) && + string.Equals(receipt.ProviderId, request.ProviderId, StringComparison.Ordinal) && + string.Equals(receipt.SurfaceId, request.SurfaceId, StringComparison.Ordinal) && + string.Equals(receipt.Authority, _target.AuthorityRuntimeId, StringComparison.Ordinal) && + IsKnownReceiptState(receipt.State); + + private static bool IsKnownReceiptState(string state) => + string.Equals(state, "pending", StringComparison.OrdinalIgnoreCase) || + IsTerminalReceiptState(state); + + private static bool IsTerminalReceiptState(string state) => + string.Equals(state, "accepted", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "denied", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "reconciled", StringComparison.OrdinalIgnoreCase); + private sealed class ReceiptSubscription : IDisposable { private IDisposable? _lease; 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 36d906a..2e15172 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -2,6 +2,7 @@ using System; using System.Reflection; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Sockets; using System.IO; @@ -151,6 +152,41 @@ namespace GameCult.Eve.UnityScene.Tests await WaitUntilAsync(() => outbox.PendingCount == 0); } + [Test] + public void PendingReceiptIsObservedWithoutRetiringTheCommand() + { + 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("launch-1", "launch"); + var pendingField = typeof(EveUnityCultMeshLiveProviderTransport).GetField( + "_pendingCommands", + BindingFlags.NonPublic | BindingFlags.Instance); + var publish = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod( + "PublishReceipt", + BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(pendingField, Is.Not.Null); + Assert.That(publish, Is.Not.Null); + var pending = (IDictionary)pendingField!.GetValue(transport)!; + pending.Add(request.CommandId, request); + var observed = new List(); + transport.CommandReceiptAvailable += observed.Add; + + publish!.Invoke(transport, new object[] { Receipt(request, "pending", "receipt-pending") }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.True); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending" })); + + publish.Invoke(transport, new object[] { Receipt(request, "accepted", "receipt-accepted") }); + + Assert.That(pending.ContainsKey(request.CommandId), Is.False); + Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending", "accepted" })); + } + [Test] public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute() { @@ -198,6 +234,22 @@ namespace GameCult.Eve.UnityScene.Tests DateTimeOffset.UtcNow, "test-client"); + private static EveCommandReceiptDocument Receipt( + EveSurfaceCommandRequest request, + string state, + string receiptId) => new EveCommandReceiptDocument( + receiptId, + request.CommandId, + request.Command, + state, + "AetheriaEve", + "test.runtime", + request.ProviderId, + request.SurfaceId, + "", + DateTimeOffset.UtcNow.ToString("O"), + 1); + private static async Task AwaitOrCancel(Task task, CancellationToken cancellationToken) { var cancelled = Task.Delay(Timeout.Infinite, cancellationToken); diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index f99869f..9397238 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.121", + "version": "0.3.122", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {