feat(inventory): port retail weapon switching
Sequence primary weapon replacement through the server-confirmed AutoWield transaction: return the occupied weapon to the player pack, wait for its move event, then wield the requested item. Route authoritative CombatMode property updates so peace stays peace and war adopts the new weapon stance without client-synthesized animation. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
8e9c538519
commit
17b5712d53
14 changed files with 736 additions and 155 deletions
288
src/AcDream.App/UI/AutoWieldController.cs
Normal file
288
src/AcDream.App/UI/AutoWieldController.cs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
using System;
|
||||
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 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 PendingSwitch? _pendingSwitch;
|
||||
private bool _disposed;
|
||||
|
||||
public AutoWieldController(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Action<uint, uint>? sendWield,
|
||||
Action<uint, uint, int>? sendPutItemInContainer,
|
||||
Action<string>? toast)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_sendWield = sendWield;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_toast = toast;
|
||||
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.MoveRequestFailed += OnMoveRequestFailed;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
if (_pendingSwitch is not null)
|
||||
return true;
|
||||
if (item.ValidLocations == EquipMask.None
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return false;
|
||||
|
||||
EquipMask weaponLocation = item.ValidLocations & WeaponReadyMask;
|
||||
if (weaponLocation != EquipMask.None)
|
||||
{
|
||||
ClientObject? blocker = GetEquippedObjectAtLocation(
|
||||
WeaponReadyMask, priority: 0, item.ObjectId);
|
||||
if (blocker is not null)
|
||||
return BeginWeaponReplacement(item.ObjectId, blocker.ObjectId);
|
||||
|
||||
return SendWield(item, weaponLocation);
|
||||
}
|
||||
|
||||
EquipMask mask = 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, uint blockingItemId)
|
||||
{
|
||||
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, blockingItemId);
|
||||
|
||||
// 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.
|
||||
_sendPutItemInContainer(blockingItemId, player, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool SendWield(ClientObject item, EquipMask mask)
|
||||
{
|
||||
if (_sendWield is null)
|
||||
return false;
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
return false;
|
||||
|
||||
// Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
|
||||
_sendWield(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObject item, uint _, uint newContainerId)
|
||||
{
|
||||
if (_pendingSwitch is not { } pending
|
||||
|| item.ObjectId != pending.BlockingItemId
|
||||
|| newContainerId != _playerGuid()
|
||||
|| item.CurrentlyEquippedLocation != 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_pendingSwitch = null;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.MoveRequestFailed -= OnMoveRequestFailed;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
}
|
||||
|
||||
private readonly record struct PendingSwitch(
|
||||
uint RequestedItemId,
|
||||
uint BlockingItemId);
|
||||
}
|
||||
|
|
@ -18,41 +18,6 @@ public enum ItemPrimaryClickResult
|
|||
/// </summary>
|
||||
public sealed class ItemInteractionController : IDisposable
|
||||
{
|
||||
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 const long RetailUseThrottleMs = 200;
|
||||
private const long RetailDoubleClickMs = 500;
|
||||
|
||||
|
|
@ -77,6 +42,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly InteractionState _interactionState;
|
||||
private readonly Func<uint> _selectedObjectId;
|
||||
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
||||
private readonly AutoWieldController _autoWield;
|
||||
|
||||
private long _lastUseMs = long.MinValue / 2;
|
||||
private uint _consumedPrimaryClickTarget;
|
||||
|
|
@ -105,7 +71,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<ItemPolicyAction>? auxiliaryAction = null,
|
||||
Action<uint, uint>? sendSplitToWorld = null,
|
||||
Func<uint>? selectedObjectId = null,
|
||||
StackSplitQuantityState? stackSplitQuantity = null)
|
||||
StackSplitQuantityState? stackSplitQuantity = null,
|
||||
Action<uint, uint, int>? sendPutItemInContainer = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -129,6 +96,12 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_stackSplitQuantity = stackSplitQuantity;
|
||||
_interactionState = interactionState ?? new InteractionState();
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
_autoWield = new AutoWieldController(
|
||||
_objects,
|
||||
_playerGuid,
|
||||
_sendWield,
|
||||
sendPutItemInContainer,
|
||||
_toast);
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
|
@ -364,7 +337,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
case ItemPolicyActionKind.WieldLeft:
|
||||
case ItemPolicyActionKind.AutoSort:
|
||||
if (_objects.Get(action.ObjectId) is { } item)
|
||||
acted |= TryAutoWield(item);
|
||||
acted |= _autoWield.TryWield(item);
|
||||
break;
|
||||
case ItemPolicyActionKind.OpenContainedContainer:
|
||||
case ItemPolicyActionKind.SendUse:
|
||||
|
|
@ -472,6 +445,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_interactionState.Changed -= OnInteractionModeChanged;
|
||||
_autoWield.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
||||
|
|
@ -491,119 +465,6 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private bool TryAutoWield(ClientObject item)
|
||||
{
|
||||
if (item.ValidLocations == EquipMask.None
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
return false;
|
||||
|
||||
EquipMask mask = BestAvailableEquipMask(item);
|
||||
if (mask == EquipMask.None)
|
||||
{
|
||||
_toast?.Invoke("That slot is already in use");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
|
||||
return false;
|
||||
|
||||
_sendWield?.Invoke(item.ObjectId, (uint)mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
if (ItemEquipRules.IsAutoWearItem(item))
|
||||
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
|
||||
|
||||
return FirstAvailableEquipMask(item);
|
||||
}
|
||||
|
||||
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 EquipMask FirstAvailableEquipMask(ClientObject item)
|
||||
{
|
||||
foreach (var mask in AutoEquipOrder)
|
||||
{
|
||||
if ((item.ValidLocations & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (!EquipMaskOccupied(mask, item.ObjectId))
|
||||
return mask;
|
||||
}
|
||||
return EquipMask.None;
|
||||
}
|
||||
|
||||
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
|
||||
{
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
|
||||
{
|
||||
uint mask = 0;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.Priority;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
|
||||
{
|
||||
EquipMask mask = EquipMask.None;
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
|
||||
continue;
|
||||
if (IsEquippedByPlayer(o))
|
||||
mask |= o.CurrentlyEquippedLocation;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
|
||||
{
|
||||
if (locationMask == EquipMask.None)
|
||||
return null;
|
||||
|
||||
foreach (var o in _objects.Objects)
|
||||
{
|
||||
if (o.ObjectId == exceptGuid)
|
||||
continue;
|
||||
if (!IsEquippedByPlayer(o))
|
||||
continue;
|
||||
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
|
||||
continue;
|
||||
if ((o.Priority & priority) != 0 || priority == 0)
|
||||
return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsContainer(ClientObject item)
|
||||
=> item.ContainerTypeHint != 0
|
||||
|| item.Type.HasFlag(ItemType.Container)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue