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,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));