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

@ -46,6 +46,41 @@ public sealed class ClientObjectTableTests
Assert.Equal(1, propUpdateCount);
}
[Fact]
public void StopViewingContents_RemovesOnlyTemporaryProjection()
{
var repo = new ClientObjectTable();
ClientObject item = MakeItem(100u);
item.ContainerId = 77u;
repo.AddOrUpdate(item);
repo.ReplaceContents(77u, new uint[] { 100u });
uint? changed = null;
repo.ContainerContentsReplaced += id => changed = id;
Assert.True(repo.StopViewingContents(77u));
Assert.Empty(repo.GetContents(77u));
Assert.Same(item, repo.Get(100u));
Assert.Equal(77u, item.ContainerId);
Assert.Equal(77u, changed);
Assert.False(repo.StopViewingContents(77u));
}
[Fact]
public void StopViewingContentsTree_RemovesNestedSnapshotsButPreservesObjects()
{
var repo = new ClientObjectTable();
repo.ReplaceContents(77u, new[] { new ContainerContentEntry(88u, 1u) });
repo.ReplaceContents(88u, new[] { new ContainerContentEntry(100u, 0u) });
Assert.Equal(2, repo.StopViewingContentsTree(77u));
Assert.Empty(repo.GetContents(77u));
Assert.Empty(repo.GetContents(88u));
Assert.NotNull(repo.Get(88u));
Assert.NotNull(repo.Get(100u));
}
[Fact]
public void MoveItem_UpdatesContainerAndFiresEvent()
{

View file

@ -0,0 +1,70 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ExternalContainerStateTests
{
[Fact]
public void ViewContents_onlyCommitsTheExpectedRoot()
{
var state = new ExternalContainerState();
state.RequestOpen(0x70000001u);
Assert.False(state.ApplyViewContents(0x70000002u));
Assert.Equal(0u, state.CurrentContainerId);
Assert.True(state.ApplyViewContents(0x70000001u));
Assert.Equal(0x70000001u, state.CurrentContainerId);
}
[Fact]
public void ReplacementPublishesOldAndNewExactlyOnce()
{
var state = new ExternalContainerState();
var changes = new List<ExternalContainerTransition>();
state.Changed += changes.Add;
state.RequestOpen(1u);
state.ApplyViewContents(1u);
state.RequestOpen(2u);
state.ApplyViewContents(2u);
state.ApplyViewContents(2u);
Assert.Equal(2, changes.Count);
Assert.Equal(ExternalContainerTransitionKind.Opened, changes[0].Kind);
Assert.Equal(
new ExternalContainerTransition(
ExternalContainerTransitionKind.Replaced, 1u, 2u),
changes[1]);
}
[Fact]
public void CloseIgnoresStaleContainerAndClosesCurrent()
{
var state = Open(2u);
Assert.False(state.ApplyClose(1u));
Assert.Equal(2u, state.CurrentContainerId);
Assert.True(state.ApplyClose(2u));
Assert.Equal(0u, state.CurrentContainerId);
Assert.Equal(0u, state.RequestedContainerId);
}
[Fact]
public void RefusedReplacementRestoresCurrentAsExpectedRoot()
{
var state = Open(1u);
state.RequestOpen(2u);
Assert.True(state.ApplyUseDone(0x550u));
Assert.Equal(1u, state.CurrentContainerId);
Assert.Equal(1u, state.RequestedContainerId);
Assert.False(state.ApplyViewContents(2u));
}
private static ExternalContainerState Open(uint id)
{
var state = new ExternalContainerState();
state.RequestOpen(id);
state.ApplyViewContents(id);
return state;
}
}