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