Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client. Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
919 lines
40 KiB
C#
919 lines
40 KiB
C#
using System.Collections.Generic;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Selection;
|
|
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 TitleText = 0x100001D3u;
|
|
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 titleText = new TestElement { Width = 276, Height = 25 };
|
|
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(titleText); root.AddChild(scrollbar);
|
|
var byId = new Dictionary<uint, UiElement>
|
|
{
|
|
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
|
|
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
|
|
[ContentsCaption] = contentsCap, [TitleText] = titleText, [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,
|
|
List<(uint item, uint container, uint placement, uint amount)>? splits = null,
|
|
List<(uint source, uint target, uint amount)>? merges = null,
|
|
List<(uint source, uint target)>? mergeNotices = null,
|
|
string? ownerName = null,
|
|
Action? onClose = null,
|
|
SelectionState? selection = null,
|
|
StackSplitQuantityState? stackSplitQuantity = null)
|
|
=> InventoryController.Bind(layout, objects, () => Player,
|
|
iconIds: (_, _, _, _, _) => 0u,
|
|
strength: () => strength, datFont: null,
|
|
ownerName: ownerName is null ? null : () => ownerName,
|
|
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)),
|
|
sendStackableSplitToContainer: splits is null
|
|
? null
|
|
: (i, c, p, a) => splits.Add((i, c, p, a)),
|
|
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
|
|
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
|
|
onClose: onClose,
|
|
selection: selection ?? new SelectionState(),
|
|
stackSplitQuantity: stackSplitQuantity);
|
|
|
|
private static UiButton MakeButton(uint id)
|
|
{
|
|
var info = new ElementInfo { Id = id, Type = 1 };
|
|
return new UiButton(info, static _ => (0u, 0, 0)) { Width = 16, Height = 16 };
|
|
}
|
|
|
|
// 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 SessionTableClearRebuildsInventoryWithoutOldObjects()
|
|
{
|
|
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
SeedContained(objects, 0xAu, Player, slot: 0);
|
|
SeedBag(objects, 0xCu, slot: 1);
|
|
Bind(layout, objects);
|
|
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
|
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
|
|
|
objects.Clear();
|
|
|
|
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
|
|
Assert.Equal(0u, containers.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerationReplacementRemovesOldOwnedProjectionAndSelection()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
SeedContained(objects, 0xAu, Player, slot: 0);
|
|
var selection = new SelectionState();
|
|
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
|
Bind(layout, objects, selection: selection);
|
|
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
|
|
|
objects.ReplaceGeneration(WorldReplacement(0xAu), generation: 2);
|
|
|
|
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
|
|
Assert.Null(selection.SelectedObjectId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Populate_uses_manifest_container_hint_before_create_object_details()
|
|
{
|
|
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.ReplaceContents(Player, new[]
|
|
{
|
|
new ContainerContentEntry(0xC, 1u),
|
|
new ContainerContentEntry(0xA, 0u),
|
|
});
|
|
|
|
Bind(layout, objects);
|
|
|
|
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
|
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
|
}
|
|
|
|
[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(UiItemListFlow.RowMajor, grid.Flow);
|
|
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();
|
|
var title = layout.FindElement(TitleText)!;
|
|
Bind(layout, new ClientObjectTable(), ownerName: "Horan");
|
|
Assert.Contains("Inventory of Horan", CaptionText(title));
|
|
Assert.Contains("Burden", CaptionText(burdenCap));
|
|
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
|
|
}
|
|
|
|
[Fact]
|
|
public void Window_chrome_button_invokes_close_callback()
|
|
{
|
|
var (layout, _, _, _, _, _, _, _) = BuildLayout();
|
|
var close = MakeButton(WindowChromeController.InventoryCloseButtonId);
|
|
layout.Root.AddChild(close);
|
|
int closes = 0;
|
|
|
|
Bind(layout, new ClientObjectTable(), onClose: () => closes++);
|
|
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
|
|
|
|
Assert.Equal(1, closes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Window_maximize_button_is_not_bound_as_close()
|
|
{
|
|
var (layout, _, _, _, _, _, _, _) = BuildLayout();
|
|
var maximize = MakeButton(WindowChromeController.MaxMinButtonId);
|
|
layout.Root.AddChild(maximize);
|
|
int closes = 0;
|
|
|
|
Bind(layout, new ClientObjectTable(), onClose: () => closes++);
|
|
maximize.OnEvent(new UiEvent(0u, maximize, UiEventType.Click));
|
|
|
|
Assert.Equal(0, closes);
|
|
}
|
|
|
|
[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 Inventory_refresh_preserves_contents_pixel_scroll_offset()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 });
|
|
Bind(layout, objects);
|
|
grid.Scroll.SetScrollY(40);
|
|
|
|
objects.UpdateIntProperty(Player, 5u, 1000);
|
|
|
|
Assert.Equal(40, grid.Scroll.ScrollY);
|
|
Assert.Equal(-8f, grid.GetItem(6)!.Top);
|
|
}
|
|
|
|
[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_and_drop_feedback_are_applied_per_list_role()
|
|
{
|
|
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
|
|
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
|
|
iconIds: (_, _, _, _, _) => 0u, strength: () => 100,
|
|
selection: new SelectionState(), 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
|
|
Assert.Equal(0x060011F9u, grid.GetItem(0)!.DragAcceptSprite); // normal slot accept = green circle
|
|
Assert.Equal(0x060011F7u, containers.GetItem(0)!.DragAcceptSprite); // container drop-in = green arrow
|
|
Assert.Equal(0x060011F7u, top.GetItem(0)!.DragAcceptSprite); // main container drop-in = green arrow
|
|
}
|
|
|
|
[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 ClickGridItem_updatesSharedSelection_andExternalSelectionMovesSquare()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
SeedContained(objects, 0xAu, Player, slot: 0);
|
|
SeedContained(objects, 0xBu, Player, slot: 1);
|
|
var selection = new SelectionState();
|
|
Bind(layout, objects, selection: selection);
|
|
|
|
grid.GetItem(0)!.Clicked!();
|
|
|
|
Assert.Equal(0xAu, selection.SelectedObjectId);
|
|
Assert.True(grid.GetItem(0)!.Selected);
|
|
|
|
selection.Select(0xBu, SelectionChangeSource.World);
|
|
|
|
Assert.False(grid.GetItem(0)!.Selected);
|
|
Assert.True(grid.GetItem(1)!.Selected);
|
|
}
|
|
|
|
[Fact]
|
|
public void TargetMode_suppressesSelectedSquare_onPendingSource()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = Player,
|
|
Type = ItemType.Creature,
|
|
ItemsCapacity = 102,
|
|
});
|
|
SeedContained(objects, 0xA, Player, slot: 0);
|
|
objects.Get(0xA)!.Useability = 0x000A0008u;
|
|
var interaction = new ItemInteractionController(
|
|
objects,
|
|
playerGuid: () => Player,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null,
|
|
nowMs: () => 1_000);
|
|
|
|
InventoryController.Bind(layout, objects, () => Player,
|
|
iconIds: (_, _, _, _, _) => 0u,
|
|
strength: () => 100,
|
|
selection: new SelectionState(),
|
|
datFont: null,
|
|
itemInteraction: interaction);
|
|
grid.GetItem(0)!.Clicked!();
|
|
Assert.True(grid.GetItem(0)!.Selected);
|
|
|
|
Assert.True(interaction.ActivateItem(0xAu));
|
|
|
|
Assert.True(interaction.IsTargetModeActive);
|
|
Assert.False(grid.GetItem(0)!.Selected);
|
|
}
|
|
|
|
[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, selection: new SelectionState(), 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());
|
|
|
|
private static WeenieData WorldReplacement(uint guid) => new(
|
|
Guid: guid,
|
|
Name: "replacement",
|
|
Type: ItemType.Misc,
|
|
WeenieClassId: 1,
|
|
IconId: 0,
|
|
IconOverlayId: 0,
|
|
IconUnderlayId: 0,
|
|
Effects: 0,
|
|
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);
|
|
|
|
[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_selectedPartialStack_splitsIntoContainer_withoutMovingOriginal()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xAu,
|
|
StackSize = 10,
|
|
StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xAu, Player, 0);
|
|
var selection = new SelectionState();
|
|
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
|
var splitQuantity = new StackSplitQuantityState();
|
|
splitQuantity.Reset(10u);
|
|
splitQuantity.SetValue(1u);
|
|
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
|
|
var puts = new List<(uint item, uint container, int placement)>();
|
|
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
|
|
selection: selection, stackSplitQuantity: splitQuantity);
|
|
|
|
ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu));
|
|
|
|
Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits);
|
|
Assert.Empty(puts);
|
|
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
|
|
Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot);
|
|
Assert.Equal(10, objects.Get(0xAu)!.StackSize);
|
|
}
|
|
|
|
[Fact]
|
|
public void Drop_unselectedStack_ignoresOtherSelectionSplitQuantity_andMovesWholeStack()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 0xAu, StackSize = 10 });
|
|
objects.MoveItem(0xAu, Player, 0);
|
|
objects.AddOrUpdate(new ClientObject { ObjectId = 0xBu, StackSize = 20 });
|
|
objects.MoveItem(0xBu, Player, 1);
|
|
var selection = new SelectionState();
|
|
selection.Select(0xBu, SelectionChangeSource.Inventory);
|
|
var splitQuantity = new StackSplitQuantityState();
|
|
splitQuantity.Reset(20u);
|
|
splitQuantity.SetValue(1u);
|
|
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
|
|
var puts = new List<(uint item, uint container, int placement)>();
|
|
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
|
|
selection: selection, stackSplitQuantity: splitQuantity);
|
|
|
|
ctrl.HandleDropRelease(grid, grid.GetItem(4)!, Payload(0xAu));
|
|
|
|
Assert.Empty(splits);
|
|
Assert.Equal(new[] { (0xAu, Player, 2) }, puts);
|
|
}
|
|
|
|
[Fact]
|
|
public void Drop_matchingStackOnOccupiedStack_mergesBeforeMoveAndBroadcastsToolbarNotice()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xA,
|
|
WeenieClassId = 0x1234,
|
|
StackSize = 40,
|
|
StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xA, Player, 0);
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xB,
|
|
WeenieClassId = 0x1234,
|
|
StackSize = 95,
|
|
StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xB, Player, 1);
|
|
var puts = new List<(uint item, uint container, int placement)>();
|
|
var merges = new List<(uint source, uint target, uint amount)>();
|
|
var notices = new List<(uint source, uint target)>();
|
|
var selection = new SelectionState();
|
|
var ctrl = Bind(layout, objects, puts: puts, merges: merges,
|
|
mergeNotices: notices, selection: selection);
|
|
|
|
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
|
|
|
Assert.Equal(new[] { (0xAu, 0xBu, 5u) }, merges);
|
|
Assert.Equal(new[] { (0xAu, 0xBu) }, notices);
|
|
Assert.Empty(puts);
|
|
Assert.Equal(0xBu, selection.SelectedObjectId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Drop_selectedStack_usesSharedToolbarSplitQuantity()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xA, WeenieClassId = 0x1234, StackSize = 40, StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xA, Player, 0);
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xB, WeenieClassId = 0x1234, StackSize = 50, StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xB, Player, 1);
|
|
var selection = new SelectionState();
|
|
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
|
var split = new StackSplitQuantityState();
|
|
split.Reset(40u);
|
|
split.SetValue(7u);
|
|
var merges = new List<(uint source, uint target, uint amount)>();
|
|
var ctrl = Bind(layout, objects, merges: merges, selection: selection,
|
|
stackSplitQuantity: split);
|
|
|
|
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
|
|
|
Assert.Equal(new[] { (0xAu, 0xBu, 7u) }, merges);
|
|
}
|
|
|
|
[Fact]
|
|
public void Drop_differentStackType_fallsThroughToNormalInventoryInsert()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xA, WeenieClassId = 0x1111, StackSize = 10, StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xA, Player, 0);
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = 0xB, WeenieClassId = 0x2222, StackSize = 10, StackSizeMax = 100,
|
|
});
|
|
objects.MoveItem(0xB, Player, 1);
|
|
var puts = new List<(uint item, uint container, int placement)>();
|
|
var merges = new List<(uint source, uint target, uint amount)>();
|
|
var ctrl = Bind(layout, objects, puts: puts, merges: merges);
|
|
|
|
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
|
|
|
|
Assert.Empty(merges);
|
|
Assert.Contains((0xAu, Player, 1), puts);
|
|
}
|
|
|
|
[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.Equal(ItemDragAcceptance.Reject,
|
|
ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
|
|
Assert.Equal(ItemDragAcceptance.Accept,
|
|
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
|
}
|
|
|
|
[Fact]
|
|
public void OnDragLift_selectsItem_butKeepsItUntilServerConfirms()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
SeedContained(objects, 0xA, Player, slot: 0);
|
|
var selection = new SelectionState();
|
|
var ctrl = (IItemListDragHandler)Bind(layout, objects, selection: selection);
|
|
((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu));
|
|
|
|
Assert.Equal(0xAu, selection.SelectedObjectId);
|
|
Assert.True(grid.GetItem(0)!.Selected);
|
|
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.Equal(ItemDragAcceptance.Accept,
|
|
ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ShortcutAliasDropOnInventory_removesOnlyAlias_andNeverMovesEquippedItem()
|
|
{
|
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
|
var objects = new ClientObjectTable();
|
|
const uint helmet = 0xD00Du;
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = helmet,
|
|
Type = ItemType.Armor,
|
|
ValidLocations = EquipMask.HeadWear,
|
|
});
|
|
objects.MoveItem(helmet, Player, newSlot: 0, newEquipLocation: EquipMask.HeadWear);
|
|
var puts = new List<(uint item, uint container, int placement)>();
|
|
var ctrl = (IItemListDragHandler)Bind(layout, objects, puts: puts);
|
|
var payload = new ItemDragPayload(
|
|
helmet,
|
|
ItemDragSource.ShortcutBar,
|
|
SourceSlot: 3,
|
|
SourceCell: new UiItemSlot());
|
|
|
|
Assert.Equal(ItemDragAcceptance.None,
|
|
ctrl.OnDragOver(grid, grid.GetItem(0)!, payload));
|
|
ctrl.HandleDropRelease(grid, grid.GetItem(0)!, payload);
|
|
|
|
Assert.Empty(puts);
|
|
Assert.Equal(Player, objects.Get(helmet)!.ContainerId);
|
|
Assert.Equal(EquipMask.HeadWear, objects.Get(helmet)!.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
[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 "";
|
|
}
|
|
}
|