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

@ -1026,6 +1026,56 @@ public sealed class ClientObjectTable
ContainerContentsReplaced?.Invoke(containerId);
}
/// <summary>
/// Retires a viewed container's temporary ordered projection without inventing
/// item movement or deleting its child objects. Retail
/// <c>ACCObjectMaint::StopViewingObjectContents</c> is driven by server event
/// CloseGroundContainer; canonical ownership remains owned by move/delete wire.
/// </summary>
public bool StopViewingContents(uint containerId)
{
if (containerId == 0u || !_containerIndex.Remove(containerId))
return false;
ContainerContentsReplaced?.Invoke(containerId);
return true;
}
/// <summary>
/// Retires the root and every nested container projection reachable through
/// the last ViewContents snapshots. ACE sends a snapshot for each nested
/// container; retail queues the external root's contents tree for destruction
/// when the view closes.
/// </summary>
public int StopViewingContentsTree(uint rootContainerId)
{
if (rootContainerId == 0u)
return 0;
var pending = new Stack<uint>();
var visited = new HashSet<uint>();
var removed = new List<uint>();
pending.Push(rootContainerId);
while (pending.Count != 0)
{
uint containerId = pending.Pop();
if (!visited.Add(containerId)
|| !_containerIndex.TryGetValue(containerId, out List<uint>? contents))
continue;
foreach (uint childId in contents)
{
if (_containerIndex.ContainsKey(childId))
pending.Push(childId);
}
_containerIndex.Remove(containerId);
removed.Add(containerId);
}
foreach (uint containerId in removed)
ContainerContentsReplaced?.Invoke(containerId);
return removed.Count;
}
/// <summary>
/// Initialize canonical inventory ownership from PlayerDescription. This
/// login manifest is object-state initialization, not a move transition.

View file

@ -0,0 +1,98 @@
using System;
namespace AcDream.Core.Items;
public enum ExternalContainerTransitionKind
{
Opened,
Replaced,
Closed,
Reset,
}
public readonly record struct ExternalContainerTransition(
ExternalContainerTransitionKind Kind,
uint PreviousContainerId,
uint ContainerId);
/// <summary>
/// Owns retail <c>ClientUISystem::groundObject</c> and the outstanding external
/// container request. ACE sends ViewContents for the requested root and then for
/// nested containers; only the expected root may become the visible ground object.
/// Retail: <c>ClientUISystem::OnViewContents @ 0x00565870</c> and
/// <c>SetGroundObject @ 0x00564510</c>.
/// </summary>
public sealed class ExternalContainerState
{
public uint RequestedContainerId { get; private set; }
public uint CurrentContainerId { get; private set; }
public event Action<ExternalContainerTransition>? Changed;
public bool RequestOpen(uint containerId)
{
if (containerId == 0u || RequestedContainerId == containerId)
return false;
RequestedContainerId = containerId;
return true;
}
public bool ApplyViewContents(uint containerId)
{
if (containerId == 0u || containerId != RequestedContainerId)
return false;
uint previous = CurrentContainerId;
CurrentContainerId = containerId;
RequestedContainerId = containerId;
if (previous == containerId)
return false;
Changed?.Invoke(new ExternalContainerTransition(
previous == 0u
? ExternalContainerTransitionKind.Opened
: ExternalContainerTransitionKind.Replaced,
previous,
containerId));
return true;
}
public bool ApplyClose(uint containerId)
{
if (containerId == 0u || containerId != CurrentContainerId)
return false;
uint previous = CurrentContainerId;
CurrentContainerId = 0u;
RequestedContainerId = 0u;
Changed?.Invoke(new ExternalContainerTransition(
ExternalContainerTransitionKind.Closed,
previous,
0u));
return true;
}
public bool ApplyUseDone(uint weenieError)
{
if (weenieError == 0u || RequestedContainerId == CurrentContainerId)
return false;
RequestedContainerId = CurrentContainerId;
return true;
}
public bool Reset()
{
uint previous = CurrentContainerId;
bool changed = previous != 0u || RequestedContainerId != 0u;
CurrentContainerId = 0u;
RequestedContainerId = 0u;
if (changed)
{
Changed?.Invoke(new ExternalContainerTransition(
ExternalContainerTransitionKind.Reset,
previous,
0u));
}
return changed;
}
}