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>
293 lines
10 KiB
C#
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);
|
|
}
|
|
}
|