acdream/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Erik 047410ccc9 fix(ui): retail-faithful use-target compat gate + paperdoll doll self-target
Visual gate on the item-interaction slice found two target-use bugs:
the valid-target bullseye showed over inventory ITEMS (a coat is not a
valid healing-kit target) while the paperdoll doll showed blocked (it
should mean SELF).

- TargetCompatible is now a faithful port of
  ItemHolder::IsTargetCompatibleWithTargetingObject (0x00588070):
  a location constraint applies only when the least-limited target-use
  bit (ItemUses::GetLeastLimitedTargetUse 0x004fcd50, new
  ItemUseability.LeastLimitedTargetUse) is Contained/Wielded; the
  player as target requires the Self bit (IsUseable_SelfTarget
  0x004fcd30); and EVERY target passes retail's kind gate
  source._targetType & target->InqType(). The previous hand-rolled
  arms (Remote => accept anything, invented Viewed/Contained accepts,
  self path skipping the kind gate) were the yellow-over-items bug.
  Retail's tradeState refusal is skipped (no trade state yet).
- The paperdoll doll viewport now carries UseTargetGuidProvider =
  player and Clicked -> AcquireSelfTarget (UiViewport gains an opt-in
  Clicked handler), so hovering the doll resolves the self cursor and
  clicking it applies the armed kit to yourself.
- AcquireTarget logs one [use-target] line (wire useability, TargetType
  mask, target kind, decision) so live refusals are diagnosable from
  the launch log.
- Test fixtures updated from the guessed kit useability 0x000A0008 to
  the real USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF (0x00220008,
  ACE Usable.SourceContainedTargetRemoteOrSelf) + TargetType masks;
  new coverage pins the kind gate, the Self-bit requirement, the
  missing-mask-matches-nothing shape, and the Contained location rule.

Full suite green (3,294).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:09:13 +02:00

393 lines
13 KiB
C#

