using System; using AcDream.Core.Combat; 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 readonly CombatState? _combatState; private readonly Action? _sendChangeCombatMode; private PendingSwitch? _pendingSwitch; private PendingCombatSettlement? _pendingCombatSettlement; private bool _combatTransitionObservedDuringSwitch; 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, CombatState? combatState = null, Action? sendChangeCombatMode = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _sendWield = sendWield; _sendPutItemInContainer = sendPutItemInContainer; _toast = toast; _systemMessage = systemMessage; _combatState = combatState; _sendChangeCombatMode = sendChangeCombatMode; _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; _objects.MoveRequestFailed += OnMoveRequestFailed; _objects.WieldConfirmed += OnWieldConfirmed; _objects.Cleared += OnObjectsCleared; if (_combatState is not null) _combatState.CombatModeChanged += OnCombatModeChanged; } /// /// 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) { _pendingCombatSettlement = null; _combatTransitionObservedDuringSwitch = false; return TryWield(item, EquipMask.None, combatModeAfterWield: null); } /// /// 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) { _pendingCombatSettlement = null; _combatTransitionObservedDuringSwitch = false; return TryWield(item, requestedMask, combatModeAfterWield: null); } private bool TryWield( ClientObject item, EquipMask requestedMask, CombatMode? combatModeAfterWield) { 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, combatModeAfterWield); } return SendWield(item, requestedMask, combatModeAfterWield); } EquipMask weaponLocation = requestedMask == EquipMask.None ? item.ValidLocations & WeaponReadyMask : requestedMask & WeaponReadyMask; if (weaponLocation != EquipMask.None) { // Preserve the ready mode only when the transaction starts in // combat. During a replacement ACE legitimately reports the // intermediate stances used to put the old weapon away; those // must not erase the user's original intent before the new wield // is authoritatively confirmed. combatModeAfterWield ??= (_combatState?.CurrentMode ?? CombatMode.NonCombat) == CombatMode.NonCombat ? null : CombatModeForWeaponLocation(weaponLocation); ClientObject? blocker = GetEquippedObjectAtLocation( WeaponReadyMask, priority: 0, item.ObjectId); if (blocker is not null) return BeginWeaponReplacement( item.ObjectId, blocker, weaponLocation, combatModeAfterWield); // 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, combatModeAfterWield); if (item.AmmoType is > 0 && GetEquippedObjectAtLocation( EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo && ammo.AmmoType != item.AmmoType) return BeginWeaponReplacement( item.ObjectId, ammo, weaponLocation, combatModeAfterWield); return SendWield(item, weaponLocation, combatModeAfterWield); } 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, combatModeAfterWield: null); } private bool BeginWeaponReplacement( uint requestedItemId, ClientObject blockingItem, EquipMask requestedMask, CombatMode? combatModeAfterWield) { 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, combatModeAfterWield); // 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, CombatMode? combatModeAfterWield) { if (_sendWield is null) return false; _pendingSwitch = new PendingSwitch( item.ObjectId, BlockingItemId: 0, RequestedMask: mask, CombatModeAfterWield: combatModeAfterWield); 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, pending.CombatModeAfterWield); } private void OnWieldConfirmed(uint itemId) { if (_pendingSwitch is { BlockingItemId: 0 } pending && itemId == pending.RequestedItemId) { _pendingSwitch = null; if (pending.CombatModeAfterWield is { } readyMode) { // ACE publishes its queued dequip tail after WieldObject. Wait // for that first authoritative post-wield mode notice before // responding; sending inside WieldConfirmed races the tail. _pendingCombatSettlement = new( itemId, readyMode, CombatSettlementPhase.AwaitingPostWieldMode, _combatTransitionObservedDuringSwitch); } _combatTransitionObservedDuringSwitch = false; } } private void OnCombatModeChanged(CombatMode mode) { if (_pendingSwitch is { CombatModeAfterWield: { } switchReadyMode } && mode != switchReadyMode) { // ACE's blocker/dequip callbacks can begin their old-stance -> // Peace shuffle before WieldObject for the replacement arrives. // Remembering that authoritative transition lets the settlement // owner distinguish a later ready-mode notice followed by ACE's // queued trailing Peace from a server that settled directly. _combatTransitionObservedDuringSwitch = true; } if (_pendingCombatSettlement is not { } settlement) return; if (mode == settlement.ReadyMode) { if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace || settlement.ExpectTrailingPeace) { // ACE selected the correct weapon stance, but its queued // dequip callback still has one trailing Peace notice. _pendingCombatSettlement = settlement with { Phase = CombatSettlementPhase.SawReadyAfterPeace, }; } else { // A server that immediately settled on the intended ready // stance needs no compatibility request. _pendingCombatSettlement = null; } return; } if (mode != CombatMode.NonCombat) return; if (settlement.Phase == CombatSettlementPhase.SawReadyAfterPeace) { // This is ACE's trailing Peace after it already selected the new // weapon stance. The server's stance shuffle is now settled, so a // normal request from this notice is accepted and ordered after it. _pendingCombatSettlement = null; _sendChangeCombatMode?.Invoke(settlement.ReadyMode); } else { // First transitional Peace. ACE is still shuffling the old weapon; // requesting here races the later queued callback. _pendingCombatSettlement = settlement with { Phase = CombatSettlementPhase.SawTransitionalPeace, }; } } private void OnMoveRequestFailed(MoveRequestFailure failure) { if (_pendingSwitch is { } pending && (failure.ItemId == pending.BlockingItemId || failure.ItemId == pending.RequestedItemId)) { _pendingSwitch = null; _combatTransitionObservedDuringSwitch = false; } } private void OnObjectRemoved(ClientObject item) { if (_pendingSwitch is { } pending && (item.ObjectId == pending.BlockingItemId || item.ObjectId == pending.RequestedItemId)) { _pendingSwitch = null; _combatTransitionObservedDuringSwitch = false; } if (_pendingCombatSettlement is { } settlement && item.ObjectId == settlement.ItemId) _pendingCombatSettlement = null; } private void OnObjectsCleared() { _pendingSwitch = null; _pendingCombatSettlement = null; _combatTransitionObservedDuringSwitch = false; } /// /// A user combat-mode command supersedes any ready-mode intent retained by /// an in-flight AutoWield transaction. This prevents the ACE compatibility /// settlement from fighting a deliberate request for Peace. /// public void NotifyExplicitCombatModeRequest() { _pendingCombatSettlement = null; _combatTransitionObservedDuringSwitch = false; if (_pendingSwitch is { } pending) _pendingSwitch = pending with { CombatModeAfterWield = 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); } private static CombatMode? CombatModeForWeaponLocation(EquipMask location) { if ((location & EquipMask.MissileWeapon) != 0) return CombatMode.Missile; if ((location & EquipMask.Held) != 0) return CombatMode.Magic; if ((location & (EquipMask.MeleeWeapon | EquipMask.TwoHanded)) != 0) return CombatMode.Melee; return null; } public void Dispose() { if (_disposed) return; _disposed = true; _pendingSwitch = null; _pendingCombatSettlement = null; _combatTransitionObservedDuringSwitch = false; _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectRemoved; _objects.MoveRequestFailed -= OnMoveRequestFailed; _objects.WieldConfirmed -= OnWieldConfirmed; _objects.Cleared -= OnObjectsCleared; if (_combatState is not null) _combatState.CombatModeChanged -= OnCombatModeChanged; } private readonly record struct PendingSwitch( uint RequestedItemId, uint BlockingItemId, EquipMask RequestedMask, CombatMode? CombatModeAfterWield); private readonly record struct PendingCombatSettlement( uint ItemId, CombatMode ReadyMode, CombatSettlementPhase Phase, bool ExpectTrailingPeace); private enum CombatSettlementPhase { AwaitingPostWieldMode, SawTransitionalPeace, SawReadyAfterPeace, } }