acdream/src/AcDream.App/UI/AutoWieldController.cs
Erik e6dd8bf6fa fix(items): preserve wand stance and corpse use order
Keep active-combat AutoWield intent through ACE's pre-wield transition and queued trailing peace notice, while allowing explicit combat input to supersede it.

Queue one-shot item interactions until the frame's movement edge has been serialized, so a movement release cannot cancel ACE's server-side corpse approach callback.

Release build succeeds and all 5,890 runnable tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 19:32:01 +02:00

564 lines
20 KiB
C#

using System;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
/// Retail <c>CPlayerSystem::AutoWield @ 0x00560A60</c> transaction owner.
/// A conflicting primary weapon is first returned to the player container;
/// the requested weapon is wielded only after
/// <c>RecvNotice_ServerSaysMoveItem @ 0x00563260</c> confirms that move.
/// </summary>
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<uint> _playerGuid;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage;
private readonly CombatState? _combatState;
private readonly Action<CombatMode>? _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<uint> playerGuid,
Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null,
CombatState? combatState = null,
Action<CombatMode>? 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;
}
/// <summary>
/// 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.
/// </summary>
public bool TryWield(ClientObject item)
{
_pendingCombatSettlement = null;
_combatTransitionObservedDuringSwitch = false;
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
}
/// <summary>
/// Execute AutoWield while retaining the paperdoll's resolved target side.
/// Retail <c>gmPaperDollUI::AcceptDragObject @ 0x004A3B10</c> supplies this
/// location to <c>CPlayerSystem::AutoWield @ 0x00560A60</c> rather than
/// bypassing the transaction with a direct wire request.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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,
}
}