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>
This commit is contained in:
Erik 2026-07-17 16:18:10 +02:00
parent 48a118db91
commit 20ce67b625
27 changed files with 1750 additions and 45 deletions

View file

@ -0,0 +1,203 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.Tests.UI.Layout;
public sealed class ExternalContainerControllerTests
{
private const uint Player = 0x50000001u;
private const uint Chest = 0x70000001u;
private const uint Item = 0x60000001u;
private sealed class TestElement : UiElement { }
private sealed class Harness : IDisposable
{
public readonly ExternalContainerState State = new();
public readonly ClientObjectTable Objects = new();
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState Split = new();
public readonly List<uint> Uses = new();
public readonly List<uint> NoLonger = new();
public readonly List<uint> Pickups = new();
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> Splits = new();
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
public readonly UiItemList Top = new() { Width = 36f, Height = 36f };
public readonly UiItemList Containers = new() { Width = 680f, Height = 36f };
public readonly UiItemList Contents = new() { Width = 784f, Height = 32f };
public readonly RetailWindowHandle Window;
public readonly ItemInteractionController Interaction;
public readonly ExternalContainerLifecycleController Lifecycle;
public readonly ExternalContainerController Controller;
public bool InRange = true;
public Harness()
{
var root = new TestElement { Width = 800f, Height = 110f };
var close = new UiButton(
new ElementInfo { Id = ExternalContainerController.CloseButtonId, Type = 1 },
static _ => (0u, 0, 0));
var scrollbar = new UiScrollbar { Width = 784f, Height = 16f };
root.AddChild(Top);
root.AddChild(Containers);
root.AddChild(Contents);
root.AddChild(close);
root.AddChild(scrollbar);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[ExternalContainerController.TopContainerId] = Top,
[ExternalContainerController.ContainerListId] = Containers,
[ExternalContainerController.ContentsListId] = Contents,
[ExternalContainerController.ContentsScrollbarId] = scrollbar,
[ExternalContainerController.CloseButtonId] = close,
});
Window = RetailWindowFrame.Mount(
Screen,
root,
static _ => (0u, 0, 0),
new RetailWindowFrame.Options
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.Imported,
Visible = false,
Draggable = false,
Resizable = false,
});
Interaction = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => State.CurrentContainerId,
placeInBackpack: Pickups.Add,
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)),
sendSplitToContainer: (item, container, placement, amount) =>
Splits.Add((item, container, placement, amount)),
requestExternalContainer: id => State.RequestOpen(id));
Lifecycle = new ExternalContainerLifecycleController(State, Objects, NoLonger.Add);
Controller = ExternalContainerController.Bind(
layout,
State,
Objects,
Selection,
Interaction,
Split,
static (_, _, _, _, _) => 0u,
static (_, _, _, _, _) => 0u,
Uses.Add,
(item, container, placement) => Puts.Add((item, container, placement)),
(item, container, placement, amount) =>
Splits.Add((item, container, placement, amount)),
_ => InRange,
Window);
Screen.WindowManager.AttachController(WindowNames.ExternalContainer, Controller);
}
public void Open(uint id = Chest, params ContainerContentEntry[] contents)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = id,
Type = ItemType.Container,
ItemsCapacity = 24,
Useability = ItemUseability.Remote,
});
State.RequestOpen(id);
Objects.ReplaceContents(id, contents);
State.ApplyViewContents(id);
}
public void Dispose()
{
Lifecycle.Dispose();
Interaction.Dispose();
Screen.WindowManager.Dispose();
}
}
[Fact]
public void AuthoritativeView_ShowsAuthoredStripAndPopulatesLists()
{
using var h = new Harness();
h.Open(Chest, new ContainerContentEntry(Item, 0u));
Assert.True(h.Window.IsVisible);
Assert.Equal(Chest, h.Top.GetItem(0)!.ItemId);
Assert.Equal(Item, h.Contents.GetItem(0)!.ItemId);
Assert.True(h.Contents.HorizontalScroll);
Assert.Same(h.Contents.Scroll,
Assert.IsType<UiScrollbar>(h.Window.ContentRoot.Children.Last()).Model);
}
[Fact]
public void DoubleClickLoot_UsesSharedItemPolicyAndPicksUpItem()
{
using var h = new Harness();
h.Open(Chest, new ContainerContentEntry(Item, 0u));
h.Contents.GetItem(0)!.DoubleClicked!();
Assert.Equal(new uint[] { Item }, h.Pickups);
}
[Fact]
public void CloseAndRangeExit_SendUseOnceWithoutNoLongerViewing()
{
using var h = new Harness();
h.Open();
h.InRange = false;
h.Controller.Tick();
h.Controller.Tick();
Assert.Equal(new uint[] { Chest }, h.Uses);
Assert.Empty(h.NoLonger);
Assert.False(h.Window.IsVisible);
h.State.ApplyClose(Chest);
Assert.Empty(h.NoLonger);
}
[Fact]
public void Replacement_SendsNoLongerViewingForPreviousRootExactlyOnce()
{
using var h = new Harness();
h.Open(Chest);
h.Open(0x70000002u);
Assert.Equal(new uint[] { Chest }, h.NoLonger);
Assert.Equal(0x70000002u, h.State.CurrentContainerId);
Assert.True(h.Window.IsVisible);
}
[Fact]
public void DropOwnedPartialStackIntoChest_UsesSplitRequestWithoutLocalMove()
{
using var h = new Harness();
h.Open();
var item = new ClientObject { ObjectId = Item, StackSize = 5, Type = ItemType.Misc };
h.Objects.AddOrUpdate(item);
h.Objects.MoveItem(Item, Player, 0);
h.Selection.Select(Item, SelectionChangeSource.Inventory);
h.Split.Reset(5u, 2u);
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
source.SetItem(Item, 0u);
var payload = new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source);
UiItemSlot target = h.Contents.GetItem(0)!;
h.Controller.HandleDropRelease(h.Contents, target, payload);
Assert.Equal(new[] { (Item, Chest, 0u, 2u) }, h.Splits);
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
Assert.Empty(h.Puts);
}
}

View file

@ -54,7 +54,7 @@ public class InventoryControllerTests
}
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
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,
@ -68,7 +68,6 @@ public class InventoryControllerTests
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
@ -410,7 +409,7 @@ public class InventoryControllerTests
}
[Fact]
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
public void ClickMainPackCell_afterBag_returnsToMainPack_withoutExternalCloseWire()
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
@ -418,34 +417,30 @@ public class InventoryControllerTests
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);
var ctrl = Bind(layout, objects, uses: uses);
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()
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>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
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)
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
}
[Fact]
@ -469,15 +464,13 @@ public class InventoryControllerTests
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);
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
Assert.Empty(closes);
}
[Fact]