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

@ -848,6 +848,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound;
private (System.Numerics.Vector3 Position,
@ -2002,6 +2007,12 @@ public sealed class GameWindow : IDisposable
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
sendGive: (target, item, amount) =>
_liveSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
(_characterOptions1
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
@ -2643,7 +2654,11 @@ public sealed class GameWindow : IDisposable
_retailUiRuntime?.HandleConfirmationDone(done),
friends: Friends,
squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components);
onDesiredComponents: components => DesiredComponents = components,
onCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -11332,7 +11347,10 @@ public sealed class GameWindow : IDisposable
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_itemInteractionController?.DropToWorld(itemPayload);
{
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
_itemInteractionController?.PlaceIn3D(itemPayload, target);
}
}
// L.0 follow-up: persisted-settings cache populated by
@ -11997,6 +12015,9 @@ public sealed class GameWindow : IDisposable
/// yourself by clicking your own toon); plain selection never does.
/// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf)
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
{
if (_cameraController is null || _window is null) return null;
@ -12015,7 +12036,7 @@ public sealed class GameWindow : IDisposable
}
return AcDream.Core.Selection.WorldPicker.Pick(
mouseX: _lastMouseX, mouseY: _lastMouseY,
mouseX: mouseX, mouseY: mouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _entitiesByServerGuid.Values,

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);