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
|
|
@ -2032,7 +2032,9 @@ public sealed class GameWindow : IDisposable
|
|||
sendSplitToWorld: (item, amount) =>
|
||||
_liveSession?.SendStackableSplitTo3D(item, amount),
|
||||
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
|
||||
stackSplitQuantity: _stackSplitQuantity);
|
||||
stackSplitQuantity: _stackSplitQuantity,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
|
|
@ -2527,6 +2529,7 @@ public sealed class GameWindow : IDisposable
|
|||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||||
// the table is populated before the render handler runs.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
|
||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
|
|
|
|||
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)
|
||||
|
|
|
|||
47
src/AcDream.Core.Net/CombatStateWiring.cs
Normal file
47
src/AcDream.Core.Net/CombatStateWiring.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Routes the local player's server-owned combat-mode quality into
|
||||
/// <see cref="CombatState"/>. Retail
|
||||
/// <c>ClientCombatSystem::OnQualityChanged @ 0x0056C1B0</c> reads
|
||||
/// PropertyInt 40 and calls <c>SetCombatMode</c> on every change.
|
||||
/// </summary>
|
||||
public static class CombatStateWiring
|
||||
{
|
||||
public const uint CombatModePropertyId = 40u;
|
||||
|
||||
public static void Wire(WorldSession session, CombatState combat)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
|
||||
session.PlayerIntPropertyUpdated += update =>
|
||||
ApplyPlayerIntProperty(combat, update.Property, update.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure event adapter exposed for conformance tests. Composite or unknown
|
||||
/// values are ignored: retail's quality contains one concrete mode.
|
||||
/// </summary>
|
||||
public static bool ApplyPlayerIntProperty(
|
||||
CombatState combat,
|
||||
uint property,
|
||||
int value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
if (property != CombatModePropertyId)
|
||||
return false;
|
||||
|
||||
CombatMode mode = (CombatMode)value;
|
||||
if (mode is not (CombatMode.NonCombat
|
||||
or CombatMode.Melee
|
||||
or CombatMode.Missile
|
||||
or CombatMode.Magic))
|
||||
return false;
|
||||
|
||||
combat.SetCombatMode(mode);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +314,7 @@ public static class GameEventWiring
|
|||
string itemInfo = item is null
|
||||
? "unknown"
|
||||
: $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}";
|
||||
bool rolledBack = items.RollbackMove(p.Value.ItemGuid);
|
||||
bool rolledBack = items.RejectMove(p.Value.ItemGuid, p.Value.WeenieError);
|
||||
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ namespace AcDream.Core.Items;
|
|||
/// </summary>
|
||||
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
|
||||
|
||||
/// <summary>
|
||||
/// Server rejection of an inventory move request. <paramref name="RolledBack"/>
|
||||
/// is true when the table restored a locally optimistic move; false for
|
||||
/// server-confirmed transactions which deliberately left the local projection
|
||||
/// unchanged while waiting.
|
||||
/// </summary>
|
||||
public readonly record struct MoveRequestFailure(
|
||||
uint ItemId,
|
||||
uint WeenieError,
|
||||
bool RolledBack);
|
||||
|
||||
/// <summary>
|
||||
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
||||
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
||||
|
|
@ -74,12 +85,22 @@ public sealed class ClientObjectTable
|
|||
/// </summary>
|
||||
public event Action<ClientObject>? MoveRolledBack;
|
||||
|
||||
/// <summary>
|
||||
/// Fires for every InventoryServerSaveFailed response, including requests
|
||||
/// which did not mutate the table optimistically. Transaction coordinators
|
||||
/// use this to abandon server-confirmed multi-step moves without timers.
|
||||
/// </summary>
|
||||
public event Action<MoveRequestFailure>? MoveRequestFailed;
|
||||
|
||||
/// <summary>Fires when an object is removed from the session.</summary>
|
||||
public event Action<ClientObject>? ObjectRemoved;
|
||||
|
||||
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
||||
public event Action<ClientObject>? ObjectUpdated;
|
||||
|
||||
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
|
||||
public event Action? Cleared;
|
||||
|
||||
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
|
||||
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
|
||||
/// <see cref="ClientObject.Effects"/>.</summary>
|
||||
|
|
@ -254,6 +275,19 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconcile an InventoryServerSaveFailed response and publish the failure
|
||||
/// even when no optimistic snapshot exists. Retail's blocked AutoWield path
|
||||
/// waits for the server before changing its local item lists, so failure is
|
||||
/// still a meaningful transaction event in that case.
|
||||
/// </summary>
|
||||
public bool RejectMove(uint itemId, uint weenieError)
|
||||
{
|
||||
bool rolledBack = RollbackMove(itemId);
|
||||
MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack));
|
||||
return rolledBack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
||||
/// space, stolen, etc).
|
||||
|
|
@ -592,5 +626,6 @@ public sealed class ClientObjectTable
|
|||
_containers.Clear();
|
||||
_containerIndex.Clear();
|
||||
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
||||
Cleared?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue