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

View file

@ -1,22 +1,12 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Interaction;
internal interface ISelectionInteractionTransport
{
bool IsInWorld { get; }
bool TrySendUse(uint serverGuid, out uint sequence);
bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence);
}
internal sealed class WorldSessionSelectionInteractionTransport(
Func<WorldSession?> session)
: ISelectionInteractionTransport
: IRuntimeInteractionTransport
{
private readonly Func<WorldSession?> _session = session
?? throw new ArgumentNullException(nameof(session));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -169,7 +169,14 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.TrackedTargetHealthCount}:" +
$"{checkpoint.Actions.InteractionRevision}:" +
$"{(int)checkpoint.Actions.InteractionMode}:" +
$"{checkpoint.Actions.InteractionSourceObjectId:X8};" +
$"{checkpoint.Actions.InteractionSourceObjectId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.Revision}:" +
$"{checkpoint.Actions.InteractionTransactions.LastUseSourceId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.LastUseTargetId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" +
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

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

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.Selection;
using AcDream.Core.World;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Interaction;
@ -69,7 +70,7 @@ public sealed class SelectionInteractionControllerTests
}
}
private sealed class Transport : ISelectionInteractionTransport
private sealed class Transport : IRuntimeInteractionTransport
{
private uint _sequence;
public bool IsInWorld { get; set; } = true;
@ -160,7 +161,7 @@ public sealed class SelectionInteractionControllerTests
});
Items = new ItemInteractionController(
Objects,
new InventoryTransactionState(Objects),
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(),
() => Player,
sendUse: null,
@ -464,6 +465,21 @@ public sealed class SelectionInteractionControllerTests
Assert.Equal(new[] { Target }, stale.Transport.Uses);
}
[Fact]
public void HiddenTargetCancelsQueuedUseBeforeFrameDrain()
{
var h = new Harness();
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.OnEntityHidden(Target);
h.Controller.DrainOutbound();
Assert.Null(h.Selection.SelectedObjectId);
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Items.BusyCount);
}
[Fact]
public void PickupInputWaitsForFrameDrainThenUsesPendingDestination()
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -179,7 +179,8 @@ public sealed class GameRuntimeContractTests
TrackedTargetHealthCount: 1,
InteractionRevision: 16,
InteractionMode: InteractionModeKind.Use,
InteractionSourceObjectId: 0u),
InteractionSourceObjectId: 0u,
InteractionTransactions: default),
default,
default);
@ -190,7 +191,8 @@ public sealed class GameRuntimeContractTests
Assert.Contains("character=5:6:7:8:0:0", entry.Text);
Assert.Contains("social=9:10:11", entry.Text);
Assert.Contains(
"actions=14:50000001:15:4:1:16:1:00000000",
"actions=14:50000001:15:4:1:16:1:00000000:0:00000000:" +
"00000000:00000000:00000000:0:0",
entry.Text);
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
@ -9,7 +10,8 @@ public sealed class RuntimeActionStateTests
[Fact]
public void ViewProjectsTheExactCanonicalChildrenAndRevisions()
{
using var actions = new RuntimeActionState();
using var inventory = NewInventoryTransactions();
using var actions = new RuntimeActionState(inventory);
actions.Selection.Select(
0x50000001u,
@ -38,7 +40,8 @@ public sealed class RuntimeActionStateTests
[Fact]
public void ResetSessionConvergesEveryChildAfterObserverFailures()
{
var actions = new RuntimeActionState();
using var inventory = NewInventoryTransactions();
var actions = new RuntimeActionState(inventory);
actions.Selection.Select(
0x50000001u,
SelectionChangeSource.World);
@ -69,7 +72,8 @@ public sealed class RuntimeActionStateTests
[Fact]
public void DisposeIsTerminalAndConvergedAfterObserverFailures()
{
var actions = new RuntimeActionState();
using var inventory = NewInventoryTransactions();
var actions = new RuntimeActionState(inventory);
actions.Selection.Select(
0x50000001u,
SelectionChangeSource.World);
@ -94,8 +98,10 @@ public sealed class RuntimeActionStateTests
[Fact]
public void InstancesAreFullyIsolated()
{
using var first = new RuntimeActionState();
using var second = new RuntimeActionState();
using var firstInventory = NewInventoryTransactions();
using var secondInventory = NewInventoryTransactions();
using var first = new RuntimeActionState(firstInventory);
using var second = new RuntimeActionState(secondInventory);
first.Selection.Select(
0x50000001u,
@ -114,7 +120,21 @@ public sealed class RuntimeActionStateTests
0,
0,
InteractionModeKind.None,
0u),
0u,
new RuntimeInteractionTransactionSnapshot(
IsDisposed: false,
Revision: 0,
LastUseSourceId: 0u,
LastUseTargetId: 0u,
AwaitingAppraisalId: 0u,
CurrentAppraisalId: 0u,
OutboundCount: 0,
HasPendingPickup: false,
PendingPickupToken: 0u,
DispatchFailureCount: 0)),
second.View.Snapshot);
}
private static InventoryTransactionState NewInventoryTransactions() =>
new(new ClientObjectTable());
}

View file

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