Port global toolbar use/select/create actions, migrate the collapsed Ctrl-number bindings, and route them through a focused retained-UI controller. Preserve shortcut aliases as aliases across inventory and paperdoll drops with retail's neutral/accept/reject drag states, preventing physical item moves such as unwielding an equipped helmet. Co-Authored-By: Codex <codex@openai.com>
606 lines
21 KiB
C#
606 lines
21 KiB
C#
using System;
|
|
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Result of offering a primary click to active item-target mode.</summary>
|
|
public enum ItemPrimaryClickResult
|
|
{
|
|
NotActive,
|
|
ConsumedSuccess,
|
|
ConsumedRejected,
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
private static readonly EquipMask[] AutoEquipOrder =
|
|
{
|
|
EquipMask.HeadWear,
|
|
EquipMask.ChestWear,
|
|
EquipMask.AbdomenWear,
|
|
EquipMask.UpperArmWear,
|
|
EquipMask.LowerArmWear,
|
|
EquipMask.HandWear,
|
|
EquipMask.UpperLegWear,
|
|
EquipMask.LowerLegWear,
|
|
EquipMask.FootWear,
|
|
EquipMask.ChestArmor,
|
|
EquipMask.AbdomenArmor,
|
|
EquipMask.UpperArmArmor,
|
|
EquipMask.LowerArmArmor,
|
|
EquipMask.UpperLegArmor,
|
|
EquipMask.LowerLegArmor,
|
|
EquipMask.NeckWear,
|
|
EquipMask.WristWearLeft,
|
|
EquipMask.WristWearRight,
|
|
EquipMask.FingerWearLeft,
|
|
EquipMask.FingerWearRight,
|
|
EquipMask.Shield,
|
|
EquipMask.MissileAmmo,
|
|
EquipMask.MeleeWeapon,
|
|
EquipMask.MissileWeapon,
|
|
EquipMask.Held,
|
|
EquipMask.TwoHanded,
|
|
EquipMask.TrinketOne,
|
|
EquipMask.Cloak,
|
|
EquipMask.SigilOne,
|
|
EquipMask.SigilTwo,
|
|
EquipMask.SigilThree,
|
|
};
|
|
|
|
private const long RetailUseThrottleMs = 200;
|
|
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, uint>? _sendUseWithTarget;
|
|
private readonly Action<uint, uint>? _sendWield;
|
|
private readonly Action<uint>? _sendDrop;
|
|
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>? _placeInBackpack;
|
|
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
|
private readonly InteractionState _interactionState;
|
|
|
|
private long _lastUseMs = long.MinValue / 2;
|
|
private uint _consumedPrimaryClickTarget;
|
|
private long _consumedPrimaryClickMs = long.MinValue / 2;
|
|
private int _busyCount;
|
|
private bool _disposed;
|
|
|
|
public ItemInteractionController(
|
|
ClientObjectTable objects,
|
|
Func<uint> playerGuid,
|
|
Action<uint>? sendUse,
|
|
Action<uint, uint>? sendUseWithTarget,
|
|
Action<uint, uint>? sendWield,
|
|
Action<uint>? sendDrop,
|
|
Func<long>? nowMs = null,
|
|
Action<string>? toast = null,
|
|
InteractionState? interactionState = 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>? placeInBackpack = null,
|
|
Action<ItemPolicyAction>? auxiliaryAction = null)
|
|
{
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
|
_sendUse = sendUse;
|
|
_sendUseWithTarget = sendUseWithTarget;
|
|
_sendWield = sendWield;
|
|
_sendDrop = sendDrop;
|
|
_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;
|
|
_auxiliaryAction = auxiliaryAction;
|
|
_interactionState = interactionState ?? new InteractionState();
|
|
_interactionState.Changed += OnInteractionModeChanged;
|
|
}
|
|
|
|
public event Action? StateChanged;
|
|
|
|
public uint PlayerGuid => _playerGuid();
|
|
|
|
public InteractionState InteractionState => _interactionState;
|
|
|
|
public int BusyCount => _busyCount;
|
|
|
|
/// <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 IsPendingSource(uint itemGuid)
|
|
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
|
|
|
/// <summary>
|
|
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
|
/// toolbar shortcut creation. Ownership includes self, equipped items, and
|
|
/// arbitrarily nested carried containers.
|
|
/// </summary>
|
|
public bool IsOwnedByPlayer(uint itemGuid)
|
|
=> _objects.Get(itemGuid) is { } item
|
|
&& (item.ObjectId == _playerGuid()
|
|
|| IsCarriedByPlayer(item)
|
|
|| IsEquippedByPlayer(item));
|
|
|
|
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)
|
|
{
|
|
if (!IsTargetModeActive)
|
|
{
|
|
long now = _nowMs();
|
|
if (targetGuid != 0
|
|
&& targetGuid == _consumedPrimaryClickTarget
|
|
&& now - _consumedPrimaryClickMs <= RetailDoubleClickMs)
|
|
return ItemPrimaryClickResult.ConsumedRejected;
|
|
_consumedPrimaryClickTarget = 0;
|
|
return ItemPrimaryClickResult.NotActive;
|
|
}
|
|
|
|
bool accepted = AcquireTarget(targetGuid);
|
|
_consumedPrimaryClickTarget = targetGuid;
|
|
_consumedPrimaryClickMs = _nowMs();
|
|
return accepted
|
|
? ItemPrimaryClickResult.ConsumedSuccess
|
|
: ItemPrimaryClickResult.ConsumedRejected;
|
|
}
|
|
|
|
public ItemPrimaryClickResult OfferSelfPrimaryClick()
|
|
=> OfferPrimaryClick(_playerGuid());
|
|
|
|
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;
|
|
|
|
var input = new ItemUsePolicyInput(
|
|
Snapshot(item),
|
|
_playerGuid(),
|
|
_groundObjectId(),
|
|
_readyForInventoryRequest() && _busyCount == 0,
|
|
_activeVendorId(),
|
|
BypassClassification: false,
|
|
UseCurrentSelection: false,
|
|
SelectedTarget: null,
|
|
ConfirmVolatileRareUses: true,
|
|
InNonCombatMode: _inNonCombatMode());
|
|
var decision = ItemInteractionPolicy.DecideUse(input);
|
|
return ExecuteUseActions(decision.Actions);
|
|
}
|
|
|
|
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 (!_readyForInventoryRequest() || _busyCount != 0)
|
|
return false;
|
|
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
|
_busyCount++;
|
|
return true;
|
|
}
|
|
|
|
public bool AcquireSelfTarget()
|
|
=> IsTargetModeActive && AcquireTarget(_playerGuid());
|
|
|
|
public void CancelTargetMode()
|
|
{
|
|
if (!IsTargetModeActive) return;
|
|
ClearTargetMode();
|
|
}
|
|
|
|
public bool DropToWorld(ItemDragPayload payload)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
|
|
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
|
return false;
|
|
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
|
return false;
|
|
|
|
int splitSize = Math.Max(1, item.StackSize);
|
|
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
|
Snapshot(item),
|
|
_playerGuid(),
|
|
_groundObjectId(),
|
|
_readyForInventoryRequest() && _busyCount == 0,
|
|
TargetId: 0,
|
|
Target: null,
|
|
AllowGroundFallback: true,
|
|
MergeAccepted: false,
|
|
DragOnPlayerOpensSecureTrade: true,
|
|
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;
|
|
foreach (var action in actions)
|
|
{
|
|
switch (action.Kind)
|
|
{
|
|
case ItemPolicyActionKind.PlaceInBackpack:
|
|
_placeInBackpack?.Invoke(action.ObjectId);
|
|
acted |= _placeInBackpack is not null;
|
|
break;
|
|
case ItemPolicyActionKind.WieldRight:
|
|
case ItemPolicyActionKind.WieldLeft:
|
|
case ItemPolicyActionKind.AutoSort:
|
|
if (_objects.Get(action.ObjectId) is { } item)
|
|
acted |= TryAutoWield(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)
|
|
{
|
|
_sendUse?.Invoke(action.ObjectId);
|
|
openedOrUsed = action.ObjectId;
|
|
acted |= _sendUse is not null;
|
|
}
|
|
break;
|
|
case ItemPolicyActionKind.SendUseWithTarget:
|
|
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
|
|
acted |= _sendUseWithTarget is not null;
|
|
break;
|
|
case ItemPolicyActionKind.EnterTargetMode:
|
|
EnterTargetMode(action.ObjectId);
|
|
acted = true;
|
|
break;
|
|
case ItemPolicyActionKind.IncrementBusy:
|
|
_busyCount++;
|
|
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 void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
|
|
{
|
|
foreach (var action in actions)
|
|
{
|
|
switch (action.Kind)
|
|
{
|
|
case ItemPolicyActionKind.DropToWorld:
|
|
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
|
|
_sendDrop?.Invoke(action.ObjectId);
|
|
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 _)
|
|
=> StateChanged?.Invoke();
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_interactionState.Changed -= OnInteractionModeChanged;
|
|
}
|
|
|
|
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
|
public void CompleteUse(uint _)
|
|
{
|
|
if (_busyCount == 0) return;
|
|
_busyCount--;
|
|
StateChanged?.Invoke();
|
|
}
|
|
|
|
private bool ConsumeUseThrottle()
|
|
{
|
|
long now = _nowMs();
|
|
if (now - _lastUseMs < RetailUseThrottleMs)
|
|
return false;
|
|
_lastUseMs = now;
|
|
return true;
|
|
}
|
|
|
|
private bool TryAutoWield(ClientObject item)
|
|
{
|
|
if (item.ValidLocations == EquipMask.None
|
|
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
|
return false;
|
|
|
|
EquipMask mask = BestAvailableEquipMask(item);
|
|
if (mask == EquipMask.None)
|
|
{
|
|
_toast?.Invoke("That slot is already in use");
|
|
return false;
|
|
}
|
|
|
|
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
|
return false;
|
|
|
|
_sendWield?.Invoke(item.ObjectId, (uint)mask);
|
|
return true;
|
|
}
|
|
|
|
private EquipMask BestAvailableEquipMask(ClientObject item)
|
|
{
|
|
if (ItemEquipRules.IsAutoWearItem(item))
|
|
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
|
|
|
return FirstAvailableEquipMask(item);
|
|
}
|
|
|
|
private bool AutoWearIsLegal(ClientObject item)
|
|
{
|
|
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
|
|
if ((item.Priority & priorityMask) == 0)
|
|
return true;
|
|
|
|
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
|
|
return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
|
|
}
|
|
|
|
private EquipMask FirstAvailableEquipMask(ClientObject item)
|
|
{
|
|
foreach (var mask in AutoEquipOrder)
|
|
{
|
|
if ((item.ValidLocations & mask) == EquipMask.None)
|
|
continue;
|
|
if (!EquipMaskOccupied(mask, item.ObjectId))
|
|
return mask;
|
|
}
|
|
return EquipMask.None;
|
|
}
|
|
|
|
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
|
{
|
|
foreach (var o in _objects.Objects)
|
|
{
|
|
if (o.ObjectId == exceptGuid)
|
|
continue;
|
|
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
|
|
continue;
|
|
if (IsEquippedByPlayer(o))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
|
{
|
|
uint mask = 0;
|
|
foreach (var o in _objects.Objects)
|
|
{
|
|
if (o.ObjectId == exceptGuid)
|
|
continue;
|
|
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
|
continue;
|
|
if (IsEquippedByPlayer(o))
|
|
mask |= o.Priority;
|
|
}
|
|
return mask;
|
|
}
|
|
|
|
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
|
{
|
|
EquipMask mask = EquipMask.None;
|
|
foreach (var o in _objects.Objects)
|
|
{
|
|
if (o.ObjectId == exceptGuid)
|
|
continue;
|
|
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
|
continue;
|
|
if (IsEquippedByPlayer(o))
|
|
mask |= o.CurrentlyEquippedLocation;
|
|
}
|
|
return mask;
|
|
}
|
|
|
|
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
|
|
{
|
|
if (locationMask == EquipMask.None)
|
|
return null;
|
|
|
|
foreach (var o in _objects.Objects)
|
|
{
|
|
if (o.ObjectId == exceptGuid)
|
|
continue;
|
|
if (!IsEquippedByPlayer(o))
|
|
continue;
|
|
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
|
continue;
|
|
if ((o.Priority & priority) != 0 || priority == 0)
|
|
return o;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|