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

@ -516,15 +516,15 @@ optimistic pre-confirm projection instead of treating equipment as backpack
contents. The connected gate completed NonCombat → Missile → NonCombat →
Missile, including a Jump press, without a server rejection.
**2026-07-17 active-switch follow-up:** A connected Magic wand -> missile
weapon trace exposed ACE's queued `Magic -> Melee -> NonCombat -> Missile ->
NonCombat` tail. `AutoWieldController` now retains the desired ready mode
through every confirmed blocker move and, only for a transaction that began
in active combat, arms settlement on authoritative `WieldObject`. An already-
correct first mode notice clears it; ACE's observed `NonCombat -> Missile ->
NonCombat` sequence sends the normal mode request from its trailing notice,
after the queued callback that caused that notice.
This keeps the missile bar present while retaining
**2026-07-17 active-switch follow-up:** Connected primary-weapon replacement
traces exposed ACE's queued old-stance transition and trailing `NonCombat`.
`AutoWieldController` now retains the desired ready mode through every
confirmed blocker move and, only for a transaction that began in active
combat, arms settlement on authoritative `WieldObject`. It recognizes both a
pre-wield transition followed by `ready -> NonCombat` and a wholly post-wield
`NonCombat -> ready -> NonCombat` tail, then sends the normal mode request from
the trailing notice after the queued callback that caused it. An explicit user
combat request cancels settlement. This keeps the correct missile or magic bar present while retaining
ACE ownership of every state and motion; peace-mode switching is unchanged.
The narrow ACE compatibility difference is registered as AP-118.
@ -2319,6 +2319,15 @@ request now retires the previous ground-object presentation immediately, as
retail `SetGroundObject` does. This removes the stale range watcher whose close
Use canceled ACE's active MoveTo chain before the new corpse could open.
**2026-07-17 input-order follow-up:** A second connected trace showed the
remaining far-R failure as `UseDone(0)` with no `ViewContents`. Silk delivered
R before the object phase had serialized a preceding movement-key release, so
ACE received `Use` and then `MoveToState`; ACE correctly cancelled the
Use-created MoveTo callback while acdream's local MoveTo kept walking. The new
`OutboundInteractionQueue` drains keyboard Use, world double-click, and pickup
immediately after the same frame's movement output and before inbound dispatch.
The flow still sends exactly one request and relies on ACE's arrival callback.
**Acceptance:** replacement sends `NoLongerViewingContents` once; authored close/range sends `Use` once and waits for `0x0052`; owned main/side-pack navigation sends neither; root/nested projections retire without deleting objects; corpse Use never attempts to pick up the corpse; a full-stack loot drop immediately shows a ghosted pending copy at the chosen destination slot and confirmation/failure clears it; canonical transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate.
---

View file

@ -215,7 +215,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, an immediately correct mode clears settlement; local ACE's observed `NonCombat -> ready mode -> NonCombat` tail causes one normal `ChangeCombatMode` request from the trailing notice. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during wand-to-bow replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later replacement/logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)

View file

@ -116,11 +116,20 @@ on server-confirmed WieldObject(item):
retain the ready mode implied by the confirmed equip location
on combat-mode notices after confirmed WieldObject:
if the first notice is the retained ready mode:
if the first notice is the retained ready mode
and no authoritative transition occurred during blocker removal:
clear the retained mode; the server already settled correctly
else if notices form NonCombat -> retained ready mode -> NonCombat:
else if blocker removal already emitted a non-ready transition
and notices finish retained ready mode -> NonCombat:
clear the retained mode
request that ready mode from ACE's trailing NonCombat notice
else if post-wield notices form NonCombat -> retained ready mode -> NonCombat:
clear the retained mode
request that ready mode from ACE's trailing NonCombat notice
on explicit user combat-mode request:
clear retained AutoWield mode intent
the user's newer request wins
on PrivateUpdatePropertyInt(property = CombatMode, value):
if value is exactly NonCombat, Melee, Missile, or Magic:
@ -145,12 +154,17 @@ not need a second request against the retail server.
acdream therefore preserves the user's desired ready mode only when AutoWield
begins in active combat. Authoritative `WieldObject` arms settlement. A server
whose first subsequent notice is already the desired mode needs no request.
Local ACE instead emits `NonCombat -> desired mode -> NonCombat`; the client
that emitted no blocker transition and immediately reports the desired mode
needs no request. Local ACE can begin its old-weapon transition before
`WieldObject`, report the desired mode for the new weapon, and then publish a
queued trailing `NonCombat`; it can also emit the complete
`NonCombat -> desired mode -> NonCombat` tail after `WieldObject`. The client
sends one normal `ChangeCombatMode` request from that trailing notice,
necessarily after the queued callback that caused it. All resulting state and animation remain server-
authoritative. No update is ignored, delayed, or locally fabricated, and a
transaction begun in peace sends no mode request. This narrow server-
transaction begun in peace sends no mode request. An explicit user combat-mode
request cancels retained settlement so the compatibility owner cannot fight a
deliberate switch to Peace. This narrow server-
compatibility adaptation is recorded as AP-118.
The request is not complete when `GetAndWieldItem` is merely sent. Retail

View file

@ -30,6 +30,7 @@ Named Sept-2013 retail symbols:
- `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0`
- `ItemHolder::AttemptToPlaceInContainer @ 0x00588140`
- `ItemHolder::UseObject @ 0x00588A80`
- `CommandInterpreter::SendMovementEvent @ 0x006B4680`
The exact client dispatch for server event `CloseGroundContainer (0x0052)` is
at `0x0055B187` in `acclient_2013_pseudo_c.txt`.
@ -40,6 +41,10 @@ Cross-references:
`GameActionNoLongerViewingContents`, `Container.SendInventory`, and
`Container.FinishClose` confirm the authoritative packet order and that ACE
can send `ViewContents` for the root followed by its nested containers.
- ACE `GameActionMoveToState.Handle` confirms that a movement state received
after `Use` cancels the Use-created `CreateMoveToChain`. A cancelled chain
still reaches `TryUseItem(success:false)` and produces successful `UseDone`
without calling `Container.Open`, explaining the approach-with-no-loot trace.
- holtburger protocol `inventory/events.rs` and `inventory/actions.rs` confirm
the exact wire shapes. Its world inventory handler independently models open
container contents as temporary preview retention and retires that preview on
@ -71,6 +76,13 @@ thumb, and arrow sprites from LayoutDesc `0x2100003E`.
## Pseudocode
```text
OnInputFrame():
sample movement-key levels
serialize any movement press/release edge as MoveToState
drain later one-shot Use/PickUp inputs in original FIFO order
// A preceding movement release must reach ACE before Use. Sending it
// afterward cancels ACE's server-side approach callback.
UseWorldObject(objectId):
decision = ItemHolder policy
send Use(objectId)
@ -210,6 +222,11 @@ DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
- `ItemInteractionController` remains the only interpreter of retail item-use
and placement policy. It gains explicit adapters for `SetGroundObject` and
`PlaceInContainer`; panels do not reimplement those decisions.
- `OutboundInteractionQueue` owns the input-frame ordering seam. Keyboard Use,
world double-click, and keyboard pickup are captured by the Silk callback,
then drained immediately after `LocalPlayerOutboundController` serializes
that frame's movement edge and before inbound network dispatch. It sends one
request; it is not an arrival retry.
- `GameWindow` only creates the lifecycle/UI bindings and supplies network
delegates. No external-container feature body lives there.

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;

