The prior commit pinned 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) — it rendered as a GREEN TILE (green slot, no pack) at the visual gate. Dat-exported the candidates (AcDream.Cli dump-sprite-sheet / export-ui-sprite): 0x0600127E is the 32x32 brown backpack (user-hinted, PNG-confirmed). Swap the pinned literal; the test + AP-51 register row updated to the visually-verified id. Container type-underlay (green) + backpack base still composited via _iconIds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
394 lines
19 KiB
C#
394 lines
19 KiB
C#
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)
|
|
=> InventoryController.Bind(layout, objects, () => Player,
|
|
iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests
|
|
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
|
|
// (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);
|
|
}
|
|
|
|
// 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 "";
|
|
}
|
|
}
|