acdream/src/AcDream.App/UI/ItemInteractionController.cs
Erik 92ea3977b6
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
C1 an out-of-range Use now approaches first via the existing
client-predicted BeginApproach (Pickup's far-range shape mirrored;
retail's ItemHolder::UseObject @0x00588A80 has no range check and the
dispatch stays immediate). C2 Add-to-List stages into the Buying tab
via VendorStagingList (RemoveProfileFromList's two shapes,
pc:200497-200537), Buy All sends ONE batched 0x005F and flushes
staging on send exactly as retail does (SendShopEvent -> Flush,
pc:204075-204076 — not UseDone-gated), and X-close over a non-empty
staging list shows retail's confirm string recovered verbatim from the
binary data segment (0x007b5bd8) through the existing dialog factory.
C3 the Selling tab's list is the sole drop target (retail's single
IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports
InqAcceptability with all rejection strings recovered verbatim from
the raw data segment; the sell side prices with BuyPrice (retail's
inverted naming: what the vendor PAYS) and 0x0060 carries no trailing
currency field, unlike Buy. C4 the status-bar reproduction test PASSES
against the production toolbar mount — retail's toolbar shows count +
name with the split bar and NO price parenthetical (that figure is the
vendor row's own cost text); no code change, the live gate referees.
C5 pack order verified correct, untouched.

Register: AP-161 narrowed to its two pre-existing cosmetic gaps;
AP-162 extended over Buy All; AP-164 (non-sellable bitfield
unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal
test, bounded), AP-166 (purse text + pending-sell highlight cosmetic)
filed.

Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 11:43:11 +02:00

1494 lines
57 KiB
C#

using System;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI;
/// <summary>Result of offering a primary click to active item-target mode.</summary>
public enum ItemPrimaryClickResult
{
NotActive,
ConsumedSuccess,
ConsumedRejected,
}
public readonly record struct PendingBackpackPlacement(
ulong Token,
uint ItemId,
uint ContainerId,
int Placement,
ClientObject? ItemIdentity);
/// <summary>
/// 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.
/// </summary>
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<uint> _playerGuid;
private readonly Func<long> _nowMs;
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendExamine;
private readonly Action<uint, uint>? _sendUseWithTarget;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
private readonly Func<uint> _activeVendorId;
private readonly Func<uint> _groundObjectId;
private readonly Func<bool> _playerOnGround;
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint, uint, int>? _placeInBackpack;
private readonly Func<uint> _backpackContainerId;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId -> true
// when the wire send actually happened. F4 (Slice 6 review): a plain
// Action can't tell TryBuy apart from a silent no-op (no session / not
// in world) — the bool return is what lets TryBuy release the
// reservation instead of leaking BusyCount forever.
private readonly Func<uint, uint, int, uint, bool>? _sendBuy;
// Slice 6b/6c: vendorGuid, staged (amount, itemGuid) entries[, alternate
// currency] -> true when the wire send actually happened. Same
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
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<uint> playerGuid,
Action<uint>? sendUse,
Action<uint, uint>? sendUseWithTarget,
Action<uint, uint>? sendWield,
Action<uint>? sendDrop,
Action<uint>? sendExamine = null,
Func<long>? nowMs = null,
Action<string>? toast = null,
Func<bool>? readyForInventoryRequest = null,
Func<uint>? activeVendorId = null,
Func<uint>? groundObjectId = null,
Func<bool>? playerOnGround = null,
Func<bool>? inNonCombatMode = null,
Func<uint, bool>? isComponentPack = null,
Action<uint, uint, int>? placeInBackpack = null,
Func<uint>? backpackContainerId = null,
Action<ItemPolicyAction>? auxiliaryAction = null,
Action<uint, uint>? sendSplitToWorld = null,
Func<uint>? selectedObjectId = null,
StackSplitQuantityState? stackSplitQuantity = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = null,
Func<bool>? dragOnPlayerOpensSecureTrade = null,
Action<string>? systemMessage = null,
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null,
Func<uint, uint, int, uint, bool>? sendBuy = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = 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;
_sendBuy = sendBuy;
_sendBuyAll = sendBuyAll;
_sendSell = sendSell;
_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;
/// <summary>
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: 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.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
/// <summary>
/// Withdraws a waiting pickup projection when the approach is rejected or
/// cancelled before a request reaches the server.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
/// <summary>
/// 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.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
/// <summary>
/// Publishes the exact split-to-world request after its wire send has
/// succeeded. Retail <c>ACCWeenieObject::UIAttemptSplitTo3D @
/// 0x0058D850</c> retains the source class, selected amount, and request
/// time so the subsequently created ground stack can be recognized.
/// </summary>
public event Action<WorldDropDispatch>? 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;
/// <summary>
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
/// </summary>
public bool EnsureInventoryRequestReady()
{
if (CanMakeInventoryRequest)
return true;
if (_transactions.HasPendingRequest
|| _transactions.BusyCount != 0
|| _autoWield.IsBusy)
_systemMessage?.Invoke(InventoryRequestBusyMessage);
return false;
}
/// <summary>
/// Slice 6.3: retail's Buy button — <c>gmVendorUI::BuySingleItem</c>
/// (<c>pc:201661</c>, <c>0x004C2820</c>). Immediate single-item purchase
/// of <paramref name="itemGuid"/> (a vendor shop item) at
/// <paramref name="amount"/> units, with the vendor's own trade currency
/// (0 = pyreal). Rides the EXISTING one-request-at-a-time reservation
/// ordinary Use takes (research doc §A.4: every
/// <c>HandleActionBuyItem</c> path ends in exactly one
/// <c>SendUseDoneEvent()</c>, the SAME completion signal
/// <c>RuntimeInteractionTransactionState.CompleteUse</c> already
/// resolves via the wired <c>UseDone (0x01C7)</c> handler) — no second
/// gate, and retail's client-side busy-count increment
/// (<c>ClientUISystem::IncrementBusyCount</c>, <c>pc:201765</c>) is
/// exactly what <see cref="EnsureInventoryRequestReady"/>'s
/// <c>BusyCount == 0</c> check already guards for every other request.
/// </summary>
/// <remarks>
/// F4 (Slice 6 review): <see cref="_sendBuy"/> reports whether the wire
/// send actually happened (false when there is no live session or the
/// session is not in world). Mirrors
/// <c>RuntimeInteractionTransactionState.TryDispatchUse</c>'s shape —
/// every rejecting path calls <see cref="ItemUseRequestReservation.CancelBeforeDispatch"/>
/// before returning, never <see cref="ItemUseRequestReservation.MarkDispatched"/>.
/// The OLD void-returning delegate could not distinguish "sent" from a
/// session-null no-op, so a buy attempted while disconnected/mid-teardown
/// permanently marked the reservation dispatched — <c>BusyCount</c> then
/// never balances, because no <c>UseDone</c> will ever arrive for a
/// request that was never actually sent, leaking "You can only move or
/// use one item at a time" forever.
/// </remarks>
public bool TryBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
{
if (vendorGuid == 0u || itemGuid == 0u || amount <= 0 || _sendBuy is null)
return false;
if (!EnsureInventoryRequestReady())
return false;
ItemUseRequestReservation reservation = BeginUseRequestReservation();
bool dispatched;
try
{
dispatched = _sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
if (!dispatched)
{
reservation.CancelBeforeDispatch();
return false;
}
reservation.MarkDispatched();
return true;
}
/// <summary>
/// Slice 6b: retail's "Buy All" button (<c>gmVendorUI::HandleButtonClicks</c>
/// case <c>0x100000ca</c>, <c>pc:204011-204079</c>) — the ONE path that
/// sends a multi-item Buy in a single wire call. Rides the SAME
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
/// Retail's client-side affordability/pack-capacity pre-checks
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported
/// here either, for the same reason <see cref="TryBuy"/>'s own doc
/// comment already gives for the single-item Buy: the server is
/// authoritative either way (register AP-162, extended to this batched
/// path rather than filing a second near-duplicate row).
/// </summary>
public bool TryBuyAll(
uint vendorGuid,
IReadOnlyList<(int Amount, uint ItemGuid)> items,
uint alternateCurrencyId)
{
if (vendorGuid == 0u || items is null || items.Count == 0 || _sendBuyAll is null)
return false;
if (!EnsureInventoryRequestReady())
return false;
ItemUseRequestReservation reservation = BeginUseRequestReservation();
bool dispatched;
try
{
dispatched = _sendBuyAll(vendorGuid, items, alternateCurrencyId);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
if (!dispatched)
{
reservation.CancelBeforeDispatch();
return false;
}
reservation.MarkDispatched();
return true;
}
/// <summary>
/// Slice 6c: retail's Sell path — both "Sell Item"
/// (<c>gmVendorUI::HandleButtonClicks</c> case <c>0x100000d2</c>,
/// <c>pc:204101-204112</c>, a one-entry list) and "Sell All" (case
/// <c>0x100000d3</c>, <c>pc:204113-204129</c>, an n-entry list) reuse
/// this single method — retail's own <c>CM_Vendor::Event_Sell</c> has no
/// separate single-item opcode the way Buy does. Same reservation dance
/// as <see cref="TryBuy"/>/<see cref="TryBuyAll"/>.
/// </summary>
public bool TrySell(
uint vendorGuid,
IReadOnlyList<(int Amount, uint ItemGuid)> items)
{
if (vendorGuid == 0u || items is null || items.Count == 0 || _sendSell is null)
return false;
if (!EnsureInventoryRequestReady())
return false;
ItemUseRequestReservation reservation = BeginUseRequestReservation();
bool dispatched;
try
{
dispatched = _sendSell(vendorGuid, items);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
if (!dispatched)
{
reservation.CancelBeforeDispatch();
return false;
}
reservation.MarkDispatched();
return true;
}
/// <summary>
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
/// list which already displays the waiting projection.
/// </summary>
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");
}
/// <summary>
/// Dispatches one retail inventory request. A provisional global
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
/// then becomes dispatched immediately after a successful wire send.
/// The callback returns false when it could not dispatch anything.
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
/// <c>0x0058D590..0x0058D850</c>.
/// </summary>
public bool TryDispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> 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);
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> 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.
/// </summary>
public void IncrementBusyCount()
=> _runtimeTransactions.IncrementBusyCount();
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
/// </summary>
public event Action<ItemPolicyAction>? 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;
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
/// toolbar shortcut creation.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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());
/// <summary>
/// Retail toolbar Use button: use the current selection immediately, or arm
/// one-shot <c>TARGET_MODE_USE</c> when no object is selected.
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
/// </summary>
public bool UseSelectedOrEnterMode(uint selectedObjectId)
{
if (selectedObjectId != 0)
return ActivateItem(selectedObjectId);
return _interactionState.EnterUse();
}
/// <summary>
/// Retail toolbar Examine button: appraise the current selection immediately,
/// or arm one-shot <c>TARGET_MODE_EXAMINE</c> when no object is selected.
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
/// </summary>
public bool ExamineSelectedOrEnterMode(uint selectedObjectId)
{
if (selectedObjectId == 0)
return _interactionState.EnterExamine();
if (_sendExamine is null)
return false;
RequestAppraisal(selectedObjectId);
return true;
}
/// <summary>
/// Retail <c>gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0</c>.
/// One shared UI-busy reference covers the currently pending appraisal,
/// even when another examine request replaces its GUID before a response.
/// </summary>
private void RequestAppraisal(uint objectId)
{
if (objectId == 0 || _sendExamine is null)
return;
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
/// <summary>
/// Accepts only the pending or current appraisal, matching
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
/// </summary>
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);
}
/// <summary>
/// Retail combat-time creature/player refresh sends CM_Item directly and
/// therefore does not acquire a second UI-busy reference.
/// </summary>
public bool RefreshCurrentAppraisal()
{
if (_sendExamine is null)
return false;
return _runtimeTransactions.RefreshCurrentAppraisal(_sendExamine);
}
/// <summary>
/// Releases only the object-appraisal transaction before the local spell
/// examination subview takes ownership of the shared floaty window.
///
/// Retail <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> decrements the
/// appraisal busy reference when a response is pending, clears both pending
/// and current object ids, and sends <c>CM_Item::Event_Appraise(0)</c>. It
/// does not clear unrelated use/inventory busy references.
/// </summary>
public void CancelObjectAppraisalForSpell()
{
if (_sendExamine is null)
return;
bool ownedBusyReference = _transactions.BusyCount > 0;
if (_runtimeTransactions.CancelObjectAppraisalForSpell(_sendExamine)
&& !ownedBusyReference)
{
StateChanged?.Invoke();
}
}
/// <summary>
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
/// from the live selected object's public description. This is deliberately
/// a read-only query; activation still flows through <see cref="ActivateItem"/>.
/// </summary>
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);
}
/// <summary>
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
/// publishes the waiting destination slot before issuing the move request,
/// exactly like double-click pickup through ItemHolder.
/// </summary>
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<Exception> 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<Exception> 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;
}
/// <summary>
/// Publishes retail's destination waiting projection, dispatches the move,
/// then records the global request. Failed dispatch withdraws only the
/// projection created by this call.
/// </summary>
public bool TryDispatchPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
Func<bool> 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<Exception> 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;
}
/// <summary>
/// Returns whether the object is a member of retail's current
/// <c>ClientUISystem::groundObject</c>. Such objects are container
/// contents, not independently projected world objects, so
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> sends their
/// container transfer directly without a 3-D approach lookup.
/// </summary>
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);
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
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());
/// <summary>
/// Completes the positive branch of retail
/// <c>ClientUISystem::UsageCallback @ 0x00565B20</c>. 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.
/// </summary>
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);
/// <summary>
/// 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 <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>.
/// </summary>
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<ItemPolicyAction> 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<ItemPolicyAction> 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<Exception> failures = [];
DispatchAll(StateChanged, failures);
if (failures.Count != 0)
throw new AggregateException(
"One or more item-interaction observers failed.",
failures);
}
private void OnTransactionStateChanged()
{
List<Exception> 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<Exception> 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();
}
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
public void CompleteUse(uint error)
=> _runtimeTransactions.CompleteUse(error);
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
public void ClearBusy()
=> _transactions.ClearBusy();
/// <summary>
/// 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.
/// </summary>
public void ResetSession()
=> ResetSessionCore(resetRuntime: true);
/// <summary>
/// Clears only retained UI transaction presentation. Runtime's canonical
/// transaction, interaction, and busy owners are reset once by the shared
/// generation-reset transaction.
/// </summary>
internal void ResetGenerationPresentation()
=> ResetSessionCore(resetRuntime: false);
private void ResetSessionCore(bool resetRuntime)
{
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
if (resetRuntime)
{
// Runtime owns the transaction reset. This controller preserves
// the pre-J4 single UI notification emitted by InteractionState.
_transactions.StateChanged -= OnTransactionStateChanged;
try
{
_runtimeTransactions.ResetSession();
}
finally
{
if (!_disposed)
_transactions.StateChanged += OnTransactionStateChanged;
}
}
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;
List<Exception> failures = [];
if (resetRuntime)
{
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<Exception> failures)
{
if (listeners is null)
return;
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(); }
catch (Exception error) { failures.Add(error); }
}
}
private static void DispatchAll<T>(
Action<T>? listeners,
T value,
List<Exception> failures)
{
if (listeners is null)
return;
foreach (Action<T> 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());
}
/// <summary>
/// App adapter for Core's pure port of
/// <c>ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070</c>.
/// </summary>
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;
}
}