using System; using AcDream.Core.Items; namespace AcDream.App.UI; /// Result of offering a primary click to active item-target mode. public enum ItemPrimaryClickResult { NotActive, ConsumedSuccess, ConsumedRejected, } /// /// 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 { 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 _playerGuid; private readonly Func _nowMs; private readonly Action? _sendUse; private readonly Action? _sendUseWithTarget; private readonly Action? _sendWield; private readonly Action? _sendDrop; 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 Action? _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 playerGuid, Action? sendUse, Action? sendUseWithTarget, Action? sendWield, Action? sendDrop, Func? nowMs = null, Action? toast = null, InteractionState? interactionState = null, Func? readyForInventoryRequest = null, Func? activeVendorId = null, Func? groundObjectId = null, Func? playerOnGround = null, Func? inNonCombatMode = null, Func? isComponentPack = null, Action? placeInBackpack = null, Action? 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; /// /// 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 IsPendingSource(uint itemGuid) => itemGuid != 0 && itemGuid == PendingSourceItem; /// /// Retail ACCWeenieObject::IsOwnedByPlayer projection shared with /// toolbar shortcut creation. Ownership includes self, equipped items, and /// arbitrarily nested carried containers. /// 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); } /// /// 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) { 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 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 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; } /// Retail UseDone (0x01C7) releases one UI busy reference. 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()); } /// /// 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; } }