fix(items): preserve wand stance and corpse use order

Keep active-combat AutoWield intent through ACE's pre-wield transition and queued trailing peace notice, while allowing explicit combat input to supersede it.

Queue one-shot item interactions until the frame's movement edge has been serialized, so a movement release cannot cancel ACE's server-side corpse approach callback.

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

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 19:32:01 +02:00
parent 0392c6d721
commit e6dd8bf6fa
10 changed files with 303 additions and 22 deletions

View file

@ -69,6 +69,7 @@ internal sealed class AutoWieldController : IDisposable
private PendingSwitch? _pendingSwitch;
private PendingCombatSettlement? _pendingCombatSettlement;
private bool _combatTransitionObservedDuringSwitch;
private bool _disposed;
public bool IsBusy => _pendingSwitch is not null;
@ -109,6 +110,7 @@ internal sealed class AutoWieldController : IDisposable
public bool TryWield(ClientObject item)
{
_pendingCombatSettlement = null;
_combatTransitionObservedDuringSwitch = false;
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
}
@ -121,6 +123,7 @@ internal sealed class AutoWieldController : IDisposable
public bool TryWield(ClientObject item, EquipMask requestedMask)
{
_pendingCombatSettlement = null;
_combatTransitionObservedDuringSwitch = false;
return TryWield(item, requestedMask, combatModeAfterWield: null);
}
@ -309,18 +312,32 @@ internal sealed class AutoWieldController : IDisposable
_pendingCombatSettlement = new(
itemId,
readyMode,
CombatSettlementPhase.AwaitingPostWieldMode);
CombatSettlementPhase.AwaitingPostWieldMode,
_combatTransitionObservedDuringSwitch);
}
_combatTransitionObservedDuringSwitch = false;
}
}
private void OnCombatModeChanged(CombatMode mode)
{
if (_pendingSwitch is { CombatModeAfterWield: { } switchReadyMode }
&& mode != switchReadyMode)
{
// ACE's blocker/dequip callbacks can begin their old-stance ->
// Peace shuffle before WieldObject for the replacement arrives.
// Remembering that authoritative transition lets the settlement
// owner distinguish a later ready-mode notice followed by ACE's
// queued trailing Peace from a server that settled directly.
_combatTransitionObservedDuringSwitch = true;
}
if (_pendingCombatSettlement is not { } settlement)
return;
if (mode == settlement.ReadyMode)
{
if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace)
if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace
|| settlement.ExpectTrailingPeace)
{
// ACE selected the correct weapon stance, but its queued
// dequip callback still has one trailing Peace notice.
@ -365,7 +382,10 @@ internal sealed class AutoWieldController : IDisposable
if (_pendingSwitch is { } pending
&& (failure.ItemId == pending.BlockingItemId
|| failure.ItemId == pending.RequestedItemId))
{
_pendingSwitch = null;
_combatTransitionObservedDuringSwitch = false;
}
}
private void OnObjectRemoved(ClientObject item)
@ -373,7 +393,10 @@ internal sealed class AutoWieldController : IDisposable
if (_pendingSwitch is { } pending
&& (item.ObjectId == pending.BlockingItemId
|| item.ObjectId == pending.RequestedItemId))
{
_pendingSwitch = null;
_combatTransitionObservedDuringSwitch = false;
}
if (_pendingCombatSettlement is { } settlement
&& item.ObjectId == settlement.ItemId)
_pendingCombatSettlement = null;
@ -383,6 +406,20 @@ internal sealed class AutoWieldController : IDisposable
{
_pendingSwitch = null;
_pendingCombatSettlement = null;
_combatTransitionObservedDuringSwitch = false;
}
/// <summary>
/// A user combat-mode command supersedes any ready-mode intent retained by
/// an in-flight AutoWield transaction. This prevents the ACE compatibility
/// settlement from fighting a deliberate request for Peace.
/// </summary>
public void NotifyExplicitCombatModeRequest()
{
_pendingCombatSettlement = null;
_combatTransitionObservedDuringSwitch = false;
if (_pendingSwitch is { } pending)
_pendingSwitch = pending with { CombatModeAfterWield = null };
}
private EquipMask BestAvailableEquipMask(ClientObject item)
@ -496,6 +533,7 @@ internal sealed class AutoWieldController : IDisposable
_disposed = true;
_pendingSwitch = null;
_pendingCombatSettlement = null;
_combatTransitionObservedDuringSwitch = false;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.MoveRequestFailed -= OnMoveRequestFailed;
@ -514,7 +552,8 @@ internal sealed class AutoWieldController : IDisposable
private readonly record struct PendingCombatSettlement(
uint ItemId,
CombatMode ReadyMode,
CombatSettlementPhase Phase);
CombatSettlementPhase Phase,
bool ExpectTrailingPeace);
private enum CombatSettlementPhase
{

View file

@ -317,6 +317,10 @@ public sealed class ItemInteractionController : IDisposable
return _autoWield.TryWield(item, targetMask);
}
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
public void NotifyExplicitCombatModeRequest()
=> _autoWield.NotifyExplicitCombatModeRequest();
public bool AcquireTarget(uint targetGuid)
{
if (!IsTargetModeActive || targetGuid == 0) return false;