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;
+ }
+ }
+}