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