View file

@ -0,0 +1,56 @@
using AcDream.App.Input;
namespace AcDream.App.Tests.Input;
public sealed class OutboundInteractionQueueTests
{
[Fact]
public void Drain_PreservesInputOrder()
{
var queue = new OutboundInteractionQueue();
var actual = new List<string>();
queue.Enqueue(() => actual.Add("use-corpse"));
queue.Enqueue(() => actual.Add("pickup-item"));
queue.Drain();
Assert.Equal(new[] { "use-corpse", "pickup-item" }, actual);
Assert.Equal(0, queue.Count);
}
[Fact]
public void Drain_ReentrantEnqueueWaitsForNextFrame()
{
var queue = new OutboundInteractionQueue();
var actual = new List<string>();
queue.Enqueue(() =>
{
actual.Add("first");
queue.Enqueue(() => actual.Add("next-frame"));
});
queue.Drain();
Assert.Equal(new[] { "first" }, actual);
Assert.Equal(1, queue.Count);
queue.Drain();
Assert.Equal(new[] { "first", "next-frame" }, actual);
Assert.Equal(0, queue.Count);
}
[Fact]
public void Clear_DropsPendingSessionActions()
{
var queue = new OutboundInteractionQueue();
bool invoked = false;
queue.Enqueue(() => invoked = true);
queue.Clear();
queue.Drain();
Assert.False(invoked);
}
}

View file

@ -655,6 +655,89 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.CombatModeRequests);
}
[Fact]
public void BowToWand_inActiveCombat_reassertsMagicAfterAceTrailingPeace()
{
var h = new Harness();
h.Combat.SetCombatMode(CombatMode.Missile);
const uint bow = 0x50000B93u;
const uint wand = 0x50000B94u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = bow,
Name = "Shortbow",
Type = ItemType.MissileWeapon,
CombatUse = 2,
ValidLocations = EquipMask.MissileWeapon,
});
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
h.AddContained(wand, item =>
{
item.Name = "Training Wand";
item.Type = ItemType.Caster;
item.CombatUse = 2;
item.ValidLocations = EquipMask.Held;
});
Assert.True(h.Controller.ActivateItem(wand));
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
// ACE begins the old weapon's stance shuffle before confirming the
// replacement wield.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields);
Assert.True(h.Objects.ApplyConfirmedServerWield(
wand,
Player,
EquipMask.Held));
h.Combat.SetCombatMode(CombatMode.Magic);
Assert.Empty(h.CombatModeRequests);
// The queued dequip callback publishes one final Peace after ACE has
// already selected Magic. Reassert only at that ordered boundary.
h.Combat.SetCombatMode(CombatMode.NonCombat);
Assert.Equal(new[] { CombatMode.Magic }, h.CombatModeRequests);
}
[Fact]
public void ExplicitCombatRequest_cancelsPendingAutoWieldSettlement()
{
var h = new Harness();
h.Combat.SetCombatMode(CombatMode.Missile);
const uint bow = 0x50000B95u;
const uint wand = 0x50000B96u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = bow,
Type = ItemType.MissileWeapon,
ValidLocations = EquipMask.MissileWeapon,
});
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
h.AddContained(wand, item =>
{
item.Type = ItemType.Caster;
item.ValidLocations = EquipMask.Held;
});
Assert.True(h.Controller.ActivateItem(wand));
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerWield(
wand,
Player,
EquipMask.Held));
h.Combat.SetCombatMode(CombatMode.Magic);
h.Controller.NotifyExplicitCombatModeRequest();
h.Combat.SetCombatMode(CombatMode.NonCombat);
Assert.Empty(h.CombatModeRequests);
}
[Fact]
public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace()
{
@ -678,7 +761,6 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Combat.SetCombatMode(CombatMode.Melee);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));