fix(items): finish corpse pickup and combat switching

Port retail's first-slot ShowPendingInPlayer path for double-click loot and carry the current owned-container destination through deferred pickup. Retire the previous ground-container view as soon as a replacement is requested so its range close cannot cancel ACE's active MoveTo chain.

Preserve active-combat weapon intent across ACE's authoritative wand-to-missile stance tail while leaving peace-mode switches unchanged.

Release build succeeds and all 5,885 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 18:35:29 +02:00
parent d51a0fc825
commit 0392c6d721
17 changed files with 631 additions and 81 deletions

View file

@ -1,4 +1,5 @@
using System;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.UI;
@ -63,8 +64,11 @@ internal sealed class AutoWieldController : IDisposable
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 _disposed;
public bool IsBusy => _pendingSwitch is not null;
@ -75,7 +79,9 @@ internal sealed class AutoWieldController : IDisposable
Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null)
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));
@ -83,12 +89,16 @@ internal sealed class AutoWieldController : IDisposable
_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>
@ -97,7 +107,10 @@ internal sealed class AutoWieldController : IDisposable
/// as retail's inventory-ready gate prevents a second concurrent request.
/// </summary>
public bool TryWield(ClientObject item)
=> TryWield(item, EquipMask.None);
{
_pendingCombatSettlement = null;
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
}
/// <summary>
/// Execute AutoWield while retaining the paperdoll's resolved target side.
@ -106,6 +119,15 @@ internal sealed class AutoWieldController : IDisposable
/// bypassing the transaction with a direct wire request.
/// </summary>
public bool TryWield(ClientObject item, EquipMask requestedMask)
{
_pendingCombatSettlement = null;
return TryWield(item, requestedMask, combatModeAfterWield: null);
}
private bool TryWield(
ClientObject item,
EquipMask requestedMask,
CombatMode? combatModeAfterWield)
{
ArgumentNullException.ThrowIfNull(item);
@ -130,9 +152,13 @@ internal sealed class AutoWieldController : IDisposable
if (GetEquippedObjectAtLocation(
requestedMask, priority: 0, item.ObjectId) is { } blocker)
{
return BeginWeaponReplacement(item.ObjectId, blocker, requestedMask);
return BeginWeaponReplacement(
item.ObjectId,
blocker,
requestedMask,
combatModeAfterWield);
}
return SendWield(item, requestedMask);
return SendWield(item, requestedMask, combatModeAfterWield);
}
EquipMask weaponLocation = requestedMask == EquipMask.None
@ -140,10 +166,24 @@ internal sealed class AutoWieldController : IDisposable
: 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);
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
@ -151,15 +191,23 @@ internal sealed class AutoWieldController : IDisposable
if (BlocksUseOfShield(item)
&& GetEquippedObjectAtLocation(
EquipMask.Shield, priority: 0, item.ObjectId) is { } shield)
return BeginWeaponReplacement(item.ObjectId, shield, weaponLocation);
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);
return BeginWeaponReplacement(
item.ObjectId,
ammo,
weaponLocation,
combatModeAfterWield);
return SendWield(item, weaponLocation);
return SendWield(item, weaponLocation, combatModeAfterWield);
}
EquipMask mask = requestedMask != EquipMask.None
@ -171,13 +219,14 @@ internal sealed class AutoWieldController : IDisposable
return false;
}
return SendWield(item, mask);
return SendWield(item, mask, combatModeAfterWield: null);
}
private bool BeginWeaponReplacement(
uint requestedItemId,
ClientObject blockingItem,
EquipMask requestedMask)
EquipMask requestedMask,
CombatMode? combatModeAfterWield)
{
if (_sendPutItemInContainer is null)
{
@ -192,7 +241,8 @@ internal sealed class AutoWieldController : IDisposable
_pendingSwitch = new PendingSwitch(
requestedItemId,
blockingItem.ObjectId,
requestedMask);
requestedMask,
combatModeAfterWield);
// Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0).
// Do not change the local equip projection yet: the server's move event
@ -203,14 +253,18 @@ internal sealed class AutoWieldController : IDisposable
return true;
}
private bool SendWield(ClientObject item, EquipMask mask)
private bool SendWield(
ClientObject item,
EquipMask mask,
CombatMode? combatModeAfterWield)
{
if (_sendWield is null)
return false;
_pendingSwitch = new PendingSwitch(
item.ObjectId,
BlockingItemId: 0,
RequestedMask: mask);
RequestedMask: mask,
CombatModeAfterWield: combatModeAfterWield);
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
{
_pendingSwitch = null;
@ -235,14 +289,75 @@ internal sealed class AutoWieldController : IDisposable
// vacant ready slot and send GetAndWieldItem for the requested weapon.
_pendingSwitch = null;
if (_objects.Get(pending.RequestedItemId) is { } requested)
TryWield(requested, pending.RequestedMask);
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);
}
}
}
private void OnCombatModeChanged(CombatMode mode)
{
if (_pendingCombatSettlement is not { } settlement)
return;
if (mode == settlement.ReadyMode)
{
if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace)
{
// 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)
@ -259,10 +374,16 @@ internal sealed class AutoWieldController : IDisposable
&& (item.ObjectId == pending.BlockingItemId
|| item.ObjectId == pending.RequestedItemId))
_pendingSwitch = null;
if (_pendingCombatSettlement is { } settlement
&& item.ObjectId == settlement.ItemId)
_pendingCombatSettlement = null;
}
private void OnObjectsCleared()
=> _pendingSwitch = null;
{
_pendingSwitch = null;
_pendingCombatSettlement = null;
}
private EquipMask BestAvailableEquipMask(ClientObject item)
{
@ -357,21 +478,48 @@ internal sealed class AutoWieldController : IDisposable
|| 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;
_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);
EquipMask RequestedMask,
CombatMode? CombatModeAfterWield);
private readonly record struct PendingCombatSettlement(
uint ItemId,
CombatMode ReadyMode,
CombatSettlementPhase Phase);
private enum CombatSettlementPhase
{
AwaitingPostWieldMode,
SawTransitionalPeace,
SawReadyAfterPeace,
}
}