feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
291
tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Normal file
291
tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
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;
|
||||
private const uint HealthKitUseability = 0x000A0008u;
|
||||
|
||||
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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue