using System; using AcDream.Core.Items; namespace AcDream.App.UI; /// /// Retail CPlayerSystem::AutoWield @ 0x00560A60 transaction owner. /// A conflicting primary weapon is first returned to the player container; /// the requested weapon is wielded only after /// RecvNotice_ServerSaysMoveItem @ 0x00563260 confirms that move. /// internal sealed class AutoWieldController : IDisposable { // Retail AutoWield immediate 0x03500000: all mutually-exclusive // primary weapon-ready locations (shield and missile ammo are separate). internal const EquipMask WeaponReadyMask = EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded; private const byte CombatUseMissile = 0x02; private const byte CombatUseTwoHanded = 0x05; 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 readonly ClientObjectTable _objects; private readonly Func _playerGuid; private readonly Action? _sendWield; private readonly Action? _sendPutItemInContainer; private readonly Action? _toast; private readonly Action? _systemMessage; private PendingSwitch? _pendingSwitch; private bool _disposed; public bool IsBusy => _pendingSwitch is not null; public AutoWieldController( ClientObjectTable objects, Func playerGuid, Action? sendWield, Action? sendPutItemInContainer, Action? toast, Action? systemMessage = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _sendWield = sendWield; _sendPutItemInContainer = sendPutItemInContainer; _toast = toast; _systemMessage = systemMessage; _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; _objects.MoveRequestFailed += OnMoveRequestFailed; _objects.WieldConfirmed += OnWieldConfirmed; _objects.Cleared += OnObjectsCleared; } /// /// Execute the UI-facing AutoWield request. A request received while the /// prior conflicting weapon is awaiting its server move is consumed, just /// as retail's inventory-ready gate prevents a second concurrent request. /// public bool TryWield(ClientObject item) => TryWield(item, EquipMask.None); /// /// Execute AutoWield while retaining the paperdoll's resolved target side. /// Retail gmPaperDollUI::AcceptDragObject @ 0x004A3B10 supplies this /// location to CPlayerSystem::AutoWield @ 0x00560A60 rather than /// bypassing the transaction with a direct wire request. /// public bool TryWield(ClientObject item, EquipMask requestedMask) { ArgumentNullException.ThrowIfNull(item); if (_pendingSwitch is not null) return true; if (item.ValidLocations == EquipMask.None) return false; if (requestedMask != EquipMask.None && (requestedMask & item.ValidLocations) != requestedMask) return false; // gmPaperDollUI::AcceptDragObject can ask AutoWield to move an item // that is already worn (the classic left-ring -> right-ring drop). // It is not a blocker transaction: the object itself is the requested // item, so preserve the paperdoll's explicit destination and let the // ordinary GetAndWieldItem acknowledgement relocate it. if (item.CurrentlyEquippedLocation != EquipMask.None) { if (requestedMask == EquipMask.None || requestedMask == item.CurrentlyEquippedLocation) return false; if (GetEquippedObjectAtLocation( requestedMask, priority: 0, item.ObjectId) is { } blocker) { return BeginWeaponReplacement(item.ObjectId, blocker, requestedMask); } return SendWield(item, requestedMask); } EquipMask weaponLocation = requestedMask == EquipMask.None ? item.ValidLocations & WeaponReadyMask : requestedMask & WeaponReadyMask; if (weaponLocation != EquipMask.None) { ClientObject? blocker = GetEquippedObjectAtLocation( WeaponReadyMask, priority: 0, item.ObjectId); if (blocker is not null) return BeginWeaponReplacement(item.ObjectId, blocker, weaponLocation); // Retail checks secondary blockers only after the primary weapon // slot is vacant, then re-enters AutoWield after each confirmed // move. ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0. if (BlocksUseOfShield(item) && GetEquippedObjectAtLocation( EquipMask.Shield, priority: 0, item.ObjectId) is { } shield) return BeginWeaponReplacement(item.ObjectId, shield, weaponLocation); if (item.AmmoType is > 0 && GetEquippedObjectAtLocation( EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo && ammo.AmmoType != item.AmmoType) return BeginWeaponReplacement(item.ObjectId, ammo, weaponLocation); return SendWield(item, weaponLocation); } EquipMask mask = requestedMask != EquipMask.None ? requestedMask : BestAvailableEquipMask(item); if (mask == EquipMask.None) { _toast?.Invoke("That slot is already in use"); return false; } return SendWield(item, mask); } private bool BeginWeaponReplacement( uint requestedItemId, ClientObject blockingItem, EquipMask requestedMask) { if (_sendPutItemInContainer is null) { _toast?.Invoke("That slot is already in use"); return false; } uint player = _playerGuid(); if (player == 0) return false; _pendingSwitch = new PendingSwitch( requestedItemId, blockingItem.ObjectId, requestedMask); // Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0). // Do not change the local equip projection yet: the server's move event // is the transaction boundary and preserves its stance-specific motion. _systemMessage?.Invoke( $"Moving {blockingItem.GetAppropriateName()} to your backpack"); _sendPutItemInContainer(blockingItem.ObjectId, player, 0); return true; } private bool SendWield(ClientObject item, EquipMask mask) { if (_sendWield is null) return false; _pendingSwitch = new PendingSwitch( item.ObjectId, BlockingItemId: 0, RequestedMask: mask); if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) { _pendingSwitch = null; return false; } // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. _sendWield(item.ObjectId, (uint)mask); return true; } private void OnObjectMoved(ClientObjectMove move) { if (_pendingSwitch is not { } pending || pending.BlockingItemId == 0 || move.ItemId != pending.BlockingItemId || move.Current.ContainerId != _playerGuid() || move.Current.EquipLocation != EquipMask.None) return; // Clear before re-entry: the second AutoWield pass must see the newly // vacant ready slot and send GetAndWieldItem for the requested weapon. _pendingSwitch = null; if (_objects.Get(pending.RequestedItemId) is { } requested) TryWield(requested, pending.RequestedMask); } private void OnWieldConfirmed(uint itemId) { if (_pendingSwitch is { BlockingItemId: 0 } pending && itemId == pending.RequestedItemId) _pendingSwitch = null; } private void OnMoveRequestFailed(MoveRequestFailure failure) { if (_pendingSwitch is { } pending && (failure.ItemId == pending.BlockingItemId || failure.ItemId == pending.RequestedItemId)) _pendingSwitch = null; } private void OnObjectRemoved(ClientObject item) { if (_pendingSwitch is { } pending && (item.ObjectId == pending.BlockingItemId || item.ObjectId == pending.RequestedItemId)) _pendingSwitch = null; } private void OnObjectsCleared() => _pendingSwitch = null; private EquipMask BestAvailableEquipMask(ClientObject item) { if (ItemEquipRules.IsAutoWearItem(item)) return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None; foreach (EquipMask mask in AutoEquipOrder) { if ((item.ValidLocations & mask) == EquipMask.None) continue; if (!EquipMaskOccupied(mask, item.ObjectId)) return mask; } return EquipMask.None; } 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 bool EquipMaskOccupied(EquipMask mask, uint exceptGuid) => GetEquippedObjectAtLocation(mask, priority: 0, exceptGuid) is not null; private uint EquippedAutoWearPriorityMask(uint exceptGuid) { uint mask = 0; foreach (ClientObject item in _objects.Objects) { if (item.ObjectId == exceptGuid || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None || !IsEquippedByPlayer(item)) continue; mask |= item.Priority; } return mask; } private EquipMask EquippedAutoWearLocationMask(uint exceptGuid) { EquipMask mask = EquipMask.None; foreach (ClientObject item in _objects.Objects) { if (item.ObjectId == exceptGuid || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None || !IsEquippedByPlayer(item)) continue; mask |= item.CurrentlyEquippedLocation; } return mask; } private ClientObject? GetEquippedObjectAtLocation( EquipMask locationMask, uint priority, uint exceptGuid) { if (locationMask == EquipMask.None) return null; foreach (ClientObject item in _objects.Objects) { if (item.ObjectId == exceptGuid || !IsEquippedByPlayer(item) || (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None) continue; if ((item.Priority & priority) != 0 || priority == 0) return item; } return null; } private bool IsEquippedByPlayer(ClientObject item) { uint player = _playerGuid(); return item.CurrentlyEquippedLocation != EquipMask.None && (item.WielderId == player || item.ContainerId == player); } internal static bool BlocksUseOfShield(ClientObject item) { byte combatUse = item.CombatUse ?? 0; return combatUse == CombatUseTwoHanded || (combatUse == CombatUseMissile && item.AmmoType is > 0) || item.Type.HasFlag(ItemType.Caster); } public void Dispose() { if (_disposed) return; _disposed = true; _pendingSwitch = null; _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectRemoved; _objects.MoveRequestFailed -= OnMoveRequestFailed; _objects.WieldConfirmed -= OnWieldConfirmed; _objects.Cleared -= OnObjectsCleared; } private readonly record struct PendingSwitch( uint RequestedItemId, uint BlockingItemId, EquipMask RequestedMask); }