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 AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
@ -21,11 +22,14 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<uint> ExternalRequests = new();
public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new();
public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
public readonly List<string> Toasts = new();
public readonly List<string> SystemMessages = new();
public readonly List<CombatMode> CombatModeRequests = new();
public readonly CombatState Combat = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public uint SelectedObject;
public bool NonCombatMode;
@ -65,6 +69,8 @@ public sealed class ItemInteractionControllerTests
stackSplitQuantity: SplitQuantity,
inNonCombatMode: () => NonCombatMode,
groundObjectId: () => GroundObject,
placeInBackpack: (item, container, placement) =>
BackpackPlacements.Add((item, container, placement)),
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)),
sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
@ -76,7 +82,9 @@ public sealed class ItemInteractionControllerTests
{
GroundObject = id;
ExternalRequests.Add(id);
});
},
combatState: Combat,
sendChangeCombatMode: CombatModeRequests.Add);
}
public ItemInteractionController Controller { get; }
@ -158,6 +166,38 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Puts);
}
[Fact]
public void DoubleClickItemInOpenCorpse_placesThatItemInBackpack()
{
var h = new Harness();
const uint corpse = 0x70000012u;
const uint loot = 0x70000013u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = corpse,
Type = ItemType.Container,
ItemsCapacity = 24,
PublicWeenieBitfield = (uint)(
PublicWeenieFlags.Openable
| PublicWeenieFlags.Stuck
| PublicWeenieFlags.Corpse),
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = loot,
Name = "Loot",
Type = ItemType.Misc,
});
h.Objects.MoveItem(loot, corpse, 0);
h.GroundObject = corpse;
Assert.True(h.Controller.ActivateItem(loot));
Assert.Equal(new[] { (loot, Player, 0) }, h.BackpackPlacements);
Assert.Empty(h.Uses);
Assert.Empty(h.Wields);
}
[Fact]
public void DropOwnedItemOnOpenExternalContainer_SendsPutWithoutOptimisticMove()
{
@ -548,6 +588,107 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
}
[Theory]
[InlineData(CombatMode.NonCombat, false)]
[InlineData(CombatMode.Magic, true)]
public void WandToBow_reassertsMissileModeOnlyForActiveCombatTransaction(
CombatMode startingMode,
bool expectsMissileMode)
{
var h = new Harness();
h.Combat.SetCombatMode(startingMode);
const uint wand = 0x50000B91u;
const uint bow = 0x50000B92u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = wand,
Name = "Training Wand",
Type = ItemType.Caster,
CombatUse = 2,
ValidLocations = EquipMask.Held,
});
h.Objects.MoveItem(wand, Player, -1, EquipMask.Held);
h.AddContained(bow, item =>
{
item.Name = "Shortbow";
item.Type = ItemType.MissileWeapon;
item.CombatUse = 2;
item.ValidLocations = EquipMask.MissileWeapon;
});
Assert.True(h.Controller.ActivateItem(bow));
Assert.Equal(new[] { (wand, Player, 0) }, h.Puts);
// ACE reports several transitional combat modes while putting the
// wand away. The transaction retains the initial active-combat intent
// rather than consulting this intermediate state on its second pass.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Empty(h.CombatModeRequests);
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow,
Player,
EquipMask.MissileWeapon));
Assert.Empty(h.CombatModeRequests);
// The first authoritative post-wield mode is the transaction's server
// settlement stream. Local ACE publishes Peace -> Missile -> Peace;
// only the trailing Peace is late enough to accept the reassertion.
if (expectsMissileMode)
{
h.Combat.SetCombatMode(CombatMode.NonCombat);
Assert.Empty(h.CombatModeRequests);
h.Combat.SetCombatMode(CombatMode.Missile);
Assert.Empty(h.CombatModeRequests);
h.Combat.SetCombatMode(CombatMode.NonCombat);
}
else
{
h.Combat.SetCombatMode(CombatMode.Missile);
}
if (expectsMissileMode)
Assert.Equal(new[] { CombatMode.Missile }, h.CombatModeRequests);
else
Assert.Empty(h.CombatModeRequests);
}
[Fact]
public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace()
{
var h = new Harness();
h.Combat.SetCombatMode(CombatMode.Magic);
const uint wand = 0x50000BA1u;
const uint bow = 0x50000BA2u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = wand,
Type = ItemType.Caster,
CombatUse = 2,
ValidLocations = EquipMask.Held,
});
h.Objects.MoveItem(wand, Player, -1, EquipMask.Held);
h.AddContained(bow, item =>
{
item.Type = ItemType.MissileWeapon;
item.CombatUse = 2;
item.ValidLocations = EquipMask.MissileWeapon;
});
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));
h.Combat.SetCombatMode(CombatMode.Missile);
h.Combat.SetCombatMode(CombatMode.NonCombat);
Assert.Empty(h.CombatModeRequests);
}
[Fact]
public void PreviouslyWieldedBow_AfterAuthoritativeUnwield_DoubleClickWieldsAgain()
{

View file

@ -111,7 +111,7 @@ public sealed class ExternalContainerControllerTests
sendWield: null,
sendDrop: null,
groundObjectId: () => State.CurrentContainerId,
placeInBackpack: Pickups.Add,
placeInBackpack: (item, _, _) => Pickups.Add(item),
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)),
sendSplitToContainer: (item, container, placement, amount) =>
@ -213,6 +213,35 @@ public sealed class ExternalContainerControllerTests
Assert.True(h.Window.IsVisible);
}
[Fact]
public void ReplacementRequest_HidesOldViewWithoutSendingRangeCloseUse()
{
using var h = new Harness();
const uint replacement = 0x70000002u;
h.Open(Chest);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = replacement,
Type = ItemType.Container,
ItemsCapacity = 24,
Useability = ItemUseability.Remote,
});
h.State.RequestOpen(replacement);
h.InRange = false;
h.Controller.Tick();
Assert.Equal(0u, h.State.CurrentContainerId);
Assert.Equal(replacement, h.State.RequestedContainerId);
Assert.False(h.Window.IsVisible);
Assert.Empty(h.Uses);
Assert.Equal(new uint[] { Chest }, h.NoLonger);
h.State.ApplyViewContents(replacement);
Assert.Equal(replacement, h.State.CurrentContainerId);
Assert.True(h.Window.IsVisible);
}
[Fact]
public void DropOwnedPartialStackIntoChest_UsesSplitRequestWithoutLocalMove()
{

View file

@ -663,6 +663,49 @@ public class InventoryControllerTests
Assert.Equal(Player, objects.Get(loot)!.ContainerId);
}
[Fact]
public void DoubleClickLoot_ReservesFirstOpenPackSlotBeforePickupIsSent()
{
const uint chest = 0x70000011u;
const uint loot = 0x70000012u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
SeedContained(objects, 0xBu, Player, slot: 1);
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
var pickups = new List<(uint item, uint container, int placement)>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: (item, container, placement) =>
pickups.Add((item, container, placement)));
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
itemInteraction: interaction);
Assert.True(interaction.ActivateItem(loot));
Assert.Equal(new[] { (loot, Player, 0) }, pickups);
Assert.Equal(chest, objects.Get(loot)!.ContainerId);
Assert.Equal(loot, grid.GetItem(0)!.ItemId);
Assert.True(grid.GetItem(0)!.WaitingVisual);
Assert.Equal(0xAu, grid.GetItem(1)!.ItemId);
Assert.Equal(0xBu, grid.GetItem(2)!.ItemId);
}
[Fact]
public void LootDrop_ServerFailureRemovesOnlyThePendingProjection()
{

View file

@ -6,7 +6,7 @@ namespace AcDream.App.Tests.World;
public sealed class ExternalContainerLifecycleControllerTests
{
[Fact]
public void Replacement_RetiresPreviousRootExactlyOnce()
public void ReplacementRequest_RetiresPreviousRootBeforeViewContents()
{
var state = new ExternalContainerState();
var objects = new ClientObjectTable();
@ -18,6 +18,10 @@ public sealed class ExternalContainerLifecycleControllerTests
state.RequestOpen(10u);
state.ApplyViewContents(10u);
state.RequestOpen(20u);
Assert.Equal(new uint[] { 10u }, retired);
Assert.Empty(objects.GetContents(10u));
state.ApplyViewContents(20u);
state.ApplyViewContents(20u);