merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open doors fully passable) + the full collision/streaming/dense-town-FPS arc meet the paperdoll/inventory work. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
01594b4cfd
95 changed files with 17201 additions and 167 deletions
43
tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs
Normal file
43
tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class DatWidgetFactoryZOrderTests
|
||||
{
|
||||
private static (uint, int, int) NoChrome(uint id) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void ZOrder_HigherZLevel_DrawsBehind()
|
||||
{
|
||||
// The gmInventoryUI backdrop (ZLevel 100, ReadOrder 4) must draw BEHIND the panels
|
||||
// (ZLevel 0, ReadOrder 1): lower ZOrder = drawn first = behind. Before the fix, ZOrder
|
||||
// came from ReadOrder only, so the backdrop (4) painted over the panels (1).
|
||||
var backdrop = new ElementInfo { Type = 3, ReadOrder = 4, ZLevel = 100 };
|
||||
var panel = new ElementInfo { Type = 3, ReadOrder = 1, ZLevel = 0 };
|
||||
|
||||
var b = DatWidgetFactory.Create(backdrop, NoChrome, null);
|
||||
var p = DatWidgetFactory.Create(panel, NoChrome, null);
|
||||
|
||||
Assert.True(b!.ZOrder < p!.ZOrder, $"backdrop ZOrder {b.ZOrder} should be < panel ZOrder {p.ZOrder}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZOrder_AllZeroZLevel_EqualsReadOrder()
|
||||
{
|
||||
// Regression guard: a vitals-style window (every element ZLevel 0) keeps ZOrder == ReadOrder.
|
||||
var e = new ElementInfo { Type = 3, ReadOrder = 5, ZLevel = 0 };
|
||||
var w = DatWidgetFactory.Create(e, NoChrome, null);
|
||||
Assert.Equal(5, w!.ZOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZOrder_SameZLevel_OrderedByReadOrder()
|
||||
{
|
||||
// Within one ZLevel, ReadOrder is the tiebreaker (higher ReadOrder draws on top).
|
||||
var lo = new ElementInfo { Type = 3, ReadOrder = 2, ZLevel = 2 };
|
||||
var hi = new ElementInfo { Type = 3, ReadOrder = 7, ZLevel = 2 };
|
||||
var l = DatWidgetFactory.Create(lo, NoChrome, null);
|
||||
var h = DatWidgetFactory.Create(hi, NoChrome, null);
|
||||
Assert.True(l!.ZOrder < h!.ZOrder);
|
||||
}
|
||||
}
|
||||
294
tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Normal file
294
tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class DragDropSpineTests
|
||||
{
|
||||
// A spy handler used across the spine tests.
|
||||
private sealed class SpyHandler : IItemListDragHandler
|
||||
{
|
||||
public bool AcceptResult = true;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastLift = (list, cell, p); }
|
||||
|
||||
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastOver = (list, cell, p); return AcceptResult; }
|
||||
|
||||
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastDrop = (list, cell, p); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_holdsAllFields()
|
||||
{
|
||||
var src = new UiItemSlot();
|
||||
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(3, p.SourceSlot);
|
||||
Assert.Same(src, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UiItemList_registerDragHandler_roundtrips()
|
||||
{
|
||||
var list = new UiItemList(_ => (0u, 0, 0));
|
||||
Assert.Null(list.DragHandler);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
Assert.Same(h, list.DragHandler);
|
||||
}
|
||||
|
||||
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
|
||||
[Fact]
|
||||
public void GetDragPayload_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragPayload());
|
||||
|
||||
[Fact]
|
||||
public void GetDragPayload_boundCell_snapshotsFields()
|
||||
{
|
||||
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
|
||||
Assert.Equal(0x5001u, p.ObjId);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
|
||||
Assert.Equal(4, p.SourceSlot);
|
||||
Assert.Same(cell, p.SourceCell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_emptyCell_isNull()
|
||||
=> Assert.Null(new UiItemSlot().GetDragGhost());
|
||||
|
||||
[Fact]
|
||||
public void GetDragGhost_boundCell_returnsIconTuple()
|
||||
{
|
||||
var cell = new UiItemSlot { Width = 32, Height = 32 };
|
||||
cell.SetItem(0x5001u, 0x99u);
|
||||
var g = cell.GetDragGhost();
|
||||
Assert.NotNull(g);
|
||||
Assert.Equal(0x99u, g!.Value.tex);
|
||||
Assert.Equal(32, g.Value.w);
|
||||
Assert.Equal(32, g.Value.h);
|
||||
}
|
||||
|
||||
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
|
||||
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
return (list, list.Cell, h);
|
||||
}
|
||||
|
||||
private static ItemDragPayload SomePayload()
|
||||
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = true;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = false;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragOver_resetsOverlayToNeutral()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_accepted_dispatchesToHandler()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
Assert.Same(list, h.LastDrop!.Value.list);
|
||||
Assert.Same(cell, h.LastDrop.Value.cell);
|
||||
Assert.Same(p, h.LastDrop.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropReleased_dispatchesToHandler_regardlessOfData0()
|
||||
{
|
||||
// Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a
|
||||
// hit), so the handler is authoritative — it dispatches whether or not Data0 is set.
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
||||
Assert.NotNull(h.LastDrop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragBegin_callsHandlerOnDragLift()
|
||||
{
|
||||
var (list, cell, h) = ListWithHandler();
|
||||
var p = SomePayload();
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p));
|
||||
Assert.NotNull(h.LastLift);
|
||||
Assert.Same(list, h.LastLift!.Value.list);
|
||||
Assert.Same(cell, h.LastLift.Value.cell);
|
||||
Assert.Same(p, h.LastLift.Value.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost
|
||||
cell.Clear(); // simulate the lift emptying the source
|
||||
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
list.Cell.SetItem(0x5001u, 0x99u);
|
||||
var h = new SpyHandler();
|
||||
list.RegisterDragHandler(h);
|
||||
root.AddChild(list);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // BeginDrag → OnDragLift
|
||||
root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space
|
||||
Assert.NotNull(h.LastLift); // lift happened
|
||||
Assert.Null(h.LastDrop); // no drop dispatched (off-bar)
|
||||
Assert.Null(root.DragSource); // cleaned up
|
||||
}
|
||||
|
||||
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
|
||||
// A bound, hit-testable slot inside a list, sized for the hit-test.
|
||||
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
root.AddChild(list);
|
||||
return (root, list, list.Cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_arms_whenPayloadNonNull()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // >3px → promote to drag
|
||||
Assert.Same(cell, root.DragSource);
|
||||
Assert.IsType<ItemDragPayload>(root.DragPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
|
||||
{
|
||||
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10);
|
||||
Assert.Null(root.DragSource); // never armed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_withoutDrag_firesUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
|
||||
Assert.True(used);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletedDrag_doesNotFireUse()
|
||||
{
|
||||
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||
bool used = false;
|
||||
cell.Clicked = () => used = true;
|
||||
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||
root.OnMouseMove(20, 10); // promote to drag
|
||||
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
|
||||
Assert.False(used);
|
||||
}
|
||||
|
||||
// ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ──
|
||||
[Fact]
|
||||
public void DragEnter_orphanCell_noList_defaultsToReject()
|
||||
{
|
||||
var cell = new UiItemSlot(); // no parent list → FindList() null
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_listWithoutHandler_defaultsToReject()
|
||||
{
|
||||
var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler
|
||||
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
// ── item drag inside a Draggable window (the LIVE toolbar topology) ──────
|
||||
// Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar
|
||||
// frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag
|
||||
// (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window
|
||||
// drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier
|
||||
// RootWithBoundSlot tests put the slot directly under the root (no draggable
|
||||
// ancestor), so they could not catch this.
|
||||
private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId)
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
|
||||
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 };
|
||||
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||
frame.AddChild(list);
|
||||
root.AddChild(frame);
|
||||
return (root, frame, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow()
|
||||
{
|
||||
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
|
||||
// Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px.
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310);
|
||||
Assert.Same(list.Cell, root.DragSource); // item drag armed
|
||||
Assert.Equal(10f, frame.Left); // window did NOT move
|
||||
Assert.Equal(300f, frame.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag()
|
||||
{
|
||||
var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source
|
||||
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
||||
root.OnMouseMove(40, 310);
|
||||
Assert.Null(root.DragSource); // no item drag
|
||||
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
|
||||
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
|
||||
}
|
||||
}
|
||||
528
tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
Normal file
528
tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class InventoryControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
// UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts
|
||||
// (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children).
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
// Element ids (spec §1).
|
||||
private const uint ContentsGrid = 0x100001C6u;
|
||||
private const uint ContainerList = 0x100001CAu;
|
||||
private const uint TopContainer = 0x100001C9u;
|
||||
private const uint BurdenMeter = 0x100001D9u;
|
||||
private const uint BurdenText = 0x100001D8u;
|
||||
private const uint BurdenCaption = 0x100001D7u;
|
||||
private const uint ContentsCaption = 0x100001C5u;
|
||||
private const uint ContentsScrollbar = 0x100001C7u;
|
||||
|
||||
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
|
||||
UiItemList top, UiMeter meter, UiElement burdenText,
|
||||
UiElement burdenCap, UiElement contentsCap) BuildLayout()
|
||||
{
|
||||
var grid = new UiItemList { Width = 192, Height = 96 };
|
||||
var containers = new UiItemList { Width = 36, Height = 252 };
|
||||
var top = new UiItemList { Width = 36, Height = 36 };
|
||||
var meter = new UiMeter { Width = 11, Height = 58 };
|
||||
var burdenText = new TestElement { Width = 36, Height = 15 };
|
||||
var burdenCap = new TestElement { Width = 36, Height = 15 };
|
||||
var contentsCap = new TestElement { Width = 192, Height = 15 };
|
||||
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
|
||||
var root = new TestElement { Width = 300, Height = 362 };
|
||||
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
|
||||
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
|
||||
root.AddChild(contentsCap); root.AddChild(scrollbar);
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
||||
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
||||
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
|
||||
};
|
||||
return (new ImportedLayout(root, byId), grid, containers, top, meter,
|
||||
burdenText, burdenCap, contentsCap);
|
||||
}
|
||||
|
||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null)
|
||||
=> InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
strength: () => strength, datFont: null,
|
||||
sendUse: uses is null ? null : g => uses.Add(g),
|
||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
|
||||
|
||||
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
|
||||
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity });
|
||||
t.MoveItem(bag, Player, slot);
|
||||
}
|
||||
|
||||
// Place an object in a container at a slot via the faithful production path:
|
||||
// AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex
|
||||
// (the same machinery server-driven moves use), so GetContents returns it slot-ordered.
|
||||
private static void SeedContained(ClientObjectTable t, uint guid, uint container, int slot,
|
||||
int burden = 0, ItemType type = ItemType.None)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, Burden = burden, Type = type });
|
||||
t.MoveItem(guid, container, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_fills_contents_grid_with_loose_items_only()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Seed via the indexed path: AddOrUpdate registers the object, MoveItem places it in
|
||||
// the container at a slot (the faithful "server moved item into container" flow that
|
||||
// calls Reindex). GetContents — retail's per-container list — then returns them ordered.
|
||||
SeedContained(objects, 0xA, Player, slot: 0, burden: 10); // loose
|
||||
SeedContained(objects, 0xB, Player, slot: 1, burden: 20); // loose
|
||||
SeedContained(objects, 0xC, Player, slot: 2, type: ItemType.Container); // side bag
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // 2 loose + 100 empty (main-pack capacity)
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0xBu, grid.GetItem(1)!.ItemId);
|
||||
Assert.Equal(0u, grid.GetItem(2)!.ItemId); // padded empty
|
||||
Assert.Equal(7, containers.GetNumUIItems()); // 1 side bag + 6 empty (no capacity → 7-slot column)
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipped_items_are_excluded_from_the_grid_and_selector()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // loose pack item
|
||||
// A self-wielded item: MoveItem with an equip location indexes it under the player
|
||||
// (the live wield path), but it must NOT show in the pack grid or the selector.
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xD });
|
||||
objects.MoveItem(0xD, Player, newSlot: 1, newEquipLocation: EquipMask.MeleeWeapon);
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // 1 loose + 101 empty (main-pack capacity)
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(7, containers.GetNumUIItems()); // 7 empty slots (no bags; the equipped item isn't here)
|
||||
Assert.Equal(0u, containers.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_is_six_columns_thirtytwo_px()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Equal(6, grid.Columns);
|
||||
Assert.Equal(32f, grid.CellWidth);
|
||||
Assert.Equal(32f, grid.CellHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectAdded_for_player_item_rebuilds_grid()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // empty grid still shows the full 102-slot pack
|
||||
Assert.Equal(0u, grid.GetItem(0)!.ItemId); // slot 0 empty before the add
|
||||
|
||||
objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0,
|
||||
null, null, null, 5, Player, null, null, null, null, null, null, null, null, null));
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // still 102; the item fills slot 0
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Burden_meter_fill_and_percent_from_load()
|
||||
{
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%".
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 });
|
||||
Bind(layout, objects, strength: 100);
|
||||
|
||||
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3);
|
||||
Assert.True(meter.Vertical);
|
||||
// The % text caption child reads "50%".
|
||||
Assert.Contains("50%", CaptionText(burdenText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Captions_render_known_strings()
|
||||
{
|
||||
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
Assert.Contains("Burden", CaptionText(burdenCap));
|
||||
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
|
||||
{
|
||||
// B-Wire: the burden bar must read the server's wire EncumbranceVal (PropertyInt 5),
|
||||
// NOT the client-side carried-Burden sum (retires AP-48 — the sum is only the fallback).
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Str 100 → capacity 15000. A carried item sums to 3000 (would read 20%), but the
|
||||
// server says EncumbranceVal = 7500 → load 0.5 → fill 0.1667 → "50%". Wire wins.
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 3000 });
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[5] = 7500; // EncumbranceVal (PropertyInt 5)
|
||||
objects.UpsertProperties(Player, bundle);
|
||||
|
||||
Bind(layout, objects, strength: 100);
|
||||
|
||||
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); // 7500/15000/3 (wire), not 3000-based 0.0667
|
||||
Assert.Contains("50%", CaptionText(burdenText)); // not "20%"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_player_int_update_refreshes_burden()
|
||||
{
|
||||
// B-Wire C1d: a live PrivateUpdatePropertyInt (0x02CD) for the player's EncumbranceVal
|
||||
// fires ObjectUpdated on the PLAYER object; Concerns now includes o.ObjectId == player,
|
||||
// so the burden bar repaints. Without the C1d branch this update would be ignored.
|
||||
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[5] = 3000; // initial EncumbranceVal → load 0.2 → "20%"
|
||||
objects.UpsertProperties(Player, bundle);
|
||||
Bind(layout, objects, strength: 100);
|
||||
Assert.Contains("20%", CaptionText(burdenText));
|
||||
|
||||
objects.UpdateIntProperty(Player, 5u, 9000); // live 0x02CD: → load 0.6 → "60%"
|
||||
|
||||
Assert.Contains("60%", CaptionText(burdenText));
|
||||
Assert.Equal(0.2f, meter.Fill() ?? -1f, 3); // 9000/15000/3
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable());
|
||||
var bar = (UiScrollbar)layout.FindElement(ContentsScrollbar)!;
|
||||
Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contents_grid_pads_empty_slots_to_main_pack_capacity()
|
||||
{
|
||||
// The grid shows the full main-pack capacity (default 102) — occupied + empty — so the
|
||||
// pack reads like retail's fixed 102-slot grid you scroll, not just the loose items.
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 });
|
||||
SeedContained(objects, 0xA, Player, slot: 0, burden: 5); // one loose item
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(102, grid.GetNumUIItems()); // 1 item + 101 empty frames = 102
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0u, grid.GetItem(50)!.ItemId); // a padded empty slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Side_bag_column_pads_empty_slots_up_to_capacity()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 });
|
||||
SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container); // one side bag
|
||||
|
||||
Bind(layout, objects);
|
||||
|
||||
Assert.Equal(3, containers.GetNumUIItems()); // 1 bag + 2 empty = capacity 3
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); // the bag
|
||||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // empty frame
|
||||
Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_sprites_are_applied_to_the_three_lists()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
|
||||
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
|
||||
|
||||
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
|
||||
Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell
|
||||
Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickSideBag_sendsUse_andSwapsGridToBagContents()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item
|
||||
SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1)
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag
|
||||
var uses = new List<uint>();
|
||||
Bind(layout, objects, uses: uses);
|
||||
|
||||
// The side-bag column's first cell holds the bag guid; click it.
|
||||
containers.GetItem(0)!.Clicked!();
|
||||
|
||||
Assert.Contains(0xCu, uses); // Use(bag) sent
|
||||
Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents
|
||||
Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
|
||||
{
|
||||
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item
|
||||
SeedBag(objects, 0xC, slot: 1);
|
||||
SeedContained(objects, 0xB1, 0xC, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open the bag
|
||||
top.GetItem(0)!.Clicked!(); // click the main-pack cell
|
||||
|
||||
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
|
||||
Assert.DoesNotContain(Player, uses); // no Use for the main pack
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
|
||||
Assert.Equal(102, grid.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC1, slot: 0);
|
||||
SeedBag(objects, 0xC2, slot: 1);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
|
||||
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
|
||||
|
||||
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
|
||||
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenBag_marksTriangleAndSquare_onTheBagCell()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0);
|
||||
Bind(layout, objects, uses: new List<uint>());
|
||||
|
||||
containers.GetItem(0)!.Clicked!();
|
||||
|
||||
Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag
|
||||
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer()
|
||||
{
|
||||
var (layout, grid, _, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var uses = new List<uint>();
|
||||
var closes = new List<uint>();
|
||||
Bind(layout, objects, uses: uses, closes: closes);
|
||||
|
||||
grid.GetItem(0)!.Clicked!(); // select the loose item
|
||||
|
||||
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
|
||||
Assert.Empty(uses); // selection sends no wire
|
||||
Assert.Empty(closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_mainPackCell_isOpenContainer()
|
||||
{
|
||||
var (layout, _, _, top, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainPackCell_requestsConstantBackpackIcon_notPlayerBodyIcon() // AP-51 retire
|
||||
{
|
||||
var (layout, _, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
// Give the player a (wrong) body icon to prove the main-pack cell does NOT use it.
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player, IconId = 0x06001234u });
|
||||
(ItemType type, uint icon)? mainPackCall = null;
|
||||
InventoryController.Bind(layout, objects, () => Player,
|
||||
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
|
||||
strength: () => 100, datFont: null);
|
||||
|
||||
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
|
||||
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).
|
||||
Assert.NotNull(mainPackCall);
|
||||
Assert.Equal(ItemType.Container, mainPackCall!.Value.type);
|
||||
Assert.Equal(0x0600127Eu, mainPackCall.Value.icon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SideBagCell_capacityBar_reflectsContents()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a side bag (cap 24)
|
||||
for (uint i = 0; i < 6; i++) SeedContained(objects, 0xB0u + i, 0xC, slot: (int)i); // 6 items inside
|
||||
Bind(layout, objects);
|
||||
|
||||
// Retail UpdateCapacityDisplay: fill = contained / itemsCapacity = 6/24 = 0.25 (exact in float).
|
||||
Assert.Equal(0.25f, containers.GetItem(0)!.CapacityFill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LooseGridItem_hasNoCapacityBar()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // a loose, non-container item
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container
|
||||
}
|
||||
|
||||
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
SeedContained(objects, 0xB, Player, slot: 1);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
// Drag a fresh item 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1).
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container
|
||||
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onEmptyGridCell_appendsToFirstEmpty()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_onSideBagCell_movesIntoThatBag()
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
|
||||
var puts = new List<(uint, uint, int)>();
|
||||
var ctrl = Bind(layout, objects, puts: puts);
|
||||
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag)
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu));
|
||||
|
||||
Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0)
|
||||
Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_fullSideBag_rejects_butGridAccepts()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1...
|
||||
SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
|
||||
Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_isNoOp_itemStaysUntilServerConfirms()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 0);
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu));
|
||||
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_closedBag_unknownCount_advisoryAccepts() // AP-61
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a bag with capacity but NO indexed contents (closed)
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
// GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept.
|
||||
Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back
|
||||
{
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xA, Player, slot: 3); // item starts in the main pack at slot 3
|
||||
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
|
||||
var ctrl = Bind(layout, objects);
|
||||
|
||||
((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu));
|
||||
Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant)
|
||||
|
||||
objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed)
|
||||
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack
|
||||
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot
|
||||
}
|
||||
|
||||
// Reads the text of the UiText caption child attached by the controller.
|
||||
private static string CaptionText(UiElement host)
|
||||
{
|
||||
foreach (var c in host.Children)
|
||||
if (c is UiText t)
|
||||
{
|
||||
var lines = t.LinesProvider();
|
||||
if (lines.Count > 0) return lines[0].Text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Real-dat smoke test for the gmInventoryUI frame (0x21000023) import + the sub-window
|
||||
/// mount. Locks the #145-continuation fix: the mount keeps each mounted panel slot's OWN
|
||||
/// frame ZLevel instead of inheriting the sub-window root's ZLevel 1000. Before the fix the
|
||||
/// merge's zero-wins-base rule gave the slots ZLevel 1000 → the #145 ZOrder fold
|
||||
/// (ReadOrder − ZLevel·10000) sank them to ≈ −10,000,000, BEHIND the frame's Alphablend
|
||||
/// backdrop (ZLevel 100 → ≈ −1,000,000), and the backdrop washed out the panels' captions,
|
||||
/// burden meter, and item cells. Skips when the live dat directory is absent (CI).
|
||||
/// </summary>
|
||||
public class InventoryFrameImportProbe
|
||||
{
|
||||
private const uint Frame = 0x21000023u;
|
||||
private const uint Backdrop = 0x100001D0u; // full-window Alphablend backdrop (ZLevel 100)
|
||||
private const uint BackpackPanel = 0x100001CEu; // mounted gmBackpackUI slot
|
||||
private const uint ItemsPanel = 0x100001CFu; // mounted gm3DItemsUI slot
|
||||
private const uint PaperdollPanel = 0x100001CDu; // mounted gmPaperDollUI slot
|
||||
private const uint BurdenMeter = 0x100001D9u; // backpack content (proves the mount attached it)
|
||||
private const uint ContentsGrid = 0x100001C6u; // 3D-items content
|
||||
|
||||
private static string? DatDir()
|
||||
{
|
||||
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paperdoll_equip_slots_resolve_to_item_lists()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
// A representative spread across the slot grid (head, shield, the weapon composite, cloak,
|
||||
// trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList).
|
||||
foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu })
|
||||
{
|
||||
var el = layout!.FindElement(id);
|
||||
Assert.True(el is UiItemList,
|
||||
$"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mounted_panels_sit_in_front_of_the_backdrop()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip (this is a smoke test)
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
|
||||
Assert.NotNull(layout);
|
||||
|
||||
var backdrop = layout!.FindElement(Backdrop);
|
||||
Assert.NotNull(backdrop); // the full-window backdrop is a direct child of the frame
|
||||
|
||||
// The sub-window mount brings each panel's content into the by-id index.
|
||||
Assert.NotNull(layout.FindElement(BurdenMeter)); // backpack burden meter
|
||||
Assert.NotNull(layout.FindElement(ContentsGrid)); // 3D-items contents grid
|
||||
|
||||
// The fix: every mounted panel slot must draw IN FRONT of the backdrop
|
||||
// (higher ZOrder = painted later = on top), so the backdrop sits behind the content.
|
||||
foreach (var (id, name) in new[]
|
||||
{
|
||||
(BackpackPanel, "backpack"), (ItemsPanel, "3D-items"), (PaperdollPanel, "paperdoll"),
|
||||
})
|
||||
{
|
||||
var panel = layout.FindElement(id);
|
||||
Assert.True(panel is not null, $"{name} panel 0x{id:X8} missing from the imported tree");
|
||||
Assert.True(panel!.ZOrder > backdrop!.ZOrder,
|
||||
$"{name} panel ZOrder {panel.ZOrder} must be > backdrop ZOrder {backdrop.ZOrder} " +
|
||||
"(else the Alphablend backdrop overpaints/washes out the panel content)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Real-dat test for <see cref="ItemListCellTemplate.ResolveEmptySprite"/>: the inventory
|
||||
/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute
|
||||
/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar
|
||||
/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record.
|
||||
/// Skips when the live dat directory is absent (CI).
|
||||
/// </summary>
|
||||
public class ItemListCellTemplateTests
|
||||
{
|
||||
private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug)
|
||||
|
||||
private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u;
|
||||
private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu;
|
||||
private const uint MainPack = 0x100001C9u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o;
|
||||
|
||||
private static string? DatDir()
|
||||
{
|
||||
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip (smoke test)
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (var (name, layout, elem) in new[]
|
||||
{
|
||||
("contents", ContentsLayout, ContentsGrid),
|
||||
("side-bag", BackpackLayout, SideBag),
|
||||
("main-pack", BackpackLayout, MainPack),
|
||||
})
|
||||
{
|
||||
uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem);
|
||||
_out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}");
|
||||
Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)");
|
||||
Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix");
|
||||
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inventory_lists_resolve_the_pinned_retail_sprites()
|
||||
{
|
||||
var datDir = DatDir();
|
||||
if (datDir is null) return; // CI: no live dat — skip
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
// Pinned from the live dat + the 2026-06-22 visual gate. Contents = the 32x32 prototype's
|
||||
// ItemSlot_Empty; the 36x36 container/main-pack resolve THROUGH inheritance to their inner
|
||||
// ItemSlot_Empty (NOT the 0x06005D9C open-container triangle the first cut grabbed — a
|
||||
// structural-only assertion let that wrong-but-valid 0x06 sprite pass, so we pin exacts).
|
||||
Assert.Equal(0x06004D20u, ItemListCellTemplate.ResolveEmptySprite(dats, ContentsLayout, ContentsGrid));
|
||||
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, SideBag));
|
||||
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, MainPack));
|
||||
}
|
||||
}
|
||||
148
tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs
Normal file
148
tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public class PaperdollControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x40000005u;
|
||||
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
|
||||
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
|
||||
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
|
||||
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
|
||||
|
||||
private sealed class RootElement : UiElement { }
|
||||
|
||||
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
|
||||
{
|
||||
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
|
||||
var lists = new Dictionary<uint, UiItemList>();
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
var root = new RootElement { Width = 224, Height = 214 };
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var list = new UiItemList { Width = 32, Height = 32 };
|
||||
lists[id] = list; byId[id] = list; root.AddChild(list);
|
||||
}
|
||||
return (new ImportedLayout(root, byId), lists);
|
||||
}
|
||||
|
||||
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u)
|
||||
=> PaperdollController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0x1234u,
|
||||
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
|
||||
emptySlotSprite: emptySlot);
|
||||
|
||||
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player });
|
||||
t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc);
|
||||
}
|
||||
|
||||
private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations)
|
||||
{
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations });
|
||||
t.MoveItem(guid, Pack, newSlot: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_shows_equipped_item_in_its_slot()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot
|
||||
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_matches_a_weapon_into_the_composite_slot()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_accepts_only_valid_locations()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack
|
||||
var ctrl = Bind(layout, objects);
|
||||
var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
|
||||
Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_wields_optimistically_and_sends_wire()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xD01u, EquipMask.HeadWear);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
|
||||
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
|
||||
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
|
||||
Assert.Single(wields);
|
||||
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell);
|
||||
ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload);
|
||||
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
Bind(layout, new ClientObjectTable(), emptySlot: 0x06004D20u);
|
||||
Assert.Equal(0x06004D20u, lists[HeadSlot].Cell.EmptySprite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects); // slots empty at bind
|
||||
SeedPackItem(objects, 0xF01u, EquipMask.HeadWear); // a helm appears in the pack (not on the doll)
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
|
||||
objects.WieldItemOptimistic(0xF01u, Player, EquipMask.HeadWear); // player wields it → ObjectMoved(to=player)
|
||||
Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
Bind(layout, objects);
|
||||
const uint npc = 0x60000001u;
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xF02u, WielderId = npc, CurrentlyEquippedLocation = EquipMask.HeadWear,
|
||||
});
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); // the NPC's helm is NOT on the player's doll
|
||||
}
|
||||
}
|
||||
|
|
@ -89,8 +89,8 @@ public class ToolbarControllerTests
|
|||
|
||||
ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
|
||||
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
|
||||
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
|
||||
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
|
||||
|
||||
Assert.Equal(0x5001u, used);
|
||||
}
|
||||
|
|
@ -421,4 +421,177 @@ public class ToolbarControllerTests
|
|||
|
||||
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
|
||||
}
|
||||
|
||||
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
|
||||
|
||||
/// <summary>Bind registers the controller as each slot list's drag handler and
|
||||
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
|
||||
[Fact]
|
||||
public void Bind_registersDragHandler_andStampsSlots()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo,
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
for (int i = 0; i < Row1.Length; i++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
|
||||
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
|
||||
}
|
||||
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
|
||||
for (int j = 0; j < Row2.Length; j++)
|
||||
{
|
||||
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
|
||||
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
|
||||
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
|
||||
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_acceptsRealItem()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
|
||||
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
|
||||
[Fact]
|
||||
public void OnDragOver_rejectsZeroObjId()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
var list = slots[Row1[0]];
|
||||
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
Assert.False(ctrl.OnDragOver(list, list.Cell, payload));
|
||||
}
|
||||
|
||||
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
|
||||
private static (System.Collections.Generic.List<(uint i,uint g)> adds,
|
||||
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
|
||||
{
|
||||
var adds = new System.Collections.Generic.List<(uint,uint)>();
|
||||
var removes = new System.Collections.Generic.List<uint>();
|
||||
add = (i, g) => adds.Add((i, g));
|
||||
rem = i => removes.Add(i);
|
||||
return (adds, removes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
|
||||
Assert.Contains(3u, removes);
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
|
||||
new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
|
||||
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
|
||||
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
|
||||
Assert.Contains((5u, 0x5001u), adds);
|
||||
Assert.Contains((3u, 0x5002u), adds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
|
||||
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload);
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
|
||||
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
|
||||
Assert.Contains((7u, 0x5001u), adds);
|
||||
Assert.DoesNotContain(adds, a => a.i == 3u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_ontoSelf_reAddsToSource()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
|
||||
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
|
||||
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
|
||||
var (adds, removes) = NewSpies(out var add, out var rem);
|
||||
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
|
||||
sendAddShortcut: add, sendRemoveShortcut: rem);
|
||||
|
||||
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
|
||||
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied
|
||||
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
|
||||
|
||||
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
|
||||
Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent
|
||||
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
|
||||
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolbarSlots_useGreenCrossAcceptSprite()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
ToolbarController.Bind(layout, new ClientObjectTable(),
|
||||
() => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
|
||||
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
Normal file
22
tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class LayoutImporterMountTests
|
||||
{
|
||||
[Fact]
|
||||
public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase()
|
||||
=> Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenDerivedHasOwnChildren()
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5));
|
||||
|
||||
[Fact]
|
||||
public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes
|
||||
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0));
|
||||
}
|
||||
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiCollapsibleFrameTests
|
||||
{
|
||||
private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b)
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1))
|
||||
{
|
||||
CollapsedHeight = 96f,
|
||||
ExpandedHeight = 128f,
|
||||
};
|
||||
row2a = new UiPanel(); row2b = new UiPanel();
|
||||
f.SecondRow = new UiElement[] { row2a, row2b };
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 100f; // nearer the collapsed stop (midpoint 112)
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(96f, f.Height);
|
||||
Assert.False(a.Visible);
|
||||
Assert.False(b.Visible);
|
||||
Assert.False(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 120f; // nearer the expanded stop
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(128f, f.Height);
|
||||
Assert.True(a.Visible);
|
||||
Assert.True(b.Visible);
|
||||
Assert.True(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_notConfigured_isNoOp()
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0
|
||||
f.Height = 50f;
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_respectsResizableEdgesMask_bottomOnly()
|
||||
{
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Resizable = true, ResizableEdges = ResizeEdges.Bottom };
|
||||
// bottom edge (y=200) → Bottom only
|
||||
Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5));
|
||||
// top edge (y=100) → masked out → None
|
||||
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5));
|
||||
}
|
||||
}
|
||||
43
tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
Normal file
43
tests/AcDream.App.Tests/UI/UiItemListGridTests.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiItemListGridTests
|
||||
{
|
||||
[Fact]
|
||||
public void CellOffset_RowMajor()
|
||||
{
|
||||
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36));
|
||||
Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36));
|
||||
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GridMode_PositionsCellsInColumns()
|
||||
{
|
||||
var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 };
|
||||
list.Flush(); // drop the ctor's default cell
|
||||
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
|
||||
|
||||
var c4 = list.GetItem(4)!;
|
||||
Assert.Equal(36f, c4.Left);
|
||||
Assert.Equal(36f, c4.Top);
|
||||
Assert.Equal(36f, c4.Width);
|
||||
Assert.Equal(36f, c4.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillMode_SizesSingleCellToList()
|
||||
{
|
||||
// CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy).
|
||||
var list = new UiItemList { Width = 36, Height = 36 };
|
||||
list.Flush();
|
||||
list.AddItem(new UiItemSlot());
|
||||
|
||||
var c = list.Cell;
|
||||
Assert.Equal(0f, c.Left);
|
||||
Assert.Equal(0f, c.Top);
|
||||
Assert.Equal(36f, c.Width);
|
||||
Assert.Equal(36f, c.Height);
|
||||
}
|
||||
}
|
||||
83
tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
Normal file
83
tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class UiItemListScrollTests
|
||||
{
|
||||
private static UiItemList Grid(int items)
|
||||
{
|
||||
var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 };
|
||||
list.Flush(); // drop the default single cell
|
||||
for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot());
|
||||
list.LayoutCells(); // drive the scroll model + position cells
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowCount_ceils_to_whole_rows()
|
||||
{
|
||||
Assert.Equal(0, UiItemList.RowCount(0, 6));
|
||||
Assert.Equal(1, UiItemList.RowCount(1, 6));
|
||||
Assert.Equal(7, UiItemList.RowCount(41, 6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grid_drives_scroll_model_from_content()
|
||||
{
|
||||
var list = Grid(30); // 5 rows × 32 = 160 content, 96 view
|
||||
Assert.Equal(160, list.Scroll.ContentHeight);
|
||||
Assert.Equal(96, list.Scroll.ViewHeight);
|
||||
Assert.Equal(64, list.Scroll.MaxScroll);
|
||||
Assert.True(list.Scroll.HasOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_top_first_three_rows_visible_rest_clipped()
|
||||
{
|
||||
var list = Grid(30);
|
||||
Assert.True(list.GetItem(0)!.Visible); // row 0, top 0
|
||||
Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96)
|
||||
Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrolled_one_row_shifts_window_and_clips_top_row()
|
||||
{
|
||||
var list = Grid(30);
|
||||
list.Scroll.SetScrollY(32);
|
||||
list.LayoutCells();
|
||||
Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above
|
||||
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top
|
||||
Assert.True(list.GetItem(18)!.Visible); // row 3 now visible
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scroll_survives_the_per_frame_anchor_pass()
|
||||
{
|
||||
// Regression (the escaping-grid bug): UiElement.DrawSelfAndChildren runs ApplyAnchor
|
||||
// on every child AFTER OnDraw/LayoutCells. Grid cells must be exempt (Anchors=None) so
|
||||
// the anchor pass can't reset the scroll offset LayoutCells applied — otherwise the
|
||||
// grid translates out of the view when scrolled.
|
||||
var list = Grid(30); // cells added (Anchors=None) + laid out
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++) // first anchor pass (would capture base)
|
||||
list.GetItem(i)!.ApplyAnchor(list.Width, list.Height);
|
||||
list.Scroll.SetScrollY(32); // scroll one row
|
||||
list.LayoutCells();
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++) // second anchor pass (the draw traversal)
|
||||
list.GetItem(i)!.ApplyAnchor(list.Width, list.Height);
|
||||
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 stays at the view top
|
||||
Assert.False(list.GetItem(0)!.Visible); // row 0 stays clipped above
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wheel_down_scrolls_toward_the_bottom()
|
||||
{
|
||||
var list = Grid(30); // max scroll 64, LineHeight 32
|
||||
// Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY.
|
||||
var e = new UiEvent(0u, null, UiEventType.Scroll, Data0: -1);
|
||||
bool handled = list.OnEvent(e);
|
||||
Assert.True(handled);
|
||||
Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down
|
||||
}
|
||||
}
|
||||
|
|
@ -22,4 +22,25 @@ public class UiItemListTests
|
|||
var list = new UiItemList();
|
||||
Assert.Same(list.GetItem(0), list.Cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellEmptySprite_stamps_existing_and_future_cells()
|
||||
{
|
||||
var list = new UiItemList(); // ctor adds one default cell
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set
|
||||
|
||||
list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite
|
||||
Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped
|
||||
|
||||
list.AddItem(new UiItemSlot());
|
||||
Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellEmptySprite_zero_leaves_the_UiItemSlot_default()
|
||||
{
|
||||
var list = new UiItemList();
|
||||
list.CellEmptySprite = 0u;
|
||||
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,4 +141,30 @@ public class UiItemSlotTests
|
|||
s.SetShortcutNum(0, peace: true);
|
||||
Assert.Null(s.ActiveDigitArray());
|
||||
}
|
||||
|
||||
// ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 /
|
||||
// SetSelectedState 0x004e1240) ───────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected);
|
||||
|
||||
[Fact]
|
||||
public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer);
|
||||
|
||||
[Fact]
|
||||
public void SelectedSprite_default_isGreenYellowSquare()
|
||||
=> Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite);
|
||||
|
||||
[Fact]
|
||||
public void OpenContainerSprite_default_isTriangle()
|
||||
=> Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite);
|
||||
|
||||
// ── Container capacity bar (decomp UpdateCapacityDisplay 0x004e16e0, element 0x10000347) ──
|
||||
[Fact]
|
||||
public void CapacityFill_defaultsHidden() => Assert.Equal(-1f, new UiItemSlot().CapacityFill);
|
||||
|
||||
[Fact]
|
||||
public void CapacityBackSprite_default() => Assert.Equal(0x06004D22u, new UiItemSlot().CapacityBackSprite);
|
||||
|
||||
[Fact]
|
||||
public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite);
|
||||
}
|
||||
|
|
|
|||
35
tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
Normal file
35
tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiMeterVerticalTests
|
||||
{
|
||||
[Fact]
|
||||
public void Vertical_fill_from_bottom_occupies_lower_fraction()
|
||||
{
|
||||
// 11x58 bar, 50% → fill rect is the bottom 29px.
|
||||
var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true);
|
||||
Assert.Equal(0f, x);
|
||||
Assert.Equal(29f, y, 3); // h - h*p = 58 - 29
|
||||
Assert.Equal(11f, w);
|
||||
Assert.Equal(29f, h, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vertical_fill_from_top_starts_at_zero()
|
||||
{
|
||||
var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false);
|
||||
Assert.Equal(0f, y);
|
||||
Assert.Equal(14.5f, h, 3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 0f)]
|
||||
[InlineData(2f, 58f)]
|
||||
public void Vertical_fill_clamps_fraction(float pct, float expectedH)
|
||||
{
|
||||
var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true);
|
||||
Assert.Equal(expectedH, h, 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ public class UiRootInputTests
|
|||
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
||||
{
|
||||
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40);
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
||||
Assert.Equal(10f, x); Assert.Equal(20f, y);
|
||||
Assert.Equal(130f, w); Assert.Equal(65f, h);
|
||||
}
|
||||
|
|
@ -168,11 +168,32 @@ public class UiRootInputTests
|
|||
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
|
||||
// origin shifts so the RIGHT edge (110) stays put → x = 70.
|
||||
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40);
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
||||
Assert.Equal(40f, w);
|
||||
Assert.Equal(70f, x);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRect_Bottom_ClampsToMaxH()
|
||||
{
|
||||
// dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged.
|
||||
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
|
||||
Assert.Equal(128f, h);
|
||||
Assert.Equal(20f, y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResizeRect_Top_ClampsToMaxH()
|
||||
{
|
||||
// Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128,
|
||||
// origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128.
|
||||
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
|
||||
Assert.Equal(128f, h);
|
||||
Assert.Equal(20f + 50f - 128f, y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_DetectsCornerAndInteriorNone()
|
||||
{
|
||||
|
|
@ -236,4 +257,77 @@ public class UiRootInputTests
|
|||
Assert.Equal(8f, x); Assert.Equal(24f, y);
|
||||
Assert.Equal(200f, w); Assert.Equal(14f, h);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
|
||||
Assert.True(win.Visible);
|
||||
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
|
||||
Assert.False(win.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
Assert.True(root.ShowWindow("inventory"));
|
||||
Assert.True(win.Visible);
|
||||
Assert.True(root.HideWindow("inventory"));
|
||||
Assert.False(win.Visible);
|
||||
|
||||
Assert.False(root.ShowWindow("nope"));
|
||||
Assert.False(root.HideWindow("nope"));
|
||||
Assert.False(root.ToggleWindow("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowWindow_RaisesAbovePeers()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var a = new UiPanel { Width = 100, Height = 100 };
|
||||
var b = new UiPanel { Width = 100, Height = 100 };
|
||||
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
||||
root.AddChild(a); root.AddChild(b); root.AddChild(win);
|
||||
root.RegisterWindow("inventory", win);
|
||||
|
||||
root.ShowWindow("inventory");
|
||||
Assert.True(win.ZOrder > a.ZOrder);
|
||||
Assert.True(win.ZOrder > b.ZOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BringToFront_SetsStrictlyGreatestZOrder()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
|
||||
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
|
||||
root.AddChild(a); root.AddChild(b);
|
||||
|
||||
root.BringToFront(a);
|
||||
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MouseDown_OnWindow_RaisesItAbovePeers()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
|
||||
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
|
||||
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
|
||||
root.AddChild(peer); root.AddChild(clicked);
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
|
||||
Assert.True(clicked.ZOrder > peer.ZOrder);
|
||||
root.OnMouseUp(UiMouseButton.Left, 50, 50);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,25 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(0x2000u, item.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_WieldObject_ConfirmsOptimisticWield()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
const uint player = 0x2000u;
|
||||
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
|
||||
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
|
||||
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
|
||||
|
||||
byte[] payload = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
||||
d.Dispatch(env!.Value); // server confirms the wield
|
||||
|
||||
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_PopupString_RoutesToChatLog()
|
||||
{
|
||||
|
|
@ -496,4 +515,150 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(0x5001u, got![0].ObjectGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject()
|
||||
{
|
||||
// PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler
|
||||
// must land it in the player ClientObject so the burden bar reads the wire value.
|
||||
const uint playerGuid = 0x50000001u;
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var items = new ClientObjectTable();
|
||||
GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(),
|
||||
new ChatLog(), playerGuid: () => playerGuid);
|
||||
|
||||
var sb = new MemoryStream();
|
||||
using var w = new BinaryWriter(sb);
|
||||
w.Write(0x00000001u); // propertyFlags = PropertyInt32
|
||||
w.Write(0x52u); // weenieType
|
||||
// int table: u16 count, u16 buckets, then key/val pairs
|
||||
w.Write((ushort)1); // count
|
||||
w.Write((ushort)8); // buckets (ignored)
|
||||
w.Write(5u); // key = EncumbranceVal
|
||||
w.Write(1500u); // val
|
||||
// vector + has_health (no vector blocks)
|
||||
w.Write(0u); // vectorFlags = None
|
||||
w.Write(0u); // has_health
|
||||
// strict trailer
|
||||
w.Write(0u); // option_flags = None
|
||||
w.Write(0u); // options1
|
||||
w.Write(0u); // legacy hotbar count
|
||||
w.Write(0u); // spellbook_filters
|
||||
w.Write(0u); // inventory count
|
||||
w.Write(0u); // equipped count
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
var player = items.Get(playerGuid);
|
||||
Assert.NotNull(player);
|
||||
Assert.Equal(1500, player!.Properties.Ints[5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ViewContents_RecordsMembershipInClientObjectTable()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
|
||||
// containerGuid 0xC9 + 2 entries
|
||||
byte[] payload = new byte[8 + 2 * 8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
|
||||
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
|
||||
// First view of container 0xC9: two items.
|
||||
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u });
|
||||
Assert.Equal(2, items.GetContents(0x500000C9u).Count);
|
||||
|
||||
// Second view: only one item — the other was removed server-side. Additive merge would keep both.
|
||||
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u });
|
||||
Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u));
|
||||
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
||||
}
|
||||
|
||||
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
|
||||
{
|
||||
byte[] payload = new byte[8 + itemGuids.Length * 8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length);
|
||||
for (int i = 0; i < itemGuids.Length; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType
|
||||
}
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
// An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1.
|
||||
items.RecordMembership(0x50000B01u, containerId: 0x500000C0u);
|
||||
items.MoveItem(0x50000B01u, 0x500000C0u, 2);
|
||||
items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0);
|
||||
Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic)
|
||||
|
||||
// Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError).
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back
|
||||
Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
items.RecordMembership(0x50000B02u, containerId: 0x500000C0u);
|
||||
items.MoveItem(0x50000B02u, 0x500000C0u, 2);
|
||||
items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot)
|
||||
|
||||
// Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType).
|
||||
byte[] payload = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
|
||||
Assert.False(items.RollbackMove(0x50000B02u));
|
||||
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
|
||||
{
|
||||
var (d, items, _, _, _) = MakeAll();
|
||||
items.RecordMembership(0x50000A01u, containerId: 0x500000C9u);
|
||||
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,15 +56,18 @@ public sealed class AppraiseTests
|
|||
[Fact]
|
||||
public void ParsePutObjInContainer_RoundTrip()
|
||||
{
|
||||
byte[] payload = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u);
|
||||
// 4 fields (Task 7 fix: containerType added) — 16 bytes required.
|
||||
byte[] payload = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
|
||||
|
||||
var parsed = GameEvents.ParsePutObjInContainer(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x1001u, parsed!.Value.ItemGuid);
|
||||
Assert.Equal(0x2001u, parsed.Value.ContainerGuid);
|
||||
Assert.Equal(3u, parsed.Value.Placement);
|
||||
Assert.Equal(1u, parsed.Value.ContainerType);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,4 +36,42 @@ public sealed class DeleteObjectTests
|
|||
Assert.Equal(0x80000439u, parsed!.Value.Guid);
|
||||
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard: TryParse (0xF747 = true destroy) must always produce
|
||||
/// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is
|
||||
/// the ONLY path that sets FromPickup = true (in WorldSession).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryParse_AlwaysReturnsFromPickupFalse()
|
||||
{
|
||||
Span<byte> body = stackalloc byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001);
|
||||
|
||||
var parsed = DeleteObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.False(parsed!.Value.FromPickup,
|
||||
"DeleteObject 0xF747 is a true destroy — FromPickup must be false.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FromPickup = true can be constructed via the record directly (as
|
||||
/// WorldSession does for PickupEvent 0xF74A). Default is false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parsed_DefaultFromPickupIsFalse()
|
||||
{
|
||||
var p = new DeleteObject.Parsed(0x80000001u, 1);
|
||||
Assert.False(p.FromPickup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsed_FromPickupTrueCanBeSet()
|
||||
{
|
||||
var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true);
|
||||
Assert.True(p.FromPickup);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class GameEventsInventoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseViewContents_twoEntries_returnsContainerAndItems()
|
||||
{
|
||||
// containerGuid + count(2) + 2×{guid, containerType}
|
||||
var b = new byte[4 + 4 + 2 * 8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item)
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container)
|
||||
|
||||
var p = GameEvents.ParseViewContents(b);
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(0x500000C9u, p!.Value.ContainerGuid);
|
||||
Assert.Equal(2, p.Value.Items.Count);
|
||||
Assert.Equal(0x50000A01u, p.Value.Items[0].Guid);
|
||||
Assert.Equal(0u, p.Value.Items[0].ContainerType);
|
||||
Assert.Equal(0x50000A02u, p.Value.Items[1].Guid);
|
||||
Assert.Equal(1u, p.Value.Items[1].ContainerType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseViewContents_zeroCount_returnsEmptyList()
|
||||
{
|
||||
var b = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u);
|
||||
var p = GameEvents.ParseViewContents(b);
|
||||
Assert.NotNull(p);
|
||||
Assert.Empty(p!.Value.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseViewContents_truncated_returnsNull()
|
||||
=> Assert.Null(GameEvents.ParseViewContents(new byte[4]));
|
||||
|
||||
[Fact]
|
||||
public void ParsePutObjInContainer_readsAllFourFields()
|
||||
{
|
||||
var b = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType
|
||||
|
||||
var p = GameEvents.ParsePutObjInContainer(b);
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
|
||||
Assert.Equal(0x500000C9u, p.Value.ContainerGuid);
|
||||
Assert.Equal(3u, p.Value.Placement);
|
||||
Assert.Equal(1u, p.Value.ContainerType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseInventoryServerSaveFailed_readsGuidAndError()
|
||||
{
|
||||
var b = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError
|
||||
|
||||
var p = GameEvents.ParseInventoryServerSaveFailed(b);
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
|
||||
Assert.Equal(0x0Au, p.Value.WeenieError);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,18 +74,25 @@ public sealed class InventoryActionsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddShortcut_ThreeFields()
|
||||
public void BuildAddShortcut_ItemShortcut_FieldLayout()
|
||||
{
|
||||
byte[] body = InventoryActions.BuildAddShortcut(
|
||||
seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1);
|
||||
// ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0.
|
||||
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0);
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(InventoryActions.AddShortcutOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
Assert.Equal(0x3E1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index
|
||||
Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid
|
||||
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId
|
||||
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s()
|
||||
{
|
||||
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3);
|
||||
Assert.Equal(0x1234, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -106,4 +113,38 @@ public sealed class InventoryActionsTests
|
|||
Assert.Equal(42u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDropItem_layout()
|
||||
{
|
||||
byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u);
|
||||
Assert.Equal(16, b.Length);
|
||||
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
|
||||
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
|
||||
Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
|
||||
Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGetAndWieldItem_layout()
|
||||
{
|
||||
byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u);
|
||||
Assert.Equal(20, b.Length);
|
||||
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
|
||||
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
|
||||
Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
|
||||
Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
|
||||
Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNoLongerViewingContents_layout()
|
||||
{
|
||||
byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u);
|
||||
Assert.Equal(16, b.Length);
|
||||
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
|
||||
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
|
||||
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
|
||||
Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class InventoryRemoveObjectTests
|
||||
{
|
||||
private static byte[] Build(uint guid, uint opcode = 0x0024u)
|
||||
{
|
||||
var b = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid);
|
||||
return b;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_valid_returnsGuid()
|
||||
{
|
||||
var p = InventoryRemoveObject.TryParse(Build(0x50000A07u));
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(0x50000A07u, p!.Value.Guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_wrongOpcode_returnsNull()
|
||||
=> Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u)));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_truncated_returnsNull()
|
||||
=> Assert.Null(InventoryRemoveObject.TryParse(new byte[7]));
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class PrivateUpdatePropertyIntTests
|
||||
{
|
||||
// 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid.
|
||||
private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu)
|
||||
{
|
||||
var b = new byte[13];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
|
||||
b[4] = seq;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value);
|
||||
return b;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_encumbranceVal_returnsPropValue()
|
||||
{
|
||||
var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500));
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(5u, p!.Value.Property);
|
||||
Assert.Equal(1500, p.Value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_wrongOpcode_returnsNull()
|
||||
=> Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu)));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_truncated_returnsNull()
|
||||
=> Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12]));
|
||||
}
|
||||
37
tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs
Normal file
37
tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class SetStackSizeTests
|
||||
{
|
||||
private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u)
|
||||
{
|
||||
var b = new byte[17];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
|
||||
b[4] = seq;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
|
||||
return b;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_validStack_returnsGuidStackValue()
|
||||
{
|
||||
var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125));
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal(0x50000A01u, p!.Value.Guid);
|
||||
Assert.Equal(25, p.Value.StackSize);
|
||||
Assert.Equal(125, p.Value.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_wrongOpcode_returnsNull()
|
||||
=> Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u)));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_truncated_returnsNull()
|
||||
=> Assert.Null(SetStackSize.TryParse(new byte[16]));
|
||||
}
|
||||
|
|
@ -7,10 +7,13 @@ namespace AcDream.Core.Net.Tests;
|
|||
/// <summary>
|
||||
/// D.5.4 Task 7 — ObjectTableWiring.
|
||||
///
|
||||
/// Integration test is omitted: WorldSession.EntitySpawned has no internal
|
||||
/// test seam to fire it without a real Tick + packet bytes, so subscription
|
||||
/// correctness is covered by build (type-checks) + the live run. Only the
|
||||
/// pure mapping (ToWeenieData) is unit-tested here.
|
||||
/// WorldSession.EntitySpawned/EntityDeleted have no in-process test seam (the
|
||||
/// ctor opens a UDP socket), so the subscription wiring is tested via the guard
|
||||
/// logic extracted as a local handler — this is the same lambda body that
|
||||
/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table
|
||||
/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but
|
||||
/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the
|
||||
/// weenie from both. FromPickup = true guards the eviction.
|
||||
/// </summary>
|
||||
public sealed class ObjectTableWiringTests
|
||||
{
|
||||
|
|
@ -94,4 +97,80 @@ public sealed class ObjectTableWiringTests
|
|||
Assert.Equal(100, d.MaxStructure);
|
||||
Assert.Equal(4.5f, d.Workmanship);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
|
||||
// Testing it directly avoids needing a live WorldSession (UDP socket).
|
||||
// -------------------------------------------------------------------------
|
||||
private static Action<DeleteObject.Parsed> MakeEntityDeletedHandler(ClientObjectTable table)
|
||||
=> d => { if (!d.FromPickup) table.Remove(d.Guid); };
|
||||
|
||||
private static WeenieData MinimalWeenie(uint guid) => new(
|
||||
Guid: guid,
|
||||
Name: "Test Item",
|
||||
Type: null,
|
||||
WeenieClassId: 1u,
|
||||
IconId: 0x06000001u,
|
||||
IconOverlayId: 0u,
|
||||
IconUnderlayId: 0u,
|
||||
Effects: 0u,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
|
||||
/// <summary>
|
||||
/// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable.
|
||||
/// The 3-D render entity is removed (EntityDeleted still fires), but the weenie
|
||||
/// data persists so the follow-up InventoryPutObjInContainer can find it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var handler = MakeEntityDeletedHandler(table);
|
||||
|
||||
const uint guid = 0x80000100u;
|
||||
table.Ingest(MinimalWeenie(guid));
|
||||
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
||||
|
||||
// Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A).
|
||||
// The handler itself represents the EntityDeleted subscription — firing it IS the event.
|
||||
handler(new DeleteObject.Parsed(guid, 0, FromPickup: true));
|
||||
|
||||
// Post-condition: weenie still in table (it moved into a container, was NOT destroyed).
|
||||
// EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity.
|
||||
Assert.NotNull(table.Get(guid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable.
|
||||
/// Regression guard — true destroys (0xF747) must still clean up the table.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var handler = MakeEntityDeletedHandler(table);
|
||||
|
||||
const uint guid = 0x80000200u;
|
||||
table.Ingest(MinimalWeenie(guid));
|
||||
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
||||
|
||||
// Fire EntityDeleted as WorldSession does for DeleteObject (0xF747).
|
||||
handler(new DeleteObject.Parsed(guid, 0, FromPickup: false));
|
||||
|
||||
// Post-condition: weenie evicted (truly destroyed).
|
||||
Assert.Null(table.Get(guid));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
tests/AcDream.Core.Tests/Items/BurdenMathTests.cs
Normal file
40
tests/AcDream.Core.Tests/Items/BurdenMathTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class BurdenMathTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(100, 0, 15000)] // base: str*150
|
||||
[InlineData(100, 3, 24000)] // +clamp(3*30,0,150)=90 -> +90*100
|
||||
[InlineData(100, 10, 30000)] // 10*30=300 clamped to 150 -> +150*100
|
||||
[InlineData(0, 5, 0)] // str<=0 -> 0
|
||||
public void EncumbranceCapacity_matches_retail(int str, int aug, int expected)
|
||||
=> Assert.Equal(expected, BurdenMath.EncumbranceCapacity(str, aug));
|
||||
|
||||
[Theory]
|
||||
[InlineData(15000, 7500, 0.5f)]
|
||||
[InlineData(15000, 0, 0f)]
|
||||
[InlineData(0, 100, 0f)] // cap<=0 guard
|
||||
public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
|
||||
=> Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 0f)]
|
||||
[InlineData(0.5f, 0.16667f)]
|
||||
[InlineData(1.0f, 0.33333f)]
|
||||
[InlineData(3.0f, 1.0f)] // full at 300%
|
||||
[InlineData(4.0f, 1.0f)] // clamped
|
||||
public void LoadToFill_is_third_clamped(float load, float expected)
|
||||
=> Assert.Equal(expected, BurdenMath.LoadToFill(load), 4);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f, 0)]
|
||||
[InlineData(0.5f, 50)]
|
||||
[InlineData(1.0f, 100)]
|
||||
[InlineData(3.0f, 300)] // 300% = full
|
||||
[InlineData(4.0f, 300)] // SATURATES at 300% (computed from the clamped fill, decomp 176544-176576)
|
||||
public void LoadToPercent_saturates_at_300(float load, int expected)
|
||||
=> Assert.Equal(expected, BurdenMath.LoadToPercent(load));
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class ClientObjectTableBurdenTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
[Fact]
|
||||
public void SumCarriedBurden_sums_pack_sidebag_and_wielded_but_not_unrelated()
|
||||
{
|
||||
var t = new ClientObjectTable();
|
||||
// loose pack item
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 100 });
|
||||
// side bag in pack
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, Burden = 50,
|
||||
Type = ItemType.Container });
|
||||
// item inside the side bag (2-deep)
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = 0xB, Burden = 30 });
|
||||
// wielded (ContainerId 0, WielderId = player)
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xD, WielderId = Player, Burden = 200 });
|
||||
// unrelated object in the world
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0xE, ContainerId = 0, Burden = 999 });
|
||||
|
||||
Assert.Equal(380, t.SumCarriedBurden(Player)); // 100+50+30+200
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumCarriedBurden_unknown_owner_is_zero()
|
||||
=> Assert.Equal(0, new ClientObjectTable().SumCarriedBurden(Player));
|
||||
}
|
||||
|
|
@ -319,6 +319,126 @@ public sealed class ClientObjectTableTests
|
|||
Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_RecordsAllInListOrder()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
|
||||
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
|
||||
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge)
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
|
||||
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u));
|
||||
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering
|
||||
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
|
||||
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ReordersToNewListOrder()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u });
|
||||
table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u });
|
||||
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ZeroContainer_NoOp()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0u, new uint[] { 0xA01u });
|
||||
Assert.Empty(table.GetContents(0u));
|
||||
Assert.Null(table.Get(0xA01u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveItemOptimistic_movesAndRemembersPreMove()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3);
|
||||
table.MoveItemOptimistic(0x700u, 0xC1u, 0);
|
||||
Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant)
|
||||
Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered
|
||||
Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container
|
||||
Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmMove_clearsPending_soRollbackIsNoOp()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x701u, container: 0xC0u));
|
||||
table.MoveItemOptimistic(0x701u, 0xC1u, 0);
|
||||
table.ConfirmMove(0x701u);
|
||||
Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback
|
||||
Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveItemOptimistic_twice_keepsTheOriginalPreMove()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x702u, container: 0xC0u));
|
||||
table.MoveItemOptimistic(0x702u, 0xC1u, 0);
|
||||
table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm
|
||||
table.RollbackMove(0x702u);
|
||||
Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackMove_unknownItem_false()
|
||||
=> Assert.False(new ClientObjectTable().RollbackMove(0xDEADu));
|
||||
|
||||
[Fact]
|
||||
public void MoveItemOptimistic_twice_oneConfirm_stillRollsBack() // I1: an early confirm must not strand the item
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x910u, container: 0xC0u)); table.MoveItem(0x910u, 0xC0u, 5);
|
||||
table.MoveItemOptimistic(0x910u, 0xC1u, 0); // move 1 (outstanding 1, snapshot C0/5)
|
||||
table.MoveItemOptimistic(0x910u, 0xC2u, 0); // move 2 (outstanding 2)
|
||||
table.ConfirmMove(0x910u); // server confirms move 1 → outstanding 1, STILL pending
|
||||
Assert.True(table.RollbackMove(0x910u)); // move 2 rejected → can still roll back (not stranded at C2)
|
||||
Assert.Equal(0xC0u, table.Get(0x910u)!.ContainerId);
|
||||
Assert.Equal(5, table.Get(0x910u)!.ContainerSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_dropsPendingMoves_soRecycledGuidIsntMisRolledBack() // I2: pending must not survive a table flush
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x911u, container: 0xC0u));
|
||||
table.MoveItemOptimistic(0x911u, 0xC1u, 0); // pending snapshot for 0x911
|
||||
table.Clear(); // teleport/logoff flushes the table
|
||||
|
||||
table.Ingest(FullWeenie(0x911u, container: 0xC5u)); // a recycled guid reappears fresh
|
||||
Assert.False(table.RollbackMove(0x911u)); // no stale pending → no mis-rollback
|
||||
Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveItemOptimistic_insertBefore_shiftsOthers_keepsGaplessOrder() // the "items change position" bug
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.Ingest(FullWeenie(0x920u, container: 0xC0u)); table.MoveItem(0x920u, 0xC0u, 0);
|
||||
table.Ingest(FullWeenie(0x921u, container: 0xC0u)); table.MoveItem(0x921u, 0xC0u, 1);
|
||||
table.Ingest(FullWeenie(0x922u, container: 0xC0u)); table.MoveItem(0x922u, 0xC0u, 2);
|
||||
|
||||
// Move 0x922 (index 2) to index 0 → order [0x922, 0x920, 0x921], slots renumbered 0,1,2 (no ties,
|
||||
// no reshuffle). A raw slot-set would leave 0x922 + 0x920 both at slot 0 → unstable sort.
|
||||
table.MoveItemOptimistic(0x922u, 0xC0u, 0);
|
||||
Assert.Equal(new[] { 0x922u, 0x920u, 0x921u }, table.GetContents(0xC0u));
|
||||
Assert.Equal(0, table.Get(0x922u)!.ContainerSlot);
|
||||
Assert.Equal(1, table.Get(0x920u)!.ContainerSlot);
|
||||
Assert.Equal(2, table.Get(0x921u)!.ContainerSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out
|
||||
{
|
||||
|
|
@ -333,4 +453,67 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u));
|
||||
Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WieldItemOptimistic_equipsInstantly_setsContainerAndEquip()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u, pack = 0x40000005u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x940u });
|
||||
table.MoveItem(0x940u, pack, newSlot: 2); // a pack item (WielderId stays 0)
|
||||
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
|
||||
var o = table.Get(0x940u)!;
|
||||
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
|
||||
Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm)
|
||||
Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WieldItemOptimistic_rollback_unequips_andReturnsToPack()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u, pack = 0x40000005u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x941u });
|
||||
table.MoveItem(0x941u, pack, newSlot: 2);
|
||||
table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear);
|
||||
Assert.True(table.RollbackMove(0x941u)); // server rejected the wield
|
||||
var o = table.Get(0x941u)!;
|
||||
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped
|
||||
Assert.Equal(pack, o.ContainerId); // back in the pack
|
||||
Assert.Equal(2, o.ContainerSlot); // at its original slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
|
||||
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
|
||||
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
|
||||
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
|
||||
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
|
||||
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WieldItemOptimistic_unknownItem_false()
|
||||
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
|
||||
|
||||
[Fact]
|
||||
public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending)
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u, pack = 0x40000005u, pack2 = 0x40000006u;
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x950u });
|
||||
table.MoveItem(0x950u, pack, newSlot: 2); // pre-wield: pack slot 2
|
||||
table.WieldItemOptimistic(0x950u, player, EquipMask.HeadWear); // outstanding 1, snapshot (pack, 2, None)
|
||||
table.MoveItemOptimistic(0x950u, pack2, 0); // outstanding 2 (same snapshot)
|
||||
table.ConfirmMove(0x950u); // confirm the first → outstanding 1, still pending
|
||||
Assert.True(table.RollbackMove(0x950u)); // the second rejected → roll back
|
||||
var o = table.Get(0x950u)!;
|
||||
Assert.Equal(pack, o.ContainerId); // to the ORIGINAL pre-wield container
|
||||
Assert.Equal(2, o.ContainerSlot); // and slot
|
||||
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class ClientObjectTableUpdateTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpsertProperties_unknownObject_createsThenMerges()
|
||||
{
|
||||
var t = new ClientObjectTable();
|
||||
bool added = false;
|
||||
t.ObjectAdded += _ => added = true;
|
||||
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[5] = 1234; // EncumbranceVal
|
||||
|
||||
t.UpsertProperties(0x50000001u, bundle);
|
||||
|
||||
var o = t.Get(0x50000001u);
|
||||
Assert.NotNull(o);
|
||||
Assert.Equal(1234, o!.Properties.Ints[5]);
|
||||
Assert.True(added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertProperties_existingObject_mergesAndFiresUpdated()
|
||||
{
|
||||
var t = new ClientObjectTable();
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u });
|
||||
bool updated = false;
|
||||
t.ObjectUpdated += _ => updated = true;
|
||||
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[5] = 99;
|
||||
t.UpsertProperties(0x50000001u, bundle);
|
||||
|
||||
Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]);
|
||||
Assert.True(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated()
|
||||
{
|
||||
var t = new ClientObjectTable();
|
||||
t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 });
|
||||
bool updated = false;
|
||||
t.ObjectUpdated += _ => updated = true;
|
||||
|
||||
bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(25, t.Get(0x600u)!.StackSize);
|
||||
Assert.Equal(125, t.Get(0x600u)!.Value);
|
||||
Assert.True(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStackSize_unknownObject_returnsFalse()
|
||||
=> Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1));
|
||||
}
|
||||
54
tests/AcDream.Core.Tests/Items/EquipMaskTests.cs
Normal file
54
tests/AcDream.Core.Tests/Items/EquipMaskTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Pins every EquipMask member to the verbatim retail INVENTORY_LOC value
|
||||
/// (docs/research/named-retail/acclient.h:3193). The wire delivers these exact bits
|
||||
/// (ACE EquipMask == retail INVENTORY_LOC); any drift desyncs the paperdoll element-id→mask
|
||||
/// map and the GetAndWieldItem wire. This test is the anti-regression lock.
|
||||
/// </summary>
|
||||
public sealed class EquipMaskTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, EquipMask.None)]
|
||||
[InlineData(0x00000001u, EquipMask.HeadWear)]
|
||||
[InlineData(0x00000002u, EquipMask.ChestWear)]
|
||||
[InlineData(0x00000004u, EquipMask.AbdomenWear)]
|
||||
[InlineData(0x00000008u, EquipMask.UpperArmWear)]
|
||||
[InlineData(0x00000010u, EquipMask.LowerArmWear)]
|
||||
[InlineData(0x00000020u, EquipMask.HandWear)]
|
||||
[InlineData(0x00000040u, EquipMask.UpperLegWear)]
|
||||
[InlineData(0x00000080u, EquipMask.LowerLegWear)]
|
||||
[InlineData(0x00000100u, EquipMask.FootWear)]
|
||||
[InlineData(0x00000200u, EquipMask.ChestArmor)]
|
||||
[InlineData(0x00000400u, EquipMask.AbdomenArmor)]
|
||||
[InlineData(0x00000800u, EquipMask.UpperArmArmor)]
|
||||
[InlineData(0x00001000u, EquipMask.LowerArmArmor)]
|
||||
[InlineData(0x00002000u, EquipMask.UpperLegArmor)]
|
||||
[InlineData(0x00004000u, EquipMask.LowerLegArmor)]
|
||||
[InlineData(0x00008000u, EquipMask.NeckWear)]
|
||||
[InlineData(0x00010000u, EquipMask.WristWearLeft)]
|
||||
[InlineData(0x00020000u, EquipMask.WristWearRight)]
|
||||
[InlineData(0x00040000u, EquipMask.FingerWearLeft)]
|
||||
[InlineData(0x00080000u, EquipMask.FingerWearRight)]
|
||||
[InlineData(0x00100000u, EquipMask.MeleeWeapon)]
|
||||
[InlineData(0x00200000u, EquipMask.Shield)]
|
||||
[InlineData(0x00400000u, EquipMask.MissileWeapon)]
|
||||
[InlineData(0x00800000u, EquipMask.MissileAmmo)]
|
||||
[InlineData(0x01000000u, EquipMask.Held)]
|
||||
[InlineData(0x02000000u, EquipMask.TwoHanded)]
|
||||
[InlineData(0x04000000u, EquipMask.TrinketOne)]
|
||||
[InlineData(0x08000000u, EquipMask.Cloak)]
|
||||
[InlineData(0x10000000u, EquipMask.SigilOne)]
|
||||
[InlineData(0x20000000u, EquipMask.SigilTwo)]
|
||||
[InlineData(0x40000000u, EquipMask.SigilThree)]
|
||||
public void Member_has_canonical_retail_value(uint expected, EquipMask member)
|
||||
=> Assert.Equal(expected, (uint)member);
|
||||
|
||||
[Fact]
|
||||
public void Weapon_ready_slot_composite_is_0x3500000() // acclient.h:3235 WEAPON_READY_SLOT_LOC
|
||||
=> Assert.Equal(0x3500000u,
|
||||
(uint)(EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded));
|
||||
}
|
||||
39
tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
Normal file
39
tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public class ShortcutStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange()
|
||||
{
|
||||
var store = new ShortcutStore();
|
||||
store.Load(new (int, uint)[]
|
||||
{
|
||||
(0, 0x5001u),
|
||||
(3, 0x5002u),
|
||||
(5, 0u), // empty/spell → skipped
|
||||
(99, 0x5003u), // out of range → skipped
|
||||
});
|
||||
Assert.Equal(0x5001u, store.Get(0));
|
||||
Assert.Equal(0x5002u, store.Get(3));
|
||||
Assert.Equal(0u, store.Get(5));
|
||||
Assert.True(store.IsEmpty(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetRemoveGet_roundtrip_andBoundsSafe()
|
||||
{
|
||||
var store = new ShortcutStore();
|
||||
store.Set(4, 0xABCDu);
|
||||
Assert.Equal(0xABCDu, store.Get(4));
|
||||
Assert.False(store.IsEmpty(4));
|
||||
store.Remove(4);
|
||||
Assert.True(store.IsEmpty(4));
|
||||
Assert.Equal(0u, store.Get(-1));
|
||||
Assert.Equal(0u, store.Get(18));
|
||||
store.Set(18, 1u); // no-op, no throw
|
||||
store.Remove(-1); // no-op, no throw
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue