feat(inventory): port retail item giving (#216)

Preserve SmartBox drag-release coordinates, route the picked 3-D target through the retail AttemptPlaceIn3D policy, and send authoritative GiveObject requests with the selected stack quantity. Honor PlayerDescription's player secure-trade option and document the remaining trade-system boundary.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 20:28:38 +02:00
parent a944e49b9c
commit 30d294506c
13 changed files with 421 additions and 10 deletions

View file

@ -30,6 +30,7 @@ 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, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
private readonly Func<uint> _activeVendorId;
@ -42,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
private readonly AutoWieldController _autoWield;
private long _lastUseMs = long.MinValue / 2;
@ -72,7 +74,9 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint>? sendSplitToWorld = null,
Func<uint>? selectedObjectId = null,
StackSplitQuantityState? stackSplitQuantity = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = null,
Func<bool>? dragOnPlayerOpensSecureTrade = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -82,6 +86,7 @@ public sealed class ItemInteractionController : IDisposable
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
@ -94,6 +99,7 @@ public sealed class ItemInteractionController : IDisposable
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
_autoWield = new AutoWieldController(
@ -310,6 +316,14 @@ public sealed class ItemInteractionController : IDisposable
}
public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u);
/// <summary>
/// Retail inventory drag released into SmartBox. The release target is the
/// world object under the cursor, or zero for empty ground. This is the live
/// adapter for <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>.
/// </summary>
public bool PlaceIn3D(ItemDragPayload payload, uint targetGuid)
{
ArgumentNullException.ThrowIfNull(payload);
@ -321,16 +335,17 @@ public sealed class ItemInteractionController : IDisposable
uint fullStack = (uint)Math.Max(1, item.StackSize);
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
ClientObject? target = targetGuid == 0u ? null : _objects.Get(targetGuid);
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
TargetId: 0,
Target: null,
TargetId: targetGuid,
Target: target is null ? null : Snapshot(target),
AllowGroundFallback: true,
MergeAccepted: false,
DragOnPlayerOpensSecureTrade: true,
DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade(),
PlayerOnGround: _playerOnGround(),
SplitSize: splitSize));
ExecutePlacementActions(decision.Actions);
@ -410,6 +425,15 @@ public sealed class ItemInteractionController : IDisposable
// the server assigns the split-off ground stack a new guid.
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount);
break;
case ItemPolicyActionKind.GiveToTarget:
// UIAttemptGive @ 0x0058D620 sends the request and leaves
// the source untouched until the authoritative server
// InventoryRemoveObject / SetStackSize response arrives.
_sendGive?.Invoke(
action.TargetId,
action.ObjectId,
(uint)Math.Max(1, action.Amount));
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);