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>
564 lines
20 KiB
C#
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,
|
|
}
|
|
}
|