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

@ -0,0 +1,41 @@
namespace AcDream.App.Input;
/// <summary>
/// Orders input-originated item interactions after the local player's movement
/// edge has been serialized for the same frame, but before inbound network
/// dispatch.
/// </summary>
/// <remarks>
/// Silk input callbacks arrive before the retail object phase. A movement-key
/// release and a later Use press can therefore be observed in one frame even
/// though the movement controller has not yet emitted the release packet.
/// Draining here preserves that event order on the wire: the release is sent
/// first, then the one-shot interaction. ACE otherwise interprets the later
/// MoveToState as cancellation of the Use-created MoveTo chain.
/// </remarks>
public sealed class OutboundInteractionQueue
{
private readonly Queue<Action> _pending = new();
public int Count => _pending.Count;
public void Enqueue(Action action)
{
ArgumentNullException.ThrowIfNull(action);
_pending.Enqueue(action);
}
/// <summary>
/// Drain only the actions present at the frame boundary. An action that
/// enqueues another action leaves it for the next frame instead of making
/// the current dispatch recursively unbounded.
/// </summary>
public void Drain()
{
int count = _pending.Count;
while (count-- > 0)
_pending.Dequeue().Invoke();
}
public void Clear() => _pending.Clear();
}

View file

@ -165,6 +165,7 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.OutboundInteractionQueue _outboundInteractions = new();
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
@ -2917,6 +2918,7 @@ public sealed class GameWindow : IDisposable
_magicRuntime?.Reset();
_itemInteractionController?.ClearBusy();
_selection.Reset();
_outboundInteractions.Clear();
_pendingPostArrivalAction = null;
_particleVisibility.Reset();
try
@ -10775,6 +10777,12 @@ public sealed class GameWindow : IDisposable
// remote objects. Its outbound movement snapshot is completed here,
// before SmartBox dispatch can apply F751/ForcePosition.
_localPlayerFrame.AdvanceBeforeNetwork(dt);
// Retail input ordering: movement edges reach MoveToState before a
// later Use/PickUp action from the same input frame. ACE cancels an
// active server MoveTo chain when it receives MoveToState, so sending
// Use directly from Silk's key callback inverted that order and left
// the client walking to a corpse whose server callback was cancelled.
_outboundInteractions.Drain();
_scriptRunner?.PublishTime(_physicsScriptGameTime);
if (_liveEntities is { } liveEntities)
{
@ -12966,7 +12974,7 @@ public sealed class GameWindow : IDisposable
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selection.SelectedObjectId is uint pickupTarget)
SendPickUp(pickupTarget);
_outboundInteractions.Enqueue(() => SendPickUp(pickupTarget));
else
_debugVm?.AddToast("Nothing selected");
break;
@ -13004,6 +13012,7 @@ public sealed class GameWindow : IDisposable
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
Combat.CurrentMode,
defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest();
_liveSession.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
@ -13237,7 +13246,15 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})"));
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
if (useImmediately)
{
// Route world double-click through the same retail
// ItemHolder policy as keyboard Use. This both establishes
// ClientUISystem::groundObject for containers and preserves
// movement-release-before-Use wire ordering.
_outboundInteractions.Enqueue(() =>
_itemInteractionController?.ActivateItem(guid));
}
}
else
{
@ -13259,7 +13276,9 @@ public sealed class GameWindow : IDisposable
// the same policy owner used by the toolbar. Keeping a second classifier here
// made BF_CORPSE containers look pickupable and sent PutItemInContainer,
// which ACE correctly rejected as Stuck (0x0029).
_itemInteractionController?.UseSelectedOrEnterMode(sel);
uint selected = sel;
_outboundInteractions.Enqueue(() =>
_itemInteractionController?.UseSelectedOrEnterMode(selected));
}
private void SendUse(uint guid)

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;