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 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? _sendExamine; private readonly Action? _sendUseWithTarget; private readonly Action? _sendWield; private readonly Action? _sendDrop; private readonly Action? _sendSplitToWorld; 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 readonly Func _selectedObjectId; private readonly StackSplitQuantityState? _stackSplitQuantity; private readonly AutoWieldController _autoWield; 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, Action? sendExamine = null, 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, Action? sendSplitToWorld = null, Func? selectedObjectId = null, StackSplitQuantityState? stackSplitQuantity = null, Action? sendPutItemInContainer = 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; _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; _selectedObjectId = selectedObjectId ?? (() => 0u); _stackSplitQuantity = stackSplitQuantity; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; _autoWield = new AutoWieldController( _objects, _playerGuid, _sendWield, sendPutItemInContainer, _toast); } public event Action? StateChanged; public uint PlayerGuid => _playerGuid(); public InteractionState InteractionState => _interactionState; public int BusyCount => _busyCount; public bool CanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy; /// /// 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. 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) { 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) _sendExamine!(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; _sendExamine(selectedObjectId); return true; } 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(), CanMakeInventoryRequest, _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 (!CanMakeInventoryRequest) return false; _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); _busyCount++; 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 || _sendUse is null) return false; _sendUse(objectId); _busyCount++; return true; } public void CancelTargetMode() { if (!IsAnyTargetModeActive) 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; uint fullStack = (uint)Math.Max(1, item.StackSize); int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize( item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack); var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput( Snapshot(item), _playerGuid(), _groundObjectId(), CanMakeInventoryRequest, 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 |= _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) { _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.SplitToWorld: // UIAttemptSplitTo3D leaves the original object in its container; // the server assigns the split-off ground stack a new guid. _sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount); 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; _autoWield.Dispose(); } /// 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 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; } }