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
303
tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Normal file
303
tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
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;
|
||||
private const uint HealthKitUseability = 0x000A0008u;
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue