refactor(runtime): own interaction transactions

Move use throttling, appraisal identity, queued interactions, and exact post-arrival pickup state beneath RuntimeActionState. Keep App as the picker, movement, transport, and retained-presentation adapter while preserving retail send and UseDone ordering. Add reset, disposal, GUID-reuse, callback-reentrancy, and transport-failure coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 11:13:09 +02:00
parent be73bccf5a
commit f5f7b4177f
31 changed files with 1365 additions and 432 deletions

View file

@ -284,7 +284,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DeferredSelectionUiAuthority selection = late.Selection; DeferredSelectionUiAuthority selection = late.Selection;
return new ItemInteractionController( return new ItemInteractionController(
d.Inventory.Objects, d.Inventory.Objects,
d.Inventory.Transactions, d.Actions.Transactions,
d.Actions.Interaction, d.Actions.Interaction,
playerGuid: () => d.PlayerIdentity.ServerGuid, playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: null, sendUse: null,

View file

@ -1,47 +0,0 @@
namespace AcDream.App.Input;
/// <summary>
/// Orders input-originated item interactions after the local player's movement
/// edge has been serialized for the same frame, but before inbound network
/// dispatch.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class OutboundInteractionQueue
{
private readonly Queue<Action> _pending = new();
private uint _clearEpoch;
public int Count => _pending.Count;
public void Enqueue(Action action)
{
ArgumentNullException.ThrowIfNull(action);
_pending.Enqueue(action);
}
/// <summary>
/// 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.
/// </summary>
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++;
}
}

View file

@ -1,10 +1,10 @@
using AcDream.App.Input;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Ui; using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Interaction; namespace AcDream.App.Interaction;
@ -19,31 +19,17 @@ internal sealed class SelectionInteractionController
private readonly SelectionState _selection; private readonly SelectionState _selection;
private readonly IWorldSelectionQuery _query; private readonly IWorldSelectionQuery _query;
private readonly ItemInteractionController _items; private readonly ItemInteractionController _items;
private readonly ISelectionInteractionTransport _transport; private readonly RuntimeInteractionTransactionState _transactions;
private readonly IRuntimeInteractionTransport _transport;
private readonly IPlayerInteractionMovementSink _movement; private readonly IPlayerInteractionMovementSink _movement;
private readonly OutboundInteractionQueue _outbound = new();
private readonly PlayerApproachCompletionState _approachCompletions; private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast; private readonly Action<string>? _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( public SelectionInteractionController(
SelectionState selection, SelectionState selection,
IWorldSelectionQuery query, IWorldSelectionQuery query,
ItemInteractionController items, ItemInteractionController items,
ISelectionInteractionTransport transport, IRuntimeInteractionTransport transport,
IPlayerInteractionMovementSink movement, IPlayerInteractionMovementSink movement,
Action<string>? toast = null, Action<string>? toast = null,
PlayerApproachCompletionState? approachCompletions = null) PlayerApproachCompletionState? approachCompletions = null)
@ -51,6 +37,7 @@ internal sealed class SelectionInteractionController
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query)); _query = query ?? throw new ArgumentNullException(nameof(query));
_items = items ?? throw new ArgumentNullException(nameof(items)); _items = items ?? throw new ArgumentNullException(nameof(items));
_transactions = _items.RuntimeTransactions;
_transport = transport ?? throw new ArgumentNullException(nameof(transport)); _transport = transport ?? throw new ArgumentNullException(nameof(transport));
_movement = movement ?? throw new ArgumentNullException(nameof(movement)); _movement = movement ?? throw new ArgumentNullException(nameof(movement));
_toast = toast; _toast = toast;
@ -88,13 +75,9 @@ internal sealed class SelectionInteractionController
if (_selection.SelectedObjectId is uint pickupTarget) if (_selection.SelectedObjectId is uint pickupTarget)
{ {
EnqueueIdentityBound( EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Pickup,
pickupTarget, pickupTarget,
requireLiveEntity: true, requireLiveEntity: true);
guid =>
{
if (ValidatePickupTarget(guid, showToast: true))
_items.PlaceWorldItemInBackpack(guid);
});
} }
else else
{ {
@ -178,12 +161,9 @@ internal sealed class SelectionInteractionController
_toast?.Invoke($"Selected: {label}"); _toast?.Invoke($"Selected: {label}");
if (useImmediately) if (useImmediately)
EnqueueIdentityBound( EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Activate,
guid, guid,
requireLiveEntity: true, requireLiveEntity: true);
selected =>
{
_items.ActivateItem(selected);
});
} }
/// <summary> /// <summary>
@ -213,12 +193,9 @@ internal sealed class SelectionInteractionController
return; return;
} }
EnqueueIdentityBound( EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Use,
selected, selected,
requireLiveEntity: false, requireLiveEntity: false);
guid =>
{
_items.UseSelectedOrEnterMode(guid);
});
} }
public void SendUse(uint serverGuid) public void SendUse(uint serverGuid)
@ -229,45 +206,19 @@ internal sealed class SelectionInteractionController
ItemUseRequestReservation? reservation) ItemUseRequestReservation? reservation)
{ {
CancelPendingApproach(); 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"); _toast?.Invoke("Not in world");
reservation?.CancelBeforeDispatch(); if (result == RuntimeInteractionDispatchResult.Dispatched)
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();
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
return;
}
reservation?.CancelBeforeDispatch();
} }
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
@ -302,20 +253,38 @@ internal sealed class SelectionInteractionController
if (approach.IsCloseRange) if (approach.IsCloseRange)
{ {
var pending = new PendingPostArrivalPickup( bool armed = false;
itemGuid,
approach.Target.LocalEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
ApproachToken: default);
bool started = _movement.BeginApproach( bool started = _movement.BeginApproach(
approach, approach,
token => _pendingPostArrival = pending with { ApproachToken = token }); token =>
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid) {
CancelPendingApproach(); armed = _transactions.TryArmPostArrivalPickup(
else if (!started) itemGuid,
CancelPickupPresentation(itemGuid, pendingPlacementToken); 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; return;
} }
@ -328,16 +297,18 @@ internal sealed class SelectionInteractionController
{ {
return; return;
} }
uint sequence = 0u; var immediate = new RuntimePendingPickup(
if (_items.TryDispatchInventoryRequest( Token: 0u,
InventoryRequestKind.Pickup, itemGuid,
itemGuid, approach.Target.LocalEntityId,
() => _transport.TrySendPickup( destinationContainerId,
itemGuid, placement,
destinationContainerId, pendingPlacementToken,
placement, ApproachToken: default);
out sequence), if (_transactions.TryDispatchPickup(
pendingPlacementToken)) immediate,
_transport,
out uint sequence))
{ {
Console.WriteLine( Console.WriteLine(
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
@ -350,13 +321,28 @@ internal sealed class SelectionInteractionController
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary> /// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
public void OnNaturalMoveToComplete() 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; return;
_pendingPostArrival = null; if (!accepted)
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return;
}
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
{ {
CancelPickupPresentation( CancelPickupPresentation(
@ -373,15 +359,10 @@ internal sealed class SelectionInteractionController
{ {
return; return;
} }
if (!_items.TryDispatchInventoryRequest( if (!_transactions.TryDispatchPickup(
InventoryRequestKind.Pickup, pending,
pending.ServerGuid, _transport,
() => _transport.TrySendPickup( out _))
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _),
pending.PendingPlacementToken))
{ {
CancelPickupPresentation( CancelPickupPresentation(
pending.ServerGuid, pending.ServerGuid,
@ -393,22 +374,29 @@ internal sealed class SelectionInteractionController
{ {
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion)) while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
{ {
if (_pendingPostArrival?.ApproachToken != completion.Token) HandleApproachCompletion(
continue; new RuntimeInteractionApproachToken(
if (completion.IsNatural) completion.Token.ControllerLifetime,
HandleNaturalMoveToComplete(); completion.Token.ApproachGeneration),
else completion.IsNatural);
CancelPendingApproach();
} }
_outbound.Drain(); _transactions.DrainOutbound(DispatchQueuedInteraction);
} }
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
public void OnEntityHidden(uint serverGuid) public void OnEntityHidden(uint serverGuid)
{ {
if (_pendingPostArrival?.ServerGuid == serverGuid) _transactions.CancelQueuedInteractions(serverGuid);
CancelPendingApproach(); if (_transactions.TryCancelPendingPickup(
serverGuid,
localEntityId: null,
out RuntimePendingPickup cancelled))
{
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
if (_selection.SelectedObjectId == serverGuid) if (_selection.SelectedObjectId == serverGuid)
{ {
_selection.Clear( _selection.Clear(
@ -420,11 +408,17 @@ internal sealed class SelectionInteractionController
public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists) public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists)
{ {
ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record);
if (_pendingPostArrival is { } pending _transactions.CancelQueuedInteractions(
&& pending.ServerGuid == record.ServerGuid record.ServerGuid,
&& pending.LocalEntityId == record.LocalEntityId) record.LocalEntityId);
if (_transactions.TryCancelPendingPickup(
record.ServerGuid,
record.LocalEntityId,
out RuntimePendingPickup cancelled))
{ {
CancelPendingApproach(); CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
} }
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
{ {
@ -443,11 +437,8 @@ internal sealed class SelectionInteractionController
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
try { _selection.Reset(); } try { _selection.Reset(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
try { _outbound.Clear(); }
catch (Exception error) { failures.Add(error); }
try { _approachCompletions.Clear(); } try { _approachCompletions.Clear(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
_pendingPostArrival = null;
if (failures.Count != 0) if (failures.Count != 0)
throw new AggregateException( throw new AggregateException(
@ -471,11 +462,10 @@ internal sealed class SelectionInteractionController
} }
private bool EnqueueIdentityBound( private bool EnqueueIdentityBound(
RuntimeQueuedInteractionKind kind,
uint serverGuid, uint serverGuid,
bool requireLiveEntity, bool requireLiveEntity)
Action<uint> action)
{ {
ArgumentNullException.ThrowIfNull(action);
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId) uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
? localId ? localId
: null; : null;
@ -488,16 +478,15 @@ internal sealed class SelectionInteractionController
return false; return false;
} }
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item); var identity = new RuntimeInteractionIdentity(
_outbound.Enqueue(() => serverGuid,
{ localEntityId,
if (IsCurrent(identity)) item);
action(identity.ServerGuid); _transactions.Enqueue(new RuntimeQueuedInteraction(kind, identity));
});
return true; return true;
} }
private bool IsCurrent(QueuedInteractionIdentity identity) private bool IsCurrent(RuntimeInteractionIdentity identity)
=> (identity.LocalEntityId is not uint localId => (identity.LocalEntityId is not uint localId
|| _query.IsCurrent(identity.ServerGuid, localId)) || _query.IsCurrent(identity.ServerGuid, localId))
&& (identity.ClientObject is not { } item && (identity.ClientObject is not { } item
@ -505,14 +494,39 @@ internal sealed class SelectionInteractionController
private void CancelPendingApproach() private void CancelPendingApproach()
{ {
if (_pendingPostArrival is not { } pending) if (!_transactions.TryCancelPendingPickup(
out RuntimePendingPickup pending))
return; return;
_pendingPostArrival = null;
CancelPickupPresentation( CancelPickupPresentation(
pending.ServerGuid, pending.ServerGuid,
pending.PendingPlacementToken); 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) private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
=> _items.CancelPendingBackpackPlacement(itemGuid, token); => _items.CancelPendingBackpackPlacement(itemGuid, token);

View file

@ -1,22 +1,12 @@
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Interaction; 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( internal sealed class WorldSessionSelectionInteractionTransport(
Func<WorldSession?> session) Func<WorldSession?> session)
: ISelectionInteractionTransport : IRuntimeInteractionTransport
{ {
private readonly Func<WorldSession?> _session = session private readonly Func<WorldSession?> _session = session
?? throw new ArgumentNullException(nameof(session)); ?? throw new ArgumentNullException(nameof(session));

View file

@ -258,7 +258,7 @@ internal sealed class LiveSessionRuntimeFactory
OnUseDone: error => OnUseDone: error =>
{ {
_domain.Inventory.ExternalContainers.ApplyUseDone(error); _domain.Inventory.ExternalContainers.ApplyUseDone(error);
_domain.Inventory.Transactions.CompleteUse(error); _domain.Actions.Transactions.CompleteUse(error);
}, },
_domain.Inventory.ItemMana, _domain.Inventory.ItemMana,
ExternalContainers: _domain.Inventory.ExternalContainers, ExternalContainers: _domain.Inventory.ExternalContainers,

View file

@ -326,7 +326,7 @@ public sealed class GameWindow :
// J4/J5.1: Runtime owns communication, selection, combat, and target-mode // J4/J5.1: Runtime owns communication, selection, combat, and target-mode
// state. App, UI, plugins, and live-session routing borrow exact children. // state. App, UI, plugins, and live-session routing borrow exact children.
private readonly RuntimeCommunicationState _runtimeCommunication = new(); private readonly RuntimeCommunicationState _runtimeCommunication = new();
private readonly RuntimeActionState _runtimeActions = new(); private readonly RuntimeActionState _runtimeActions;
public AcDream.Core.Selection.SelectionState Selection => public AcDream.Core.Selection.SelectionState Selection =>
_runtimeActions.Selection; _runtimeActions.Selection;
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
@ -561,6 +561,7 @@ public sealed class GameWindow :
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects); _runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);
_runtimeCharacter = new RuntimeCharacterState(); _runtimeCharacter = new RuntimeCharacterState();
var alphaScratchBudgets = var alphaScratchBudgets =
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create( AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(

View file

@ -396,12 +396,12 @@ internal static class GameWindowShutdownManifest
Hard( Hard(
"runtime character state", "runtime character state",
live.Character.Dispose), live.Character.Dispose),
Hard(
"runtime inventory state",
live.Inventory.Dispose),
Hard( Hard(
"runtime action state", "runtime action state",
live.Actions.Dispose), live.Actions.Dispose),
Hard(
"runtime inventory state",
live.Inventory.Dispose),
Hard( Hard(
"runtime entity/object lifetime", "runtime entity/object lifetime",
live.EntityObjects.Dispose), live.EntityObjects.Dispose),

View file

@ -141,7 +141,8 @@ public static class FixtureProvider
var selection = new AcDream.Core.Selection.SelectionState(); var selection = new AcDream.Core.Selection.SelectionState();
var itemInteraction = new ItemInteractionController( var itemInteraction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(
new InventoryTransactionState(objects)),
new AcDream.Runtime.Gameplay.InteractionState(), new AcDream.Runtime.Gameplay.InteractionState(),
() => SampleData.PlayerGuid, () => SampleData.PlayerGuid,
sendUse: null, sendUse: null,

View file

@ -27,7 +27,6 @@ public readonly record struct PendingBackpackPlacement(
/// </summary> /// </summary>
public sealed class ItemInteractionController : IDisposable public sealed class ItemInteractionController : IDisposable
{ {
private const long RetailUseThrottleMs = 200;
internal const string InventoryRequestBusyMessage = internal const string InventoryRequestBusyMessage =
"You can only move or use one item at a time"; "You can only move or use one item at a time";
private const long RetailDoubleClickMs = 500; private const long RetailDoubleClickMs = 500;
@ -62,19 +61,17 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<string>? _systemMessage; private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield; private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse; private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
private readonly InventoryTransactionState _transactions; private readonly InventoryTransactionState _transactions;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget; private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2; private long _consumedPrimaryClickMs = long.MinValue / 2;
private uint _awaitingAppraisalId;
private uint _currentAppraisalId;
private PendingBackpackPlacement? _pendingBackpackPlacement; private PendingBackpackPlacement? _pendingBackpackPlacement;
private bool _disposed; private bool _disposed;
public ItemInteractionController( public ItemInteractionController(
ClientObjectTable objects, ClientObjectTable objects,
InventoryTransactionState transactions, RuntimeInteractionTransactionState runtimeTransactions,
InteractionState interactionState, InteractionState interactionState,
Func<uint> playerGuid, Func<uint> playerGuid,
Action<uint>? sendUse, Action<uint>? sendUse,
@ -136,13 +133,14 @@ public sealed class ItemInteractionController : IDisposable
_requestUse = requestUse; _requestUse = requestUse;
_interactionState = interactionState _interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState)); ?? throw new ArgumentNullException(nameof(interactionState));
_transactions = transactions _runtimeTransactions = runtimeTransactions
?? throw new ArgumentNullException(nameof(transactions)); ?? throw new ArgumentNullException(nameof(runtimeTransactions));
_transactions = _runtimeTransactions.Inventory;
if (!ReferenceEquals(_transactions.Objects, _objects)) if (!ReferenceEquals(_transactions.Objects, _objects))
{ {
throw new ArgumentException( throw new ArgumentException(
"The inventory transaction owner must borrow the controller's exact object table.", "The inventory transaction owner must borrow the controller's exact object table.",
nameof(transactions)); nameof(runtimeTransactions));
} }
_autoWield = new AutoWieldController( _autoWield = new AutoWieldController(
_objects, _objects,
@ -184,9 +182,12 @@ public sealed class ItemInteractionController : IDisposable
public uint PlayerGuid => _playerGuid(); public uint PlayerGuid => _playerGuid();
public InteractionState InteractionState => _interactionState; public InteractionState InteractionState => _interactionState;
public RuntimeInteractionTransactionState RuntimeTransactions =>
_runtimeTransactions;
public int BusyCount => _transactions.BusyCount; public int BusyCount => _transactions.BusyCount;
public uint CurrentAppraisalId => _currentAppraisalId; public uint CurrentAppraisalId =>
_runtimeTransactions.CurrentAppraisalId;
public bool CanMakeInventoryRequest => public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest; BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest;
@ -277,7 +278,7 @@ public sealed class ItemInteractionController : IDisposable
/// Item__UseDone too; AttackDone belongs only to the combat attack owner. /// Item__UseDone too; AttackDone belongs only to the combat attack owner.
/// </summary> /// </summary>
public void IncrementBusyCount() public void IncrementBusyCount()
=> _transactions.IncrementBusyCount(); => _runtimeTransactions.IncrementBusyCount();
/// <summary> /// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is /// 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) if (objectId == 0 || _sendExamine is null)
return; return;
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
if (_awaitingAppraisalId == 0)
IncrementBusyCount();
_awaitingAppraisalId = objectId;
_sendExamine(objectId);
} }
/// <summary> /// <summary>
@ -416,25 +413,14 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId) public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
{ {
if (objectId == 0 bool ownedBusyReference = _transactions.BusyCount > 0;
|| (objectId != _awaitingAppraisalId RuntimeAppraisalResponseAcceptance acceptance =
&& objectId != _currentAppraisalId)) _runtimeTransactions.AcceptAppraisalResponse(objectId);
return default; if (acceptance.FirstResponse && !ownedBusyReference)
StateChanged?.Invoke();
bool firstResponse = objectId == _awaitingAppraisalId;
if (firstResponse)
{
_awaitingAppraisalId = 0;
_currentAppraisalId = objectId;
bool ownedBusyReference = _transactions.BusyCount > 0;
_transactions.CompleteUse(0u);
if (!ownedBusyReference)
StateChanged?.Invoke();
}
return new AppraisalResponseAcceptance( return new AppraisalResponseAcceptance(
Accepted: true, acceptance.Accepted,
FirstResponse: firstResponse); acceptance.FirstResponse);
} }
/// <summary> /// <summary>
@ -443,10 +429,9 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public bool RefreshCurrentAppraisal() public bool RefreshCurrentAppraisal()
{ {
if (_currentAppraisalId == 0 || _sendExamine is null) if (_sendExamine is null)
return false; return false;
_sendExamine(_currentAppraisalId); return _runtimeTransactions.RefreshCurrentAppraisal(_sendExamine);
return true;
} }
/// <summary> /// <summary>
@ -460,24 +445,14 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public void CancelObjectAppraisalForSpell() public void CancelObjectAppraisalForSpell()
{ {
if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u) if (_sendExamine is null)
return; return;
bool ownedBusyReference = _transactions.BusyCount > 0;
if (_awaitingAppraisalId != 0u) 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(); StateChanged?.Invoke();
} }
_sendExamine?.Invoke(0u);
} }
/// <summary> /// <summary>
@ -791,8 +766,11 @@ public sealed class ItemInteractionController : IDisposable
if (!EnsureInventoryRequestReady()) if (!EnsureInventoryRequestReady())
return false; return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid); _runtimeTransactions.TryDispatchTargetedUse(
_transactions.IncrementBusyCount(); sourceGuid,
targetGuid,
_sendUseWithTarget,
incrementBusy: true);
return true; return true;
} }
@ -827,7 +805,7 @@ public sealed class ItemInteractionController : IDisposable
else else
{ {
_sendUse!(objectId); _sendUse!(objectId);
_transactions.IncrementBusyCount(); _runtimeTransactions.IncrementBusyCount();
} }
return true; return true;
} }
@ -926,8 +904,11 @@ public sealed class ItemInteractionController : IDisposable
} }
break; break;
case ItemPolicyActionKind.SendUseWithTarget: case ItemPolicyActionKind.SendUseWithTarget:
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId); acted |= _runtimeTransactions.TryDispatchTargetedUse(
acted |= _sendUseWithTarget is not null; action.ObjectId,
action.TargetId,
_sendUseWithTarget,
incrementBusy: false);
break; break;
case ItemPolicyActionKind.SetGroundObject: case ItemPolicyActionKind.SetGroundObject:
_requestExternalContainer?.Invoke(action.ObjectId); _requestExternalContainer?.Invoke(action.ObjectId);
@ -944,7 +925,7 @@ public sealed class ItemInteractionController : IDisposable
} }
else else
{ {
_transactions.IncrementBusyCount(); _runtimeTransactions.IncrementBusyCount();
} }
break; break;
case ItemPolicyActionKind.Reject: case ItemPolicyActionKind.Reject:
@ -965,7 +946,7 @@ public sealed class ItemInteractionController : IDisposable
} }
private ItemUseRequestReservation BeginUseRequestReservation() private ItemUseRequestReservation BeginUseRequestReservation()
=> _transactions.BeginUseRequestReservation(); => _runtimeTransactions.BeginUseRequestReservation();
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions) private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
{ {
@ -1160,8 +1141,8 @@ public sealed class ItemInteractionController : IDisposable
} }
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary> /// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
public void CompleteUse(uint _) public void CompleteUse(uint error)
=> _transactions.CompleteUse(0u); => _runtimeTransactions.CompleteUse(error);
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary> /// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
public void ClearBusy() public void ClearBusy()
@ -1176,22 +1157,18 @@ public sealed class ItemInteractionController : IDisposable
{ {
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement; PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null; _pendingBackpackPlacement = null;
// The canonical owner publishes its reset to Runtime observers, while // Runtime owns the transaction reset. This controller preserves the
// this controller preserves the pre-J4 single UI notification emitted // pre-J4 single UI notification emitted by InteractionState below.
// by InteractionState.ResetSession below.
_transactions.StateChanged -= OnTransactionStateChanged; _transactions.StateChanged -= OnTransactionStateChanged;
try try
{ {
_transactions.ResetSession(); _runtimeTransactions.ResetSession();
} }
finally finally
{ {
if (!_disposed) if (!_disposed)
_transactions.StateChanged += OnTransactionStateChanged; _transactions.StateChanged += OnTransactionStateChanged;
} }
_awaitingAppraisalId = 0;
_currentAppraisalId = 0;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u; _consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2; _consumedPrimaryClickMs = long.MinValue / 2;
@ -1238,13 +1215,7 @@ public sealed class ItemInteractionController : IDisposable
} }
private bool ConsumeUseThrottle() private bool ConsumeUseThrottle()
{ => _runtimeTransactions.TryConsumeUseThrottle(_nowMs());
long now = _nowMs();
if (now - _lastUseMs < RetailUseThrottleMs)
return false;
_lastUseMs = now;
return true;
}
private static bool IsContainer(ClientObject item) private static bool IsContainer(ClientObject item)
=> item.ContainerTypeHint != 0 => item.ContainerTypeHint != 0

View file

@ -13,7 +13,8 @@ public readonly record struct RuntimeActionSnapshot(
int TrackedTargetHealthCount, int TrackedTargetHealthCount,
long InteractionRevision, long InteractionRevision,
InteractionModeKind InteractionMode, InteractionModeKind InteractionMode,
uint InteractionSourceObjectId); uint InteractionSourceObjectId,
RuntimeInteractionTransactionSnapshot InteractionTransactions);
public interface IRuntimeActionView public interface IRuntimeActionView
{ {

View file

@ -169,7 +169,14 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.TrackedTargetHealthCount}:" + $"{checkpoint.Actions.TrackedTargetHealthCount}:" +
$"{checkpoint.Actions.InteractionRevision}:" + $"{checkpoint.Actions.InteractionRevision}:" +
$"{(int)checkpoint.Actions.InteractionMode}:" + $"{(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}:" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
} }

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
namespace AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Gameplay;
@ -6,12 +7,14 @@ namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeActionOwnershipSnapshot( public readonly record struct RuntimeActionOwnershipSnapshot(
bool IsDisposed, bool IsDisposed,
bool InternalSubscriptionsAttached, bool InternalSubscriptionsAttached,
bool InteractionTransactionsDisposed,
uint SelectedObjectId, uint SelectedObjectId,
uint PreviousObjectId, uint PreviousObjectId,
uint PreviousValidObjectId, uint PreviousValidObjectId,
CombatMode CombatMode, CombatMode CombatMode,
int TrackedTargetHealthCount, int TrackedTargetHealthCount,
InteractionMode InteractionMode, InteractionMode InteractionMode,
RuntimeInteractionTransactionSnapshot InteractionTransactions,
long SelectionRevision, long SelectionRevision,
long CombatRevision, long CombatRevision,
long InteractionRevision) long InteractionRevision)
@ -19,12 +22,14 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
public bool IsConverged => public bool IsConverged =>
IsDisposed IsDisposed
&& !InternalSubscriptionsAttached && !InternalSubscriptionsAttached
&& InteractionTransactionsDisposed
&& SelectedObjectId == 0u && SelectedObjectId == 0u
&& PreviousObjectId == 0u && PreviousObjectId == 0u
&& PreviousValidObjectId == 0u && PreviousValidObjectId == 0u
&& CombatMode == CombatMode.NonCombat && CombatMode == CombatMode.NonCombat
&& TrackedTargetHealthCount == 0 && TrackedTargetHealthCount == 0
&& InteractionMode == InteractionMode.None; && InteractionMode == InteractionMode.None
&& InteractionTransactions.IsConverged;
} }
/// <summary> /// <summary>
@ -40,11 +45,14 @@ public sealed class RuntimeActionState : IDisposable
private long _combatRevision; private long _combatRevision;
private long _interactionRevision; private long _interactionRevision;
public RuntimeActionState() public RuntimeActionState(InventoryTransactionState inventoryTransactions)
{ {
ArgumentNullException.ThrowIfNull(inventoryTransactions);
Selection = new SelectionState(); Selection = new SelectionState();
Combat = new CombatState(); Combat = new CombatState();
Interaction = new InteractionState(); Interaction = new InteractionState();
Transactions = new RuntimeInteractionTransactionState(
inventoryTransactions);
View = new ActionView(this); View = new ActionView(this);
Selection.Changed += OnSelectionChanged; Selection.Changed += OnSelectionChanged;
@ -57,18 +65,21 @@ public sealed class RuntimeActionState : IDisposable
public SelectionState Selection { get; } public SelectionState Selection { get; }
public CombatState Combat { get; } public CombatState Combat { get; }
public InteractionState Interaction { get; } public InteractionState Interaction { get; }
public RuntimeInteractionTransactionState Transactions { get; }
public IRuntimeActionView View { get; } public IRuntimeActionView View { get; }
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
public RuntimeActionOwnershipSnapshot CaptureOwnership() => new( public RuntimeActionOwnershipSnapshot CaptureOwnership() => new(
_disposed, _disposed,
_internalSubscriptionsAttached, _internalSubscriptionsAttached,
Transactions.IsDisposed,
Selection.SelectedObjectId ?? 0u, Selection.SelectedObjectId ?? 0u,
Selection.PreviousObjectId ?? 0u, Selection.PreviousObjectId ?? 0u,
Selection.PreviousValidObjectId ?? 0u, Selection.PreviousValidObjectId ?? 0u,
Combat.CurrentMode, Combat.CurrentMode,
Combat.TrackedTargetCount, Combat.TrackedTargetCount,
Interaction.Current, Interaction.Current,
Transactions.CaptureOwnership(),
Interlocked.Read(ref _selectionRevision), Interlocked.Read(ref _selectionRevision),
Interlocked.Read(ref _combatRevision), Interlocked.Read(ref _combatRevision),
Interlocked.Read(ref _interactionRevision)); Interlocked.Read(ref _interactionRevision));
@ -82,6 +93,7 @@ public sealed class RuntimeActionState : IDisposable
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? failures = null; List<Exception>? failures = null;
Try(Transactions.ResetSession, ref failures);
Try(Interaction.ResetSession, ref failures); Try(Interaction.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures); Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures); Try(Combat.Clear, ref failures);
@ -101,6 +113,7 @@ public sealed class RuntimeActionState : IDisposable
List<Exception>? failures = null; List<Exception>? failures = null;
try try
{ {
Try(Transactions.Dispose, ref failures);
Try(Interaction.ResetSession, ref failures); Try(Interaction.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures); Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures); Try(Combat.Clear, ref failures);
@ -160,7 +173,8 @@ public sealed class RuntimeActionState : IDisposable
owner.Combat.TrackedTargetCount, owner.Combat.TrackedTargetCount,
Interlocked.Read(ref owner._interactionRevision), Interlocked.Read(ref owner._interactionRevision),
owner.Interaction.Current.Kind, owner.Interaction.Current.Kind,
owner.Interaction.Current.SourceObjectId); owner.Interaction.Current.SourceObjectId,
owner.Transactions.CaptureOwnership());
public bool TryGetHealth(uint objectId, out float healthPercent) public bool TryGetHealth(uint objectId, out float healthPercent)
{ {

View file

@ -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;
}
/// <summary>
/// Canonical presentation-independent owner for retail interaction
/// transactions. The shared inventory gate remains owned by
/// <see cref="InventoryTransactionState"/> and is borrowed exactly.
/// </summary>
/// <remarks>
/// Ordinary Use follows <c>ItemHolder::UseObject @ 0x00588A80</c>: consume the
/// strict 0.2-second gate, send immediately, then transfer the busy reference
/// to <c>ClientUISystem::Handle_Item__UseDone @ 0x00564900</c>. Pickup keeps
/// the existing local approach transaction and exact post-arrival token.
/// </remarks>
public sealed class RuntimeInteractionTransactionState : IDisposable
{
public const long RetailUseThrottleMs = 200;
private readonly InventoryTransactionState _inventory;
private readonly Queue<RuntimeQueuedInteraction> _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<uint, uint>? 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<uint> 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<uint> sendAppraisal)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(sendAppraisal);
if (_currentAppraisalId == 0u)
return false;
sendAppraisal(_currentAppraisalId);
return true;
}
public bool CancelObjectAppraisalForSpell(Action<uint> 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;
}
/// <summary>
/// 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.
/// </summary>
public void DrainOutbound(Action<RuntimeQueuedInteraction> 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);
}

View file

@ -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<string>();
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<string>();
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<string>();
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<string>();
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);
}
}

View file

@ -8,6 +8,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Interaction; 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; private uint _sequence;
public bool IsInWorld { get; set; } = true; public bool IsInWorld { get; set; } = true;
@ -160,7 +161,7 @@ public sealed class SelectionInteractionControllerTests
}); });
Items = new ItemInteractionController( Items = new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(), new InteractionState(),
() => Player, () => Player,
sendUse: null, sendUse: null,
@ -464,6 +465,21 @@ public sealed class SelectionInteractionControllerTests
Assert.Equal(new[] { Target }, stale.Transport.Uses); 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] [Fact]
public void PickupInputWaitsForFrameDrainThenUsesPendingDestination() public void PickupInputWaitsForFrameDrainThenUsesPendingDestination()
{ {

View file

@ -681,7 +681,7 @@ public sealed class CurrentGameRuntimeAdapterTests
EntityObjects); EntityObjects);
Objects = EntityObjects.Objects; Objects = EntityObjects.Objects;
Communication = new RuntimeCommunicationState(); Communication = new RuntimeCommunicationState();
Actions = new RuntimeActionState(); Actions = new RuntimeActionState(InventoryState.Transactions);
MovementInput = new DispatcherMovementInputSource(); MovementInput = new DispatcherMovementInputSource();
GameplayInput = new GameplayInputFrameController( GameplayInput = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
@ -701,7 +701,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_items = new ItemInteractionController( _items = new ItemInteractionController(
Objects, Objects,
InventoryState.Transactions, Actions.Transactions,
Actions.Interaction, Actions.Interaction,
() => PlayerGuid, () => PlayerGuid,
sendUse: null, sendUse: null,
@ -766,11 +766,11 @@ public sealed class CurrentGameRuntimeAdapterTests
{ {
Runtime.Dispose(); Runtime.Dispose();
Character.Dispose(); Character.Dispose();
InventoryState.Dispose();
Communication.Dispose(); Communication.Dispose();
_session.Dispose(); _session.Dispose();
_items.Dispose(); _items.Dispose();
Actions.Dispose(); Actions.Dispose();
InventoryState.Dispose();
Entities.Clear(); Entities.Clear();
} }
} }
@ -1149,7 +1149,7 @@ public sealed class CurrentGameRuntimeAdapterTests
} }
private sealed class SelectionTransport(Func<bool> isInWorld) private sealed class SelectionTransport(Func<bool> isInWorld)
: ISelectionInteractionTransport : IRuntimeInteractionTransport
{ {
public bool IsInWorld => isInWorld(); public bool IsInWorld => isInWorld();

View file

@ -15,7 +15,11 @@ public sealed class RuntimeActionOwnershipTests
string program = ReadAppSource(root, "Program.cs"); string program = ReadAppSource(root, "Program.cs");
Assert.Contains( Assert.Contains(
"private readonly RuntimeActionState _runtimeActions = new();", "private readonly RuntimeActionState _runtimeActions;",
gameWindow,
StringComparison.Ordinal);
Assert.Contains(
"_runtimeActions = new RuntimeActionState(_runtimeInventory.Transactions);",
gameWindow, gameWindow,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
@ -54,6 +58,9 @@ public sealed class RuntimeActionOwnershipTests
Assert.Empty(Regex.Matches( Assert.Empty(Regex.Matches(
production, production,
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?InteractionState\s*\(")); @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?InteractionState\s*\("));
Assert.Empty(Regex.Matches(
production,
@"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?RuntimeInteractionTransactionState\s*\("));
Assert.Equal( Assert.Equal(
1, 1,
Regex.Matches( Regex.Matches(
@ -94,14 +101,19 @@ public sealed class RuntimeActionOwnershipTests
"GameWindowLifetime.cs"); "GameWindowLifetime.cs");
Assert.Contains("d.Actions.Interaction,", ui, StringComparison.Ordinal); 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.Selection", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal); Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal);
Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal); Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal);
Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal); Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal);
Assert.Contains("_domain.Actions.Combat", liveSession, 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("_actions.Selection", commands, StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"InteractionState interactionState,", "RuntimeInteractionTransactionState runtimeTransactions,",
itemInteraction, itemInteraction,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain( Assert.DoesNotContain(
@ -114,8 +126,8 @@ public sealed class RuntimeActionOwnershipTests
StringComparison.Ordinal); StringComparison.Ordinal);
AssertAppearsInOrder( AssertAppearsInOrder(
shutdown, shutdown,
"\"runtime inventory state\"",
"\"runtime action state\"", "\"runtime action state\"",
"\"runtime inventory state\"",
"\"runtime entity/object lifetime\""); "\"runtime entity/object lifetime\"");
Assert.False(File.Exists(Path.Combine( Assert.False(File.Exists(Path.Combine(
root, root,

View file

@ -46,7 +46,7 @@ public sealed class RuntimeInventoryOwnershipTests
"ItemInteractionController.cs"); "ItemInteractionController.cs");
Assert.Contains( Assert.Contains(
"d.Inventory.Transactions", "d.Actions.Transactions",
ui, ui,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain( Assert.DoesNotContain(
@ -62,7 +62,7 @@ public sealed class RuntimeInventoryOwnershipTests
itemInteraction, itemInteraction,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"InventoryTransactionState transactions,", "RuntimeInteractionTransactionState runtimeTransactions,",
itemInteraction, itemInteraction,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
@ -70,7 +70,7 @@ public sealed class RuntimeInventoryOwnershipTests
session, session,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"_domain.Inventory.Transactions.CompleteUse(error)", "_domain.Actions.Transactions.CompleteUse(error)",
session, session,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain( Assert.DoesNotContain(
@ -79,6 +79,7 @@ public sealed class RuntimeInventoryOwnershipTests
StringComparison.Ordinal); StringComparison.Ordinal);
AssertAppearsInOrder( AssertAppearsInOrder(
shutdown, shutdown,
"\"runtime action state\"",
"\"runtime inventory state\"", "\"runtime inventory state\"",
"\"runtime entity/object lifetime\""); "\"runtime entity/object lifetime\"");
} }

View file

@ -153,7 +153,7 @@ public sealed class CursorFeedbackControllerTests
var objects = SeedTargetObjects(); var objects = SeedTargetObjects();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -190,7 +190,7 @@ public sealed class CursorFeedbackControllerTests
objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -230,7 +230,7 @@ public sealed class CursorFeedbackControllerTests
var objects = SeedTargetObjects(); var objects = SeedTargetObjects();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -260,7 +260,7 @@ public sealed class CursorFeedbackControllerTests
var objects = SeedTargetObjects(); var objects = SeedTargetObjects();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,

View file

@ -1,6 +1,7 @@
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI; namespace AcDream.App.Tests.UI;
@ -32,6 +33,7 @@ public sealed class ItemInteractionControllerTests
public readonly CombatState Combat = new(); public readonly CombatState Combat = new();
public readonly StackSplitQuantityState SplitQuantity = new(); public readonly StackSplitQuantityState SplitQuantity = new();
public readonly InventoryTransactionState SharedTransactions; public readonly InventoryTransactionState SharedTransactions;
public readonly RuntimeInteractionTransactionState RuntimeTransactions;
public uint SelectedObject; public uint SelectedObject;
public bool NonCombatMode; public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true; public bool DragOnPlayerOpensSecureTrade = true;
@ -56,10 +58,12 @@ public sealed class ItemInteractionControllerTests
}); });
Objects.MoveItem(Pack, Player, 0); Objects.MoveItem(Pack, Player, 0);
SharedTransactions = new InventoryTransactionState(Objects); SharedTransactions = new InventoryTransactionState(Objects);
RuntimeTransactions = new RuntimeInteractionTransactionState(
SharedTransactions);
Controller = new ItemInteractionController( Controller = new ItemInteractionController(
Objects, Objects,
SharedTransactions, RuntimeTransactions,
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: requestUse is null ? Uses.Add : null, sendUse: requestUse is null ? Uses.Add : null,
@ -338,10 +342,12 @@ public sealed class ItemInteractionControllerTests
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
using var transactions = using var transactions =
new InventoryTransactionState(new ClientObjectTable()); new InventoryTransactionState(new ClientObjectTable());
using var runtimeTransactions =
new RuntimeInteractionTransactionState(transactions);
Assert.Throws<ArgumentException>(() => new ItemInteractionController( Assert.Throws<ArgumentException>(() => new ItemInteractionController(
objects, objects,
transactions, runtimeTransactions,
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,

View file

@ -858,7 +858,7 @@ public sealed class AppraisalUiControllerTests
List<uint> sent) List<uint> sent)
=> new( => new(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => 0x50000002u, playerGuid: () => 0x50000002u,
sendUse: null, sendUse: null,

View file

@ -106,7 +106,7 @@ public sealed class ExternalContainerControllerTests
Interaction = new ItemInteractionController( Interaction = new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: Uses.Add, sendUse: Uses.Add,

View file

@ -508,7 +508,7 @@ public class InventoryControllerTests
var appraisals = new List<uint>(); var appraisals = new List<uint>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -546,7 +546,7 @@ public class InventoryControllerTests
objects.Get(0xA)!.Useability = 0x000A0008u; objects.Get(0xA)!.Useability = 0x000A0008u;
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -716,7 +716,7 @@ public class InventoryControllerTests
var pickups = new List<(uint item, uint container, int placement)>(); var pickups = new List<(uint item, uint container, int placement)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -762,7 +762,7 @@ public class InventoryControllerTests
var eventOrder = new List<(string Kind, uint Item, ulong Token)>(); var eventOrder = new List<(string Kind, uint Item, ulong Token)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -815,7 +815,7 @@ public class InventoryControllerTests
var eventOrder = new List<(string Kind, uint Item)>(); var eventOrder = new List<(string Kind, uint Item)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -874,7 +874,7 @@ public class InventoryControllerTests
var messages = new List<string>(); var messages = new List<string>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -944,7 +944,7 @@ public class InventoryControllerTests
var messages = new List<string>(); var messages = new List<string>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -1008,7 +1008,7 @@ public class InventoryControllerTests
var merges = new List<(uint Source, uint Target, uint Amount)>(); var merges = new List<(uint Source, uint Target, uint Amount)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -1069,7 +1069,7 @@ public class InventoryControllerTests
var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>(); var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -1117,7 +1117,7 @@ public class InventoryControllerTests
var puts = new List<(uint Item, uint Container, int Placement)>(); var puts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -1158,7 +1158,7 @@ public class InventoryControllerTests
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
@ -1198,7 +1198,7 @@ public class InventoryControllerTests
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,

View file

@ -52,7 +52,7 @@ public class PaperdollControllerTests
{ {
var itemInteraction = new ItemInteractionController( var itemInteraction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
() => Player, () => Player,
sendUse: null, sendUse: null,

View file

@ -265,7 +265,7 @@ public class ToolbarControllerTests
var useWithTarget = new List<(uint Source, uint Target)>(); var useWithTarget = new List<(uint Source, uint Target)>();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
@ -332,7 +332,7 @@ public class ToolbarControllerTests
var directPuts = new List<(uint Item, uint Container, int Placement)>(); var directPuts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
@ -382,7 +382,7 @@ public class ToolbarControllerTests
var messages = new List<string>(); var messages = new List<string>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
@ -512,7 +512,7 @@ public class ToolbarControllerTests
uint selected = item; uint selected = item;
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
() => player, () => player,
sendUse: uses.Add, sendUse: uses.Add,
@ -571,7 +571,7 @@ public class ToolbarControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
@ -637,7 +637,7 @@ public class ToolbarControllerTests
var wields = new List<(uint Item, uint Location)>(); var wields = new List<(uint Item, uint Location)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
@ -804,7 +804,7 @@ public class ToolbarControllerTests
var appraisals = new List<uint>(); var appraisals = new List<uint>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
@ -854,7 +854,7 @@ public class ToolbarControllerTests
uint sentTarget = 0; uint sentTarget = 0;
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
@ -897,7 +897,7 @@ public class ToolbarControllerTests
repo.MoveItem(item, pack, 0); repo.MoveItem(item, pack, 0);
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(repo)),
new InteractionState(), new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,

View file

@ -18,7 +18,7 @@ public sealed class RetailItemConfirmationControllerTests
var uses = new List<uint>(); var uses = new List<uint>();
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
@ -54,7 +54,7 @@ public sealed class RetailItemConfirmationControllerTests
var uses = new List<uint>(); var uses = new List<uint>();
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
@ -84,7 +84,7 @@ public sealed class RetailItemConfirmationControllerTests
var messages = new List<string>(); var messages = new List<string>();
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,

View file

@ -163,7 +163,7 @@ public sealed class RetailUiInteractionFlowTests
{ {
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(), new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: Uses.Add, sendUse: Uses.Add,
@ -199,7 +199,7 @@ public sealed class RetailUiInteractionFlowTests
{ {
itemInteraction ??= new ItemInteractionController( itemInteraction ??= new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects), new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(), new InteractionState(),
() => Player, () => Player,
sendUse: null, sendUse: null,

View file

@ -179,7 +179,8 @@ public sealed class GameRuntimeContractTests
TrackedTargetHealthCount: 1, TrackedTargetHealthCount: 1,
InteractionRevision: 16, InteractionRevision: 16,
InteractionMode: InteractionModeKind.Use, InteractionMode: InteractionModeKind.Use,
InteractionSourceObjectId: 0u), InteractionSourceObjectId: 0u,
InteractionTransactions: default),
default, default,
default); default);
@ -190,7 +191,8 @@ public sealed class GameRuntimeContractTests
Assert.Contains("character=5:6:7:8:0:0", entry.Text); Assert.Contains("character=5:6:7:8:0:0", entry.Text);
Assert.Contains("social=9:10:11", entry.Text); Assert.Contains("social=9:10:11", entry.Text);
Assert.Contains( 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); entry.Text);
} }

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -9,7 +10,8 @@ public sealed class RuntimeActionStateTests
[Fact] [Fact]
public void ViewProjectsTheExactCanonicalChildrenAndRevisions() public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
{ {
using var actions = new RuntimeActionState(); using var inventory = NewInventoryTransactions();
using var actions = new RuntimeActionState(inventory);
actions.Selection.Select( actions.Selection.Select(
0x50000001u, 0x50000001u,
@ -38,7 +40,8 @@ public sealed class RuntimeActionStateTests
[Fact] [Fact]
public void ResetSessionConvergesEveryChildAfterObserverFailures() public void ResetSessionConvergesEveryChildAfterObserverFailures()
{ {
var actions = new RuntimeActionState(); using var inventory = NewInventoryTransactions();
var actions = new RuntimeActionState(inventory);
actions.Selection.Select( actions.Selection.Select(
0x50000001u, 0x50000001u,
SelectionChangeSource.World); SelectionChangeSource.World);
@ -69,7 +72,8 @@ public sealed class RuntimeActionStateTests
[Fact] [Fact]
public void DisposeIsTerminalAndConvergedAfterObserverFailures() public void DisposeIsTerminalAndConvergedAfterObserverFailures()
{ {
var actions = new RuntimeActionState(); using var inventory = NewInventoryTransactions();
var actions = new RuntimeActionState(inventory);
actions.Selection.Select( actions.Selection.Select(
0x50000001u, 0x50000001u,
SelectionChangeSource.World); SelectionChangeSource.World);
@ -94,8 +98,10 @@ public sealed class RuntimeActionStateTests
[Fact] [Fact]
public void InstancesAreFullyIsolated() public void InstancesAreFullyIsolated()
{ {
using var first = new RuntimeActionState(); using var firstInventory = NewInventoryTransactions();
using var second = new RuntimeActionState(); using var secondInventory = NewInventoryTransactions();
using var first = new RuntimeActionState(firstInventory);
using var second = new RuntimeActionState(secondInventory);
first.Selection.Select( first.Selection.Select(
0x50000001u, 0x50000001u,
@ -114,7 +120,21 @@ public sealed class RuntimeActionStateTests
0, 0,
0, 0,
InteractionModeKind.None, 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); second.View.Snapshot);
} }
private static InventoryTransactionState NewInventoryTransactions() =>
new(new ClientObjectTable());
} }

View file

@ -15,7 +15,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = new RuntimeActionState(); var actions = new RuntimeActionState(inventory.Transactions);
inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
@ -114,7 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = new RuntimeActionState(); var actions = new RuntimeActionState(inventory.Transactions);
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@ -132,10 +132,10 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Chat.OnSystemMessage("failure-isolated event", 0u); communication.Chat.OnSystemMessage("failure-isolated event", 0u);
Assert.Equal(1, communication.DispatchFailureCount); Assert.Equal(1, communication.DispatchFailureCount);
actions.Dispose();
Assert.Throws<AggregateException>(inventory.Dispose); Assert.Throws<AggregateException>(inventory.Dispose);
Assert.Throws<AggregateException>(character.Dispose); Assert.Throws<AggregateException>(character.Dispose);
communication.Dispose(); communication.Dispose();
actions.Dispose();
RuntimeGameplayOwnershipSnapshot retired = RuntimeGameplayOwnershipSnapshot retired =
RuntimeGameplayOwnership.Capture( RuntimeGameplayOwnership.Capture(

View file

@ -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<uint>();
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<InvalidOperationException>(() =>
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<RuntimeQueuedInteractionKind>();
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<RuntimeQueuedInteractionKind>();
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<InvalidOperationException>(() =>
state.DrainOutbound(static _ =>
throw new InvalidOperationException("observer")));
Assert.Equal(1, state.DispatchFailureCount);
Assert.Equal(1, state.OutboundCount);
var seen = new List<RuntimeQueuedInteractionKind>();
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<uint> 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;
}
}
}