acdream/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
Erik 20ce67b625 feat(items): port retail external-container looting
Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses.

Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196.

Release build succeeds and all 5,875 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 16:18:10 +02:00

912 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 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),
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_returnsToMainPack_withoutExternalCloseWire()
{
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 ctrl = Bind(layout, objects, uses: uses);
containers.GetItem(0)!.Clicked!(); // open the bag
top.GetItem(0)!.Clicked!(); // click the main-pack cell
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_opensEach_withoutExternalCloseWire()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC1, slot: 0);
SeedBag(objects, 0xC2, slot: 1);
var uses = new List<uint>();
Bind(layout, objects, uses: uses);
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)
}
[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>();
Bind(layout, objects, uses: uses);
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
}
[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 "";
}
}