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,51 @@
using AcDream.Core.Items;
namespace AcDream.App.World;
/// <summary>
/// Owns the network side effect of replacing retail's current ground object.
/// Presentation controllers observe the same state but do not own session
/// lifecycle messages.
/// </summary>
public sealed class ExternalContainerLifecycleController : IDisposable
{
private readonly ExternalContainerState _state;
private readonly ClientObjectTable _objects;
private readonly Action<uint> _sendNoLongerViewingContents;
private bool _disposed;
public ExternalContainerLifecycleController(
ExternalContainerState state,
ClientObjectTable objects,
Action<uint> sendNoLongerViewingContents)
{
_state = state ?? throw new ArgumentNullException(nameof(state));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_sendNoLongerViewingContents = sendNoLongerViewingContents
?? throw new ArgumentNullException(nameof(sendNoLongerViewingContents));
_state.Changed += OnChanged;
}
private void OnChanged(ExternalContainerTransition transition)
{
// ClientUISystem::SetGroundObject @ 0x00564510 always queues the old
// root's contents tree for destruction when the ground object changes.
if (transition.PreviousContainerId != 0u)
_objects.StopViewingContentsTree(transition.PreviousContainerId);
// Only replacement emits Event_NoLongerViewingContents. The authored
// close/range path instead sends Use(root) and waits for event 0x0052.
if (transition.Kind == ExternalContainerTransitionKind.Replaced
&& transition.PreviousContainerId != 0u)
{
_sendNoLongerViewingContents(transition.PreviousContainerId);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_state.Changed -= OnChanged;
}
}