using System;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI;
/// Result of offering a primary click to active item-target mode.
public enum ItemPrimaryClickResult
{
NotActive,
ConsumedSuccess,
ConsumedRejected,
}
public readonly record struct PendingBackpackPlacement(
ulong Token,
uint ItemId,
uint ContainerId,
int Placement,
ClientObject? ItemIdentity);
///
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating
/// ItemHolder::UseObject fragments in each panel.
///
public sealed class ItemInteractionController : IDisposable
{
internal const string InventoryRequestBusyMessage =
"You can only move or use one item at a time";
private const long RetailDoubleClickMs = 500;
private readonly ClientObjectTable _objects;
private readonly Func _playerGuid;
private readonly Func _nowMs;
private readonly Action? _sendUse;
private readonly Action? _sendExamine;
private readonly Action? _sendUseWithTarget;
private readonly Action? _sendWield;
private readonly Action? _sendDrop;
private readonly Action? _sendSplitToWorld;
private readonly Action? _sendPutItemInContainer;
private readonly Action? _sendSplitToContainer;
private readonly Action? _sendGive;
private readonly Action? _toast;
private readonly Func _readyForInventoryRequest;
private readonly Func _activeVendorId;
private readonly Func _groundObjectId;
private readonly Func _playerOnGround;
private readonly Func _inNonCombatMode;
private readonly Func _isComponentPack;
private readonly Action? _placeInBackpack;
private readonly Func _backpackContainerId;
private readonly Action? _requestExternalContainer;
private readonly Action? _auxiliaryAction;
private readonly InteractionState _interactionState;
private readonly Func _selectedObjectId;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private readonly Func _dragOnPlayerOpensSecureTrade;
private readonly Action? _systemMessage;
private readonly AutoWieldController _autoWield;
private readonly Action? _requestUse;
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
private readonly InventoryTransactionState _transactions;
private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private bool _disposed;
public ItemInteractionController(
ClientObjectTable objects,
RuntimeInteractionTransactionState runtimeTransactions,
InteractionState interactionState,
Func playerGuid,
Action? sendUse,
Action? sendUseWithTarget,
Action? sendWield,
Action? sendDrop,
Action? sendExamine = null,
Func? nowMs = null,
Action? toast = null,
Func? readyForInventoryRequest = null,
Func? activeVendorId = null,
Func? groundObjectId = null,
Func? playerOnGround = null,
Func? inNonCombatMode = null,
Func? isComponentPack = null,
Action? placeInBackpack = null,
Func? backpackContainerId = null,
Action? auxiliaryAction = null,
Action? sendSplitToWorld = null,
Func? selectedObjectId = null,
StackSplitQuantityState? stackSplitQuantity = null,
Action? sendPutItemInContainer = null,
Action? sendGive = null,
Func? dragOnPlayerOpensSecureTrade = null,
Action? systemMessage = null,
Action? sendSplitToContainer = null,
Action? requestExternalContainer = null,
CombatState? combatState = null,
Action? sendChangeCombatMode = null,
Action? requestUse = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendUse = sendUse;
_sendExamine = sendExamine;
_sendUseWithTarget = sendUseWithTarget;
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
_activeVendorId = activeVendorId ?? (() => 0u);
_groundObjectId = groundObjectId ?? (() => 0u);
_playerOnGround = playerOnGround ?? (() => true);
_inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_backpackContainerId = backpackContainerId ?? _playerGuid;
_requestExternalContainer = requestExternalContainer;
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_systemMessage = systemMessage;
_requestUse = requestUse;
_interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState));
_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(runtimeTransactions));
}
_autoWield = new AutoWieldController(
_objects,
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
_interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted;
_transactions.ObjectTableCleared += OnInventoryObjectsCleared;
}
public event Action? StateChanged;
///
/// Retail CM_Item::SendNotice_ShowPendingInPlayer: the inventory
/// panel inserts a waiting projection before the pickup request is sent.
/// The destination and placement are the same values sent on the wire.
///
public event Action? PendingBackpackPlacementRequested;
///
/// Withdraws a waiting pickup projection when the approach is rejected or
/// cancelled before a request reaches the server.
///
public event Action? PendingBackpackPlacementCancelled;
///
/// Resolves the exact waiting projection after an authoritative response.
/// The token prevents a late response for a recycled GUID from erasing a
/// newer incarnation's projection in the inventory panel.
///
public event Action? PendingBackpackPlacementResolved;
///
/// Publishes the exact split-to-world request after its wire send has
/// succeeded. Retail ACCWeenieObject::UIAttemptSplitTo3D @
/// 0x0058D850 retains the source class, selected amount, and request
/// time so the subsequently created ground stack can be recognized.
///
public event Action? WorldDropDispatched;
public uint PlayerGuid => _playerGuid();
public InteractionState InteractionState => _interactionState;
public RuntimeInteractionTransactionState RuntimeTransactions =>
_runtimeTransactions;
public int BusyCount => _transactions.BusyCount;
public uint CurrentAppraisalId =>
_runtimeTransactions.CurrentAppraisalId;
public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest;
private bool BaseCanMakeInventoryRequest =>
_readyForInventoryRequest()
&& _transactions.BusyCount == 0
&& !_autoWield.IsBusy;
///
/// Retail ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest.
/// The destination ItemList's separate m_pendingItem rejection is
/// reported by .
///
public bool EnsureInventoryRequestReady()
{
if (CanMakeInventoryRequest)
return true;
if (_transactions.HasPendingRequest
|| _transactions.BusyCount != 0
|| _autoWield.IsBusy)
_systemMessage?.Invoke(InventoryRequestBusyMessage);
return false;
}
///
/// Retail UIElement_ItemList::AcceptDragObject's local
/// m_pendingItem branch. This wording belongs only to the destination
/// list which already displays the waiting projection.
///
public void ReportPendingBackpackPlacementConflict()
{
if (_pendingBackpackPlacement is not { } pending)
return;
string? itemName = _objects.Get(pending.ItemId)?.Name;
string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName;
_systemMessage?.Invoke($"Already attempting to place {name} here");
}
///
/// Dispatches one retail inventory request. A provisional global
/// ACCWeenieObject::prevRequest owner closes callback reentrancy,
/// then becomes dispatched immediately after a successful wire send.
/// The callback returns false when it could not dispatch anything.
/// Retail: RecordRequest @ 0x0058C220, UIAttempt family
/// 0x0058D590..0x0058D850.
///
public bool TryDispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func dispatch,
ulong reservationToken = 0u)
{
ArgumentNullException.ThrowIfNull(dispatch);
if (itemId == 0u)
return false;
if (reservationToken != 0u)
{
try
{
return _transactions.TryDispatch(
kind,
itemId,
dispatch,
reservationToken);
}
catch
{
CancelPendingBackpackPlacement(itemId, reservationToken);
throw;
}
}
if (!EnsureInventoryRequestReady())
return false;
return _transactions.TryDispatch(kind, itemId, dispatch);
}
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
=> _transactions.TryGetPending(out pending);
///
/// Increments retail's shared ClientUISystem busy reference after a
/// request issued by another retained controller has been sent. The
/// corresponding UseDone releases it. Retail spell casts complete through
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
///
public void IncrementBusyCount()
=> _runtimeTransactions.IncrementBusyCount();
///
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
///
public event Action? PolicyActionRequested;
public uint PendingSourceItem
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
? mode.SourceObjectId
: 0u;
public bool IsTargetModeActive
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
public bool IsAnyTargetModeActive
=> _interactionState.Current.Kind != InteractionModeKind.None;
public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem;
///
/// Retail ACCWeenieObject::IsOwnedByPlayer projection shared with
/// toolbar shortcut creation.
///
public bool IsOwnedByPlayer(uint itemGuid)
=> _objects.IsOwnedByObject(itemGuid, _playerGuid());
public bool IsCurrentTargetCompatible(uint targetGuid)
{
if (!IsTargetModeActive || targetGuid == 0) return false;
var source = _objects.Get(PendingSourceItem);
return source?.Useability is not null
&& TargetCompatible(source, targetGuid);
}
///
/// The single target-mode interception point for world and retained item
/// clicks. A rejected target is still consumed and must never fall through
/// to selection, container opening, or ordinary item activation.
///
public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid)
{
InteractionModeKind mode = _interactionState.Current.Kind;
if (mode == InteractionModeKind.None)
{
long now = _nowMs();
if (targetGuid != 0
&& targetGuid == _consumedPrimaryClickTarget
&& now - _consumedPrimaryClickMs <= RetailDoubleClickMs)
return ItemPrimaryClickResult.ConsumedRejected;
_consumedPrimaryClickTarget = 0;
return ItemPrimaryClickResult.NotActive;
}
bool accepted;
switch (mode)
{
case InteractionModeKind.Use:
ClearTargetMode();
accepted = ActivateItem(targetGuid);
break;
case InteractionModeKind.Examine:
ClearTargetMode();
accepted = targetGuid != 0 && _sendExamine is not null;
if (accepted)
RequestAppraisal(targetGuid);
break;
case InteractionModeKind.UseItemOnTarget:
accepted = AcquireTarget(targetGuid);
break;
default:
throw new InvalidOperationException($"Unknown interaction mode {mode}.");
}
_consumedPrimaryClickTarget = targetGuid;
_consumedPrimaryClickMs = _nowMs();
return accepted
? ItemPrimaryClickResult.ConsumedSuccess
: ItemPrimaryClickResult.ConsumedRejected;
}
public ItemPrimaryClickResult OfferSelfPrimaryClick()
=> OfferPrimaryClick(_playerGuid());
///
/// Retail toolbar Use button: use the current selection immediately, or arm
/// one-shot TARGET_MODE_USE when no object is selected.
/// Retail reference: gmToolbarUI::ListenToElementMessage @ 0x004BEE90.
///
public bool UseSelectedOrEnterMode(uint selectedObjectId)
{
if (selectedObjectId != 0)
return ActivateItem(selectedObjectId);
return _interactionState.EnterUse();
}
///
/// Retail toolbar Examine button: appraise the current selection immediately,
/// or arm one-shot TARGET_MODE_EXAMINE when no object is selected.
/// Retail reference: gmToolbarUI::ListenToElementMessage @ 0x004BEE90.
///
public bool ExamineSelectedOrEnterMode(uint selectedObjectId)
{
if (selectedObjectId == 0)
return _interactionState.EnterExamine();
if (_sendExamine is null)
return false;
RequestAppraisal(selectedObjectId);
return true;
}
///
/// Retail gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0.
/// One shared UI-busy reference covers the currently pending appraisal,
/// even when another examine request replaces its GUID before a response.
///
private void RequestAppraisal(uint objectId)
{
if (objectId == 0 || _sendExamine is null)
return;
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
///
/// Accepts only the pending or current appraisal, matching
/// gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0.
///
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
{
bool ownedBusyReference = _transactions.BusyCount > 0;
RuntimeAppraisalResponseAcceptance acceptance =
_runtimeTransactions.AcceptAppraisalResponse(objectId);
if (acceptance.FirstResponse && !ownedBusyReference)
StateChanged?.Invoke();
return new AppraisalResponseAcceptance(
acceptance.Accepted,
acceptance.FirstResponse);
}
///
/// Retail combat-time creature/player refresh sends CM_Item directly and
/// therefore does not acquire a second UI-busy reference.
///
public bool RefreshCurrentAppraisal()
{
if (_sendExamine is null)
return false;
return _runtimeTransactions.RefreshCurrentAppraisal(_sendExamine);
}
///
/// Releases only the object-appraisal transaction before the local spell
/// examination subview takes ownership of the shared floaty window.
///
/// Retail gmExaminationUI::ExamineSpell @ 0x004B6900 decrements the
/// appraisal busy reference when a response is pending, clears both pending
/// and current object ids, and sends CM_Item::Event_Appraise(0). It
/// does not clear unrelated use/inventory busy references.
///
public void CancelObjectAppraisalForSpell()
{
if (_sendExamine is null)
return;
bool ownedBusyReference = _transactions.BusyCount > 0;
if (_runtimeTransactions.CancelObjectAppraisalForSpell(_sendExamine)
&& !ownedBusyReference)
{
StateChanged?.Invoke();
}
}
///
/// Projects retail gmToolbarUI::HandleSelectionChanged @ 0x004BF380
/// from the live selected object's public description. This is deliberately
/// a read-only query; activation still flows through .
///
public bool IsToolbarUseEnabled(uint selectedObjectId)
{
if (selectedObjectId == 0
|| _objects.Get(selectedObjectId) is not { } item)
return false;
return ItemInteractionPolicy.IsToolbarUseEnabled(
item.Type,
item.CombatUse ?? 0,
item.Useability ?? ItemUseability.Undef);
}
public bool ActivateItem(uint itemGuid)
{
if (itemGuid == 0) return false;
if (IsTargetModeActive)
return OfferPrimaryClick(itemGuid) == ItemPrimaryClickResult.ConsumedSuccess;
var item = _objects.Get(itemGuid);
if (item is null) return false;
if (!ConsumeUseThrottle())
return true;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
_activeVendorId(),
BypassClassification: false,
UseCurrentSelection: false,
SelectedTarget: null,
ConfirmVolatileRareUses: true,
InNonCombatMode: _inNonCombatMode());
var decision = ItemInteractionPolicy.DecideUse(input);
return ExecuteUseActions(decision.Actions);
}
///
/// Retail keyboard pickup entry point. CPlayerSystem::PlaceInBackpack
/// publishes the waiting destination slot before issuing the move request,
/// exactly like double-click pickup through ItemHolder.
///
public bool PlaceWorldItemInBackpack(uint itemGuid)
{
if (itemGuid == 0u || _placeInBackpack is null)
return false;
uint containerId = _backpackContainerId();
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
out _))
{
// Retail permits only one inventory request at a time. Consume the
// input without replacing a pending projection or issuing a wire
// request while another inventory transaction owns the gate.
return true;
}
// This callback may install a retail MoveToObject approach instead of
// sending immediately. SelectionInteractionController promotes the
// reservation to Dispatched only when it actually emits the wire move.
_placeInBackpack(itemGuid, containerId, placement);
return true;
}
public bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
out PendingBackpackPlacement pending)
=> TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
InventoryRequestKind.Pickup,
out pending);
private bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
out PendingBackpackPlacement pending)
{
if (itemGuid == 0u || containerId == 0u)
{
pending = default;
return false;
}
if (_pendingBackpackPlacement is { } existing)
{
ReportPendingBackpackPlacementConflict();
pending = existing;
return false;
}
if (!EnsureInventoryRequestReady())
{
pending = default;
return false;
}
PendingBackpackPlacement candidate = default;
bool reserved;
PendingInventoryRequest published;
try
{
reserved = _transactions.TryReserve(
kind,
itemGuid,
out published,
request =>
{
candidate = new PendingBackpackPlacement(
request.Token,
itemGuid,
containerId,
placement,
request.ItemIdentity);
_pendingBackpackPlacement = candidate;
List failures = [];
DispatchAll(
PendingBackpackPlacementRequested,
candidate,
failures);
if (failures.Count != 0)
{
throw new AggregateException(
"One or more pending-placement observers failed.",
failures);
}
});
}
catch (Exception failure)
{
if (candidate.Token == 0u)
throw;
_pendingBackpackPlacement = null;
_transactions.CancelBeforeDispatch(candidate.Token);
List failures = [failure];
DispatchAll(
PendingBackpackPlacementCancelled,
candidate,
failures);
throw new AggregateException(
"Pending backpack placement publication failed.",
failures);
}
pending = candidate;
if (!reserved
|| _pendingBackpackPlacement != candidate
|| published.Token != candidate.Token
|| published.ItemId != itemGuid
|| published.Kind != kind
|| published.Dispatched)
{
return false;
}
return true;
}
///
/// Publishes retail's destination waiting projection, dispatches the move,
/// then records the global request. Failed dispatch withdraws only the
/// projection created by this call.
///
public bool TryDispatchPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
Func dispatch)
{
// Callers such as gmToolbarUI check the global prevRequest before they
// ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the
// generic busy wording when both states happen to be present.
if (!EnsureInventoryRequestReady())
return false;
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
kind,
out PendingBackpackPlacement pending))
{
return false;
}
if (_pendingBackpackPlacement != pending
|| !_transactions.TryGetPending(out PendingInventoryRequest reserved)
|| reserved.Token != pending.Token
|| reserved.ItemId != pending.ItemId
|| reserved.Kind != kind
|| reserved.Dispatched)
{
return false;
}
if (TryDispatchInventoryRequest(
kind,
itemGuid,
dispatch,
pending.Token))
return true;
CancelPendingBackpackPlacement(itemGuid, pending.Token);
return false;
}
public bool TryGetPendingBackpackPlacement(
uint itemGuid,
out PendingBackpackPlacement pending)
{
if (_pendingBackpackPlacement is { } current
&& current.ItemId == itemGuid)
{
pending = current;
return true;
}
pending = default;
return false;
}
public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u)
{
if (_pendingBackpackPlacement is not { } pending
|| (itemGuid != 0u && pending.ItemId != itemGuid)
|| (token != 0u && pending.Token != token))
{
return;
}
_pendingBackpackPlacement = null;
_transactions.CancelBeforeDispatch(pending.Token);
List failures = [];
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
if (failures.Count != 0)
{
throw new AggregateException(
"One or more pending-placement cancellation observers failed.",
failures);
}
}
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
{
item = _objects.Get(objectId)!;
return item is not null;
}
///
/// Returns whether the object is a member of retail's current
/// ClientUISystem::groundObject. Such objects are container
/// contents, not independently projected world objects, so
/// CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 sends their
/// container transfer directly without a 3-D approach lookup.
///
internal bool IsInCurrentGroundObject(uint objectId)
{
uint groundObjectId = _groundObjectId();
return groundObjectId != 0u
&& _objects.Get(objectId) is { } item
&& item.ContainerId == groundObjectId;
}
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
=> ReferenceEquals(_objects.Get(objectId), item);
///
/// Retail paperdoll drop entry point. The paperdoll resolves the exact
/// target side/location; this shared interaction owner performs AutoWield's
/// confirmed blocker transaction before sending GetAndWieldItem.
///
public bool WieldFromPaperdoll(uint itemGuid, EquipMask targetMask)
{
if (itemGuid == 0u || targetMask == EquipMask.None)
return false;
if (_objects.Get(itemGuid) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
return _autoWield.TryWield(item, targetMask);
}
/// User combat-mode input supersedes AutoWield's retained mode.
public void NotifyExplicitCombatModeRequest()
=> _autoWield.NotifyExplicitCombatModeRequest();
public bool AcquireTarget(uint targetGuid)
{
if (!IsTargetModeActive || targetGuid == 0) return false;
uint sourceGuid = PendingSourceItem;
ClearTargetMode();
var source = _objects.Get(sourceGuid);
if (source?.Useability is not { } useability)
return false;
bool compatible = TargetCompatible(source, targetGuid);
var target = _objects.Get(targetGuid);
Console.WriteLine(
$"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}"
+ $" tgt=0x{targetGuid:X8} tgtKind={(target is null ? "none" : $"0x{(uint)target.Type:X8}")}"
+ $" -> {(compatible ? "SEND UseWithTarget" : "refused")}");
if (!compatible)
return false;
if (!EnsureInventoryRequestReady())
return false;
_runtimeTransactions.TryDispatchTargetedUse(
sourceGuid,
targetGuid,
_sendUseWithTarget,
incrementBusy: true);
return true;
}
public bool AcquireSelfTarget()
=> IsTargetModeActive && AcquireTarget(_playerGuid());
///
/// Completes the positive branch of retail
/// ClientUISystem::UsageCallback @ 0x00565B20. Confirmation policy has
/// already consumed the original activation, so acceptance sends the retained
/// object id and enters the same busy state as an ordinary use.
///
public bool ExecuteConfirmedUse(uint objectId)
{
if (objectId == 0u || (_requestUse is null && _sendUse is null))
return false;
if (!EnsureInventoryRequestReady())
return false;
if (_requestUse is not null)
{
ItemUseRequestReservation reservation = BeginUseRequestReservation();
try
{
_requestUse(objectId, reservation);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
}
else
{
_sendUse!(objectId);
_runtimeTransactions.IncrementBusyCount();
}
return true;
}
public void CancelTargetMode()
{
if (!IsAnyTargetModeActive) return;
ClearTargetMode();
}
public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u);
///
/// Retail inventory drag released into SmartBox. The release target is the
/// world object under the cursor, or zero for empty ground. This is the live
/// adapter for ItemHolder::AttemptPlaceIn3D @ 0x00588600.
///
public bool PlaceIn3D(ItemDragPayload payload, uint targetGuid)
{
ArgumentNullException.ThrowIfNull(payload);
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
uint fullStack = (uint)Math.Max(1, item.StackSize);
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
ClientObject? target = targetGuid == 0u ? null : _objects.Get(targetGuid);
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
TargetId: targetGuid,
Target: target is null ? null : Snapshot(target),
AllowGroundFallback: true,
MergeAccepted: false,
DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade(),
PlayerOnGround: _playerOnGround(),
SplitSize: splitSize));
ExecutePlacementActions(decision.Actions);
return decision.ReturnValue;
}
private bool ExecuteUseActions(System.Collections.Generic.IReadOnlyList actions)
{
uint openedOrUsed = 0;
bool acted = false;
bool busyOwnedByUseReservation = false;
foreach (var action in actions)
{
switch (action.Kind)
{
case ItemPolicyActionKind.PlaceInBackpack:
acted |= PlaceWorldItemInBackpack(action.ObjectId);
break;
case ItemPolicyActionKind.WieldRight:
case ItemPolicyActionKind.WieldLeft:
case ItemPolicyActionKind.AutoSort:
if (_objects.Get(action.ObjectId) is { } item)
acted |= _autoWield.TryWield(item);
break;
case ItemPolicyActionKind.OpenContainedContainer:
case ItemPolicyActionKind.SendUse:
// Retail's wire Use and local OpenContainedContainer notice are
// distinct. acdream currently represents both with SendUse, so
// coalesce the pair to one packet.
if (openedOrUsed != action.ObjectId)
{
if (_requestUse is not null)
{
ItemUseRequestReservation reservation =
BeginUseRequestReservation();
try
{
_requestUse(action.ObjectId, reservation);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
busyOwnedByUseReservation = true;
}
else
{
_sendUse?.Invoke(action.ObjectId);
}
openedOrUsed = action.ObjectId;
acted |= _requestUse is not null || _sendUse is not null;
}
break;
case ItemPolicyActionKind.SendUseWithTarget:
acted |= _runtimeTransactions.TryDispatchTargetedUse(
action.ObjectId,
action.TargetId,
_sendUseWithTarget,
incrementBusy: false);
break;
case ItemPolicyActionKind.SetGroundObject:
_requestExternalContainer?.Invoke(action.ObjectId);
acted |= _requestExternalContainer is not null;
break;
case ItemPolicyActionKind.EnterTargetMode:
EnterTargetMode(action.ObjectId);
acted = true;
break;
case ItemPolicyActionKind.IncrementBusy:
if (busyOwnedByUseReservation)
{
busyOwnedByUseReservation = false;
}
else
{
_runtimeTransactions.IncrementBusyCount();
}
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
if (!handled)
_toast?.Invoke(PolicyActionMessage(action));
acted |= handled || _toast is not null;
break;
}
}
return acted;
}
private ItemUseRequestReservation BeginUseRequestReservation()
=> _runtimeTransactions.BeginUseRequestReservation();
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList actions)
{
foreach (var action in actions)
{
switch (action.Kind)
{
case ItemPolicyActionKind.DropToWorld:
TryDispatchInventoryRequest(
InventoryRequestKind.DropToWorld,
action.ObjectId,
() =>
{
if (_sendDrop is null
|| !_objects.MoveItemOptimistic(
action.ObjectId,
newContainerId: 0u,
newSlot: -1))
{
return false;
}
_sendDrop(action.ObjectId);
return true;
});
break;
case ItemPolicyActionKind.SplitToWorld:
// UIAttemptSplitTo3D leaves the original object in its container;
// the server assigns the split-off ground stack a new guid.
TryDispatchInventoryRequest(
InventoryRequestKind.SplitToWorld,
action.ObjectId,
() =>
{
if (_sendSplitToWorld is null)
return false;
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
if (_transactions.TryGetPending(out PendingInventoryRequest pending)
&& pending.Kind is InventoryRequestKind.SplitToWorld
&& pending.ItemId == action.ObjectId)
{
WorldDropDispatched?.Invoke(new WorldDropDispatch(
pending,
(uint)action.Amount));
}
return true;
});
break;
case ItemPolicyActionKind.GiveToTarget:
// UIAttemptGive @ 0x0058D620 sends the request and leaves
// the source untouched until the authoritative server
// InventoryRemoveObject / SetStackSize response arrives.
TryDispatchInventoryRequest(
InventoryRequestKind.Give,
action.ObjectId,
() =>
{
if (_sendGive is null)
return false;
_sendGive(
action.TargetId,
action.ObjectId,
(uint)Math.Max(1, action.Amount));
return true;
});
break;
case ItemPolicyActionKind.PlaceInContainer:
{
uint fullStack = (uint)Math.Max(
1,
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
uint amount = (uint)Math.Max(1, action.Amount);
InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer;
TryDispatchInventoryRequest(
kind,
action.ObjectId,
() =>
{
if (amount < fullStack)
{
if (_sendSplitToContainer is null)
return false;
_sendSplitToContainer(
action.ObjectId,
action.TargetId,
0u,
amount);
}
else
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(
action.ObjectId,
action.TargetId,
0);
}
return true;
});
break;
}
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
if (_auxiliaryAction is null && PolicyActionRequested is null)
_toast?.Invoke(PolicyActionMessage(action));
break;
}
}
}
private void EnterTargetMode(uint sourceGuid)
{
_consumedPrimaryClickTarget = 0;
_interactionState.EnterUseItemOnTarget(sourceGuid);
var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}");
}
private void ClearTargetMode()
{
_interactionState.Clear();
}
private static string PolicyActionMessage(ItemPolicyAction action)
=> action.Kind switch
{
ItemPolicyActionKind.ConfirmPlayerKillerSwitch =>
"Confirm using this Player Killer altar before continuing.",
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch =>
"Confirm using this Non-Player Killer altar before continuing.",
ItemPolicyActionKind.ConfirmVolatileRare =>
"Confirm using this volatile rare before continuing.",
ItemPolicyActionKind.OpenSecureTrade or ItemPolicyActionKind.StartSecureTrade =>
"Secure trade is not open.",
ItemPolicyActionKind.OpenSalvage => "Open the salvage panel to use that item.",
_ => "That item action is not available here.",
};
private void OnInteractionModeChanged(InteractionModeTransition _)
{
List failures = [];
DispatchAll(StateChanged, failures);
if (failures.Count != 0)
throw new AggregateException(
"One or more item-interaction observers failed.",
failures);
}
private void OnTransactionStateChanged()
{
List failures = [];
DispatchAll(StateChanged, failures);
if (failures.Count != 0)
{
throw new AggregateException(
"One or more item-interaction observers failed.",
failures);
}
}
private void OnInventoryRequestCompleted(PendingInventoryRequest request)
{
if (_pendingBackpackPlacement is { } placement
&& placement.Token == request.Token
&& placement.ItemId == request.ItemId)
{
_pendingBackpackPlacement = null;
List failures = [];
DispatchAll(PendingBackpackPlacementResolved, placement, failures);
if (failures.Count != 0)
{
throw new AggregateException(
"One or more pending-placement resolution observers failed.",
failures);
}
}
}
private void OnInventoryObjectsCleared()
{
_pendingBackpackPlacement = null;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null;
_autoWield.Dispose();
}
/// Retail UseDone (0x01C7) releases one UI busy reference.
public void CompleteUse(uint error)
=> _runtimeTransactions.CompleteUse(error);
/// Session teardown drops every outstanding client UI busy reference.
public void ClearBusy()
=> _transactions.ClearBusy();
///
/// Ends all session-scoped ItemHolder state. Target modes, click
/// suppression, and use throttles must not survive a reconnect where the
/// same numeric GUID can identify a different object.
///
public void ResetSession()
{
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
// Runtime owns the transaction reset. This controller preserves the
// pre-J4 single UI notification emitted by InteractionState below.
_transactions.StateChanged -= OnTransactionStateChanged;
try
{
_runtimeTransactions.ResetSession();
}
finally
{
if (!_disposed)
_transactions.StateChanged += OnTransactionStateChanged;
}
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;
List failures = [];
try { _interactionState.ResetSession(); }
catch (Exception error) { failures.Add(error); }
if (pendingPlacement is { } pending)
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
if (failures.Count != 0)
throw new AggregateException(
"One or more item-interaction reset observers failed.",
failures);
}
public readonly record struct AppraisalResponseAcceptance(
bool Accepted,
bool FirstResponse);
private static void DispatchAll(Action? listeners, List failures)
{
if (listeners is null)
return;
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(); }
catch (Exception error) { failures.Add(error); }
}
}
private static void DispatchAll(
Action? listeners,
T value,
List failures)
{
if (listeners is null)
return;
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(value); }
catch (Exception error) { failures.Add(error); }
}
}
private bool ConsumeUseThrottle()
=> _runtimeTransactions.TryConsumeUseThrottle(_nowMs());
private static bool IsContainer(ClientObject item)
=> item.ContainerTypeHint != 0
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private ItemPolicyObject Snapshot(ClientObject item)
{
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
if (item.ObjectId == _playerGuid())
flags |= PublicWeenieFlags.Player;
bool owned = item.ObjectId == _playerGuid()
|| IsCarriedByPlayer(item)
|| IsEquippedByPlayer(item);
int stackSize = Math.Max(1, item.StackSize);
return new ItemPolicyObject(
item.ObjectId,
item.Type,
flags,
item.ContainerId,
item.WielderId,
item.ValidLocations,
item.CurrentlyEquippedLocation,
item.CombatUse ?? 0,
item.ItemsCapacity,
item.ContainersCapacity,
item.Useability ?? 0u,
item.TargetType ?? 0u,
owned,
IsContainer(item),
item.IsComponentPack || _isComponentPack(item.WeenieClassId),
item.TradeState,
stackSize,
stackSize,
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
&& item.ObjectId != _playerGuid());
}
///
/// App adapter for Core's pure port of
/// ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070.
///
private bool TargetCompatible(ClientObject source, uint targetGuid)
{
var target = _objects.Get(targetGuid);
if (target is null)
return false; // retail: GetWeenieObject(target) null → incompatible
return ItemInteractionPolicy.IsTargetCompatible(
Snapshot(source), Snapshot(target), _playerGuid());
}
private bool IsEquippedByPlayer(ClientObject item)
{
uint player = _playerGuid();
return item.CurrentlyEquippedLocation != EquipMask.None
&& (item.WielderId == player || item.ContainerId == player);
}
private bool IsCarriedByPlayer(ClientObject item)
{
uint player = _playerGuid();
uint container = item.ContainerId;
for (int hops = 0; container != 0 && hops < 8; hops++)
{
if (container == player) return true;
container = _objects.Get(container)?.ContainerId ?? 0u;
}
return false;
}
}