diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 81007e89..26a71386 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -37,6 +37,7 @@ public sealed class InventoryController private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px) private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items") + private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll) private readonly ClientObjectTable _objects; private readonly Func _playerGuid; @@ -52,6 +53,11 @@ public sealed class InventoryController private int _burdenPercent; private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); + private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid + private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) + private readonly Action? _sendUse; + private readonly Action? _sendNoLongerViewing; + // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)). private const uint EncumbranceValProperty = 5u; // total carried burden private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation @@ -65,12 +71,16 @@ public sealed class InventoryController UiDatFont? datFont, uint contentsEmptySprite, uint sideBagEmptySprite, - uint mainPackEmptySprite) + uint mainPackEmptySprite, + Action? sendUse, + Action? sendNoLongerViewing) { _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _strength = strength; + _sendUse = sendUse; + _sendNoLongerViewing = sendNoLongerViewing; _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; _containerList = layout.FindElement(ContainerListId) as UiItemList; @@ -123,7 +133,7 @@ public sealed class InventoryController // elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail // (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.) AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); - AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont); + AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont); AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont); // Rebuild on any change to the player's possessions. @@ -144,9 +154,12 @@ public sealed class InventoryController UiDatFont? datFont, uint contentsEmptySprite = 0u, uint sideBagEmptySprite = 0u, - uint mainPackEmptySprite = 0u) + uint mainPackEmptySprite = 0u, + Action? sendUse = null, + Action? sendNoLongerViewing = null) => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, - contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite); + contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, + sendUse, sendNoLongerViewing); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) @@ -156,59 +169,49 @@ public sealed class InventoryController private bool Concerns(ClientObject o) { uint p = _playerGuid(); - return o.ObjectId == p // B-Wire: the player object IS the burden source + return o.ObjectId == p // the player object IS the burden source || o.ContainerId == p || o.WielderId == p + || o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep } - /// Retail gm*UI display refresh: partition the player's direct contents into loose - /// items (the "Contents of Backpack" grid) and side bags (the pack-selector list). The - /// container index () IS retail's per-container - /// item list — items enter it via the CreateObject (Ingest) / server-move (MoveItem) flows - /// that call Reindex, slot-sorted. public void Populate() { uint p = _playerGuid(); - var contents = _objects.GetContents(p); + uint open = EffectiveOpen(); _contentsGrid?.Flush(); _containerList?.Flush(); - foreach (var guid in contents) + // Side-bag column: ALWAYS the player's bags (constant across container switches; only the + // open/selected indicators move). Equipped items never appear here. + foreach (var guid in _objects.GetContents(p)) { var item = _objects.Get(guid); - if (item is null) continue; - // Equipped items belong on the paperdoll (Sub-phase C), never in the pack grid or - // the side-bag list. A mid-session self-wield routes an item through - // MoveItem(item, WielderGuid=player), so it transiently lands in GetContents(player); - // retail's gm3DItemsUI shows pack contents only, gated on the wielded location. - if (item.CurrentlyEquippedLocation != EquipMask.None) continue; + if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - var list = isBag ? _containerList : _contentsGrid; - if (list is null) continue; - uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, - item.IconOverlayId, item.Effects); - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; - cell.SetItem(guid, tex); - list.AddItem(cell); + if (isBag) AddCell(_containerList, guid, isContainer: true); } - // Contents grid: show the FULL main-pack capacity (default 102) — occupied slots + empty - // frames — so the pack reads like retail (a fixed 102-slot grid you scroll), not just the - // loose items. The grid shows the SELECTED pack (default = the main pack / player); side-pack - // contents are a container-switch (later). Capacity = the player's ItemsCapacity, else 102. + // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has + // no sub-bags, so the isBag skip is a no-op when a bag is open.) + foreach (var guid in _objects.GetContents(open)) + { + var item = _objects.Get(guid); + if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; + bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; + if (!isBag) AddCell(_contentsGrid, guid, isContainer: false); + } + + // Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24). if (_contentsGrid is not null) { - int cap = _objects.Get(p)?.ItemsCapacity ?? 0; - int slots = cap > 0 ? cap : MainPackSlots; - while (_contentsGrid.GetNumUIItems() < slots) - _contentsGrid.AddItem(new UiItemSlot { SpriteResolve = _contentsGrid.SpriteResolve }); + int cap = _objects.Get(open)?.ItemsCapacity ?? 0; + int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots); + while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid); } - // Side-bag column: pad with empty slot frames up to the player's container capacity - // (clamped to the 7-slot column) so it reads like retail (bags on top, empty frames - // below) rather than one lone cell. Falls back to the full 7 slots when capacity is - // unknown (divergence AP-52). + // Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52). if (_containerList is not null) { int capacity = _objects.Get(p)?.ContainersCapacity ?? 0; @@ -216,24 +219,103 @@ public sealed class InventoryController int slots = capacity > 0 ? capacity : SideBagSlots; slots = Math.Max(slots, bags); slots = Math.Min(slots, SideBagSlots); - while (_containerList.GetNumUIItems() < slots) - _containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve }); + while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList); } - // Main-pack cell: the player's own container. Icon = placeholder until a backpack - // RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future - // click can select it. + // Main-pack cell: the player's own container — clicking it opens/selects the main pack. if (_topContainer is not null) { _topContainer.Flush(); var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, 0u); + main.Clicked = () => OpenContainer(p); _topContainer.AddItem(main); } + ApplyIndicators(); RefreshBurden(); } + /// The effective open container — the explicit one, or the player (main pack) by default. + /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. + private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); + + /// Add a populated cell wired to its click role: container cell → open+select, + /// item cell → select-only. + private void AddCell(UiItemList? list, uint guid, bool isContainer) + { + if (list is null) return; + var item = _objects.Get(guid); + uint tex = item is null ? 0u + : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); + var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; + cell.SetItem(guid, tex); + if (isContainer) cell.Clicked = () => OpenContainer(guid); + else cell.Clicked = () => SelectItem(guid); + list.AddItem(cell); + } + + private static void AddEmptyCell(UiItemList list) + => list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve }); + + /// Select an item (panel-wide green square) without changing the open container or + /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). + private void SelectItem(uint guid) + { + if (guid == 0) return; + _selectedItem = guid; + ApplyIndicators(); + } + + /// Open a container (side bag or main pack): it becomes both the selected item and the + /// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and + /// NoLongerViewingContents when switching away from a previously-open side bag. Retail: + /// gmBackpackUI container-selection + CM_Physics::Event_Use. + private void OpenContainer(uint guid) + { + if (guid == 0) return; + _selectedItem = guid; // the container cell is also the selected item + uint open = EffectiveOpen(); + if (guid == open) { ApplyIndicators(); return; } // already open — just move the square + + uint p = _playerGuid(); + if (open != p && open != 0) + _sendNoLongerViewing?.Invoke(open); // close the previously-open side bag + _openContainer = guid; + if (guid != p) + _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) + Populate(); // repaint the grid for the new open container + } + + /// Stamp the open-container triangle + selected-item square onto every cell per the + /// retail keying (item.itemID == openContainerId / selectedItemId). + private void ApplyIndicators() + { + SetIndicators(_contentsGrid); + SetIndicators(_containerList); + SetIndicators(_topContainer); + } + + private void SetIndicators(UiItemList? list) + { + if (list is null) return; + uint open = EffectiveOpen(); + for (int i = 0; i < list.GetNumUIItems(); i++) + { + var cell = list.GetItem(i); + if (cell is null) continue; + cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem; + cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; + } + } + + /// The open container's display name for the contents caption. + private string OpenContainerName() + { + uint open = EffectiveOpen(); + return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); + } + private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) { if (host is null) return; diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index fc2a5de2..f4fd5526 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -51,10 +51,19 @@ public class InventoryControllerTests } private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100) + int? strength = 100, List? uses = null, List? closes = null) => InventoryController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests - strength: () => strength, datFont: null); + strength: () => strength, datFont: null, + sendUse: uses is null ? null : g => uses.Add(g), + sendNoLongerViewing: closes is null ? null : g => closes.Add(g)); + + // 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 @@ -253,6 +262,105 @@ public class InventoryControllerTests 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(); + 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(); + var closes = new List(); + 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(); + var closes = new List(); + 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()); + + 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(); + var closes = new List(); + 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 + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) {