diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index cee206a4..d151880b 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -284,7 +284,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory DeferredSelectionUiAuthority selection = late.Selection; return new ItemInteractionController( d.Inventory.Objects, - d.Inventory.Transactions, + d.Actions.Transactions, d.Actions.Interaction, playerGuid: () => d.PlayerIdentity.ServerGuid, sendUse: null, diff --git a/src/AcDream.App/Input/OutboundInteractionQueue.cs b/src/AcDream.App/Input/OutboundInteractionQueue.cs deleted file mode 100644 index 0c50f248..00000000 --- a/src/AcDream.App/Input/OutboundInteractionQueue.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace AcDream.App.Input; - -/// -/// Orders input-originated item interactions after the local player's movement -/// edge has been serialized for the same frame, but before inbound network -/// dispatch. -/// -/// -/// Silk input callbacks arrive before the retail object phase. A movement-key -/// release and a later Use press can therefore be observed in one frame even -/// though the movement controller has not yet emitted the release packet. -/// Draining here preserves that event order on the wire: the release is sent -/// first, then the one-shot interaction. ACE otherwise interprets the later -/// MoveToState as cancellation of the Use-created MoveTo chain. -/// -public sealed class OutboundInteractionQueue -{ - private readonly Queue _pending = new(); - private uint _clearEpoch; - - public int Count => _pending.Count; - - public void Enqueue(Action action) - { - ArgumentNullException.ThrowIfNull(action); - _pending.Enqueue(action); - } - - /// - /// Drain only the actions present at the frame boundary. An action that - /// enqueues another action leaves it for the next frame instead of making - /// the current dispatch recursively unbounded. - /// - public void Drain() - { - int count = _pending.Count; - uint epoch = _clearEpoch; - while (count-- > 0 && epoch == _clearEpoch && _pending.Count > 0) - _pending.Dequeue().Invoke(); - } - - public void Clear() - { - _pending.Clear(); - _clearEpoch++; - } -} diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 1efe1ed0..49f9191f 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -1,10 +1,10 @@ -using AcDream.App.Input; using AcDream.App.UI; using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.Ui; +using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Interaction; @@ -19,31 +19,17 @@ internal sealed class SelectionInteractionController private readonly SelectionState _selection; private readonly IWorldSelectionQuery _query; private readonly ItemInteractionController _items; - private readonly ISelectionInteractionTransport _transport; + private readonly RuntimeInteractionTransactionState _transactions; + private readonly IRuntimeInteractionTransport _transport; private readonly IPlayerInteractionMovementSink _movement; - private readonly OutboundInteractionQueue _outbound = new(); private readonly PlayerApproachCompletionState _approachCompletions; private readonly Action? _toast; - private PendingPostArrivalPickup? _pendingPostArrival; - - private readonly record struct QueuedInteractionIdentity( - uint ServerGuid, - uint? LocalEntityId, - ClientObject? ClientObject); - - private readonly record struct PendingPostArrivalPickup( - uint ServerGuid, - uint LocalEntityId, - uint DestinationContainerId, - int Placement, - ulong PendingPlacementToken, - PlayerApproachToken ApproachToken); public SelectionInteractionController( SelectionState selection, IWorldSelectionQuery query, ItemInteractionController items, - ISelectionInteractionTransport transport, + IRuntimeInteractionTransport transport, IPlayerInteractionMovementSink movement, Action? toast = null, PlayerApproachCompletionState? approachCompletions = null) @@ -51,6 +37,7 @@ internal sealed class SelectionInteractionController _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); _items = items ?? throw new ArgumentNullException(nameof(items)); + _transactions = _items.RuntimeTransactions; _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _movement = movement ?? throw new ArgumentNullException(nameof(movement)); _toast = toast; @@ -88,13 +75,9 @@ internal sealed class SelectionInteractionController if (_selection.SelectedObjectId is uint pickupTarget) { EnqueueIdentityBound( + RuntimeQueuedInteractionKind.Pickup, pickupTarget, - requireLiveEntity: true, - guid => - { - if (ValidatePickupTarget(guid, showToast: true)) - _items.PlaceWorldItemInBackpack(guid); - }); + requireLiveEntity: true); } else { @@ -178,12 +161,9 @@ internal sealed class SelectionInteractionController _toast?.Invoke($"Selected: {label}"); if (useImmediately) EnqueueIdentityBound( + RuntimeQueuedInteractionKind.Activate, guid, - requireLiveEntity: true, - selected => - { - _items.ActivateItem(selected); - }); + requireLiveEntity: true); } /// @@ -213,12 +193,9 @@ internal sealed class SelectionInteractionController return; } EnqueueIdentityBound( + RuntimeQueuedInteractionKind.Use, selected, - requireLiveEntity: false, - guid => - { - _items.UseSelectedOrEnterMode(guid); - }); + requireLiveEntity: false); } public void SendUse(uint serverGuid) @@ -229,45 +206,19 @@ internal sealed class SelectionInteractionController ItemUseRequestReservation? reservation) { CancelPendingApproach(); - if (!_transport.IsInWorld) - { + bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); + RuntimeInteractionDispatchResult result = + _transactions.TryDispatchUse( + serverGuid, + ownedByPlayer, + ownedByPlayer || _query.IsUseable(serverGuid), + reservation, + _transport, + out uint sequence); + if (result == RuntimeInteractionDispatchResult.NotInWorld) _toast?.Invoke("Not in world"); - reservation?.CancelBeforeDispatch(); - return; - } - - // ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent before - // CPlayerSystem::UsingItem for every accepted ordinary Use. The latter - // is presentation/local inventory policy; it never delays the wire - // request behind a client-side TurnTo/MoveTo completion. ACE owns the - // authoritative approach chain for spatial targets. - if (_items.IsOwnedByPlayer(serverGuid)) - { - SendUseNow(serverGuid, reservation); - return; - } - - if (!_query.IsUseable(serverGuid)) - { - reservation?.CancelBeforeDispatch(); - return; - } - - SendUseNow(serverGuid, reservation); - } - - private void SendUseNow( - uint serverGuid, - ItemUseRequestReservation? reservation) - { - if (_transport.TrySendUse(serverGuid, out uint sequence)) - { - reservation?.MarkDispatched(); + if (result == RuntimeInteractionDispatchResult.Dispatched) Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); - return; - } - - reservation?.CancelBeforeDispatch(); } public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) @@ -302,20 +253,38 @@ internal sealed class SelectionInteractionController if (approach.IsCloseRange) { - var pending = new PendingPostArrivalPickup( - itemGuid, - approach.Target.LocalEntityId, - destinationContainerId, - placement, - pendingPlacementToken, - ApproachToken: default); + bool armed = false; bool started = _movement.BeginApproach( approach, - token => _pendingPostArrival = pending with { ApproachToken = token }); - if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid) - CancelPendingApproach(); - else if (!started) - CancelPickupPresentation(itemGuid, pendingPlacementToken); + token => + { + armed = _transactions.TryArmPostArrivalPickup( + itemGuid, + approach.Target.LocalEntityId, + destinationContainerId, + placement, + pendingPlacementToken, + new RuntimeInteractionApproachToken( + token.ControllerLifetime, + token.ApproachGeneration), + out _); + }); + if (!started || !armed) + { + if (_transactions.TryCancelPendingPickup( + itemGuid, + approach.Target.LocalEntityId, + out RuntimePendingPickup cancelled)) + { + CancelPickupPresentation( + cancelled.ServerGuid, + cancelled.PendingPlacementToken); + } + else + { + CancelPickupPresentation(itemGuid, pendingPlacementToken); + } + } return; } @@ -328,16 +297,18 @@ internal sealed class SelectionInteractionController { return; } - uint sequence = 0u; - if (_items.TryDispatchInventoryRequest( - InventoryRequestKind.Pickup, - itemGuid, - () => _transport.TrySendPickup( - itemGuid, - destinationContainerId, - placement, - out sequence), - pendingPlacementToken)) + var immediate = new RuntimePendingPickup( + Token: 0u, + itemGuid, + approach.Target.LocalEntityId, + destinationContainerId, + placement, + pendingPlacementToken, + ApproachToken: default); + if (_transactions.TryDispatchPickup( + immediate, + _transport, + out uint sequence)) { Console.WriteLine( $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); @@ -350,13 +321,28 @@ internal sealed class SelectionInteractionController /// Fires only after natural MoveToComplete(None), never cancellation. public void OnNaturalMoveToComplete() - => HandleNaturalMoveToComplete(); - - private void HandleNaturalMoveToComplete() { - if (_pendingPostArrival is not { } pending) + if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pending)) + HandleApproachCompletion(pending.ApproachToken, natural: true); + } + + private void HandleApproachCompletion( + RuntimeInteractionApproachToken approachToken, + bool natural) + { + bool accepted = _transactions.TryResolveApproachCompletion( + approachToken, + natural, + out RuntimePendingPickup pending); + if (pending.Token == 0u) return; - _pendingPostArrival = null; + if (!accepted) + { + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); + return; + } if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) { CancelPickupPresentation( @@ -373,15 +359,10 @@ internal sealed class SelectionInteractionController { return; } - if (!_items.TryDispatchInventoryRequest( - InventoryRequestKind.Pickup, - pending.ServerGuid, - () => _transport.TrySendPickup( - pending.ServerGuid, - pending.DestinationContainerId, - pending.Placement, - out _), - pending.PendingPlacementToken)) + if (!_transactions.TryDispatchPickup( + pending, + _transport, + out _)) { CancelPickupPresentation( pending.ServerGuid, @@ -393,22 +374,29 @@ internal sealed class SelectionInteractionController { while (_approachCompletions.TryTake(out PlayerApproachCompletion completion)) { - if (_pendingPostArrival?.ApproachToken != completion.Token) - continue; - if (completion.IsNatural) - HandleNaturalMoveToComplete(); - else - CancelPendingApproach(); + HandleApproachCompletion( + new RuntimeInteractionApproachToken( + completion.Token.ControllerLifetime, + completion.Token.ApproachGeneration), + completion.IsNatural); } - _outbound.Drain(); + _transactions.DrainOutbound(DispatchQueuedInteraction); } public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); public void OnEntityHidden(uint serverGuid) { - if (_pendingPostArrival?.ServerGuid == serverGuid) - CancelPendingApproach(); + _transactions.CancelQueuedInteractions(serverGuid); + if (_transactions.TryCancelPendingPickup( + serverGuid, + localEntityId: null, + out RuntimePendingPickup cancelled)) + { + CancelPickupPresentation( + cancelled.ServerGuid, + cancelled.PendingPlacementToken); + } if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( @@ -420,11 +408,17 @@ internal sealed class SelectionInteractionController public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists) { ArgumentNullException.ThrowIfNull(record); - if (_pendingPostArrival is { } pending - && pending.ServerGuid == record.ServerGuid - && pending.LocalEntityId == record.LocalEntityId) + _transactions.CancelQueuedInteractions( + record.ServerGuid, + record.LocalEntityId); + if (_transactions.TryCancelPendingPickup( + record.ServerGuid, + record.LocalEntityId, + out RuntimePendingPickup cancelled)) { - CancelPendingApproach(); + CancelPickupPresentation( + cancelled.ServerGuid, + cancelled.PendingPlacementToken); } if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) { @@ -443,11 +437,8 @@ internal sealed class SelectionInteractionController catch (Exception error) { failures.Add(error); } try { _selection.Reset(); } catch (Exception error) { failures.Add(error); } - try { _outbound.Clear(); } - catch (Exception error) { failures.Add(error); } try { _approachCompletions.Clear(); } catch (Exception error) { failures.Add(error); } - _pendingPostArrival = null; if (failures.Count != 0) throw new AggregateException( @@ -471,11 +462,10 @@ internal sealed class SelectionInteractionController } private bool EnqueueIdentityBound( + RuntimeQueuedInteractionKind kind, uint serverGuid, - bool requireLiveEntity, - Action action) + bool requireLiveEntity) { - ArgumentNullException.ThrowIfNull(action); uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId) ? localId : null; @@ -488,16 +478,15 @@ internal sealed class SelectionInteractionController return false; } - var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item); - _outbound.Enqueue(() => - { - if (IsCurrent(identity)) - action(identity.ServerGuid); - }); + var identity = new RuntimeInteractionIdentity( + serverGuid, + localEntityId, + item); + _transactions.Enqueue(new RuntimeQueuedInteraction(kind, identity)); return true; } - private bool IsCurrent(QueuedInteractionIdentity identity) + private bool IsCurrent(RuntimeInteractionIdentity identity) => (identity.LocalEntityId is not uint localId || _query.IsCurrent(identity.ServerGuid, localId)) && (identity.ClientObject is not { } item @@ -505,14 +494,39 @@ internal sealed class SelectionInteractionController private void CancelPendingApproach() { - if (_pendingPostArrival is not { } pending) + if (!_transactions.TryCancelPendingPickup( + out RuntimePendingPickup pending)) return; - _pendingPostArrival = null; CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); } + private void DispatchQueuedInteraction( + RuntimeQueuedInteraction interaction) + { + RuntimeInteractionIdentity identity = interaction.Identity; + if (!IsCurrent(identity)) + return; + + switch (interaction.Kind) + { + case RuntimeQueuedInteractionKind.Activate: + _items.ActivateItem(identity.ServerGuid); + break; + case RuntimeQueuedInteractionKind.Use: + _items.UseSelectedOrEnterMode(identity.ServerGuid); + break; + case RuntimeQueuedInteractionKind.Pickup: + if (ValidatePickupTarget(identity.ServerGuid, showToast: true)) + _items.PlaceWorldItemInBackpack(identity.ServerGuid); + break; + default: + throw new InvalidOperationException( + $"Unknown queued interaction kind {interaction.Kind}."); + } + } + private void CancelPickupPresentation(uint itemGuid, ulong token = 0u) => _items.CancelPendingBackpackPlacement(itemGuid, token); diff --git a/src/AcDream.App/Interaction/SelectionInteractionTransport.cs b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs index 7af08489..a571adb0 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionTransport.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs @@ -1,22 +1,12 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; namespace AcDream.App.Interaction; -internal interface ISelectionInteractionTransport -{ - bool IsInWorld { get; } - bool TrySendUse(uint serverGuid, out uint sequence); - bool TrySendPickup( - uint itemGuid, - uint destinationContainerId, - int placement, - out uint sequence); -} - internal sealed class WorldSessionSelectionInteractionTransport( Func session) - : ISelectionInteractionTransport + : IRuntimeInteractionTransport { private readonly Func _session = session ?? throw new ArgumentNullException(nameof(session)); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index ee17faca..e8614c41 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -258,7 +258,7 @@ internal sealed class LiveSessionRuntimeFactory OnUseDone: error => { _domain.Inventory.ExternalContainers.ApplyUseDone(error); - _domain.Inventory.Transactions.CompleteUse(error); + _domain.Actions.Transactions.CompleteUse(error); }, _domain.Inventory.ItemMana, ExternalContainers: _domain.Inventory.ExternalContainers, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c800ee57..62eeb479 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -326,7 +326,7 @@ public sealed class GameWindow : // J4/J5.1: Runtime owns communication, selection, combat, and target-mode // state. App, UI, plugins, and live-session routing borrow exact children. private readonly RuntimeCommunicationState _runtimeCommunication = new(); - private readonly RuntimeActionState _runtimeActions = new(); + private readonly RuntimeActionState _runtimeActions; public AcDream.Core.Selection.SelectionState Selection => _runtimeActions.Selection; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; @@ -561,6 +561,7 @@ public sealed class GameWindow : { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects); + _runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions); _runtimeCharacter = new RuntimeCharacterState(); var alphaScratchBudgets = AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create( diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index ee818ba1..360663c8 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -396,12 +396,12 @@ internal static class GameWindowShutdownManifest Hard( "runtime character state", live.Character.Dispose), - Hard( - "runtime inventory state", - live.Inventory.Dispose), Hard( "runtime action state", live.Actions.Dispose), + Hard( + "runtime inventory state", + live.Inventory.Dispose), Hard( "runtime entity/object lifetime", live.EntityObjects.Dispose), diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 4a4f8ac5..4491e1f9 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -141,7 +141,8 @@ public static class FixtureProvider var selection = new AcDream.Core.Selection.SelectionState(); var itemInteraction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState( + new InventoryTransactionState(objects)), new AcDream.Runtime.Gameplay.InteractionState(), () => SampleData.PlayerGuid, sendUse: null, diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 51c6cfb0..711118e2 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -27,7 +27,6 @@ public readonly record struct PendingBackpackPlacement( /// public sealed class ItemInteractionController : IDisposable { - private const long RetailUseThrottleMs = 200; internal const string InventoryRequestBusyMessage = "You can only move or use one item at a time"; private const long RetailDoubleClickMs = 500; @@ -62,19 +61,17 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _systemMessage; private readonly AutoWieldController _autoWield; private readonly Action? _requestUse; + private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly InventoryTransactionState _transactions; - private long _lastUseMs = long.MinValue / 2; private uint _consumedPrimaryClickTarget; private long _consumedPrimaryClickMs = long.MinValue / 2; - private uint _awaitingAppraisalId; - private uint _currentAppraisalId; private PendingBackpackPlacement? _pendingBackpackPlacement; private bool _disposed; public ItemInteractionController( ClientObjectTable objects, - InventoryTransactionState transactions, + RuntimeInteractionTransactionState runtimeTransactions, InteractionState interactionState, Func playerGuid, Action? sendUse, @@ -136,13 +133,14 @@ public sealed class ItemInteractionController : IDisposable _requestUse = requestUse; _interactionState = interactionState ?? throw new ArgumentNullException(nameof(interactionState)); - _transactions = transactions - ?? throw new ArgumentNullException(nameof(transactions)); + _runtimeTransactions = runtimeTransactions + ?? throw new ArgumentNullException(nameof(runtimeTransactions)); + _transactions = _runtimeTransactions.Inventory; if (!ReferenceEquals(_transactions.Objects, _objects)) { throw new ArgumentException( "The inventory transaction owner must borrow the controller's exact object table.", - nameof(transactions)); + nameof(runtimeTransactions)); } _autoWield = new AutoWieldController( _objects, @@ -184,9 +182,12 @@ public sealed class ItemInteractionController : IDisposable public uint PlayerGuid => _playerGuid(); public InteractionState InteractionState => _interactionState; + public RuntimeInteractionTransactionState RuntimeTransactions => + _runtimeTransactions; public int BusyCount => _transactions.BusyCount; - public uint CurrentAppraisalId => _currentAppraisalId; + public uint CurrentAppraisalId => + _runtimeTransactions.CurrentAppraisalId; public bool CanMakeInventoryRequest => BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest; @@ -277,7 +278,7 @@ public sealed class ItemInteractionController : IDisposable /// Item__UseDone too; AttackDone belongs only to the combat attack owner. /// public void IncrementBusyCount() - => _transactions.IncrementBusyCount(); + => _runtimeTransactions.IncrementBusyCount(); /// /// Raised for retail confirmation/auxiliary actions whose retained panel is @@ -403,11 +404,7 @@ public sealed class ItemInteractionController : IDisposable { if (objectId == 0 || _sendExamine is null) return; - - if (_awaitingAppraisalId == 0) - IncrementBusyCount(); - _awaitingAppraisalId = objectId; - _sendExamine(objectId); + _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); } /// @@ -416,25 +413,14 @@ public sealed class ItemInteractionController : IDisposable /// public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId) { - if (objectId == 0 - || (objectId != _awaitingAppraisalId - && objectId != _currentAppraisalId)) - return default; - - bool firstResponse = objectId == _awaitingAppraisalId; - if (firstResponse) - { - _awaitingAppraisalId = 0; - _currentAppraisalId = objectId; - bool ownedBusyReference = _transactions.BusyCount > 0; - _transactions.CompleteUse(0u); - if (!ownedBusyReference) - StateChanged?.Invoke(); - } - + bool ownedBusyReference = _transactions.BusyCount > 0; + RuntimeAppraisalResponseAcceptance acceptance = + _runtimeTransactions.AcceptAppraisalResponse(objectId); + if (acceptance.FirstResponse && !ownedBusyReference) + StateChanged?.Invoke(); return new AppraisalResponseAcceptance( - Accepted: true, - FirstResponse: firstResponse); + acceptance.Accepted, + acceptance.FirstResponse); } /// @@ -443,10 +429,9 @@ public sealed class ItemInteractionController : IDisposable /// public bool RefreshCurrentAppraisal() { - if (_currentAppraisalId == 0 || _sendExamine is null) + if (_sendExamine is null) return false; - _sendExamine(_currentAppraisalId); - return true; + return _runtimeTransactions.RefreshCurrentAppraisal(_sendExamine); } /// @@ -460,24 +445,14 @@ public sealed class ItemInteractionController : IDisposable /// public void CancelObjectAppraisalForSpell() { - if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u) + if (_sendExamine is null) return; - - if (_awaitingAppraisalId != 0u) + bool ownedBusyReference = _transactions.BusyCount > 0; + if (_runtimeTransactions.CancelObjectAppraisalForSpell(_sendExamine) + && !ownedBusyReference) { - _awaitingAppraisalId = 0u; - _currentAppraisalId = 0u; - bool ownedBusyReference = _transactions.BusyCount > 0; - _transactions.CompleteUse(0u); - if (!ownedBusyReference) - StateChanged?.Invoke(); - } - else - { - _currentAppraisalId = 0u; StateChanged?.Invoke(); } - _sendExamine?.Invoke(0u); } /// @@ -791,8 +766,11 @@ public sealed class ItemInteractionController : IDisposable if (!EnsureInventoryRequestReady()) return false; - _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); - _transactions.IncrementBusyCount(); + _runtimeTransactions.TryDispatchTargetedUse( + sourceGuid, + targetGuid, + _sendUseWithTarget, + incrementBusy: true); return true; } @@ -827,7 +805,7 @@ public sealed class ItemInteractionController : IDisposable else { _sendUse!(objectId); - _transactions.IncrementBusyCount(); + _runtimeTransactions.IncrementBusyCount(); } return true; } @@ -926,8 +904,11 @@ public sealed class ItemInteractionController : IDisposable } break; case ItemPolicyActionKind.SendUseWithTarget: - _sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId); - acted |= _sendUseWithTarget is not null; + acted |= _runtimeTransactions.TryDispatchTargetedUse( + action.ObjectId, + action.TargetId, + _sendUseWithTarget, + incrementBusy: false); break; case ItemPolicyActionKind.SetGroundObject: _requestExternalContainer?.Invoke(action.ObjectId); @@ -944,7 +925,7 @@ public sealed class ItemInteractionController : IDisposable } else { - _transactions.IncrementBusyCount(); + _runtimeTransactions.IncrementBusyCount(); } break; case ItemPolicyActionKind.Reject: @@ -965,7 +946,7 @@ public sealed class ItemInteractionController : IDisposable } private ItemUseRequestReservation BeginUseRequestReservation() - => _transactions.BeginUseRequestReservation(); + => _runtimeTransactions.BeginUseRequestReservation(); private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList actions) { @@ -1160,8 +1141,8 @@ public sealed class ItemInteractionController : IDisposable } /// Retail UseDone (0x01C7) releases one UI busy reference. - public void CompleteUse(uint _) - => _transactions.CompleteUse(0u); + public void CompleteUse(uint error) + => _runtimeTransactions.CompleteUse(error); /// Session teardown drops every outstanding client UI busy reference. public void ClearBusy() @@ -1176,22 +1157,18 @@ public sealed class ItemInteractionController : IDisposable { PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement; _pendingBackpackPlacement = null; - // The canonical owner publishes its reset to Runtime observers, while - // this controller preserves the pre-J4 single UI notification emitted - // by InteractionState.ResetSession below. + // Runtime owns the transaction reset. This controller preserves the + // pre-J4 single UI notification emitted by InteractionState below. _transactions.StateChanged -= OnTransactionStateChanged; try { - _transactions.ResetSession(); + _runtimeTransactions.ResetSession(); } finally { if (!_disposed) _transactions.StateChanged += OnTransactionStateChanged; } - _awaitingAppraisalId = 0; - _currentAppraisalId = 0; - _lastUseMs = long.MinValue / 2; _consumedPrimaryClickTarget = 0u; _consumedPrimaryClickMs = long.MinValue / 2; @@ -1238,13 +1215,7 @@ public sealed class ItemInteractionController : IDisposable } private bool ConsumeUseThrottle() - { - long now = _nowMs(); - if (now - _lastUseMs < RetailUseThrottleMs) - return false; - _lastUseMs = now; - return true; - } + => _runtimeTransactions.TryConsumeUseThrottle(_nowMs()); private static bool IsContainer(ClientObject item) => item.ContainerTypeHint != 0 diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs index 721d3e64..d0f271b3 100644 --- a/src/AcDream.Runtime/GameRuntimeActionViews.cs +++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs @@ -13,7 +13,8 @@ public readonly record struct RuntimeActionSnapshot( int TrackedTargetHealthCount, long InteractionRevision, InteractionModeKind InteractionMode, - uint InteractionSourceObjectId); + uint InteractionSourceObjectId, + RuntimeInteractionTransactionSnapshot InteractionTransactions); public interface IRuntimeActionView { diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index 483ec131..d9cd8c1e 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -169,7 +169,14 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver $"{checkpoint.Actions.TrackedTargetHealthCount}:" + $"{checkpoint.Actions.InteractionRevision}:" + $"{(int)checkpoint.Actions.InteractionMode}:" + - $"{checkpoint.Actions.InteractionSourceObjectId:X8};" + + $"{checkpoint.Actions.InteractionSourceObjectId:X8}:" + + $"{checkpoint.Actions.InteractionTransactions.Revision}:" + + $"{checkpoint.Actions.InteractionTransactions.LastUseSourceId:X8}:" + + $"{checkpoint.Actions.InteractionTransactions.LastUseTargetId:X8}:" + + $"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" + + $"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" + + $"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" + + $"{checkpoint.Actions.InteractionTransactions.PendingPickupToken};" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" + $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs index d40d7d3e..f0f6197c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs @@ -1,4 +1,5 @@ using AcDream.Core.Combat; +using AcDream.Core.Items; using AcDream.Core.Selection; namespace AcDream.Runtime.Gameplay; @@ -6,12 +7,14 @@ namespace AcDream.Runtime.Gameplay; public readonly record struct RuntimeActionOwnershipSnapshot( bool IsDisposed, bool InternalSubscriptionsAttached, + bool InteractionTransactionsDisposed, uint SelectedObjectId, uint PreviousObjectId, uint PreviousValidObjectId, CombatMode CombatMode, int TrackedTargetHealthCount, InteractionMode InteractionMode, + RuntimeInteractionTransactionSnapshot InteractionTransactions, long SelectionRevision, long CombatRevision, long InteractionRevision) @@ -19,12 +22,14 @@ public readonly record struct RuntimeActionOwnershipSnapshot( public bool IsConverged => IsDisposed && !InternalSubscriptionsAttached + && InteractionTransactionsDisposed && SelectedObjectId == 0u && PreviousObjectId == 0u && PreviousValidObjectId == 0u && CombatMode == CombatMode.NonCombat && TrackedTargetHealthCount == 0 - && InteractionMode == InteractionMode.None; + && InteractionMode == InteractionMode.None + && InteractionTransactions.IsConverged; } /// @@ -40,11 +45,14 @@ public sealed class RuntimeActionState : IDisposable private long _combatRevision; private long _interactionRevision; - public RuntimeActionState() + public RuntimeActionState(InventoryTransactionState inventoryTransactions) { + ArgumentNullException.ThrowIfNull(inventoryTransactions); Selection = new SelectionState(); Combat = new CombatState(); Interaction = new InteractionState(); + Transactions = new RuntimeInteractionTransactionState( + inventoryTransactions); View = new ActionView(this); Selection.Changed += OnSelectionChanged; @@ -57,18 +65,21 @@ public sealed class RuntimeActionState : IDisposable public SelectionState Selection { get; } public CombatState Combat { get; } public InteractionState Interaction { get; } + public RuntimeInteractionTransactionState Transactions { get; } public IRuntimeActionView View { get; } public bool IsDisposed => _disposed; public RuntimeActionOwnershipSnapshot CaptureOwnership() => new( _disposed, _internalSubscriptionsAttached, + Transactions.IsDisposed, Selection.SelectedObjectId ?? 0u, Selection.PreviousObjectId ?? 0u, Selection.PreviousValidObjectId ?? 0u, Combat.CurrentMode, Combat.TrackedTargetCount, Interaction.Current, + Transactions.CaptureOwnership(), Interlocked.Read(ref _selectionRevision), Interlocked.Read(ref _combatRevision), Interlocked.Read(ref _interactionRevision)); @@ -82,6 +93,7 @@ public sealed class RuntimeActionState : IDisposable { ObjectDisposedException.ThrowIf(_disposed, this); List? failures = null; + Try(Transactions.ResetSession, ref failures); Try(Interaction.ResetSession, ref failures); Try(() => Selection.Reset(), ref failures); Try(Combat.Clear, ref failures); @@ -101,6 +113,7 @@ public sealed class RuntimeActionState : IDisposable List? failures = null; try { + Try(Transactions.Dispose, ref failures); Try(Interaction.ResetSession, ref failures); Try(() => Selection.Reset(), ref failures); Try(Combat.Clear, ref failures); @@ -160,7 +173,8 @@ public sealed class RuntimeActionState : IDisposable owner.Combat.TrackedTargetCount, Interlocked.Read(ref owner._interactionRevision), owner.Interaction.Current.Kind, - owner.Interaction.Current.SourceObjectId); + owner.Interaction.Current.SourceObjectId, + owner.Transactions.CaptureOwnership()); public bool TryGetHealth(uint objectId, out float healthPercent) { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs new file mode 100644 index 00000000..5a44b09b --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs @@ -0,0 +1,572 @@ +using AcDream.Core.Items; + +namespace AcDream.Runtime.Gameplay; + +public enum RuntimeQueuedInteractionKind +{ + Activate, + Use, + Pickup, +} + +public readonly record struct RuntimeInteractionIdentity( + uint ServerGuid, + uint? LocalEntityId, + ClientObject? ClientObject); + +public readonly record struct RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind Kind, + RuntimeInteractionIdentity Identity); + +public readonly record struct RuntimeInteractionApproachToken( + ulong ControllerLifetime, + ulong ApproachGeneration); + +public readonly record struct RuntimePendingPickup( + ulong Token, + uint ServerGuid, + uint LocalEntityId, + uint DestinationContainerId, + int Placement, + ulong PendingPlacementToken, + RuntimeInteractionApproachToken ApproachToken); + +public readonly record struct RuntimeAppraisalResponseAcceptance( + bool Accepted, + bool FirstResponse); + +public enum RuntimeInteractionDispatchResult +{ + Rejected, + NotInWorld, + NotUseable, + Dispatched, +} + +public readonly record struct RuntimeInteractionTransactionSnapshot( + bool IsDisposed, + long Revision, + uint LastUseSourceId, + uint LastUseTargetId, + uint AwaitingAppraisalId, + uint CurrentAppraisalId, + int OutboundCount, + bool HasPendingPickup, + ulong PendingPickupToken, + long DispatchFailureCount) +{ + public bool IsConverged => + IsDisposed + && LastUseSourceId == 0u + && LastUseTargetId == 0u + && AwaitingAppraisalId == 0u + && CurrentAppraisalId == 0u + && OutboundCount == 0 + && !HasPendingPickup; +} + +/// +/// Canonical presentation-independent owner for retail interaction +/// transactions. The shared inventory gate remains owned by +/// and is borrowed exactly. +/// +/// +/// Ordinary Use follows ItemHolder::UseObject @ 0x00588A80: consume the +/// strict 0.2-second gate, send immediately, then transfer the busy reference +/// to ClientUISystem::Handle_Item__UseDone @ 0x00564900. Pickup keeps +/// the existing local approach transaction and exact post-arrival token. +/// +public sealed class RuntimeInteractionTransactionState : IDisposable +{ + public const long RetailUseThrottleMs = 200; + + private readonly InventoryTransactionState _inventory; + private readonly Queue _outbound = new(); + private long _lastUseMs = long.MinValue / 2; + private uint _lastUseSourceId; + private uint _lastUseTargetId; + private uint _awaitingAppraisalId; + private uint _currentAppraisalId; + private RuntimePendingPickup? _pendingPickup; + private ulong _nextPickupToken; + private uint _clearEpoch; + private long _revision; + private long _dispatchFailureCount; + private bool _disposed; + + public RuntimeInteractionTransactionState( + InventoryTransactionState inventory) + { + _inventory = inventory + ?? throw new ArgumentNullException(nameof(inventory)); + } + + public InventoryTransactionState Inventory => _inventory; + public uint AwaitingAppraisalId => _awaitingAppraisalId; + public uint CurrentAppraisalId => _currentAppraisalId; + public int OutboundCount => _outbound.Count; + public bool HasPendingPickup => _pendingPickup is not null; + public bool IsDisposed => _disposed; + public long Revision => Interlocked.Read(ref _revision); + public long DispatchFailureCount => + Interlocked.Read(ref _dispatchFailureCount); + public Exception? LastDispatchFailure { get; private set; } + + public RuntimeInteractionTransactionSnapshot CaptureOwnership() => new( + _disposed, + Revision, + _lastUseSourceId, + _lastUseTargetId, + _awaitingAppraisalId, + _currentAppraisalId, + _outbound.Count, + _pendingPickup is not null, + _pendingPickup?.Token ?? 0u, + DispatchFailureCount); + + public bool TryConsumeUseThrottle(long nowMs) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (nowMs - _lastUseMs < RetailUseThrottleMs) + return false; + _lastUseMs = nowMs; + IncrementRevision(); + return true; + } + + public ItemUseRequestReservation BeginUseRequestReservation() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _inventory.BeginUseRequestReservation(); + } + + public RuntimeInteractionDispatchResult TryDispatchUse( + uint serverGuid, + bool ownedByPlayer, + bool useable, + ItemUseRequestReservation? reservation, + IRuntimeInteractionTransport transport, + out uint sequence) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(transport); + sequence = 0u; + + if (serverGuid == 0u) + { + reservation?.CancelBeforeDispatch(); + return RuntimeInteractionDispatchResult.Rejected; + } + if (!transport.IsInWorld) + { + reservation?.CancelBeforeDispatch(); + return RuntimeInteractionDispatchResult.NotInWorld; + } + if (!ownedByPlayer && !useable) + { + reservation?.CancelBeforeDispatch(); + return RuntimeInteractionDispatchResult.NotUseable; + } + if (!transport.TrySendUse(serverGuid, out sequence)) + { + reservation?.CancelBeforeDispatch(); + return RuntimeInteractionDispatchResult.Rejected; + } + + reservation?.MarkDispatched(); + _lastUseSourceId = serverGuid; + _lastUseTargetId = 0u; + IncrementRevision(); + return RuntimeInteractionDispatchResult.Dispatched; + } + + public bool TryDispatchTargetedUse( + uint sourceObjectId, + uint targetObjectId, + Action? dispatch, + bool incrementBusy) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (sourceObjectId == 0u + || targetObjectId == 0u + || dispatch is null) + return false; + + uint epoch = _clearEpoch; + dispatch(sourceObjectId, targetObjectId); + if (_disposed || epoch != _clearEpoch) + return false; + + _lastUseSourceId = sourceObjectId; + _lastUseTargetId = targetObjectId; + if (incrementBusy) + _inventory.IncrementBusyCount(); + IncrementRevision(); + return true; + } + + public void IncrementBusyCount() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _inventory.IncrementBusyCount(); + IncrementRevision(); + } + + public void CompleteUse(uint error) + { + ObjectDisposedException.ThrowIf(_disposed, this); + int before = _inventory.BusyCount; + _inventory.CompleteUse(error); + if (_inventory.BusyCount != before) + IncrementRevision(); + } + + public bool TryRequestAppraisal( + uint objectId, + Action sendAppraisal) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(sendAppraisal); + if (objectId == 0u) + return false; + + uint epoch = _clearEpoch; + bool acquiredBusy = _awaitingAppraisalId == 0u; + if (acquiredBusy) + { + _inventory.IncrementBusyCount(); + if (_disposed || epoch != _clearEpoch) + return false; + } + + uint previousAwaiting = _awaitingAppraisalId; + _awaitingAppraisalId = objectId; + IncrementRevision(); + try + { + sendAppraisal(objectId); + } + catch + { + // A synchronous reset/disposal or newer request owns the result. + // Roll back only the exact request installed by this call. + if (!_disposed + && epoch == _clearEpoch + && _awaitingAppraisalId == objectId) + { + _awaitingAppraisalId = previousAwaiting; + if (acquiredBusy) + _inventory.CompleteUse(0u); + IncrementRevision(); + } + throw; + } + return true; + } + + public RuntimeAppraisalResponseAcceptance AcceptAppraisalResponse( + uint objectId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (objectId == 0u + || (objectId != _awaitingAppraisalId + && objectId != _currentAppraisalId)) + { + return default; + } + + bool firstResponse = objectId == _awaitingAppraisalId; + if (firstResponse) + { + _awaitingAppraisalId = 0u; + _currentAppraisalId = objectId; + _inventory.CompleteUse(0u); + IncrementRevision(); + } + + return new RuntimeAppraisalResponseAcceptance( + Accepted: true, + FirstResponse: firstResponse); + } + + public bool RefreshCurrentAppraisal(Action sendAppraisal) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(sendAppraisal); + if (_currentAppraisalId == 0u) + return false; + sendAppraisal(_currentAppraisalId); + return true; + } + + public bool CancelObjectAppraisalForSpell(Action sendAppraisal) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(sendAppraisal); + if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u) + return false; + + if (_awaitingAppraisalId != 0u) + _inventory.CompleteUse(0u); + _awaitingAppraisalId = 0u; + _currentAppraisalId = 0u; + IncrementRevision(); + sendAppraisal(0u); + return true; + } + + public void Enqueue(RuntimeQueuedInteraction interaction) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (interaction.Identity.ServerGuid == 0u) + return; + _outbound.Enqueue(interaction); + IncrementRevision(); + } + + public int CancelQueuedInteractions( + uint serverGuid, + uint? localEntityId = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (serverGuid == 0u || _outbound.Count == 0) + return 0; + + int original = _outbound.Count; + int removed = 0; + for (int i = 0; i < original; i++) + { + RuntimeQueuedInteraction interaction = _outbound.Dequeue(); + RuntimeInteractionIdentity identity = interaction.Identity; + bool matches = + identity.ServerGuid == serverGuid + && (localEntityId is not uint exact + || identity.LocalEntityId == exact); + if (matches) + removed++; + else + _outbound.Enqueue(interaction); + } + if (removed != 0) + IncrementRevision(); + return removed; + } + + /// + /// Drains only work present at the frame boundary. Re-entrant additions + /// remain for the next frame, matching the pre-J5 ordered interaction + /// queue without retaining App delegates. + /// + public void DrainOutbound(Action dispatch) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(dispatch); + + int count = _outbound.Count; + uint epoch = _clearEpoch; + bool drained = false; + while (count-- > 0 && epoch == _clearEpoch && _outbound.Count > 0) + { + RuntimeQueuedInteraction interaction = _outbound.Dequeue(); + drained = true; + try + { + dispatch(interaction); + } + catch (Exception error) + { + Interlocked.Increment(ref _dispatchFailureCount); + LastDispatchFailure = error; + throw; + } + } + if (drained) + IncrementRevision(); + } + + public bool TryArmPostArrivalPickup( + uint serverGuid, + uint localEntityId, + uint destinationContainerId, + int placement, + ulong pendingPlacementToken, + RuntimeInteractionApproachToken approachToken, + out RuntimePendingPickup pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (serverGuid == 0u + || localEntityId == 0u + || destinationContainerId == 0u + || pendingPlacementToken == 0u + || approachToken.ControllerLifetime == 0u + || approachToken.ApproachGeneration == 0u) + { + pending = default; + return false; + } + if (_pendingPickup is not null) + { + pending = default; + return false; + } + + ulong token = ++_nextPickupToken; + if (token == 0u) + token = ++_nextPickupToken; + pending = new RuntimePendingPickup( + token, + serverGuid, + localEntityId, + destinationContainerId, + placement, + pendingPlacementToken, + approachToken); + _pendingPickup = pending; + IncrementRevision(); + return true; + } + + public bool TryResolveApproachCompletion( + RuntimeInteractionApproachToken approachToken, + bool natural, + out RuntimePendingPickup pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pendingPickup is not { } current + || current.ApproachToken != approachToken) + { + pending = default; + return false; + } + + _pendingPickup = null; + pending = current; + IncrementRevision(); + return natural; + } + + public bool TryGetPendingPickup(out RuntimePendingPickup pending) + { + if (_pendingPickup is { } current) + { + pending = current; + return true; + } + pending = default; + return false; + } + + public bool TryCancelPendingPickup(out RuntimePendingPickup pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pendingPickup is not { } current) + { + pending = default; + return false; + } + _pendingPickup = null; + pending = current; + IncrementRevision(); + return true; + } + + public bool TryCancelPendingPickup( + uint serverGuid, + uint? localEntityId, + out RuntimePendingPickup pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pendingPickup is not { } current + || current.ServerGuid != serverGuid + || (localEntityId is uint exact + && current.LocalEntityId != exact)) + { + pending = default; + return false; + } + _pendingPickup = null; + pending = current; + IncrementRevision(); + return true; + } + + public bool TryDispatchPickup( + RuntimePendingPickup pickup, + IRuntimeInteractionTransport transport, + out uint sequence) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(transport); + sequence = 0u; + if (!transport.IsInWorld) + return false; + + uint sentSequence = 0u; + bool dispatched = _inventory.TryDispatch( + InventoryRequestKind.Pickup, + pickup.ServerGuid, + () => transport.TrySendPickup( + pickup.ServerGuid, + pickup.DestinationContainerId, + pickup.Placement, + out sentSequence), + pickup.PendingPlacementToken); + sequence = sentSequence; + if (dispatched) + IncrementRevision(); + return dispatched; + } + + public void ResetSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + ResetCore(resetInventory: true); + } + + public void Dispose() + { + if (_disposed) + return; + ResetCore(resetInventory: true); + _disposed = true; + } + + private void ResetCore(bool resetInventory) + { + bool changed = + _awaitingAppraisalId != 0u + || _currentAppraisalId != 0u + || _outbound.Count != 0 + || _pendingPickup is not null + || _lastUseSourceId != 0u + || _lastUseTargetId != 0u + || _lastUseMs != long.MinValue / 2; + + _lastUseSourceId = 0u; + _lastUseTargetId = 0u; + _awaitingAppraisalId = 0u; + _currentAppraisalId = 0u; + _outbound.Clear(); + _pendingPickup = null; + _lastUseMs = long.MinValue / 2; + _clearEpoch++; + if (resetInventory) + _inventory.ResetSession(); + if (changed) + IncrementRevision(); + } + + private void IncrementRevision() => + Interlocked.Increment(ref _revision); +} + +public interface IRuntimeInteractionTransport +{ + bool IsInWorld { get; } + bool TrySendUse(uint serverGuid, out uint sequence); + bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence); +} diff --git a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs deleted file mode 100644 index e1c10600..00000000 --- a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using AcDream.App.Input; - -namespace AcDream.App.Tests.Input; - -public sealed class OutboundInteractionQueueTests -{ - [Fact] - public void Drain_PreservesInputOrder() - { - var queue = new OutboundInteractionQueue(); - var actual = new List(); - - queue.Enqueue(() => actual.Add("use-corpse")); - queue.Enqueue(() => actual.Add("pickup-item")); - - queue.Drain(); - - Assert.Equal(new[] { "use-corpse", "pickup-item" }, actual); - Assert.Equal(0, queue.Count); - } - - [Fact] - public void Drain_ReentrantEnqueueWaitsForNextFrame() - { - var queue = new OutboundInteractionQueue(); - var actual = new List(); - queue.Enqueue(() => - { - actual.Add("first"); - queue.Enqueue(() => actual.Add("next-frame")); - }); - - queue.Drain(); - - Assert.Equal(new[] { "first" }, actual); - Assert.Equal(1, queue.Count); - - queue.Drain(); - - Assert.Equal(new[] { "first", "next-frame" }, actual); - Assert.Equal(0, queue.Count); - } - - [Fact] - public void Clear_DropsPendingSessionActions() - { - var queue = new OutboundInteractionQueue(); - bool invoked = false; - queue.Enqueue(() => invoked = true); - - queue.Clear(); - queue.Drain(); - - Assert.False(invoked); - } - - [Fact] - public void Drain_ReentrantClearStopsTheCapturedBatch() - { - var queue = new OutboundInteractionQueue(); - var actual = new List(); - queue.Enqueue(() => - { - actual.Add("first"); - queue.Clear(); - }); - queue.Enqueue(() => actual.Add("stale")); - - queue.Drain(); - - Assert.Equal(new[] { "first" }, actual); - Assert.Equal(0, queue.Count); - } - - [Fact] - public void Drain_ReentrantClearAndEnqueueDefersTheNewEpoch() - { - var queue = new OutboundInteractionQueue(); - var actual = new List(); - queue.Enqueue(() => - { - actual.Add("first"); - queue.Clear(); - queue.Enqueue(() => actual.Add("new-session")); - }); - queue.Enqueue(() => actual.Add("stale")); - - queue.Drain(); - Assert.Equal(new[] { "first" }, actual); - Assert.Equal(1, queue.Count); - - queue.Drain(); - Assert.Equal(new[] { "first", "new-session" }, actual); - } -} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 29b21eef..95dddb93 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -8,6 +8,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.World; +using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Tests.Interaction; @@ -69,7 +70,7 @@ public sealed class SelectionInteractionControllerTests } } - private sealed class Transport : ISelectionInteractionTransport + private sealed class Transport : IRuntimeInteractionTransport { private uint _sequence; public bool IsInWorld { get; set; } = true; @@ -160,7 +161,7 @@ public sealed class SelectionInteractionControllerTests }); Items = new ItemInteractionController( Objects, - new InventoryTransactionState(Objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)), new InteractionState(), () => Player, sendUse: null, @@ -464,6 +465,21 @@ public sealed class SelectionInteractionControllerTests Assert.Equal(new[] { Target }, stale.Transport.Uses); } + [Fact] + public void HiddenTargetCancelsQueuedUseBeforeFrameDrain() + { + var h = new Harness(); + h.Selection.Select(Target, SelectionChangeSource.World); + h.Controller.HandleInputAction(InputAction.UseSelected); + + h.Controller.OnEntityHidden(Target); + h.Controller.DrainOutbound(); + + Assert.Null(h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Uses); + Assert.Equal(0, h.Items.BusyCount); + } + [Fact] public void PickupInputWaitsForFrameDrainThenUsesPendingDestination() { diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 801b0bd1..835469a6 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -681,7 +681,7 @@ public sealed class CurrentGameRuntimeAdapterTests EntityObjects); Objects = EntityObjects.Objects; Communication = new RuntimeCommunicationState(); - Actions = new RuntimeActionState(); + Actions = new RuntimeActionState(InventoryState.Transactions); MovementInput = new DispatcherMovementInputSource(); GameplayInput = new GameplayInputFrameController( dispatcher: null, @@ -701,7 +701,7 @@ public sealed class CurrentGameRuntimeAdapterTests _items = new ItemInteractionController( Objects, - InventoryState.Transactions, + Actions.Transactions, Actions.Interaction, () => PlayerGuid, sendUse: null, @@ -766,11 +766,11 @@ public sealed class CurrentGameRuntimeAdapterTests { Runtime.Dispose(); Character.Dispose(); - InventoryState.Dispose(); Communication.Dispose(); _session.Dispose(); _items.Dispose(); Actions.Dispose(); + InventoryState.Dispose(); Entities.Clear(); } } @@ -1149,7 +1149,7 @@ public sealed class CurrentGameRuntimeAdapterTests } private sealed class SelectionTransport(Func isInWorld) - : ISelectionInteractionTransport + : IRuntimeInteractionTransport { public bool IsInWorld => isInWorld(); diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs index 75d1f880..fc1c7382 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs @@ -15,7 +15,11 @@ public sealed class RuntimeActionOwnershipTests string program = ReadAppSource(root, "Program.cs"); Assert.Contains( - "private readonly RuntimeActionState _runtimeActions = new();", + "private readonly RuntimeActionState _runtimeActions;", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);", gameWindow, StringComparison.Ordinal); Assert.Contains( @@ -54,6 +58,9 @@ public sealed class RuntimeActionOwnershipTests Assert.Empty(Regex.Matches( production, @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?InteractionState\s*\(")); + Assert.Empty(Regex.Matches( + production, + @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeInteractionTransactionState\s*\(")); Assert.Equal( 1, Regex.Matches( @@ -94,14 +101,19 @@ public sealed class RuntimeActionOwnershipTests "GameWindowLifetime.cs"); Assert.Contains("d.Actions.Interaction,", ui, StringComparison.Ordinal); + Assert.Contains("d.Actions.Transactions,", ui, StringComparison.Ordinal); Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal); Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal); Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal); Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal); Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal); + Assert.Contains( + "_domain.Actions.Transactions.CompleteUse(error)", + liveSession, + StringComparison.Ordinal); Assert.Contains("_actions.Selection", commands, StringComparison.Ordinal); Assert.Contains( - "InteractionState interactionState,", + "RuntimeInteractionTransactionState runtimeTransactions,", itemInteraction, StringComparison.Ordinal); Assert.DoesNotContain( @@ -114,8 +126,8 @@ public sealed class RuntimeActionOwnershipTests StringComparison.Ordinal); AssertAppearsInOrder( shutdown, - "\"runtime inventory state\"", "\"runtime action state\"", + "\"runtime inventory state\"", "\"runtime entity/object lifetime\""); Assert.False(File.Exists(Path.Combine( root, diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs index 267d0e47..43fa0675 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs @@ -46,7 +46,7 @@ public sealed class RuntimeInventoryOwnershipTests "ItemInteractionController.cs"); Assert.Contains( - "d.Inventory.Transactions", + "d.Actions.Transactions", ui, StringComparison.Ordinal); Assert.DoesNotContain( @@ -62,7 +62,7 @@ public sealed class RuntimeInventoryOwnershipTests itemInteraction, StringComparison.Ordinal); Assert.Contains( - "InventoryTransactionState transactions,", + "RuntimeInteractionTransactionState runtimeTransactions,", itemInteraction, StringComparison.Ordinal); Assert.Contains( @@ -70,7 +70,7 @@ public sealed class RuntimeInventoryOwnershipTests session, StringComparison.Ordinal); Assert.Contains( - "_domain.Inventory.Transactions.CompleteUse(error)", + "_domain.Actions.Transactions.CompleteUse(error)", session, StringComparison.Ordinal); Assert.DoesNotContain( @@ -79,6 +79,7 @@ public sealed class RuntimeInventoryOwnershipTests StringComparison.Ordinal); AssertAppearsInOrder( shutdown, + "\"runtime action state\"", "\"runtime inventory state\"", "\"runtime entity/object lifetime\""); } diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index f6d7b1d6..6aee28dc 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -153,7 +153,7 @@ public sealed class CursorFeedbackControllerTests var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -190,7 +190,7 @@ public sealed class CursorFeedbackControllerTests objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -230,7 +230,7 @@ public sealed class CursorFeedbackControllerTests var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -260,7 +260,7 @@ public sealed class CursorFeedbackControllerTests var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index f00cb8af..8f2d4ba7 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1,6 +1,7 @@ using AcDream.App.UI; using AcDream.Core.Combat; using AcDream.Core.Items; +using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI; @@ -32,6 +33,7 @@ public sealed class ItemInteractionControllerTests public readonly CombatState Combat = new(); public readonly StackSplitQuantityState SplitQuantity = new(); public readonly InventoryTransactionState SharedTransactions; + public readonly RuntimeInteractionTransactionState RuntimeTransactions; public uint SelectedObject; public bool NonCombatMode; public bool DragOnPlayerOpensSecureTrade = true; @@ -56,10 +58,12 @@ public sealed class ItemInteractionControllerTests }); Objects.MoveItem(Pack, Player, 0); SharedTransactions = new InventoryTransactionState(Objects); + RuntimeTransactions = new RuntimeInteractionTransactionState( + SharedTransactions); Controller = new ItemInteractionController( Objects, - SharedTransactions, + RuntimeTransactions, new InteractionState(), playerGuid: () => Player, sendUse: requestUse is null ? Uses.Add : null, @@ -338,10 +342,12 @@ public sealed class ItemInteractionControllerTests var objects = new ClientObjectTable(); using var transactions = new InventoryTransactionState(new ClientObjectTable()); + using var runtimeTransactions = + new RuntimeInteractionTransactionState(transactions); Assert.Throws(() => new ItemInteractionController( objects, - transactions, + runtimeTransactions, new InteractionState(), playerGuid: () => Player, sendUse: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index d317baa8..58871a0d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -858,7 +858,7 @@ public sealed class AppraisalUiControllerTests List sent) => new( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => 0x50000002u, sendUse: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index d230f78d..bbcf5f93 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -106,7 +106,7 @@ public sealed class ExternalContainerControllerTests Interaction = new ItemInteractionController( Objects, - new InventoryTransactionState(Objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)), new InteractionState(), playerGuid: () => Player, sendUse: Uses.Add, diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 6f569e41..9e7dcc3f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -508,7 +508,7 @@ public class InventoryControllerTests var appraisals = new List(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -546,7 +546,7 @@ public class InventoryControllerTests objects.Get(0xA)!.Useability = 0x000A0008u; var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -716,7 +716,7 @@ public class InventoryControllerTests var pickups = new List<(uint item, uint container, int placement)>(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -762,7 +762,7 @@ public class InventoryControllerTests var eventOrder = new List<(string Kind, uint Item, ulong Token)>(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -815,7 +815,7 @@ public class InventoryControllerTests var eventOrder = new List<(string Kind, uint Item)>(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -874,7 +874,7 @@ public class InventoryControllerTests var messages = new List(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -944,7 +944,7 @@ public class InventoryControllerTests var messages = new List(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -1008,7 +1008,7 @@ public class InventoryControllerTests var merges = new List<(uint Source, uint Target, uint Amount)>(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -1069,7 +1069,7 @@ public class InventoryControllerTests var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -1117,7 +1117,7 @@ public class InventoryControllerTests var puts = new List<(uint Item, uint Container, int Placement)>(); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -1158,7 +1158,7 @@ public class InventoryControllerTests SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, @@ -1198,7 +1198,7 @@ public class InventoryControllerTests SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); using var interaction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index e8b3b99a..ff74b09f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -52,7 +52,7 @@ public class PaperdollControllerTests { var itemInteraction = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), () => Player, sendUse: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 768ef4cf..31fb6e31 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -265,7 +265,7 @@ public class ToolbarControllerTests var useWithTarget = new List<(uint Source, uint Target)>(); var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), playerGuid: () => player, sendUse: null, @@ -332,7 +332,7 @@ public class ToolbarControllerTests var directPuts = new List<(uint Item, uint Container, int Placement)>(); using var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), playerGuid: () => player, sendUse: null, @@ -382,7 +382,7 @@ public class ToolbarControllerTests var messages = new List(); using var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), playerGuid: () => player, sendUse: null, @@ -512,7 +512,7 @@ public class ToolbarControllerTests uint selected = item; var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), () => player, sendUse: uses.Add, @@ -571,7 +571,7 @@ public class ToolbarControllerTests var selection = new SelectionState(); using var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), () => player, sendUse: null, @@ -637,7 +637,7 @@ public class ToolbarControllerTests var wields = new List<(uint Item, uint Location)>(); using var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), () => player, sendUse: null, @@ -804,7 +804,7 @@ public class ToolbarControllerTests var appraisals = new List(); using var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), playerGuid: () => player, sendUse: null, @@ -854,7 +854,7 @@ public class ToolbarControllerTests uint sentTarget = 0; var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), () => player, sendUse: null, @@ -897,7 +897,7 @@ public class ToolbarControllerTests repo.MoveItem(item, pack, 0); var interaction = new ItemInteractionController( repo, - new InventoryTransactionState(repo), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)), new InteractionState(), () => player, sendUse: null, diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs index fb382edf..b5b79727 100644 --- a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs @@ -18,7 +18,7 @@ public sealed class RetailItemConfirmationControllerTests var uses = new List(); var items = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: uses.Add, @@ -54,7 +54,7 @@ public sealed class RetailItemConfirmationControllerTests var uses = new List(); var items = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: uses.Add, @@ -84,7 +84,7 @@ public sealed class RetailItemConfirmationControllerTests var messages = new List(); var items = new ItemInteractionController( objects, - new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), new InteractionState(), playerGuid: () => Player, sendUse: uses.Add, diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 3c8d217f..44b0144c 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -163,7 +163,7 @@ public sealed class RetailUiInteractionFlowTests { var interaction = new ItemInteractionController( Objects, - new InventoryTransactionState(Objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)), new InteractionState(), playerGuid: () => Player, sendUse: Uses.Add, @@ -199,7 +199,7 @@ public sealed class RetailUiInteractionFlowTests { itemInteraction ??= new ItemInteractionController( Objects, - new InventoryTransactionState(Objects), + new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)), new InteractionState(), () => Player, sendUse: null, diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs index 033282fd..40c9ab56 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -179,7 +179,8 @@ public sealed class GameRuntimeContractTests TrackedTargetHealthCount: 1, InteractionRevision: 16, InteractionMode: InteractionModeKind.Use, - InteractionSourceObjectId: 0u), + InteractionSourceObjectId: 0u, + InteractionTransactions: default), default, default); @@ -190,7 +191,8 @@ public sealed class GameRuntimeContractTests Assert.Contains("character=5:6:7:8:0:0", entry.Text); Assert.Contains("social=9:10:11", entry.Text); Assert.Contains( - "actions=14:50000001:15:4:1:16:1:00000000", + "actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" + + "00000000:00000000:00000000:0:0", entry.Text); } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs index d6cf7dd8..62058fc5 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs @@ -1,4 +1,5 @@ using AcDream.Core.Combat; +using AcDream.Core.Items; using AcDream.Core.Selection; using AcDream.Runtime.Gameplay; @@ -9,7 +10,8 @@ public sealed class RuntimeActionStateTests [Fact] public void ViewProjectsTheExactCanonicalChildrenAndRevisions() { - using var actions = new RuntimeActionState(); + using var inventory = NewInventoryTransactions(); + using var actions = new RuntimeActionState(inventory); actions.Selection.Select( 0x50000001u, @@ -38,7 +40,8 @@ public sealed class RuntimeActionStateTests [Fact] public void ResetSessionConvergesEveryChildAfterObserverFailures() { - var actions = new RuntimeActionState(); + using var inventory = NewInventoryTransactions(); + var actions = new RuntimeActionState(inventory); actions.Selection.Select( 0x50000001u, SelectionChangeSource.World); @@ -69,7 +72,8 @@ public sealed class RuntimeActionStateTests [Fact] public void DisposeIsTerminalAndConvergedAfterObserverFailures() { - var actions = new RuntimeActionState(); + using var inventory = NewInventoryTransactions(); + var actions = new RuntimeActionState(inventory); actions.Selection.Select( 0x50000001u, SelectionChangeSource.World); @@ -94,8 +98,10 @@ public sealed class RuntimeActionStateTests [Fact] public void InstancesAreFullyIsolated() { - using var first = new RuntimeActionState(); - using var second = new RuntimeActionState(); + using var firstInventory = NewInventoryTransactions(); + using var secondInventory = NewInventoryTransactions(); + using var first = new RuntimeActionState(firstInventory); + using var second = new RuntimeActionState(secondInventory); first.Selection.Select( 0x50000001u, @@ -114,7 +120,21 @@ public sealed class RuntimeActionStateTests 0, 0, InteractionModeKind.None, - 0u), + 0u, + new RuntimeInteractionTransactionSnapshot( + IsDisposed: false, + Revision: 0, + LastUseSourceId: 0u, + LastUseTargetId: 0u, + AwaitingAppraisalId: 0u, + CurrentAppraisalId: 0u, + OutboundCount: 0, + HasPendingPickup: false, + PendingPickupToken: 0u, + DispatchFailureCount: 0)), second.View.Snapshot); } + + private static InventoryTransactionState NewInventoryTransactions() => + new(new ClientObjectTable()); } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs index 2a0ade8b..21dc0cbb 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs @@ -15,7 +15,7 @@ public sealed class RuntimeGameplayOwnershipTests var inventory = new RuntimeInventoryState(entities); var character = new RuntimeCharacterState(); var communication = new RuntimeCommunicationState(); - var actions = new RuntimeActionState(); + var actions = new RuntimeActionState(inventory.Transactions); inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); @@ -114,7 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests var inventory = new RuntimeInventoryState(entities); var character = new RuntimeCharacterState(); var communication = new RuntimeCommunicationState(); - var actions = new RuntimeActionState(); + var actions = new RuntimeActionState(inventory.Transactions); inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); @@ -132,10 +132,10 @@ public sealed class RuntimeGameplayOwnershipTests communication.Chat.OnSystemMessage("failure-isolated event", 0u); Assert.Equal(1, communication.DispatchFailureCount); + actions.Dispose(); Assert.Throws(inventory.Dispose); Assert.Throws(character.Dispose); communication.Dispose(); - actions.Dispose(); RuntimeGameplayOwnershipSnapshot retired = RuntimeGameplayOwnership.Capture( diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs new file mode 100644 index 00000000..a978d559 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs @@ -0,0 +1,447 @@ +using AcDream.Core.Items; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeInteractionTransactionStateTests +{ + private const uint Item = 0x70000001u; + private const uint Container = 0x50000001u; + + [Fact] + public void UseThrottleMatchesRetailStrictBoundaryAndResets() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + + Assert.True(state.TryConsumeUseThrottle(1_000)); + Assert.False(state.TryConsumeUseThrottle(1_199)); + Assert.True(state.TryConsumeUseThrottle(1_200)); + + state.ResetSession(); + + Assert.True(state.TryConsumeUseThrottle(1_200)); + } + + [Fact] + public void OrdinaryUseTransfersBusyReferenceToAuthoritativeUseDone() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + var transport = new Transport(); + ItemUseRequestReservation reservation = + state.BeginUseRequestReservation(); + + RuntimeInteractionDispatchResult result = state.TryDispatchUse( + Item, + ownedByPlayer: false, + useable: true, + reservation, + transport, + out uint sequence); + + Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result); + Assert.Equal(1u, sequence); + Assert.Equal(1, inventory.BusyCount); + Assert.Equal(new[] { Item }, transport.Uses); + RuntimeInteractionTransactionSnapshot snapshot = + state.CaptureOwnership(); + Assert.Equal(Item, snapshot.LastUseSourceId); + Assert.Equal(0u, snapshot.LastUseTargetId); + + reservation.CancelBeforeDispatch(); + Assert.Equal(1, inventory.BusyCount); + + state.CompleteUse(0u); + Assert.Equal(0, inventory.BusyCount); + } + + [Theory] + [InlineData(false, true, RuntimeInteractionDispatchResult.NotInWorld)] + [InlineData(true, false, RuntimeInteractionDispatchResult.NotUseable)] + public void RejectedUseReleasesOnlyItsReservation( + bool inWorld, + bool useable, + RuntimeInteractionDispatchResult expected) + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + var transport = new Transport { IsInWorld = inWorld }; + ItemUseRequestReservation reservation = + state.BeginUseRequestReservation(); + + RuntimeInteractionDispatchResult result = state.TryDispatchUse( + Item, + ownedByPlayer: false, + useable, + reservation, + transport, + out _); + + Assert.Equal(expected, result); + Assert.Equal(0, inventory.BusyCount); + Assert.Empty(transport.Uses); + } + + [Fact] + public void TargetedUseRecordsExactSourceAndTarget() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + var sent = new List<(uint Source, uint Target)>(); + + Assert.True(state.TryDispatchTargetedUse( + Item, + Container, + (source, target) => sent.Add((source, target)), + incrementBusy: true)); + + Assert.Equal(new[] { (Item, Container) }, sent); + Assert.Equal(1, inventory.BusyCount); + RuntimeInteractionTransactionSnapshot snapshot = + state.CaptureOwnership(); + Assert.Equal(Item, snapshot.LastUseSourceId); + Assert.Equal(Container, snapshot.LastUseTargetId); + } + + [Fact] + public void ReentrantResetPreventsTargetedUseStateResurrection() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + var sent = new List<(uint Source, uint Target)>(); + + Assert.False(state.TryDispatchTargetedUse( + Item, + Container, + (source, target) => + { + sent.Add((source, target)); + state.ResetSession(); + }, + incrementBusy: true)); + + Assert.Equal(new[] { (Item, Container) }, sent); + Assert.Equal(0, inventory.BusyCount); + RuntimeInteractionTransactionSnapshot snapshot = + state.CaptureOwnership(); + Assert.Equal(0u, snapshot.LastUseSourceId); + Assert.Equal(0u, snapshot.LastUseTargetId); + } + + [Fact] + public void AppraisalReplacementKeepsOneBusyReferenceAndExactCurrentId() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + var sent = new List(); + + Assert.True(state.TryRequestAppraisal(Item, sent.Add)); + Assert.True(state.TryRequestAppraisal(Container, sent.Add)); + Assert.Equal(1, inventory.BusyCount); + Assert.False(state.AcceptAppraisalResponse(Item).Accepted); + + RuntimeAppraisalResponseAcceptance first = + state.AcceptAppraisalResponse(Container); + Assert.True(first.Accepted); + Assert.True(first.FirstResponse); + Assert.Equal(Container, state.CurrentAppraisalId); + Assert.Equal(0, inventory.BusyCount); + + Assert.True(state.RefreshCurrentAppraisal(sent.Add)); + Assert.Equal(new[] { Item, Container, Container }, sent); + + Assert.True(state.CancelObjectAppraisalForSpell(sent.Add)); + Assert.Equal(0u, state.CurrentAppraisalId); + Assert.Equal(0u, sent[^1]); + } + + [Fact] + public void AppraisalTransportFailureRollsBackOnlyItsBusyReference() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + + Assert.Throws(() => + state.TryRequestAppraisal( + Item, + static _ => throw new InvalidOperationException("transport"))); + + RuntimeInteractionTransactionSnapshot snapshot = + state.CaptureOwnership(); + Assert.Equal(0u, snapshot.AwaitingAppraisalId); + Assert.Equal(0u, snapshot.CurrentAppraisalId); + Assert.Equal(0, inventory.BusyCount); + } + + [Fact] + public void OutboundQueueIsTypedFifoAndReentrantWorkWaitsForNextDrain() + { + using var inventory = NewInventory(out ClientObjectTable objects); + using var state = new RuntimeInteractionTransactionState(inventory); + var item = new ClientObject { ObjectId = Item }; + objects.AddOrUpdate(item); + RuntimeInteractionIdentity identity = new(Item, 12u, item); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Activate, + identity)); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Use, + identity)); + var seen = new List(); + + state.DrainOutbound(value => + { + seen.Add(value.Kind); + if (value.Kind == RuntimeQueuedInteractionKind.Activate) + { + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Pickup, + identity)); + } + }); + + Assert.Equal( + new[] + { + RuntimeQueuedInteractionKind.Activate, + RuntimeQueuedInteractionKind.Use, + }, + seen); + Assert.Equal(1, state.OutboundCount); + + state.DrainOutbound(value => seen.Add(value.Kind)); + Assert.Equal(RuntimeQueuedInteractionKind.Pickup, seen[^1]); + Assert.Equal(0, state.OutboundCount); + } + + [Fact] + public void ReentrantSessionResetStopsTheCapturedFrameSuffix() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + RuntimeInteractionIdentity identity = new(Item, 12u, null); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Activate, + identity)); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Use, + identity)); + var seen = new List(); + + state.DrainOutbound(interaction => + { + seen.Add(interaction.Kind); + state.ResetSession(); + }); + + Assert.Equal( + new[] { RuntimeQueuedInteractionKind.Activate }, + seen); + Assert.Equal(0, state.OutboundCount); + Assert.Equal(0, inventory.BusyCount); + } + + [Fact] + public void DispatchFailureIsRecordedWithoutLosingLaterQueuedWork() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + RuntimeInteractionIdentity identity = new(Item, 12u, null); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Activate, + identity)); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Use, + identity)); + + Assert.Throws(() => + state.DrainOutbound(static _ => + throw new InvalidOperationException("observer"))); + + Assert.Equal(1, state.DispatchFailureCount); + Assert.Equal(1, state.OutboundCount); + var seen = new List(); + state.DrainOutbound(value => seen.Add(value.Kind)); + Assert.Equal(new[] { RuntimeQueuedInteractionKind.Use }, seen); + } + + [Fact] + public void HiddenAndDeletedCancellationPreservesOtherIdentityOrder() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + const uint reusedGuid = Item; + const uint otherGuid = 0x70000002u; + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Activate, + new RuntimeInteractionIdentity(reusedGuid, 11u, null))); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Use, + new RuntimeInteractionIdentity(otherGuid, 21u, null))); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Pickup, + new RuntimeInteractionIdentity(reusedGuid, 12u, null))); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Activate, + new RuntimeInteractionIdentity(otherGuid, 22u, null))); + + Assert.Equal(1, state.CancelQueuedInteractions(reusedGuid, 11u)); + Assert.Equal(3, state.OutboundCount); + Assert.Equal(1, state.CancelQueuedInteractions(reusedGuid)); + Assert.Equal(2, state.OutboundCount); + + var seen = new List<(uint Guid, uint? LocalId)>(); + state.DrainOutbound(interaction => seen.Add( + (interaction.Identity.ServerGuid, + interaction.Identity.LocalEntityId))); + + Assert.Equal( + new[] + { + (otherGuid, (uint?)21u), + (otherGuid, (uint?)22u), + }, + seen); + } + + [Fact] + public void PostArrivalPickupRequiresItsExactApproachAndReservationTokens() + { + using var inventory = NewInventory(out ClientObjectTable objects); + using var state = new RuntimeInteractionTransactionState(inventory); + objects.AddOrUpdate(new ClientObject { ObjectId = Item }); + Assert.True(inventory.TryReserve( + InventoryRequestKind.Pickup, + Item, + out PendingInventoryRequest request)); + var approach = new RuntimeInteractionApproachToken(3u, 7u); + + Assert.True(state.TryArmPostArrivalPickup( + Item, + localEntityId: 12u, + Container, + placement: 0, + request.Token, + approach, + out RuntimePendingPickup pending)); + Assert.False(state.TryResolveApproachCompletion( + new RuntimeInteractionApproachToken(3u, 8u), + natural: true, + out _)); + Assert.True(state.TryResolveApproachCompletion( + approach, + natural: true, + out RuntimePendingPickup ready)); + Assert.Equal(pending.Token, ready.Token); + + var transport = new Transport(); + Assert.True(state.TryDispatchPickup(ready, transport, out uint sequence)); + Assert.Equal(1u, sequence); + Assert.Equal(new[] { (Item, Container, 0) }, transport.Pickups); + Assert.True(inventory.TryGetPending(out PendingInventoryRequest sent)); + Assert.True(sent.Dispatched); + } + + [Fact] + public void CancellationResetAndDisposalConvergeTheCompleteLedger() + { + using var inventory = NewInventory(out ClientObjectTable objects); + var state = new RuntimeInteractionTransactionState(inventory); + objects.AddOrUpdate(new ClientObject { ObjectId = Item }); + Assert.True(inventory.TryReserve( + InventoryRequestKind.Pickup, + Item, + out PendingInventoryRequest request)); + Assert.True(state.TryArmPostArrivalPickup( + Item, + localEntityId: 12u, + Container, + placement: 0, + request.Token, + new RuntimeInteractionApproachToken(1u, 1u), + out _)); + state.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Pickup, + new RuntimeInteractionIdentity(Item, 12u, objects.Get(Item)))); + state.IncrementBusyCount(); + + state.ResetSession(); + + Assert.Equal(0, inventory.BusyCount); + Assert.False(inventory.HasPendingRequest); + Assert.Equal(0, state.OutboundCount); + Assert.False(state.HasPendingPickup); + + state.Dispose(); + Assert.True(state.CaptureOwnership().IsConverged); + state.Dispose(); + } + + [Fact] + public void InstancesDoNotShareThrottleQueueAppraisalOrPickupState() + { + using var firstInventory = NewInventory(out _); + using var secondInventory = NewInventory(out _); + using var first = + new RuntimeInteractionTransactionState(firstInventory); + using var second = + new RuntimeInteractionTransactionState(secondInventory); + + Assert.True(first.TryConsumeUseThrottle(100)); + first.TryRequestAppraisal(Item, static _ => { }); + first.Enqueue(new RuntimeQueuedInteraction( + RuntimeQueuedInteractionKind.Use, + new RuntimeInteractionIdentity(Item, 1u, null))); + + RuntimeInteractionTransactionSnapshot snapshot = + second.CaptureOwnership(); + Assert.Equal(0u, snapshot.AwaitingAppraisalId); + Assert.Equal(0, snapshot.OutboundCount); + Assert.Equal(0, secondInventory.BusyCount); + Assert.True(second.TryConsumeUseThrottle(100)); + } + + private static InventoryTransactionState NewInventory( + out ClientObjectTable objects) + { + objects = new ClientObjectTable(); + return new InventoryTransactionState(objects); + } + + private sealed class Transport : IRuntimeInteractionTransport + { + private uint _sequence; + public bool IsInWorld { get; set; } = true; + public List Uses { get; } = []; + public List<(uint Item, uint Container, int Placement)> Pickups { get; } = []; + + public bool TrySendUse(uint serverGuid, out uint sequence) + { + if (!IsInWorld) + { + sequence = 0u; + return false; + } + sequence = ++_sequence; + Uses.Add(serverGuid); + return true; + } + + public bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence) + { + if (!IsInWorld) + { + sequence = 0u; + return false; + } + sequence = ++_sequence; + Pickups.Add((itemGuid, destinationContainerId, placement)); + return true; + } + } +}