acdream/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Erik 0b74d19475 fix(ui): port exact paperdoll body selection
Resolve and sample retail's authored nine-color paperdoll click map, preserve local click coordinates, and select the stable highest-priority worn item with the player fallback. Keep targeted-use body clicks routed to self and pin both synthetic and live-DAT conformance.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 11:56:26 +02:00

420 lines
15 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 AcDream.Core.Selection.SelectionState Selection = 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 dollViewport = new UiViewport
{
DatElementId = PaperdollController.DollViewportId,
Left = 270,
Top = 80,
Width = 70,
Height = 90,
};
var dollDragMask = new UiButton(
new ElementInfo { Id = PaperdollController.DollDragMaskId, Type = 1 },
static _ => (0u, 0, 0))
{
DatElementId = PaperdollController.DollDragMaskId,
Left = 270,
Top = 80,
Width = 70,
Height = 90,
ZOrder = 1,
};
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,
[PaperdollController.DollViewportId] = dollViewport,
[PaperdollController.DollDragMaskId] = dollDragMask,
};
layoutRoot.AddChild(grid);
layoutRoot.AddChild(sideBags);
layoutRoot.AddChild(mainPack);
layoutRoot.AddChild(chestArmor);
layoutRoot.AddChild(slotsButton);
layoutRoot.AddChild(dollViewport);
layoutRoot.AddChild(dollDragMask);
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,
selection: Selection,
datFont: null,
itemInteraction: interaction);
Root.DragReleasedOutsideUi += (payload, _, _) =>
{
if (payload is ItemDragPayload itemPayload)
interaction.DropToWorld(itemPayload);
};
return interaction;
}
public void BindPaperdoll(
ItemInteractionController? itemInteraction = null,
PaperdollClickMap? clickMap = null)
=> PaperdollController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
selection: Selection,
sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u,
itemInteraction: itemInteraction,
clickMap: clickMap ?? SolidClickMap(0x00, 0x00, 0xFF));
public static PaperdollClickMap SolidClickMap(byte r, byte g, byte b)
{
const int width = 70;
const int height = 90;
var pixels = new byte[width * height * 4];
for (int i = 0; i < pixels.Length; i += 4)
{
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
pixels[i + 3] = 0xFF;
}
return new PaperdollClickMap(pixels, width, height);
}
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 InventoryTargetUse_thenPaperdollBodyClick_selfTargetsThroughUiRoot()
{
var h = new Harness();
h.SeedHealthKit();
var interaction = h.BindInventoryInteraction();
h.BindPaperdoll(interaction);
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
Assert.True(interaction.IsTargetModeActive);
Assert.True(probe.ClickElement(PaperdollController.DollDragMaskId));
Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void PaperdollBodyClick_uncoveredPart_selectsPlayerThroughUiRoot()
{
var h = new Harness();
h.BindPaperdoll();
var probe = h.Probe();
Assert.True(probe.ClickElement(PaperdollController.DollDragMaskId));
Assert.Equal(Player, h.Selection.SelectedObjectId);
}
[Fact]
public void PaperdollBodyClick_coveredPart_selectsUpperEquippedItemThroughUiRoot()
{
var h = new Harness();
h.SeedHauberk();
h.Objects.MoveItem(Hauberk, Player, -1, HauberkMask);
h.BindPaperdoll(
clickMap: Harness.SolidClickMap(0x00, 0xFF, 0x00));
var probe = h.Probe();
Assert.True(probe.ClickElement(PaperdollController.DollDragMaskId));
Assert.Equal(Hauberk, h.Selection.SelectedObjectId);
}
[Fact]
public void InventoryTargetUse_invalidInventoryItem_isConsumedWithoutSelectionFallback()
{
var h = new Harness();
h.SeedHealthKit(slot: 0);
h.SeedHauberk(slot: 1);
var interaction = h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
Assert.Empty(h.UseWithTarget);
Assert.Empty(h.Wields);
Assert.False(interaction.IsTargetModeActive);
Assert.Equal(HealthKit, h.Selection.SelectedObjectId);
}
[Fact]
public void InventoryTargetUse_invalidWornItem_isConsumedWithoutPaperdollSelectionFallback()
{
var h = new Harness();
h.SeedHealthKit(slot: 0);
h.SeedHauberk(slot: 1);
h.Objects.MoveItem(Hauberk, Player, -1, HauberkMask);
var interaction = h.BindInventoryInteraction();
h.BindPaperdoll(interaction);
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
Assert.True(probe.ClickElement(SlotsButtonId));
Assert.True(probe.ClickItem(Hauberk, ItemDragSource.Equipment));
Assert.Empty(h.UseWithTarget);
Assert.False(interaction.IsTargetModeActive);
Assert.Equal(HealthKit, h.Selection.SelectedObjectId);
}
[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);
}
}