Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks. Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state. Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration. Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates. Co-Authored-By: Codex <noreply@openai.com>
377 lines
13 KiB
C#
377 lines
13 KiB
C#
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 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 PendingSwitch? _pendingSwitch;
|
|
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)
|
|
{
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
|
_sendWield = sendWield;
|
|
_sendPutItemInContainer = sendPutItemInContainer;
|
|
_toast = toast;
|
|
_systemMessage = systemMessage;
|
|
|
|
_objects.ObjectMoved += OnObjectMoved;
|
|
_objects.ObjectRemoved += OnObjectRemoved;
|
|
_objects.MoveRequestFailed += OnMoveRequestFailed;
|
|
_objects.WieldConfirmed += OnWieldConfirmed;
|
|
_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)
|
|
=> TryWield(item, EquipMask.None);
|
|
|
|
/// <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)
|
|
{
|
|
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);
|
|
}
|
|
return SendWield(item, requestedMask);
|
|
}
|
|
|
|
EquipMask weaponLocation = requestedMask == EquipMask.None
|
|
? item.ValidLocations & WeaponReadyMask
|
|
: requestedMask & WeaponReadyMask;
|
|
if (weaponLocation != EquipMask.None)
|
|
{
|
|
ClientObject? blocker = GetEquippedObjectAtLocation(
|
|
WeaponReadyMask, priority: 0, item.ObjectId);
|
|
if (blocker is not null)
|
|
return BeginWeaponReplacement(item.ObjectId, blocker, weaponLocation);
|
|
|
|
// 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);
|
|
|
|
if (item.AmmoType is > 0
|
|
&& GetEquippedObjectAtLocation(
|
|
EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo
|
|
&& ammo.AmmoType != item.AmmoType)
|
|
return BeginWeaponReplacement(item.ObjectId, ammo, weaponLocation);
|
|
|
|
return SendWield(item, weaponLocation);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private bool BeginWeaponReplacement(
|
|
uint requestedItemId,
|
|
ClientObject blockingItem,
|
|
EquipMask requestedMask)
|
|
{
|
|
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);
|
|
|
|
// 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)
|
|
{
|
|
if (_sendWield is null)
|
|
return false;
|
|
_pendingSwitch = new PendingSwitch(
|
|
item.ObjectId,
|
|
BlockingItemId: 0,
|
|
RequestedMask: mask);
|
|
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);
|
|
}
|
|
|
|
private void OnWieldConfirmed(uint itemId)
|
|
{
|
|
if (_pendingSwitch is { BlockingItemId: 0 } pending
|
|
&& itemId == pending.RequestedItemId)
|
|
_pendingSwitch = null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
_pendingSwitch = null;
|
|
_objects.ObjectMoved -= OnObjectMoved;
|
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
|
_objects.MoveRequestFailed -= OnMoveRequestFailed;
|
|
_objects.WieldConfirmed -= OnWieldConfirmed;
|
|
_objects.Cleared -= OnObjectsCleared;
|
|
}
|
|
|
|
private readonly record struct PendingSwitch(
|
|
uint RequestedItemId,
|
|
uint BlockingItemId,
|
|
EquipMask RequestedMask);
|
|
}
|