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