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:
Erik 2026-07-12 23:06:31 +02:00
parent 8e9c538519
commit 17b5712d53
14 changed files with 736 additions and 155 deletions

View file

@ -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)