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

@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = null,
Func<bool>? dragOnPlayerOpensSecureTrade = null,
Action<string>? systemMessage = null)
Action<string>? systemMessage = null,
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -88,6 +93,8 @@ public sealed class ItemInteractionController : IDisposable
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
@ -98,6 +105,7 @@ public sealed class ItemInteractionController : IDisposable
_inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_requestExternalContainer = requestExternalContainer;
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
@ -416,6 +424,10 @@ public sealed class ItemInteractionController : IDisposable
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
acted |= _sendUseWithTarget is not null;
break;
case ItemPolicyActionKind.SetGroundObject:
_requestExternalContainer?.Invoke(action.ObjectId);
acted |= _requestExternalContainer is not null;
break;
case ItemPolicyActionKind.EnterTargetMode:
EnterTargetMode(action.ObjectId);
acted = true;
@ -464,6 +476,18 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId,
(uint)Math.Max(1, action.Amount));
break;
case ItemPolicyActionKind.PlaceInContainer:
{
uint fullStack = (uint)Math.Max(
1,
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
uint amount = (uint)Math.Max(1, action.Amount);
if (amount < fullStack)
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount);
else
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0);
break;
}
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);