using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class ItemInteractionControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
// USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF (acclient.h ITEM_USEABLE;
// ACE Usable.SourceContainedTargetRemoteOrSelf) — the classic healing-kit value.
private const uint HealthKitUseability = 0x00220008u;
private sealed class Harness
{
public readonly ClientObjectTable Objects = new();
public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
public readonly List<string> Toasts = new();
public long Now = 1_000;
public Harness()
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
Objects.MoveItem(Pack, Player, 0);
Controller = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
nowMs: () => Now,
toast: Toasts.Add);
}
public ItemInteractionController Controller { get; }
public ClientObject AddContained(uint id, Action<ClientObject>? configure = null)
{
var item = new ClientObject
{
ObjectId = id,
Name = $"Item {id:X}",
Type = ItemType.Misc,
IconId = 0x06001234u,
};
configure?.Invoke(item);
Objects.AddOrUpdate(item);
Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count);
return item;
}
}
[Fact]
public void TargetedItem_entersTargetModeAndMarksPendingSource()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
Assert.True(h.Controller.ActivateItem(0x50000A01u));
Assert.True(h.Controller.IsTargetModeActive);
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{
var h = new Harness();
h.AddContained(0x50000A01u, item =>
{
item.Useability = HealthKitUseability;
item.TargetType = (uint)ItemType.Creature; // kits target creatures/players
});
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.AcquireSelfTarget());
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
[Fact]
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
{
var h = new Harness();
h.AddContained(0x50000A01u, item =>
{
item.Useability = 0x00080008u; // TARGET_CONTAINED tool
item.TargetType = (uint)ItemType.Misc; // kind gate must pass for the send
});
h.AddContained(0x50000A02u);
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.ActivateItem(0x50000A02u));
Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
// ── Retail target-compat gate (ItemHolder::IsTargetCompatibleWithTargetingObject 0x00588070) ──
[Fact]
public void TargetCompat_wrongKind_refusesAndClearsMode()
{
var h = new Harness();
h.AddContained(0x50000A01u, item =>
{
item.Useability = HealthKitUseability;
item.TargetType = (uint)ItemType.Creature;
});
h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor); // a coat
h.Controller.ActivateItem(0x50000A01u);
Assert.False(h.Controller.ActivateItem(0x50000A02u)); // kind gate: Creature mask & Armor = 0
Assert.Empty(h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
[Fact]
public void IsCurrentTargetCompatible_appliesKindGateForCursor()
{
var h = new Harness();
h.AddContained(0x50000A01u, item =>
{
item.Useability = HealthKitUseability;
item.TargetType = (uint)ItemType.Creature;
});
h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor);
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.IsCurrentTargetCompatible(Player)); // self: Self bit + Creature kind
Assert.False(h.Controller.IsCurrentTargetCompatible(0x50000A02u)); // coat: yellow-over-items bug pin
}
[Fact]
public void SelfTarget_requiresSelfBit()
{
var h = new Harness();
h.AddContained(0x50000A01u, item =>
{
item.Useability = 0x00200008u; // TARGET_REMOTE only — no Self bit
item.TargetType = (uint)ItemType.Creature;
});
h.Controller.ActivateItem(0x50000A01u);
Assert.False(h.Controller.AcquireSelfTarget()); // IsUseable_SelfTarget 0x004fcd30
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void MissingTargetTypeMask_matchesNothing()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability); // no TargetType on wire
h.Controller.ActivateItem(0x50000A01u);
Assert.False(h.Controller.AcquireSelfTarget()); // retail: 0 mask & anything == 0
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void ContainedTarget_refusesNonOwnedWorldObject()
{
var h = new Harness();
h.AddContained(0x50000A01u, item =>
{
item.Useability = 0x00080008u; // Contained is the least-limited target bit
item.TargetType = (uint)ItemType.Misc;
});
// right KIND, but out in the world — Contained requires our inventory:
h.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x60000001u, Type = ItemType.Misc });
h.Controller.ActivateItem(0x50000A01u);
Assert.False(h.Controller.AcquireTarget(0x60000001u));
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void DirectUseItem_sendsUse()
{
var h = new Harness();
h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A03u));
Assert.Equal(new[] { 0x50000A03u }, h.Uses);
}
[Fact]
public void ContainerItem_sendsUseToOpen()
{
var h = new Harness();
h.AddContained(0x50000A04u, item =>
{
item.Type = ItemType.Container;
item.ItemsCapacity = 12;
});
Assert.True(h.Controller.ActivateItem(0x50000A04u));
Assert.Equal(new[] { 0x50000A04u }, h.Uses);
}
[Fact]
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
{
var h = new Harness();
h.AddContained(0x50000A05u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = EquipMask.HeadWear;
});
Assert.True(h.Controller.ActivateItem(0x50000A05u));
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
var equipped = h.Objects.Get(0x50000A05u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
{
var h = new Harness();
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A15u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
});
Assert.True(h.Controller.ActivateItem(0x50000A15u));
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
var equipped = h.Objects.Get(0x50000A15u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF1u,
Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000001u,
});
h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear);
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A16u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
item.Priority = 0x00000002u;
});
Assert.True(h.Controller.ActivateItem(0x50000A16u));
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
}
[Fact]
public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF2u,
Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000004u,
});
h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear);
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A17u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
item.Priority = 0x00000004u;
});
Assert.False(h.Controller.ActivateItem(0x50000A17u));
Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId);
}
[Fact]
public void EquippableItemWithNoFreeSlot_sendsNothing()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF0u,
Type = ItemType.Armor,
CurrentlyEquippedLocation = EquipMask.Shield,
});
h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield);
h.AddContained(0x50000A06u, item =>
{
item.Type = ItemType.Armor;
item.ValidLocations = EquipMask.Shield;
});
Assert.False(h.Controller.ActivateItem(0x50000A06u));
Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
}
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
{
var h = new Harness();
h.AddContained(0x50000A07u);
var payload = new ItemDragPayload(
0x50000A07u,
ItemDragSource.Inventory,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
}
[Fact]
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
{
var h = new Harness();
h.AddContained(0x50000A08u);
var payload = new ItemDragPayload(
0x50000A08u,
ItemDragSource.ShortcutBar,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.False(h.Controller.DropToWorld(payload));
Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId);
}
[Fact]
public void ActivateItem_appliesRetailUseThrottle()
{
var h = new Harness();
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
h.Controller.ActivateItem(0x50000A09u);
h.Now += 199;
h.Controller.ActivateItem(0x50000A09u);
h.Now += 1;
h.Controller.ActivateItem(0x50000A09u);
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
}
}