acdream/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.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

293 lines
10 KiB
C#

using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class RetailUiInteractionFlowTests
{
private const uint Player = 0x50000001u;
private const uint Hauberk = 0x50001001u;
private const uint HealthKit = 0x50001002u;
private const uint SlotsButtonId = 0x100005BEu;
private const uint ChestArmorSlotId = 0x100005ACu;
// USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value.
private const uint HealthKitUseability = 0x00220008u;
private const EquipMask HauberkMask =
EquipMask.ChestArmor
| EquipMask.AbdomenArmor
| EquipMask.UpperArmArmor
| EquipMask.LowerArmArmor;
private sealed class TestElement : UiElement { }
private sealed class Harness
{
public readonly UiRoot Root = new() { Width = 800, Height = 600 };
public readonly ClientObjectTable Objects = new();
public readonly ImportedLayout Layout;
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 long Now = 10_000;
public Harness()
{
var layoutRoot = new TestElement { Width = 360, Height = 180 };
var grid = ItemList(
InventoryController.ContentsGridId,
left: 10,
top: 10,
width: 192,
height: 96);
var sideBags = ItemList(
InventoryController.ContainerListId,
left: 210,
top: 10,
width: 36,
height: 96);
var mainPack = ItemList(
InventoryController.TopContainerId,
left: 210,
top: 112,
width: 36,
height: 36);
var chestArmor = ItemList(
ChestArmorSlotId,
left: 280,
top: 10,
width: 32,
height: 32);
var slotsButton = new UiButton(
new ElementInfo { Id = SlotsButtonId, Type = 1 },
static _ => (0u, 0, 0))
{
DatElementId = SlotsButtonId,
Left = 270,
Top = 56,
Width = 56,
Height = 20,
};
var meter = new UiMeter
{
DatElementId = InventoryController.BurdenMeterId,
Left = 248,
Top = 10,
Width = 11,
Height = 58,
};
var titleText = TextHost(InventoryController.TitleTextId, 10, 116, 192, 14);
var burdenText = TextHost(InventoryController.BurdenTextId, 248, 72, 36, 14);
var burdenCaption = TextHost(InventoryController.BurdenCaptionId, 248, 88, 36, 14);
var contentsCaption = TextHost(InventoryController.ContentsCaptionId, 10, 132, 192, 14);
var scrollbar = new UiScrollbar
{
DatElementId = InventoryController.ContentsScrollbarId,
Left = 192,
Top = 10,
Width = 16,
Height = 96,
};
var byId = new Dictionary<uint, UiElement>
{
[InventoryController.ContentsGridId] = grid,
[InventoryController.ContainerListId] = sideBags,
[InventoryController.TopContainerId] = mainPack,
[InventoryController.BurdenMeterId] = meter,
[InventoryController.TitleTextId] = titleText,
[InventoryController.BurdenTextId] = burdenText,
[InventoryController.BurdenCaptionId] = burdenCaption,
[InventoryController.ContentsCaptionId] = contentsCaption,
[InventoryController.ContentsScrollbarId] = scrollbar,
[ChestArmorSlotId] = chestArmor,
[SlotsButtonId] = slotsButton,
};
layoutRoot.AddChild(grid);
layoutRoot.AddChild(sideBags);
layoutRoot.AddChild(mainPack);
layoutRoot.AddChild(chestArmor);
layoutRoot.AddChild(slotsButton);
layoutRoot.AddChild(meter);
layoutRoot.AddChild(titleText);
layoutRoot.AddChild(burdenText);
layoutRoot.AddChild(burdenCaption);
layoutRoot.AddChild(contentsCaption);
layoutRoot.AddChild(scrollbar);
Layout = new ImportedLayout(layoutRoot, byId);
Root.AddChild(layoutRoot);
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
}
public ItemInteractionController BindInventoryInteraction()
{
var interaction = 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);
InventoryController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
strength: () => 100,
datFont: null,
itemInteraction: interaction);
Root.DragReleasedOutsideUi += (payload, _, _) =>
{
if (payload is ItemDragPayload itemPayload)
interaction.DropToWorld(itemPayload);
};
return interaction;
}
public void BindPaperdoll()
=> PaperdollController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u);
public RetailUiAutomationProbe Probe()
=> new(Root, Objects);
public void SeedHauberk(int slot = 0)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Hauberk,
Name = "Hauberk",
Type = ItemType.Armor,
ValidLocations = HauberkMask,
IconId = 0x06001234u,
});
Objects.MoveItem(Hauberk, Player, slot);
}
public void SeedHealthKit(int slot = 0)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = HealthKit,
Name = "Health Kit",
Type = ItemType.Misc,
IconId = 0x06001235u,
Useability = HealthKitUseability,
TargetType = (uint)ItemType.Creature, // retail kind gate
});
Objects.MoveItem(HealthKit, Player, slot);
}
private static UiItemList ItemList(uint id, float left, float top, float width, float height)
=> new(static _ => (1u, 32, 32))
{
DatElementId = id,
Left = left,
Top = top,
Width = width,
Height = height,
};
private static UiElement TextHost(uint id, float left, float top, float width, float height)
=> new TestElement
{
DatElementId = id,
Left = left,
Top = top,
Width = width,
Height = height,
ClickThrough = true,
};
}
[Fact]
public void InventoryDoubleClick_hauberk_routesThroughUiRootAndEquipsFullMask()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
Assert.True(state.Success, state.Message);
}
[Fact]
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
{
var h = new Harness();
h.SeedHealthKit();
var interaction = h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
Assert.True(interaction.IsTargetModeActive);
Assert.True(probe.ClickElement(InventoryController.TopContainerId));
Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
Assert.Equal(new[] { Hauberk }, h.Drops);
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
Assert.True(state.Success, state.Message);
}
[Fact]
public void InventoryDragToPaperdollChestArmorSlot_usesFullHauberkCoverageMask()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
h.BindPaperdoll();
var probe = h.Probe();
Assert.True(probe.ClickElement(SlotsButtonId)); // show armor slots; retail defaults to doll-view.
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
Assert.True(state.Success, state.Message);
}
}